Размышление по мотивам проекта. Архитектура — router + pipelines (AWS, Claude).
Рассмотрим сравнительно простой пример, знакомый почти всем, кто делал LLM‑агента для запросов к данным на естественном языке — будь то text2sql, аналитический ассистент или чат‑бот над базой. Обычно агенту поручают решать всё самому — и определять тип запроса, и формировать его, и выбирать форму результата и решать, нужно ли предупредить об отклонении. Именно в этом суть агента и именно для этого пишется системный промпт.
На десятке тестовых вопросов система работает без нареканий. Но позже появляются проблемы, когда вопросы начинают приходить в разных формулировках. Например, один и тот же вопрос, заданный другими словами, вдруг обрабатывается иначе — модель по‑другому интерпретировала фразу и одинаковые по структуре результаты приняли разную форму (строка, таблица, график) без объяснения причины. Или при одной той же формулировке вопроса отклонение то получает пояснение, то приходит пустым.
Первое, что обычно меняют в таких случаях — добавляют в промпт ещё одно правило. Несколько раз это действительно помогает. Потом новое правило начинает противоречить старым, и промпт превращается в набор заплаток под конкретные ситуации. Нестабильность никуда не уходит, она просто прячется теперь в том, в каком порядке моделью применяются противоречащие друг другу инструкции.
Именно здесь кроется и проблема, и ее решение. Проблема в том, что в описанном сценарии работы никто явно не решал, что в системе делает код, а что модель. Это сложилось само, по ходу генерации промпта. Спросите, почему модель выбирает форму результата запроса, а не разработчик пишет десять строк обычного кода, — и внятного ответа, как правило, не найдётся. Отсюда и решение: граница между тем, что делает код, и тем, что делает модель, — это не деталь реализации, а отдельный архитектурный объект. Если такое решение не принять явно, оно всё равно будет принято по умолчанию, и чаще всего не в пользу устойчивости системы.
Основная идея
Опыт разрешения вышеописанной ситуации можно сформулировать одной главной мыслью: устойчивость LLM‑агента зависит не от числа ограничений в промпте, а от того, насколько явно проведена и зафиксирована граница между алгоритмом (релевантным ему кодом) и моделью, достаточно самостоятельно и независимо работающей в концептуальном пространстве.
О самой границе известно. Anthropic определила её ещё в декабре 2024 года в Building Effective Agents. В workflow поток управляется заранее заданным кодом, а когда мы имеем дело с агентом, поток направляет модель. С тех пор эту мысль пересказывали много раз, включая недавние русскоязычные тексты. Например, статья «От болтливых LLM‑агентов к управляемым системам» на Хабре, вышедшая месяц назад, подчеркивает, что не стоит поручать модели то, что код сделает точнее и надёжнее. Самый известный частный случай этой границы — маршрутизация запроса: сначала дешёвая эвристика или модель, потом эскалация по порогу уверенности. Паттерн называют cascade routing и разбирают вплоть до продакшн‑деталей — дрейф порога при обновлении модели, атаки на роутер через специально составленный запрос. Пересказывать здесь подробнее смысла нет.
Суть в том, что рассмотрения требует вопрос где граница плавает и каким образом ее можно удержать.
Первое. Правило асимметрии
Для начала остановимся на том, в какую сторону разрешено менять решение, оставив в стороне вопрос о том, кто его принимает. Классификатор отнёс запрос к одной категории. Дальше выясняется, что запрос сложнее, чем казалось. Пересмотр в этом случае обязателен, но только в одну сторону. Категория может подняться до более строгой и дорогой обработки, а понижаться она никогда не может.
Ошибка повышения и ошибка понижения относятся к разным категориям. Неправильное повышение — это ошибка исполнения: потрачено больше ресурсов, чем нужно, но само основание ответа остается при этом актуальным. Это можно поправить на следующем этапе. При неправильном понижении основание ответа молча подменяется более слабым, недостаточным, но тот, кто получает результат, об этом не знает. Задним числом проверить подмену нечем, поскольку ответ выглядит как и любой другой, высказывается столь же уверенно. Сравнивать эти две ошибки по любой шкале нельзя, это несопоставимые вещи (все равно что сравнивать цену лишнего шага с ценой шага в неверном направлении).
При этом уверенность модели нельзя путать с простотой задачи. Порог уверенности классификатора показывает, насколько модель убеждена в своей интерпретации, а не насколько ситуация проста на самом деле. Если позволить высокой уверенности понижать категорию, одно подменяется другим: система, которая уверена оказывается на месте системы, которая права. Поэтому решение о повышении остаётся за кодом — простым правилом по факту обнаруженной сложности. Модели доверяют только сигнал «сложность выше ожидаемой», но не право самой понизить категорию. Стоит один раз сделать исключение и асимметрия перестаёт быть правилом. Она превращается в рекомендацию, которую легко обойти в следующей версии промпта. Каждое повышение записывается в лог вместе с изначальной и фактической категорией. Это нужно не только для аудита отдельного ответа. Это ещё и метрика качества самого классификатора: если доля повышений для одного типа запроса растёт от релиза к релизу, определение категории устарело и больше не соответствует реальности.
Второе. Необнаружимая ошибка
Второе место, где граница часто теряется, — это ошибка, которую правило асимметрии не решает. Допустим результат получен без единого технического сбоя, но по существу он неверный. Запрос выполнился, план прошёл проверку, ошибок исполнения нет. А число всё равно не то потому, что соединение построено не по тому ключу, или фильтр применён не тот, который имел в виду пользователь. Ни синтаксическая проверка, ни план выполнения такую ошибку не поймают. С их точки зрения ничего не сломано.
Первый естественный порыв — поймать такую ошибку ещё одной проверкой, вызовом модели‑арбитра. Ненадёжность этого хода уже хорошо описана — это известное ограничение паттерна LLM‑as‑judge: судья тоже языковая модель, и она тоже ошибается. Тогда возникает сакраментальный вопрос: что делать? Ответ простой — сделать сам результат проверяемым. Сгенерированный запрос к данным возвращается вместе с ответом, а не прячется за отладочным флагом. От этого выигрывает даже тот, кто не читает код — специалист всегда сможет восстановить, что именно спрашивалось. А тот, кто читает код, сразу поймает неверное соединение. Если ответ опирается на определение метрики из общего каталога, это определение называется прямо в тексте ответа. Голого числа, которое нечем перепроверить, быть не должно. И это снова решение на границе кода и модели, поскольку именно код может дать пользователю средства проверки ответа.
Третье. Отказ как решение кода
Третье место (и этот момент куда важнее, чем два предыдущих) — само решение отказать. На самом деле кажется естественным, что определить, стоит ли отвечать, тоже должна модель: она понимает язык, ей проще всего заметить, что вопрос неуместен или неправомерен. На практике это не так. В описаниях «safety router» или единого входного фильтра обычно теряется, что отказ — это не одна операция с двумя исходами пропустить или заблокировать. Это несколько логически разных причин не отвечать. У каждой из них свой особый механизм проверки. Пустой или нечитаемый ввод — это дефект формы, а не содержания. Код распознаёт его раньше, чем любая модель обращается к вопросу. Запрос, неправомерный по своей природе, — это дефект другого типа: с формой всё в порядке, но такой вопрос в принципе не должен быть задан. Это тоже решает жёсткое правило, ещё до классификации. Третий случай — это вопрос, для которого в системе просто нет данных. С вопросом тоже всё в порядке, но ему не к чему привязаться в предметной области, и это выясняется только после попытки такой привязки. Свести все три случая к одному ответу «отказано» — значит стереть различие между принципиальным правом задавать вопрос и умением системы обслуживать запросы такого типа. А ведь именно эта информация нужна при разборе отдельных инцидентов.
То есть причина не доверять выбор отказа модели не в ненадёжности классификации самой по себе, а в том, что сами эти категории разные логически. Модель, которой дали одну общую команду «отказывай в неуместных запросах», неизбежно смешает разные основания. А отказ, который можно обойти перефразировкой, вообще отказом по существу не является, а только шумом в поведении системы и здесь нет решения о границах. Хороший отказ поэтому называет и класс причины, и уровень, на котором принято решение. Формулировки «система не может ответить» для этого недостаточно. Здесь речь не только о прозрачности для пользователя, но скорее о возможности диагностики для разработчика: если растёт число отказов одного класса, сразу понятно, где искать причину — в наборе правил, в определении области данных или в самих вопросах пользователей, которые система пока не умеет обслуживать.
Вывод
Все перечисленное выше по сути является одной и той же проверкой, применённой на разной глубине — вопрос о том, кто в указанной точке принимает решение и почему именно он, а не кто‑то другой. Практический вывод для собственной системы прост. Если решение можно свести к перечислимым структурным признакам, значит, оно принадлежит коду. Если решение на самом деле состоит из нескольких логически разных вопросов — мало один раз провести границу код/модель. Нужно ещё не свернуть эти разные вопросы в одну общую реакцию — кто бы её ни принимал, код или модель.
Постскриптум
В вашем проекте границ и возможных мест разрыва может быть больше, чем описано, важно одно — для инженера поиск границы сопряжен с поиском механизма ее удержания.

