«Провайдер гарантирует JSON» — фраза, после которой команды перестают валидировать ответы. Потом наступает ночь, в которую очередь встала из-за ответа, где гарантированный JSON оказался валидным JSON-ом с невалидным содержимым.
Разделим два разных обещания:
- Синтаксис: ответ — парсящийся JSON, соответствующий структуре схемы. Это провайдеры сегодня действительно умеют гарантировать (в строгих режимах).
- Семантика: значения полей осмысленны — дата реальна, категория уместна,
summaryне содержит выдумки. Этого не гарантирует никто.
Structured output решает первую проблему. Вторая остаётся вашей.
Режимы, от слабого к сильному
JSON mode / «ответь JSON-ом» в prompt-е. Модель старается вернуть JSON, но структуру не обещает: лишние поля, отсутствующие поля, "null" строкой, markdown-обёртка ```json. Годится только как fallback для провайдеров без нормальных режимов.
Tool/function calling как транспорт схемы. Вы объявляете единственный «инструмент» со схемой нужного объекта, модель «вызывает» его аргументами. Исторически самый переносимый способ: работает у OpenAI, Anthropic и большинства совместимых провайдеров. Аргументы приходят в целом соответствующими схеме, но валидировать всё равно обязательно.
Строгий structured output. Провайдер применяет constrained decoding: модель физически не может сгенерировать токен, нарушающий схему. Синтаксис гарантирован. Плата — ограничения на схему, разные у провайдеров: не вся выразительность JSON Schema поддерживается, встречаются лимиты на вложенность, additionalProperties, форматы строк, union-типы.
Практическое следствие: схему надо проектировать под пересечение возможностей ваших провайдеров. Чем проще типы — enum, строки с ограничением длины, числа, плоские объекты, — тем переносимее контракт. Экзотика вроде oneOf с дискриминатором — источник тихих несовместимостей.
Чего строгий режим не решает
- Семантическая чушь в валидной форме:
deadline: "2019-01-01"для письма, где срока нет. Схема довольна, продукт — нет. - Вырожденные ответы: обрезка по
max_output_tokensпосреди генерации. В строгом режиме вы получите либо ошибку, либо усечённый объект — обрабатывайте оба случая. - Отказы модели: на вход с injection-попыткой или запрещённым контентом модель может вернуть отказ вместо объекта.
- Дрейф: обновление модели меняет распределение значений (чаще
other, корочеsummary). Схема та же, качество другое. Ловится только evals.
Repair loop: чинить дёшево, прежде чем звать модель
Полный цикл обработки ответа:
async def structured_call(
llm: LLMClient,
request: PromptRequest,
schema: type[BaseModel],
max_repair_attempts: int = 1,
) -> BaseModel | None:
raw = await llm.generate_structured(request, schema)
# 1. Механическая репарация — бесплатно, без модели
obj = try_parse(raw) # снять ```json-обёртку, вырезать префиксы
if obj is not None:
try:
return schema.model_validate(obj)
except ValidationError as exc:
last_error = exc
else:
last_error = ParseError(raw)
# 2. Repair retry — вернуть модели её ответ и ошибку
for _ in range(max_repair_attempts):
raw = await llm.generate_structured(
request.with_repair_context(bad_output=raw, error=str(last_error)),
schema,
)
obj = try_parse(raw)
if obj is not None:
try:
return schema.model_validate(obj)
except ValidationError as exc:
last_error = exc
# 3. Fallback — честный отказ
await send_to_manual_queue(request, raw, str(last_error))
return NoneПорядок важен, потому что важна цена:
- Механическая репарация (снять markdown-обёртку, обрезать текст до первой
{и после последней}, распарсить) — ноль токенов. В нестрогих режимах закрывает большинство сбоев. - Repair retry — один дополнительный вызов: модель получает свой невалидный ответ и текст ошибки валидации. Одна попытка. Если модель не починила ответ с ошибкой на руках, вторая попытка статистически почти ничего не добавляет — а стоит столько же.
- Fallback — ручная очередь, правила, деградация. Проектируется заранее.
Каждый исход нужно считать в метриках. Repair rate выше 2–3% означает, что схема слишком сложна, prompt противоречив или модель не тянет задачу. Успешные ретраи эту проблему только маскируют.
Семантическая валидация — вторая линия
Pydantic-валидаторы позволяют закодировать здравый смысл, до которого не дотягивается JSON Schema:
class ClientRequest(BaseModel):
deadline: date | None = None
summary: str = Field(max_length=500)
@field_validator("deadline")
@classmethod
def deadline_not_in_past(cls, v: date | None) -> date | None:
if v is not None and v < date.today() - timedelta(days=1):
raise ValueError("deadline in the past — likely hallucinated")
return vОшибка семантического валидатора попадает в тот же repair loop — модель получает шанс исправиться, а система остаётся защищённой.
Правило главы
Строгий structured output — обязательный первый рубеж, но он гарантирует форму, а не смысл. Полная схема обороны: строгий режим → механическая репарация → один repair retry с текстом ошибки → семантические валидаторы → fallback. И метрика repair rate, потому что рост ретраев — самый ранний симптом деградации всей интеграции.
Чеклист главы
- Используется самый строгий режим, доступный у провайдера; «JSON в prompt-е» — только как осознанный fallback.
- Схема укладывается в пересечение ограничений ваших провайдеров; экзотические конструкции JSON Schema исключены.
- Ответ валидируется Pydantic-ом всегда, включая строгие режимы.
- Repair loop: механическая репарация → один repair retry → fallback; все три уровня покрыты тестами.
- Семантические валидаторы ловят «валидную чушь»: даты из прошлого, суммы из воздуха, категории-заглушки.
- Repair rate — метрика с порогом алерта.
- Обрезка ответа по лимиту токенов и отказ модели обрабатываются явно.
Практикум
Задача 9.1 — стенд сравнения режимов. ⭑⭑
Возьмите экстрактор заявок из задачи 8.1 и прогоните один и тот же набор из 30 писем в трёх режимах: prompt-only JSON, tool calling, строгий structured output (у одного или двух провайдеров).
Критерии приёмки:
- Таблица: режим → доля синтаксически валидных → доля прошедших Pydantic → доля семантически осмысленных (проверка руками) → токены.
- Найден хотя бы один ответ, валидный по схеме, но семантически неверный, — приложен как экспонат.
- Проверено поведение при
max_output_tokens=50: что именно возвращает каждый режим при обрезке и как это ловит ваш код. - Написан вывод: какой режим и почему становится вашим default-ом.
Подсказки:
- Для честного сравнения фиксируйте prompt и температуру; меняйте только режим.
- Семантическую проверку 30 ответов руками не автоматизируйте — это 20 минут, и они дадут вам интуицию, которую не даст ни одна метрика.
Задача 9.2 — repair loop с метриками. ⭑⭑⭑
Реализуйте structured_call из главы полностью: механическая репарация, один repair retry, семантические валидаторы, fallback в очередь, счётчики каждого уровня. Затем устройте стресс-тест: замокайте клиента так, чтобы он возвращал типовые поломки — markdown-обёртку, обрезанный JSON, лишние поля, дату из прошлого, отказ модели текстом.
Критерии приёмки:
- Каждый вид поломки обрабатывается предсказуемо, и по логу видно, какой уровень схемы обороны сработал.
- Markdown-обёртка чинится бесплатно, без второго вызова модели, — подтверждено счётчиком токенов.
- Семантическая ошибка (дата из прошлого) уходит в repair retry с текстом ошибки, и есть тест, где модель после этого «исправляется».
- После прогона стресс-теста метрики показывают: сколько ответов прошло сразу, сколько починено механически, сколько retry, сколько в fallback.
- Порог алерта на repair rate выбран и обоснован.
Подсказки:
- Мок с очередью заранее заданных ответов (
["сломанный", "починенный"]) позволяет протестировать весь цикл за миллисекунды и ноль долларов. - Не забудьте случай «repair retry вернул ещё худший ответ» — цикл должен завершаться, а не зацикливаться.