Структурированный вывод: JSON-схемы, function calling, валидация и восстановление
Модель генерирует текст. Ваш код работает с типами. Между ними — граница, на которой ломается больше LLM-приложений, чем на любой другой: не потому что модель «не поняла задачу», а потому что она вернула ```json\n{...}\n``` вместо {...}, или написала "возраст": "тридцать два" вместо "age": 32, или добавила бодрое «Конечно! Вот извлечённые данные:» перед объектом.
Структурированный вывод — это дисциплина превращения этой границы из вероятностной в контрактную. Тема кажется скучно-технической, но именно она отделяет демо от продакшена: агент, который не может надёжно вызвать инструмент, — это чат-бот; пайплайн извлечения, который в 3% случаев отдаёт битый JSON, — это пайплайн, который упадёт 30 раз на тысяче документов.
Разберём четыре слоя: как это работает на уровне сэмплирования, какие API дают гарантии, как проектировать схему, чтобы модель её заполняла хорошо, и что делать, когда всё-таки сломалось.
Предыдущие статьи трека — основы промптинга и продвинутые техники — про то, как модель понимает задачу. Эта — про то, как она отдаёт ответ.
1. Четыре уровня гарантий
Между «попросили JSON» и «получили гарантированно валидный объект» лежит лестница из четырёх ступеней с принципиально разной силой гарантии.
Уровень 0 — просьба. «Верни JSON с полями name и age.» Работает на удивление часто и совершенно недостаточно. Каждый отказ — это отдельный класс багов: обёртка в markdown, преамбула, комментарии // внутри JSON, одинарные кавычки, NaN, обрезанный хвост.
Уровень 1 — пример. Few-shot с одним-двумя образцами точного формата убирает большую часть вариативности: модель копирует форму. Дёшево, работает везде, но остаётся статистикой.
Уровень 2 — ограниченное декодирование. Мы не просим модель — мы физически запрещаем ей сгенерировать невалидный токен. Это уже не промптинг, а вмешательство в сэмплирование.
Уровень 3 — схема в API. То же самое, но выполняется провайдером: вы отдаёте JSON Schema, провайдер компилирует её в автомат и применяет при декодировании.
Разница между 1 и 2 — качественная, а не количественная. На уровне 1 вы всё ещё пишете обработчик исключений «а вдруг не JSON». На уровне 2 этот обработчик становится проверкой на отказ инфраструктуры, а не на поведение модели.
2. Почему это работает: маска логитов
Модель на каждом шаге выдаёт вектор логитов размером со словарь (50–200 тыс. чисел). Обычно из него сэмплируют. Ограниченное декодирование добавляет один шаг: перед сэмплированием логиты запрещённых токенов заменяются на -inf, после softmax их вероятность становится строго нулевой.
Вопрос — откуда берётся маска. Наивная реализация («для каждого токена словаря проверить, останется ли строка валидной») стоила бы O(V · L) на шаг и убила бы производительность. Ключевая идея работы Efficient Guided Generation for Large Language Models (Willard & Louf, 2023, библиотека outlines): регулярное выражение или грамматика компилируются в конечный автомат один раз, и для каждого его состояния заранее вычисляется битовая маска допустимых токенов словаря. На шаге генерации остаётся O(1) поиск по индексу и побитовое И — накладные расходы падают до единиц процентов от времени декодирования.
JSON Schema — не регулярный язык (вложенность требует стека), поэтому реально используется контекстно-свободная грамматика с pushdown-автоматом. Современные движки — XGrammar (Dong et al., 2024) и GBNF в llama.cpp — делят словарь на «контекстно-независимые» токены (маска предвычисляется) и «контекстно-зависимые» (проверяются на лету, их обычно меньше процента). XGrammar заявляет накладные расходы, близкие к нулю, за счёт совмещения проверки грамматики с GPU-вычислениями.
Тонкий момент, который часто упускают: маскирование меняет распределение. Работа Guiding LLMs The Right Way (Beurer-Kellner et al., ICML 2024) показывает, что жадное применение ограничений на уровне отдельных токенов может уводить генерацию в ветку, которая локально валидна, но глобально хуже — модель «загоняется» в формат, который она не собиралась писать. Практическое следствие: ограничения гарантируют форму, но не улучшают содержание и иногда его ухудшают. К этому вернёмся в разделе про «когда ломается».
На своих весах
Если вы запускаете модель локально (llama.cpp, vLLM, Ollama), ограниченное декодирование доступно напрямую и бесплатно.
# llama.cpp: грамматика GBNF прямо в CLI
llama-cli -m model.gguf --grammar-file grammars/json.gbnf -p "Извлеки данные..."
Грамматика GBNF для узкого случая — «строго один из трёх вердиктов»:
root ::= "{" ws "\"verdict\":" ws verdict "," ws "\"score\":" ws score ws "}"
verdict ::= "\"approve\"" | "\"reject\"" | "\"escalate\""
score ::= [0-9] | "10"
ws ::= [ \t\n]*
Такая грамматика делает невалидный ответ физически невозможным — не «маловероятным», а недостижимым. Для классификаторов это лучший из доступных инструментов: он же гарантирует, что модель не напишет "approve " с пробелом и не изобретёт четвёртую категорию.
В Python через outlines:
# pip install outlines
import outlines
from pydantic import BaseModel
from typing import Literal
class Review(BaseModel):
verdict: Literal["approve", "reject", "escalate"]
score: int
reason: str
model = outlines.models.transformers("Qwen/Qwen2.5-7B-Instruct")
generator = outlines.generate.json(model, Review) # компиляция автомата — один раз
result: Review = generator("Заявка: клиент просит лимит 500к, история 2 года без просрочек.")
Компиляция автомата — заметная разовая стоимость (от десятков миллисекунд до секунд на сложных схемах). Генератор надо создавать один раз на процесс, а не на запрос. Это самая частая ошибка при внедрении outlines.
3. JSON Schema в API провайдера
На управляемых API вы не трогаете логиты — вы отдаёте схему. Провайдер компилирует её и кэширует результат компиляции (у Anthropic — на 24 часа), поэтому первый запрос с новой схемой медленнее последующих. При деплое новой версии схемы это выглядит как всплеск латентности; в графиках p99 он читается как деградация, хотя это разовая компиляция.
Что реально поддерживается
JSON Schema — большой стандарт, и ни один провайдер не поддерживает его целиком. Полный спек знать не нужно, нужно знать границу.
| Конструкция | Anthropic | Комментарий |
|---|---|---|
object, array, string, integer, number, boolean, null |
да | база |
enum, const |
да | главный инструмент управления доменом значений |
anyOf, allOf |
да | размеченные объединения работают |
$ref / $defs |
да | переиспользование определений |
format: date-time, date, time, duration, email, uri, uuid, ipv4, ipv6, hostname |
да | остальные форматы игнорируются |
additionalProperties: false |
обязательно на каждом объекте | иное значение отвергается |
required |
обязательно перечислить поля | «опциональность» делается через anyOf с null |
minimum / maximum / multipleOf |
нет | проверяйте на своей стороне |
minLength / maxLength / pattern |
нет | то же |
minItems / maxItems / uniqueItems |
нет | то же |
| рекурсивные схемы | нет | дерево произвольной глубины не выразить |
Python- и TypeScript-SDK Anthropic делают тут удобную вещь: они вырезают неподдерживаемые ограничения из схемы, уходящей в API, и проверяют их у вас на клиенте. То есть Field(ge=0, le=100) в Pydantic не сломает запрос — но и не будет гарантирован моделью, а проверится после ответа. Это ровно то поведение, которое вам нужно, но важно понимать, где проходит граница гарантии.
Отсутствие рекурсии — самое болезненное ограничение на практике. Дерево комментариев, AST, вложенная организационная структура не выражаются напрямую. Обходные пути: (а) развернуть в плоский список узлов с полем parent_id и собрать дерево у себя, (б) зафиксировать максимальную глубину и развернуть схему вручную на N уровней, (в) генерировать по одному уровню за вызов. Вариант (а) почти всегда лучший — он же дешевле по токенам.
Практика: Python + Pydantic
# pip install anthropic pydantic
from typing import Literal
from pydantic import BaseModel, Field
import anthropic
client = anthropic.Anthropic()
class LineItem(BaseModel):
description: str
quantity: int
unit_price_cents: int # деньги — только в целых копейках, никогда во float
class Invoice(BaseModel):
"""Данные счёта, извлечённые из документа."""
# Поле рассуждения СТОИТ ПЕРВЫМ: модель генерирует слева направо,
# и всё, что написано раньше, обусловливает то, что написано позже.
extraction_notes: str = Field(description="Краткий разбор: где в документе найдены суммы и даты")
vendor_name: str
invoice_number: str
issue_date: str = Field(description="ISO 8601, формат YYYY-MM-DD")
currency: Literal["RUB", "USD", "EUR"]
line_items: list[LineItem]
total_cents: int
confidence: Literal["high", "medium", "low"]
response = client.messages.parse(
model="claude-opus-4-8",
max_tokens=4096,
messages=[{"role": "user", "content": f"Извлеки данные счёта:\n\n{document_text}"}],
output_format=Invoice, # SDK сам построит JSON Schema из модели Pydantic
)
invoice = response.parsed_output # уже провалидированный экземпляр Invoice
print(invoice.total_cents, len(invoice.line_items))
messages.parse() делает три вещи: строит схему из Pydantic-модели, кладёт её в output_config.format, парсит и валидирует ответ. Если нужен контроль вручную — тот же результат через messages.create():
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=4096,
messages=[{"role": "user", "content": prompt}],
output_config={
"format": {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"vendor_name": {"type": "string"},
"total_cents": {"type": "integer"},
"currency": {"type": "string", "enum": ["RUB", "USD", "EUR"]},
},
"required": ["vendor_name", "total_cents", "currency"],
"additionalProperties": False,
},
}
},
)
# Формат гарантирован: первый текстовый блок — валидный JSON по схеме
import json
data = json.loads(next(b.text for b in response.content if b.type == "text"))
Обратите внимание: параметр называется output_config.format. Устаревший верхнеуровневый output_format на messages.create() встречается в старых примерах — не используйте его в новом коде.
Практика: TypeScript + Zod
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
const Ticket = z.object({
reasoning: z.string().describe("Почему выбраны именно эти категория и приоритет"),
category: z.enum(["billing", "technical", "account", "other"]),
priority: z.enum(["p0", "p1", "p2", "p3"]),
summary: z.string(),
requires_human: z.boolean(),
});
const client = new Anthropic();
const response = await client.messages.parse({
model: "claude-opus-4-8",
max_tokens: 2048,
messages: [{ role: "user", content: userComplaint }],
output_config: { format: zodOutputFormat(Ticket) },
});
// parsed_output может быть null — при refusal или обрыве по max_tokens
if (response.parsed_output) {
route(response.parsed_output.category, response.parsed_output.priority);
}
Нулевая проверка здесь не формальность: parsed_output действительно бывает null, и типизация об этом честно сообщает.
4. Function calling: та же машина, другая роль
Вызов инструмента — это структурированный вывод, у которого есть побочный эффект. Модель не возвращает данные вам — она возвращает заявку на вызов функции: имя и аргументы, соответствующие схеме. Механизм под капотом тот же (схема → ограничение декодирования), отличается контракт взаимодействия.
tool_use{id, name, input} Note over A: Валидация аргументов
+ проверка прав
+ гейт на опасные действия A->>T: get_weather(city="Казань") T-->>A: {"temp_c": 7, "precip": "rain"} A->>M: тот же диалог + assistant(content)
+ user(tool_result{tool_use_id}) M-->>A: stop_reason=end_turn
текст ответа A->>U: "7 градусов, дождь — зонт нужен"
Три правила, нарушение которых даёт самые частые баги цикла:
tool_resultдолжен содержатьtool_use_idиз соответствующего блокаtool_use. Не совпало — API отвергает запрос.- В историю кладётся весь
response.content, а не только текст. Выбросив блокиtool_use, вы получите ссылку в никуда. - Если модель запросила несколько инструментов в одном ответе, все результаты возвращаются одним user-сообщением. Разбив их на несколько сообщений, вы молча отучаете модель от параллельных вызовов — качественная деградация без единой ошибки в логах.
Strict-режим для инструментов
По умолчанию аргументы инструмента — «мягкая» схема: модель обычно следует ей, но гарантии нет. Флаг strict: true на определении инструмента включает ту же машинерию ограниченного декодирования:
tools = [{
"name": "create_refund",
"description": (
"Оформить возврат средств по заказу. "
"Вызывать ТОЛЬКО когда клиент явно просит возврат и заказ доставлен."
),
"strict": True, # гарантия соответствия input_schema
"input_schema": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "Идентификатор вида ORD-000000"},
"amount_cents": {"type": "integer"},
"reason": {
"type": "string",
"enum": ["defective", "not_as_described", "late_delivery", "other"],
},
},
"required": ["order_id", "amount_cents", "reason"],
"additionalProperties": False, # обязательно для strict
},
}]
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=2048,
tools=tools,
tool_choice={"type": "auto"}, # auto | any | tool | none
messages=messages,
)
Требования strict-режима: additionalProperties: false и явный required. Ограничение: strict несовместим с programmatic tool calling (когда модель вызывает ваши инструменты из кода внутри песочницы) и с принудительным tool_choice в некоторых конфигурациях.
output_config.format или инструмент — что выбрать
| Критерий | output_config.format |
Инструмент со схемой |
|---|---|---|
| Сколько форм ответа | ровно одна | несколько — модель выбирает инструмент |
| Побочный эффект | нет | да, это его смысл |
| Свободный текст рядом | нет, весь ответ — JSON | да, текст + вызовы в одном ответе |
| Совместимость с citations | нет, 400 | да |
| Типичный сценарий | извлечение, классификация, финальный ответ агента | агентный цикл, роутинг, вызовы API |
Их можно комбинировать в одном запросе: инструменты для действий плюс output_config.format для формы финального ответа. Историческая «хитрость» — объявить фиктивный инструмент только ради его схемы и заставить модель его вызвать — сегодня антипаттерн: у неё хуже эргономика и она путает модель, для которой инструмент семантически означает «сделать действие».
Проектирование инструментов
Описание инструмента — это промпт, и он важнее схемы. Практическое правило: описывайте не только что делает инструмент, но и когда его вызывать. Современные модели склонны к консервативности в выборе инструментов, и триггерное условие прямо в description даёт измеримый прирост доли корректных вызовов.
Замеряется всё это на Berkeley Function Calling Leaderboard — открытом бенчмарке, который оценивает не только «вызвал ли правильную функцию», но и корректность аргументов, параллельные и множественные вызовы, а также релевантность: способность НЕ вызывать инструмент, когда его вызывать не надо. Последняя метрика на практике важнее первой: агент, который дёргает create_refund на вопрос «а какие у вас правила возврата?», опаснее агента, который не дёргает его никогда. Академическая база — Gorilla (Patil et al., 2023) и API-Bank (Li et al., 2023).
Подробнее про агентные циклы — в статьях про агентов и ReAct и MCP.
5. Проектирование схемы: что модель заполняет хорошо
Схема гарантирует форму. Качество содержимого определяется тем, насколько удобно поля устроены для авторегрессионной генерации. Здесь есть неочевидные правила.
Порядок полей — это порядок мышления. Модель генерирует токены слева направо; поле, сгенерированное первым, обусловливает все последующие. Поэтому:
# ПЛОХО: вердикт первым — модель обязана решить до того, как «подумала»,
# а объяснение потом будет подгоняться под уже выданный вердикт (рационализация)
class Bad(BaseModel):
verdict: Literal["approve", "reject"]
reasoning: str
# ХОРОШО: рассуждение первым — это встроенный chain-of-thought,
# вердикт обусловлен уже написанным разбором
class Good(BaseModel):
reasoning: str
verdict: Literal["approve", "reject"]
Эффект тем сильнее, чем сложнее решение. Для задач с реальным рассуждением поле-скретчпад в начале объекта — самый дешёвый способ вернуть модели то, что у неё отняли жёстким форматом. Про сам механизм — продвинутые техники промптинга.
enum вместо свободной строки — всегда, когда домен конечен. Свободная строка "category" даст вам "Billing", "billing", "счета", "billing/payment" в одном датасете. enum делает нормализацию невозможной задачей — потому что ненормализованных значений не возникает. Но: длинный enum (сотни значений) раздувает схему, ест токены и ухудшает выбор. Больше ~50 значений — это уже задача поиска, а не классификации: сначала retrieve кандидатов, потом выбор из короткого списка.
Плоское лучше вложенного. Каждый уровень вложенности — это дополнительные скобки, которые модель должна удержать, и дополнительный шанс сбиться. Три уровня — потолок для надёжной работы. Глубже — разбивайте на несколько вызовов.
Имена полей — часть промпта. total_cents информативнее total, issue_date_iso информативнее date. Модель читает имя поля непосредственно перед генерацией значения — это самая близкая к значению инструкция, которая у неё есть. description в схеме работает, но имя работает сильнее.
Явное «не знаю» лучше обязательного поля. Если поля может не быть в источнике, дайте модели способ это сказать: anyOf: [{"type": "string"}, {"type": "null"}] или отдельный enum со значением not_found. Обязательное поле без выхода — это прямое приглашение к галлюцинации: модель обязана что-то написать и напишет.
Никаких float для денег и никаких дат в свободной форме. Целые копейки и явный format: date с указанием ISO 8601 в описании. Это не про LLM, это общая гигиена, но с LLM цена ошибки выше — она не выбросит исключение, а тихо вернёт 1500.0000000002.
6. Валидация и восстановление
Даже с гарантией схемы ответ может быть непригоден. Схема гарантирует синтаксис и типы — и ровно ничего больше. Валидация должна быть слоистой, с разной стратегией восстановления на каждом слое.
Жизненный цикл ответа
или сузить схему Обрыв --> [*]: срезать задачу Разбор --> Ремонт: JSON не парсится Ремонт --> Разбор: детерминированная починка Ремонт --> Повтор: починка не помогла Разбор --> Схема: JSON валиден Схема --> Повтор: ошибка валидатора Схема --> Инварианты: типы сходятся Инварианты --> Повтор: правило нарушено Инварианты --> Готово: всё сошлось Повтор --> Запрос: попытка < лимита Повтор --> Карантин: попытки исчерпаны Готово --> [*] Карантин --> [*]
Два состояния здесь принципиально важны и чаще всего игнорируются.
stop_reason == "max_tokens". Ответ обрезан посередине. Слепой повтор того же запроса даст ровно тот же обрыв — вы просто заплатите дважды. Лечение — поднять max_tokens или уменьшить объём ожидаемого вывода (например, разбить список на батчи). При структурированном выводе обрыв особенно коварен: с ограничением декодирования JSON почти-валиден до самого конца, и наивный json.loads падает на последней строке.
stop_reason == "refusal". Модель отказалась по соображениям безопасности; гарантии схемы на этот случай не распространяются. Проверять stop_reason нужно до чтения content, а не после — иначе response.content[0] упадёт по IndexError на пустом списке.
Детерминированный ремонт перед повтором
Самая частая ошибка в обработке ошибок — сразу ретраить модель. Повтор стоит полного промпта заново и удваивает хвостовую латентность. Значительная часть проблем чинится локально, за микросекунды:
import json, re
from json_repair import repair_json # pip install json-repair
FENCE = re.compile(r"^\s*```(?:json)?\s*(.*?)\s*```\s*$", re.DOTALL)
def parse_lenient(raw: str) -> dict | None:
"""Три уровня попыток, все локальные. Сложность O(n) по длине строки."""
# 1. Прямой парс — успешен почти всегда при включённой схеме
try:
return json.loads(raw)
except json.JSONDecodeError:
pass
# 2. Снять markdown-обёртку — классика уровня 0 без гарантий
if m := FENCE.match(raw):
try:
return json.loads(m.group(1))
except json.JSONDecodeError:
raw = m.group(1)
# 3. Структурный ремонт: закрыть скобки, убрать висячие запятые,
# починить одинарные кавычки. Восстанавливает и обрезанный вывод.
try:
return json.loads(repair_json(raw))
except Exception:
return None
repair_json умеет достраивать обрезанный JSON — это спасает при max_tokens, если вам достаточно частичных данных (например, первые 8 позиций из 10). Но осознанно: частичные данные должны быть помечены как частичные, а не молча уйти в БД.
Повтор с обратной связью
Когда локальный ремонт не помог, повтор должен нести информацию об ошибке. Пустой ретрай — это лотерея; ретрай с текстом ошибки валидатора — это исправление.
from pydantic import ValidationError
import anthropic, time, random
def extract_with_recovery(
client: anthropic.Anthropic,
prompt: str,
schema: type[BaseModel],
business_check, # callable(model) -> list[str] нарушений
max_attempts: int = 3,
) -> BaseModel:
"""Слоистая валидация с обратной связью. Каждая попытка = полный промпт заново,
поэтому лимит попыток жёсткий: 3 — практический потолок, дальше не сходится."""
messages = [{"role": "user", "content": prompt}]
for attempt in range(max_attempts):
try:
resp = client.messages.parse(
model="claude-opus-4-8",
max_tokens=4096,
messages=messages,
output_format=schema,
)
except (anthropic.RateLimitError, anthropic.InternalServerError):
time.sleep(min(2 ** attempt + random.random(), 30))
continue
# Слой 0: причина остановки — до чтения content
if resp.stop_reason == "refusal":
raise RuntimeError(f"Отказ модели: {resp.stop_details}")
if resp.stop_reason == "max_tokens":
raise ValueError("Вывод обрезан: поднимите max_tokens или сузьте схему")
# Слои 1–2: синтаксис и схема — сделаны SDK при parse()
obj = resp.parsed_output
if obj is None:
messages += [
{"role": "assistant", "content": resp.content},
{"role": "user", "content": "Ответ не разобран. Верни строго объект по схеме."},
]
continue
# Слой 3: бизнес-инварианты — их схема выразить не может
violations = business_check(obj)
if not violations:
return obj
messages += [
{"role": "assistant", "content": resp.content},
{"role": "user", "content":
"Ответ нарушает правила:\n- " + "\n- ".join(violations) +
"\nИсправь только перечисленное, остальное сохрани."},
]
raise ValueError(f"Не сошлось за {max_attempts} попыток")
def invoice_rules(inv: Invoice) -> list[str]:
"""Инварианты, невыразимые в JSON Schema."""
problems = []
computed = sum(i.quantity * i.unit_price_cents for i in inv.line_items)
if computed != inv.total_cents:
problems.append(f"сумма позиций {computed} не равна total_cents {inv.total_cents}")
if inv.issue_date > date.today().isoformat():
problems.append(f"дата выставления {inv.issue_date} в будущем")
if inv.total_cents <= 0:
problems.append("итоговая сумма должна быть положительной")
return problems
Три вещи в этом коде стоит отметить отдельно.
Ошибки транспорта и ошибки контента обрабатываются по-разному. 429 и 5xx — экспоненциальный откат с джиттером без изменения промпта. Ошибка валидации — новый промпт без задержки. Смешивать их в одном except — распространённый способ получить ретрай-шторм.
Ассистентский ответ обязательно кладётся в историю перед корректирующим сообщением. Иначе модель не видит, что именно она написала, и «исправляет» вслепую.
Лимит попыток жёсткий и маленький. Если три попытки с явным указанием на ошибку не сошлись, четвёртая не сойдётся тоже — проблема в схеме, промпте или во входных данных. Такой документ должен уходить в карантин на разбор человеком, а не в бесконечный цикл. Ретрай-бюджет — это статья расходов: при p95 = 2с и 5% ретраев вы получаете +100 мс к среднему и +2с к p99.
7. Когда ломается
Честный разбор ограничений. Структурированный вывод — не бесплатная гарантия, у него есть цена и края.
Формат может ухудшать качество рассуждений
Ключевая работа — Let Me Speak Freely? A Study on the Impact of Format Restrictions on Performance of Large Language Models (Tam et al., EMNLP 2024). Авторы показывают: жёсткое ограничение формата заметно ухудшает результаты на задачах, требующих рассуждения, и деградация тем сильнее, чем жёстче ограничение. Порядок эффекта — от единиц до десятков процентных пунктов на reasoning-бенчмарках, при том что на задачах классификации разницы почти нет.
Интуиция понятна: когда модель обязана начать с {"answer":, у неё нет места, чтобы подумать. Отсюда два практических вывода:
- Дайте место для рассуждения внутри схемы — поле
reasoningпервым (см. раздел 5). Это восстанавливает большую часть потери. - Или разделите на два шага: свободный ответ с рассуждением, затем отдельный дешёвый вызов, который извлекает структуру из этого текста. Второй вызов — идеальная работа для маленькой модели (
claude-haiku-4-5, 1 $/5 $ за миллион токенов против 5 $/25 $ у Opus 4.8).
Второй паттерн стоит два вызова, но на сложных задачах часто выигрывает и по качеству, и по деньгам: думает дорогая модель, а форматирует дешёвая.
Таблица симптомов
| Симптом | Настоящая причина | Что делать |
|---|---|---|
| Первый запрос после деплоя медленный, потом нормально | компиляция схемы на стороне провайдера, кэш на 24 ч | прогрев после деплоя; не считать инцидентом |
| Схема отвергается с 400 | additionalProperties не false, рекурсия, неподдерживаемое ограничение |
сверить с таблицей поддержки; ограничения — на клиент |
| JSON валиден, но значения бессмысленны | схема не проверяет семантику | бизнес-инварианты + выборочный аудит + LLM-судья |
| Модель стабильно кладёт «мусор» в обязательное поле | нет способа сказать «не знаю» | добавить null или enum со значением not_found |
| Классификация «плывёт» между прогонами | свободная строка вместо enum |
закрыть домен через enum |
| Обрыв на длинных списках | max_tokens мал, вывод растёт линейно с числом элементов |
батчинг по 10–20 элементов; поднять лимит |
| Модель перестала вызывать инструменты параллельно | результаты возвращались разными сообщениями | все tool_result — одним user-сообщением |
| Инструмент вызывается там, где не надо | в description нет условия «когда НЕ вызывать» |
описать триггер и антитриггер явно |
| Качество упало после включения strict | ограничение съело пространство для рассуждения | добавить поле reasoning первым |
| Схема работает, но 5% документов уходят в карантин | входные данные вне предполагаемого распределения | это фича: смотрите карантин, а не чините ретраями |
Стоимость
Схема — это токены. Схема на 30 полей с описаниями — это 400–800 входных токенов, которые уходят в каждом запросе. При миллионе запросов в месяц на Opus 4.8 (5 $ за миллион входных) это 2000–4000 долларов только за схему.
Два рычага:
- Кэширование префикса. Схема и определения инструментов рендерятся первыми в промпте — это идеальный кандидат для кэша. Чтение из кэша стоит ~10% от базовой цены входа. Но: любое изменение схемы инвалидирует весь кэш ниже, поэтому не генерируйте схему динамически с несортированными ключами. Детали — в статье про продакшен.
- Компактные описания.
descriptionв схеме окупается качеством, но не должен превращаться в документацию. Одна строка на поле.
Латентность: само ограниченное декодирование почти бесплатно (единицы процентов на современных движках). Дорого обходится не оно, а увеличенный объём вывода — JSON с длинными ключами может быть в полтора раза «тяжелее» эквивалентного текста, а выходные токены генерируются последовательно и стоят в 4–5 раз дороже входных.
8. Прод-практики
Схема — версионируемый артефакт. Она часть API-контракта между моделью и вашим кодом. Держите её в коде (Pydantic/Zod), а не в JSON-файле: так она типизирована, тестируема и проходит ревью. Изменение схемы = изменение поведения системы, со всеми последствиями для деплоя.
Эволюция схемы: только совместимые изменения на горячую. Добавить опциональное поле — безопасно. Убрать поле, сузить enum, сделать поле обязательным — ломающее изменение, требующее прогона на исторических данных. Практика: держите золотой набор из 50–200 примеров и прогоняйте по нему при каждом изменении схемы.
Логируйте сырой ответ, а не только распарсенный. Когда через месяц окажется, что 2% записей содержат странность, единственный способ разобраться — посмотреть, что модель вернула на самом деле. Логируйте stop_reason, usage, версию схемы и номер попытки. Без этого вы не сможете отличить «модель ошиблась» от «мы отправили не тот промпт».
Метрики, которые надо снимать:
| Метрика | О чём говорит | Тревожный порог |
|---|---|---|
| доля успеха с первой попытки | здоровье схемы и промпта | ниже 95% — проблема в схеме |
| распределение по слоям падения | где именно ломается | рост слоя 1 при strict — баг инфраструктуры |
| среднее число попыток | ретрай-бюджет | выше 1.1 — пересмотреть схему |
| доля карантина | процент вне распределения | резкий рост — сменились входные данные |
доля stop_reason=max_tokens |
адекватность лимитов | любой ненулевой — чините лимиты |
| p50/p99 латентности по попыткам | вклад ретраев в хвост | p99 больше 3×p50 — ретраи доминируют |
Тестируйте схему, а не только код. Юнит-тесты на функцию business_check — обязательны, они детерминированы. Плюс интеграционный прогон на золотом наборе с порогом по точности заполнения полей. Подробнее — в статье про оценку и бенчмарки.
Безопасность. Аргументы инструмента — это выход модели, то есть недоверенные данные, на которые может влиять пользовательский ввод. Схема гарантирует, что order_id — строка; она не гарантирует, что это ваш заказ. Авторизация проверяется на стороне приложения перед выполнением, всегда. Необратимые действия (списание денег, удаление, отправка сообщений) выносятся за гейт подтверждения. Это отдельная большая тема — безопасность и prompt injection.
9. Чек-лист
Перед выкатом структурированного вывода в прод:
- Схема описана в коде через Pydantic/Zod, а не как сырой JSON.
- Все конечные домены закрыты через
enum, не свободные строки. - У каждого поля, которого может не быть в источнике, есть
nullилиnot_found. - Поле для рассуждения стоит первым в объекте.
- Вложенность не глубже трёх уровней; деревья развёрнуты в плоский список.
- Деньги — в целых минимальных единицах, даты — в ISO 8601.
-
additionalProperties: falseи явныйrequiredна каждом объекте. - Ограничения, не поддерживаемые схемой (диапазоны, длины), проверяются на клиенте.
-
stop_reasonпроверяется до чтенияcontent. -
max_tokensне ретраится вслепую. - Локальный ремонт JSON выполняется до обращения к модели.
- Повтор несёт текст ошибки валидатора; лимит попыток ≤ 3.
- Ошибки транспорта и ошибки контента разведены по разным веткам.
- Есть карантин для непоправимых случаев, и его кто-то читает.
- Схема и определения инструментов попадают в кэшируемый префикс.
- Логируются сырой ответ,
stop_reason,usage, версия схемы, номер попытки. - Есть золотой набор и прогон по нему на каждое изменение схемы.
- Авторизация опасных действий — на стороне приложения, а не в схеме.
Мини-итог
- Структурированный вывод — это лестница гарантий, а не бинарный флаг. Просьба, пример, ограниченное декодирование, схема в API — четыре качественно разных уровня надёжности.
- Механизм — маскирование логитов по автомату грамматики. Стоит единицы процентов производительности, делает невалидный синтаксис недостижимым, а не маловероятным.
- Схема гарантирует форму и типы, и ровно ничего больше. Семантика, бизнес-инварианты и уместность вызова инструмента остаются на вашей стороне.
- Function calling — тот же механизм с побочным эффектом. Ключевое в нём — не схема, а описание условия вызова и запрет на вызов там, где не надо.
- Порядок полей — это порядок мышления модели. Рассуждение первым, вердикт после. Это самый дешёвый способ вернуть качество, потерянное на жёстком формате.
- Жёсткий формат может ухудшать рассуждение (Tam et al., 2024). Лечение — поле-скретчпад внутри схемы или разделение на «подумать дорого» + «отформатировать дёшево».
- Восстановление слоистое. Транспорт, синтаксис, схема, инварианты — четыре слоя с разной ценой починки. Чините на самом дешёвом; повтор модели — последний вариант, а не первый.
- Три попытки — потолок. Если не сошлось, проблема не в удаче, а в схеме, промпте или данных. Карантин лучше бесконечного цикла.
Источники
- Willard & Louf — Efficient Guided Generation for Large Language Models (2023), автоматный индекс словаря, основа
outlines. - Dong et al. — XGrammar: Flexible and Efficient Structured Generation Engine for Large Language Models (2024).
- Beurer-Kellner et al. — Guiding LLMs The Right Way: Fast, Non-Invasive Constrained Generation (ICML 2024), почему наивные ограничения смещают распределение.
- Tam et al. — Let Me Speak Freely? A Study on the Impact of Format Restrictions on Performance of LLMs (EMNLP 2024), цена жёсткого формата.
- Patil et al. — Gorilla: Large Language Model Connected with Massive APIs (2023).
- Li et al. — API-Bank: A Comprehensive Benchmark for Tool-Augmented LLMs (2023).
- Berkeley Function Calling Leaderboard — актуальные замеры function calling, включая метрику релевантности.
- Anthropic: Structured outputs —
output_config.format, поддерживаемое подмножество JSON Schema. - Anthropic: Tool use overview —
strict,tool_choice, циклtool_use/tool_result. - Anthropic: Handling stop reasons —
refusal,max_tokens,pause_turn. - OpenAI: Structured Outputs и Google: Structured output — для сравнения подмножеств схемы.
- JSON Schema — спецификация; RFC 8259 — сам формат JSON.
dottxt-ai/outlines,guidance-ai/guidance— ограниченное декодирование на своих весах.- GBNF в llama.cpp — грамматики для локального инференса.
567-labs/instructor— обёртка со встроенными ретраями по ошибкам валидации.mangiucugna/json_repair— детерминированная починка битого JSON.- Pydantic и Zod — определение схем в коде.
Что дальше
RAG: чанкинг, эмбеддинги, гибридный поиск, реранжирование, оценка качества — следующий шаг после того, как модель научилась отдавать данные в нужной форме: как дать ей данные, которых нет в её весах. Разберём разбиение документов, векторный и лексический поиск, реранжирование и метрики, по которым качество RAG вообще измеримо.