Validation Loops

Циклы валидации — механизм автоисправления, когда вывод модели типово верен (Structured Output), но логически неверен. Паттерн Validate → Repair → Retry: при провале проверки модель получает точное описание ошибки и переписывает ответ. Сила не в самом retry, а в содержательности ошибки.

Суть

После того как структура гарантирована, остаётся бизнес-логика: даты, диапазоны, кратности, межполевые инварианты. Их проверяют валидаторы; при провале поднимается ModelRetry, и модель видит свой прошлый ответ + причину ошибки.

Зачем это нужно

Технически валидный JSON может быть бизнес-неверным: дата конца раньше даты начала, priority=critical без флага requires_human, сумма не сходится. Кейс юр.документов: добавление output-валидаторов снизило логические ошибки с 4.7% до 0.0%, исправление с первой попытки в 94% случаев.

Как работает

Три независимых уровня проверки:

  1. JSON Schema / типы — автоматически (тип поля, обязательность, enum, диапазоны).
  2. @field_validator — правило для конкретного поля и нормализация (строку "$1,200.50"1200.50).
  3. @agent.output_validator — последний рубеж: бизнес-логика, в т.ч. обращение к БД/API через ctx.deps (Agent Deps).

При провале — raise ModelRetry("поле X провалило проверку Y по причине Z"). Защита от бесконечного цикла: retries=3 на агенте, после исчерпания — UnexpectedModelBehavior (связь с лимитами в Agent CostControl).

agent = Agent('openai:gpt-4o-mini', output_type=TicketClassification,
              deps_type=TicketDeps, retries=3)

@agent.output_validator
async def validate_output(ctx: RunContext[TicketDeps],
                          output: TicketClassification) -> TicketClassification:
    if output.priority == "critical" and not output.requires_human:
        raise ModelRetry("priority=critical обязывает requires_human=True")
    if output.estimated_minutes % 15 != 0:
        raise ModelRetry(f"{output.estimated_minutes} не кратно 15")
    return output

# число потраченных retry: result.usage().requests - 1

Чем кормить цикл: внешний сигнал против самопроверки

Цикл валидации чинит ответ только тогда, когда получает конкретный внешний сигнал. Это не деталь реализации, а условие работоспособности.

Работает. Модель вернула amount: "-12", схема отбила это ValidationError, в промпт ушло, какое поле не прошло и почему, — вторая попытка проходит. Ошибка названа, исправление адресное.

Ломает. Модель вернула правильный ответ, а ей говорят «ты уверен? проверь ещё раз». Такая самопроверка без указания на ошибку заставляет менять верное поле на неверное: модель воспринимает сомнение как сигнал, что где-то ошибка, и начинает её изобретать.

Разница в том, откуда приходит сигнал. Валидатор — внешний по отношению к модели, он проверяет по правилам, которых модель не видит и не может подстроить. Самопроверка замкнута на ту же модель с теми же слабостями, и добавляет только шум.

Практический вывод для формулировки фидбека: каждая итерация должна нести три вещи — какая ошибка, почему это ошибка, что именно меняем. Формулировки вроде «исправь» или «попробуй ещё раз» тратят вызов впустую.

Три фазы валидации

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

  1. Пре-нормализация (before). Очистка грязных данных до проверки типов. Человеческое «завтра» превращается в строгий ISO-8601, «1 500 руб.» — в число. Без этой фазы валидатор отклоняет данные, которые на самом деле корректны, просто записаны по-человечески.
  2. Типизация (core). Стандартная проверка системных типов: соответствие базовым структурам.
  3. Кросс-валидация (after). Проверка зависимостей между полями, которые по отдельности валидны. Каноничный пример: и «хрупкий груз», и «холодный склад» — допустимые значения, а вместе запрещены.

Ошибка проектирования, которую это лечит: попытка выразить всё одной схемой типов. Правила вида «А несовместимо с Б» типами не выражаются, для них нужна третья фаза.

Гарантия схемы не покрывает два выхода

Требование проверять ответ до передачи дальше Pydantic AI не принадлежит, и лучше всего это видно там, где соответствие схеме обещано на уровне API провайдера: строгий режим структурного вывода гарантирует, что ответ пройдёт по заданной схеме, обязательные поля не пропадут, а значения перечисления останутся допустимыми. Легко решить, что после такого обещания валидация не нужна.

Обещание настоящее, но у него два документированных выхода, на которых ответ схеме не соответствует вовсе:

  • отказ модели — ответ приходит с отдельным полем отказа и форму схемы не повторяет;
  • обрыв по лимиту токенов — ответ помечается незавершённым с указанием причины, и разбирать его как объект нечего: он оборван на полуслове (Bounded Tool Output).

Отсюда правило: строгий режим снимает необходимость чинить формат, но не снимает необходимость различать три исхода — объект, отказ, обрыв. Код, написанный как «строгий режим включён, значит на выходе объект», ломается ровно на этих двух путях и ломается молча: поле отсутствует, исключения нет (Unknown Not A Value).

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

Валидация patch состояния

Тот же цикл нужен между двумя шагами длинной процедуры. В State Centric Execution модель предлагает state_patch, но рантайм применяет его сначала к копии и проверяет два класса инвариантов: валидно ли новое состояние и допустим ли переход из предыдущего. Второе не выводится из JSON Schema: типово корректный patch может удалить достигнутую подцель, стереть подтверждение или вернуть завершённый этап в работу.

Ошибка не должна оставлять частично обновлённый state. Весь patch отклоняется, модель получает адресный сигнал о поле и разрешённых переходах, а после исчерпания лимита состояние остаётся на последней подтверждённой версии. Здесь особенно важно правило «проверено и отгружено — один объект»: исполнять действие можно только из той версии state, которая прошла проверку.

Чем именно кормить модель при ошибке

Раздел выше говорит, что фидбек должен быть внешним и конкретным. Конкретность имеет измеримую форму: метаданные ошибки конвертируются в плоский JSON-Path невалидного узла — ['data']['user_id'].

Это даёт модели точку, а не направление: она правит конкретное поле, не затрагивая уже успешные.

Сверка 2026-08-23: выигрыш даёт не путь до поля Эксперимент с абляциями (препринт «Structured Feedback Improves Repair in an LLM Agent Loop», Ray и Goyal, arXiv:2607.14167, проверен независимо) разбирает структурную обратную связь на три составляющие — место ошибки, наблюдённое значение и перечень допустимых значений — и показывает, что основную часть прироста даёт именно перечень допустимых значений, а не путь до узла.

Второе: свидетельств преимущества структурной формы подачи не нашлось. Проза и JSON дали сопоставимый результат, и авторы формулируют это осторожно — как отсутствие свидетельства в конкретном эксперименте, а не как доказанное отсутствие эффекта. Замер шёл на пятидесяти играх и двух моделях, более чувствительный мог бы разницу и найти. Практический вывод от этой оговорки не меняется, но записывать «опровергнуто» было бы сильнее, чем показал эксперимент. Масштаб эффекта при этом большой, а выборка маленькая: на 50 играх TextWorld с лимитом в четыре вызова Qwen2.5-Coder-14B поднялся с 28% до 72% решённых, Llama-3.1-8B — с 16% до 58%. Пятьдесят игр означают, что один исход весит два процентных пункта: направление и порядок величины эти числа показывают надёжно, разницу в несколько пунктов — уже нет.

Практический вывод меняется: JSON-Path остаётся полезным как адресация правки, но если выбирать, что положить в сообщение об ошибке в первую очередь, — это список того, что здесь допустимо, а не координата того, что сломалось.

Независимое подтверждение с другой стороны: та же тройка в отгружаемом продукте. Генератор диаграмм archify собирает каждую диагностику валидатора как {code, severity, message, subject, evidence, supportedFixes} — то есть ровно из тех трёх составляющих, которые разбирает абляция: subject называет место, evidence — наблюдённое значение, supportedFixes — перечень допустимых исправлений. Совпадение независимое: другой автор, другая задача, на препринт нет ссылки.

Два хода, которых в препринте нет и которые стоит забрать.

Перечень исправлений — это перечисление, из которого предписано выбрать. Инструкция чинящему сформулирована как ограничение, а не как совет: поменяй только продиагностированный subject, сверь evidence, выбери из supportedFixes. Проза оставляет свободу интерпретации; список её отбирает — а вместе с ней отбирает и возможность починить не то.

Место ошибки работает не только адресом, но и границей. Абляция мерила subject как подсказку «куда смотреть» и нашла его вклад небольшим. Здесь у него вторая роль — что разрешено трогать: правится продиагностированный узел, а не то, что рядом с ним. Это закрывает отказ, которого абляция не мерила: агент, получив замечание, переписывает окрестность и ломает работавшее. Замер такой правки есть — текстовые патчи чинят не хуже структурных, а ломают вдвое чаще (Harness Optimization).

Жёсткий потолок — три попытки на один сессионный шаг. Превышение трактуется не как «нужен ещё один заход», а как сигнал: либо модель деградировала, либо бизнес-правила слишком сложны для неё. И то и другое лечится не ретраем.

Контракт верификации: что запущено и чьи это провалы

Отдельный класс отказа, который валидация внутри цикла не ловит: агент сообщает «исправил» и «все тесты проходят», не запустив ни одного теста. Это не злой умысел — модель воспроизводит образец, а в образцах, на которых она училась, фраза «все тесты проходят» встречается часто.

Лечится это тем же способом, что и остальное поведение: контракт, записанный явно, вместо надежды.

Гейты берутся из проекта, а не из промпта. Список проверок вычитывается из манифеста — есть сценарий типизации, линтера, тестов, сборки — и именно он попадает в промпт. Захардкоженный список врёт в обе стороны: требует того, чего в проекте нет, и пропускает то, что есть.

Порядок задаётся ценой прогона: проверка типов первой, потому что падает быстрее всех; сборка последней, потому что самая медленная. Смысл в том, чтобы узнать о поломке раньше, а не в полноте.

Главное — область заявления. Три состояния, которые обязаны различаться в отчёте:

Что докладывать Пример
что реально запущено и с каким исходом «проверка типов прошла»
что было заблокировано «тесты не запускались: запись запрещена режимом подтверждения»
что недоступно в этом проекте «сценария линтера нет»

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

Формулировать запрет надо прямо: не раздувать частичную проверку до общего заявления об успехе. По той же причине, что и в Prompt Engineering: названное модель избегает, подразумеваемое — нет.

У артефакта та же лестница, что у проверки. Различать надо не только «запущено против незапущенного», но и степень готовности того, что сделано: создано — файл существует; проверено в браузере или прогоном — артефакт открыт и посмотрен; подтверждено на месте назначения — оно доехало туда, где им будут пользоваться. Ступени легко подменяются вверх, и правило, которое это останавливает, формулируется одной строкой: локальный файл никогда не является доказательством публикации.

Отчёт называет достигнутую ступень, а не подразумевает верхнюю. Здесь работает то же, что и с проверками: агент, не назвавший ступень, будет прочитан как достигший последней (Paraphrase Drift).

Проверено и отгружено — один объект, а не два

Валидация отвечает на вопрос, прошёл ли ответ проверку. Она не отвечает на второй вопрос: тот ли это ответ, который ушёл дальше. Между ними помещается правка — постобработка, доформатирование, «мелочь, которую видно только на выходе», — и проверенным остаётся объект, которого больше нет.

Отказ молчаливый вдвойне: отчёт о валидации честный, артефакт другой, и расхождения не видит ни лог, ни тест.

Форма, которая это закрывает, в archify устроена так: успешная финальная валидация замораживает кандидата, после неё он не правится; отгрузка делает приватный снимок точных байтов, рендерит снимок, атомарно подменяет вывод и печатает SHA-256 и размер отдельно для спецификации и отдельно для артефакта. Два чека вместо обещания — сверить их дешевле, чем полагаться на то, что между проверкой и отгрузкой никто ничего не тронул.

Тот же приём в этой базе — флаг --check у генераторов: скрипт не пишет, а падает кодом 1, если сгенерированное разошлось с тем, что лежит в репозитории.

Рядом стоит отказ машины усилить собственное утверждение. Команда, снимающая скриншоты готовой диаграммы, всегда пишет в отчёт visualReview: "pending" — даже когда все проверки прошли. Снимки сделаны, но смотреть их некому, и назвать это проверкой инструмент отказывается. Утверждение остаётся ровно той силы, какой заслуживает совершённое действие (Paraphrase Drift).

Сколько попыток и что делать, когда они кончились

Лимит. Рабочий дефолт — три попытки, и он не про «сколько шансов дать модели», а про форму кривой: первая повторная попытка исправляет большинство валидационных ошибок, вторая — заметно меньше, третья почти ничего. Дальше растёт только retry_overhead_tokens — метрика, ради которой лимит и ставят (Agent CostControl). Признак, что лимит выбран неверно: retry_count_per_run регулярно больше единицы — это не про ретраи, а про то, что схема или промпт не выдерживают первого прохода (Agent Evals).

Второй критерий остановки — по прогрессу, а не по счёту. Лимит отвечает на вопрос «сколько попыток», но не отличает петлю, которая приближается к решению, от петли, которая перебирает варианты на месте. Отличает это объективная величина: продолжать, пока число оставшихся ошибок берёт новый минимум; два круга подряд без нового минимума — остановиться и доложить нерешённое. Формулировка взята из archify, где ею ограничен цикл починки диаграммы.

Ценность в том, что критерий держится на счётчике, а не на суждении модели о собственном прогрессе — самооценка здесь не работает по той же причине, по которой не работает самопроверка вместо валидатора. Рядом идёт запрет, без которого счётчик бесполезен: ненулевой код возврата не описывается как успех. Иначе петля заканчивается не решением, а формулировкой, похожей на решение (Paraphrase Drift).

Исчерпание попыток — это событие, а не ошибка. Все три реакции нужны одновременно, а не вместо друг друга: наружу отдаётся частичный результат со статусом, а не исключение (пользователю полезнее «сделал столько, дальше не смог»); внутрь пишется алерт по fallback_rate, потому что рост этой доли означает деградацию, которую иначе никто не заметит; и только там, где частичный результат бессмысленен или опасен, остаётся честный отказ. Молчаливый фолбэк без метрики — худший из вариантов: система выглядит работающей ровно до разбора инцидента.

Связано с

  • Reflexion — что делать, когда детерминированной проверки нет и чинится стратегия целиком

  • PydanticAI — где реализованы валидаторы и ModelRetry

  • Structured Output — типовая валидация, поверх которой идёт бизнес-проверка

  • State Centric Execution — применение Validate → Repair → Retry к переходу управляющего состояния

  • Agent CostControl — retries=3 и стоп при UnexpectedModelBehavior

  • Slot Filling — почему валидацию откладывают до полного набора аргументов

  • ReAct — детектор отсутствия прогресса в агентном цикле; счётчик ошибок — его четвёртый сигнал

  • Paraphrase Drift — сила утверждения не выше силы совершённого действия

  • Harness Optimization — замер, показывающий, почему границу правки стоит удерживать