Суть
Есть три уровня контроля вывода с разной надёжностью:
| Уровень | Метод | Принцип | Надёжность |
|---|---|---|---|
| 1 | Prompt Engineering | просьба «верни JSON» | 80–95% |
| 2 | Function Calling / Tool Use | схема как hint | 95–99% |
| 3 | Native Structured Output | constrained decoding | «100%» — с тремя оговорками ниже |
На уровне 2 схема — рекомендация, модель может её нарушить. На уровне 3 нарушение исключено механически в пределах того, что маска вообще проверяет, — и это не вся схема.
Зачем это нужно
Разница между 95% и 100% — это «1 запрос из 20 тихо падает без ошибки». Для высоконагруженного агента даже 2–3% брака недопустимы: некорректный тип уходит дальше по пайплайну и роняет бэкенд тихим сбоем, который обнаруживается с задержкой.
Как работает
- Constrained decoding через FSM (Finite State Machine). На каждом шаге генерации модель выбирает токен из словаря. FSM накладывает на словарь маску по текущей позиции в схеме: вероятность недопустимого токена обнуляется до выборки. Если схема ждёт число — токен с буквами выбрать нельзя; если поле —
Literal["low","medium","high"]— другие значения заблокированы. - Схема из типов. Класс
BaseModel(PydanticAI) автоматически превращается в JSON Schema и передаётся модели какresponse_format; типы проверяются при десериализации,ValidationErrorуказывает точный путь к проблеме. Field(description=...)включается в схему — описание поля работает подсказкой при генерации без отдельного промпта; туда жеField(ge=…, le=…),pattern,Literal, Discriminated Union дляsuccess/error.- Отношение к Tool Calling: function calling даёт ту же схему как hint (уровень 2); structured output поднимает её до жёсткого ограничения (уровень 3).
- Логически неверные (но типово валидные) данные ловятся уже на следующем слое — Validation Loops.
Порядок полей в схеме — это не косметика
Схема задаёт не только формат ответа, но и последовательность, в которой модель его порождает. Поля заполняются по порядку, и то, что записано раньше, дальше уже не пересматривается: вердикт, выданный до рассуждения, рассуждением не изменится.
Отсюда правило: поле с рассуждением идёт первым, вывод — после него.
class GroundingVerdict(BaseModel):
chain_of_thought: str # сначала разбор
is_faithful: bool # потом вердикт, уже опираясь на разбор
Переставьте поля местами — и chain_of_thought превратится в постфактум-оправдание уже принятого решения. Формально схема та же, практически — другой инструмент.
Обобщение того же принципа: ограничивай формат, но не мышление. Строгая схема нужна, чтобы ответ можно было распарсить и проверить, а не чтобы запретить модели думать. Зажав вывод в одно булево поле, вы экономите токены и теряете качество на всём, что сложнее тривиального случая. И снаружи схемы всегда стоит валидатор: сама по себе она гарантирует форму, но не осмысленность содержимого.
Как устроено ограничение на уровне логитов
Constrained decoding упоминается выше как способ гарантировать формат. Механика у него такая:
- Схема контракта компилируется во внутренний конечный автомат — он описывает, какие токены допустимы в каждой позиции.
- На каждом шаге генерации инференс-сервер маскирует логиты: недопустимые токены получают вероятность, при которой выбраны быть не могут. Если автомат ожидает число, буквы блокируются физически.
- Модель выбирает только из разрешённого.
Отсюда свойство, которого не даёт никакая постобработка: синтаксически невалидный JSON не может быть сгенерирован в принципе — пропущенных кавычек и незакрытых скобок не бывает, потому что такой токен не проходит маску.
Это ровно та причина, по которой парсинг ответа регулярными выражениями считается устаревшим антипаттерном: он чинит последствия там, где ограничение убирает причину.
Важная граница: маска гарантирует синтаксис, но не смысл. Поле заполнится валидным числом — правильным ли, решает уже валидатор бизнес-правил.
Три способа, которыми гарантия всё-таки нарушается
Формулировка «100%» удобна и почти верна, но переносить её как абсолют нельзя. Документация Anthropic называет три случая (сверено 2026-08-23):
- Отказ модели. При
stop_reason: "refusal"вывод может не соответствовать схеме: срабатывает контур безопасности, а не грамматика. Поэтому проверятьstop_reasonнужно до чтения содержимого, а не после парсинга. - Упор в лимит токенов. При
stop_reason: "max_tokens"вывод обрывается на середине — синтаксически незакрытый JSON. Маска не даёт выйти за грамматику, но не обязывает модель закончить. - Ограничения, которых маска не знает. Constrained decoding поддерживает лишь подмножество JSON Schema. Не поддерживаются:
minimum,maximum,multipleOf,minLength,maxLength, рекурсивные схемы, внешние$ref, сложные типы в enum и ограничения массивов кромеminItemsсо значением 0 или 1.
Третий случай устроен тоньше, чем «ограничение теряется»
Официальные SDK не выбрасывают неподдерживаемое, а преобразуют схему: ограничение убирается из того, что уходит в API, его смысл дописывается в описание поля (minimum: 100 превращается в «Must be at least 100» в тексте описания), а ответ валидируется на клиенте против исходной схемы.
Отсюда два слоя, и их нельзя смешивать:
| Слой | Проверяется ли "minimum": 0 |
|---|---|
| Протокол — маска логитов при генерации | Нет. Маска этого ограничения не знает |
| Конечный результат через официальный SDK | Да. Клиентская валидация против исходной схемы |
Риск, следовательно, не в том, что ограничение теряется всегда. Риск в том, что оно перестаёт быть свойством протокола и становится свойством вашего кода: прямой HTTP, язык без такого SDK, отключённая валидация — и "minimum": 0 не проверяет никто.
Формулировка «маска гарантирует ту часть схемы, которую понимает, а не ту, которую вы написали» верна для слоя протокола и неверна для конечного результата — использовать её без указания слоя нельзя.
Отсюда правило, усиливающее вывод следующего раздела: валидатор снаружи схемы нужен не только для смысла, но и для тех синтаксических ограничений, которые маска не поддерживает (Validation Loops).
Схема гарантирует форму, но не смысл
Ограничение на уровне логитов даёт валидный JSON — и на этом гарантии заканчиваются. Модель может вернуть синтаксически безупречный объект с несуществующей категорией, отрицательной суммой или полем, заполненным пересказом вопроса.
Отсюда практическое правило: разбирать ответ штатным парсером JSON и жить дальше нельзя. Между парсингом и бизнес-логикой обязан стоять валидатор, проверяющий смысл — принадлежность значения справочнику, диапазоны, согласованность полей между собой.
Почему temperature=0 не даёт воспроизводимости
Распространённое заблуждение: при нулевой температуре один и тот же промпт даёт один и тот же ответ. На практике ответы различаются побайтово, и причины лежат ниже уровня семплирования — неассоциативность операций с плавающей точкой и отсутствие batch invariance: ваш запрос попадает в разный батч на стороне провайдера, и арифметика складывается иначе.
Порядок величины расхождения: в замерах на тысяче идентичных прогонов при нулевой температуре получилось порядка восьмидесяти уникальных ответов.
Следствие для инженерии: всё, что сравнивает или кэширует ответы по точному совпадению строки, сломано by design. Кэш нужно строить по семантике запроса, а проверку — по смыслу ответа, а не по его тексту.
Связано с
- PydanticAI — инструмент, реализующий структурный вывод
- Tool Calling — function calling = уровень 2 (схема как hint)
- Validation Loops — проверка бизнес-логики поверх типовой валидации
- Schema Guided Reasoning — структура схемы ещё и направляет рассуждение