Structured Output

Структурный вывод — получение от LLM ответа, гарантированно соответствующего заданной схеме типов. Ключевая идея: схема перестаёт быть просьбой в промпте и становится контрактом, который проверяется до того, как данные попадут в код. Высший уровень — когда нарушение схемы почти невозможно при генерации: маска логитов не даёт выпасть за грамматику. Но «почти» здесь не риторическое, у гарантии есть три документированных бреши.

Суть

Есть три уровня контроля вывода с разной надёжностью:

Уровень Метод Принцип Надёжность
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 упоминается выше как способ гарантировать формат. Механика у него такая:

  1. Схема контракта компилируется во внутренний конечный автомат — он описывает, какие токены допустимы в каждой позиции.
  2. На каждом шаге генерации инференс-сервер маскирует логиты: недопустимые токены получают вероятность, при которой выбраны быть не могут. Если автомат ожидает число, буквы блокируются физически.
  3. Модель выбирает только из разрешённого.

Отсюда свойство, которого не даёт никакая постобработка: синтаксически невалидный 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 — структура схемы ещё и направляет рассуждение