Суть
После того как структура гарантирована, остаётся бизнес-логика: даты, диапазоны, кратности, межполевые инварианты. Их проверяют валидаторы; при провале поднимается ModelRetry, и модель видит свой прошлый ответ + причину ошибки.
Зачем это нужно
Технически валидный JSON может быть бизнес-неверным: дата конца раньше даты начала, priority=critical без флага requires_human, сумма не сходится. Кейс юр.документов: добавление output-валидаторов снизило логические ошибки с 4.7% до 0.0%, исправление с первой попытки в 94% случаев.
Как работает
Три независимых уровня проверки:
- JSON Schema / типы — автоматически (тип поля, обязательность, enum, диапазоны).
@field_validator— правило для конкретного поля и нормализация (строку"$1,200.50"→1200.50).@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, в промпт ушло, какое поле не прошло и почему, — вторая попытка проходит. Ошибка названа, исправление адресное.
Ломает. Модель вернула правильный ответ, а ей говорят «ты уверен? проверь ещё раз». Такая самопроверка без указания на ошибку заставляет менять верное поле на неверное: модель воспринимает сомнение как сигнал, что где-то ошибка, и начинает её изобретать.
Разница в том, откуда приходит сигнал. Валидатор — внешний по отношению к модели, он проверяет по правилам, которых модель не видит и не может подстроить. Самопроверка замкнута на ту же модель с теми же слабостями, и добавляет только шум.
Практический вывод для формулировки фидбека: каждая итерация должна нести три вещи — какая ошибка, почему это ошибка, что именно меняем. Формулировки вроде «исправь» или «попробуй ещё раз» тратят вызов впустую.
Три фазы валидации
Проверка данных — не одно действие, а три, и путать их дорого: на каждой отсеивается свой класс проблем.
- Пре-нормализация (before). Очистка грязных данных до проверки типов. Человеческое «завтра» превращается в строгий ISO-8601, «1 500 руб.» — в число. Без этой фазы валидатор отклоняет данные, которые на самом деле корректны, просто записаны по-человечески.
- Типизация (core). Стандартная проверка системных типов: соответствие базовым структурам.
- Кросс-валидация (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и стоп приUnexpectedModelBehaviorSlot Filling — почему валидацию откладывают до полного набора аргументов
ReAct — детектор отсутствия прогресса в агентном цикле; счётчик ошибок — его четвёртый сигнал
Paraphrase Drift — сила утверждения не выше силы совершённого действия
Harness Optimization — замер, показывающий, почему границу правки стоит удерживать