Перейти к содержимому
PD
AI-автоматизация 8 мин чтения

Ваш AI-агент врёт, что всё сделал: как построить слой верификации

Агенты рапортуют об успехе, которого не было. Разбираю слой верификации, который я ставлю перед каждым продакшн-агентом: контракт приёмки, детерминированные проверки, артефакты-доказательства и бюджеты ретраев.

PD

Pavel Duglas

AI Automation & MVP Architect

В прошлом месяце клиент прислал мне скриншот из Slack с финальным сообщением своего агента: «Успешно обновлено 412 записей клиентов в CRM. Все проверки пройдены». В CRM было обновлено ноль записей. Агент получил 403 на первой записи, повторил попытку дважды, решил, что эндпоинт «eventually consistent», и написал победный отчёт. Никто не замечал девять дней.

Это самый дорогой режим отказа в агентных системах прямо сейчас, и это не проблема модели. Это проблема архитектуры. Агент, у которого финальный артефакт - текст, всегда сможет сгенерировать текст, похожий на успех. Если вы принимаете этот текст за доказательство, вы построили систему без обратной связи. Ниже - слой, который я ставлю между агентом и любым действием, имеющим последствия.

Режим отказа: непроверенный успех

LLM-агенты не врут в человеческом смысле. Они оптимизируют правдоподобное продолжение диалога, в котором задача завершена. Когда tool call падает, самое правдоподобное продолжение всё равно «задача выполнена» - потому что в 99% обучающих данных треды задач заканчиваются именно так.

В продакшне я вижу три разновидности.

Самоотчёт как доказательство. Агент говорит, что записал файл, отправил письмо, обновил строку. Артефакта нет. Единственное доказательство - сама фраза.

Частичное выполнение, полный рапорт. Обработал 12 из 412 элементов, упёрся в rate limit, а в саммари написал, что батч закрыт. Цифры в отчёте обычно галлюцинированные и подозрительно круглые.

Тихое сужение задачи. Вы просили распарсить 50 страниц товаров. Он нашёл 6, решил, что структура сайта изменилась, полез в sitemap, вытащил 6 URL категорий и отчитался: «Извлечено 6 товаров, судя по всему, ассортимент сократился». Формально что-то сделал. Просто не то, что вы просили.

Все три случая невидимы, если пайплайн заканчивается сообщением самого агента.

Правило первое: агент не проверяет сам себя

Лекарство скучное и старое: разделить актора и верификатор. Не «добавить шаг саморефлексии в prompt» - это та же модель, в том же контексте, с той же мотивацией красиво закрыть тред. Я говорю про физически отдельный процесс, который никогда не видел рассуждений агента и смотрит только на состояние мира.

Мой стандартный стек - четыре слоя, от самого дешёвого к дорогому. Всё, что упало на слое N, до слоя N+1 не доходит.

Слой 1: схема

Финальный вывод агента - никогда не проза. Это структурированный объект с фиксированной схемой, и поля должны быть проверяемыми снаружи.

{
  "status": "completed",
  "records_written": 412,
  "target_table": "crm_contacts",
  "write_ids": ["c_8812", "c_8813", "..."],
  "failures": [],
  "evidence": {
    "http_statuses": [200, 200, 200],
    "screenshot": "s3://runs/9f2a/final.png"
  }
}

Обратите внимание на write_ids. Счётчик галлюцинируется на лету. Список из 412 идентификаторов, каждый из которых обязан существовать в базе, - уже нет. Проектируйте схему так, чтобы для вранья нужно было выдумать проверяемые primary key. А потом проверяйте их.

Слой 2: детерминированные проверки

Обычный код. Никаких моделей. Здесь умирает 90% фальшивых успехов.

def verify(claim, db):
    if claim["status"] != "completed":
        return fail("агент не заявил завершение")
    if len(claim["write_ids"]) != claim["records_written"]:
        return fail("расхождение внутри самого отчёта")
    found = db.count_ids(claim["target_table"], claim["write_ids"])
    if found != claim["records_written"]:
        return fail(f"заявлено {claim['records_written']}, найдено {found}")
    stale = db.count_untouched_since(run_started_at, claim["write_ids"])
    if stale:
        return fail(f"{stale} строк существуют, но в этом run не менялись")
    return ok()

Последняя проверка ловит мой любимый сценарий: агент «находит» уже существующие записи и рапортует о них как о записанных. Всегда сверяйте updated_at, а не только факт существования.

Детерминированные проверки дешёвые, мгновенные, и их можно гонять на каждом run вечно. Пишите их до того, как напишете prompt. Если вы не можете выразить «готово» кодом - вы пока не понимаете, что автоматизируете.

Слой 3: независимый judge, только по артефактам

Есть задачи, где действительно нужна оценка: «корректно ли извлечено описание товара», «отвечает ли сгенерированный ответ на вопрос клиента». Здесь я использую вторую модель, но с тремя жёсткими ограничениями:

  1. Она видит только исходное ТЗ и произведённые артефакты. Никогда не видит рассуждений, плана и саммари актора. Рассуждения убедительны - в этом и проблема.
  2. Она отвечает на закрытые вопросы по рубрике, а не «хорошо ли это?». Например: есть_цена: да/нет, цена_совпадает_с_html: да/нет, язык_совпадает_с_запросом: да/нет.
  3. По возможности - другая модель, не та, что была актором. Judge на той же модели наследует те же слепые зоны, особенно в форматировании и следовании инструкциям.

Judge, возвращающий три булева, полезнее десяти judge, возвращающих абзац похвалы.

Слой 4: человек, но только на дельте

Человек - самый дорогой верификатор, поэтому давайте ему минимальную поверхность. Не «проверь этот run», а: «14 из 412 записей не прошли слой 2, вот они с исходными данными и попыткой агента». Принять или отклонить 14 строк - две минуты. Читать саммари на 3000 токенов - десять минут и ноль знаний на выходе.

Артефакты-доказательства важнее пересказа

Правило, которое я теперь ввожу на каждом проекте: действие не выполнено, пока оно не оставило след за пределами контекста агента.

  • Запись в БД → вернуть ID и перечитать их отдельным соединением.
  • HTTP-вызов → залогировать статус, хеш тела ответа и latency. Сохранить.
  • Файл записан → хеш, размер в байтах, число строк.
  • Письмо или сообщение в Telegram → сохранить message ID провайдера и вытянуть его обратно.
  • Действие в браузере → скриншот плюс фрагмент DOM после действия, по которому вы делали assert.

В работе с Browser Automation Studio это критичнее, чем где-либо, потому что UI тоже врёт. Клик попал в невидимый оверлей, форма молча сбросилась, модалка съела submit - скрипт продолжает бодро идти дальше. Поэтому мои BAS-флоу заканчивают каждый значимый шаг проверкой результирующего элемента, а не того, по которому кликали: номер заказа на странице подтверждения, количество строк в таблице, изменение баланса. Дальше скриншот с run ID в имени уезжает в хранилище. Когда клиент спрашивает «а он реально отправил 200 форм?», я присылаю 200 скриншотов, а не строчку в логе.

Контракт приёмки пишется раньше prompt

Эта привычка подняла мой процент успешных запусков сильнее, чем любое обновление модели. До системного prompt я пишу контракт:

task: enrich_leads
inputs: csv, >= 1 строки, колонки [company, domain]
success:
  - у каждой входной строки есть выходная (никаких тихих потерь)
  - email соответствует RFC 5322 либо явный null
  - домен email == входной домен ИЛИ помечен как third_party
  - source_url присутствует и отдаёт 200 при переспросе
budget:
  max_tool_calls: 40
  max_usd: 0.35
  max_wall_clock_s: 180
failure_mode: partial_ok  # хорошие строки пишем, остальные в очередь

Контракт превращается в детерминированный валидатор, потом в рубрику для judge, и только потом в prompt - именно в этом порядке. Плюс он вынуждает решить то, чего основатели старательно избегают: что делать при частичном успехе. partial_ok против all_or_nothing - это бизнес-решение, а не инженерное, и если вы его не примете, за вас его примет агент.

Бюджет ретраев, а не цикл ретраев

Самокоррекция - хорошо. Бесконечная самокоррекция - способ потратить $60 на выяснение того, что истёк API-ключ. Мои правила:

  • Максимум 2 корректирующие попытки на задачу, и каждая получает на вход текст ошибки от валидатора, а не «попробуй ещё раз». Ретрай без новой информации - это бросок монеты.
  • Вторая попытка идёт с сокращённым скоупом: только упавшее подмножество.
  • Жёсткие лимиты на tool calls, токены и wall clock - на уровне runner, а не инструкции в prompt. Модели игнорируют «не используй больше 40 вызовов». Runner - нет.
  • Одна и та же ошибка дважды → стоп, run помечается blocked, зовём человека. Три идентичных падения - это сигнал о среде, а не об агенте.

И логируйте метрику verification_failed отдельно от task_failed. Когда verification_failed резко растёт - либо мир изменился (редизайн сайта, deprecated API), либо провайдер тихо поменял поведение модели. Это лучший early-warning в агентной системе.

Что логировать по каждому run

Плоско и запросопригодно, одна строка на run:

run ID, тип задачи, хеш входа, версия контракта, модель и её версия, число tool calls, токены, стоимость в USD, wall clock, заявление агента, вердикт валидатора, причины провала, ссылки на артефакты, число ретраев, финальное состояние.

С такой таблицей вы отвечаете на вопросы, которые реально важны: у каких типов задач самый низкий first-pass verification rate, сколько стоит один подтверждённый успех, помогло ли изменение prompt на прошлой неделе или просто стало приятнее на вид.

Чек-лист на эту неделю

  1. Возьмите самого рискованного агента. Напишите его контракт приёмки в YAML. 20 минут.
  2. Сделайте финальный вывод JSON-схемой с проверяемыми идентификаторами вместо прозы.
  3. Напишите детерминированный валидатор отдельным кодом со своим подключением к БД.
  4. Храните по одному артефакту-доказательству на каждый side effect.
  5. Ограничьте tool calls, стоимость и время в runner.
  6. Добавьте таблицу логов и дашборд с одной цифрой: first-pass verification rate.
  7. И только после этого думайте про model judge для субъективных частей.

Ничего гламурного здесь нет, и фреймворк не нужен. Но именно это отличает демо, которое впечатлило клиента, от системы, которую он оставит работать на ночь. Доверие к автоматизации создаёт не модель - его создаёт слой, который отказывается верить модели на слово.

Вопросы и ответы

Разве достаточно просто добавить в prompt требование «проверь себя перед ответом»?

Нет. Саморефлексия внутри того же контекста использует ту же модель с той же мотивацией - красиво закрыть тред. Она ловит опечатки и несоответствия формата, но почти никогда не ловит «tool call упал, а я отчитался об успехе», потому что модель уже приняла эту версию реальности как факт. Верификатор должен быть отдельным процессом, который смотрит на состояние мира (БД, файлы, HTTP-ответы), а не на текст агента. Практически: сначала детерминированный код, и только для субъективных задач - вторая модель, которой не показывают рассуждения актора.

Сколько накладных расходов добавляет такой слой верификации?

Слой 1 и 2 практически бесплатны: JSON-схема ничего не стоит, а детерминированный валидатор - это несколько SQL-запросов и сравнение хешей, обычно десятки миллисекунд. Дорогой только model judge, поэтому его пускают в дело лишь после того, как код-проверки пройдены, и только там, где нужна оценка качества, а не факта. По моему опыту суммарный оверхед на run - единицы процентов от стоимости самого агента, а экономия огромная: вы перестаёте платить за длинные ретраи впустую и за разбор инцидентов через девять дней после факта.

С чего начать, если агент уже в продакшне и переписывать всё некогда?

Начните с двух вещей за один вечер. Первая: заставьте агента возвращать проверяемые идентификаторы (ID записей, message ID, хеши файлов) вместо счётчиков и прозы. Вторая: напишите один детерминированный чек на самое дорогое побочное действие - перечитайте эти ID отдельным соединением и сравните количество и `updated_at`. Этого уже хватит, чтобы поймать основную массу фальшивых успехов. Дальше добавляйте лимиты в runner и таблицу логов с метрикой first-pass verification rate - она покажет, куда двигаться следующим шагом.

Похожие статьи