ИИ-агенты и prompt engineering Структурированный вывод: JSON-схемы, function calling, валидация и восстановление
0%

Структурированный вывод: JSON-схемы, function calling, валидация и восстановление

Структурированный вывод: 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: та же машина, другая роль

Вызов инструмента — это структурированный вывод, у которого есть побочный эффект. Модель не возвращает данные вам — она возвращает заявку на вызов функции: имя и аргументы, соответствующие схеме. Механизм под капотом тот же (схема → ограничение декодирования), отличается контракт взаимодействия.

Три правила, нарушение которых даёт самые частые баги цикла:

  1. tool_result должен содержать tool_use_id из соответствующего блока tool_use. Не совпало — API отвергает запрос.
  2. В историю кладётся весь response.content, а не только текст. Выбросив блоки tool_use, вы получите ссылку в никуда.
  3. Если модель запросила несколько инструментов в одном ответе, все результаты возвращаются одним 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. Валидация и восстановление

Даже с гарантией схемы ответ может быть непригоден. Схема гарантирует синтаксис и типы — и ровно ничего больше. Валидация должна быть слоистой, с разной стратегией восстановления на каждом слое.

Слои валидации и стратегии восстановления

Жизненный цикл ответа

Два состояния здесь принципиально важны и чаще всего игнорируются.

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). Лечение — поле-скретчпад внутри схемы или разделение на «подумать дорого» + «отформатировать дёшево».
  • Восстановление слоистое. Транспорт, синтаксис, схема, инварианты — четыре слоя с разной ценой починки. Чините на самом дешёвом; повтор модели — последний вариант, а не первый.
  • Три попытки — потолок. Если не сошлось, проблема не в удаче, а в схеме, промпте или данных. Карантин лучше бесконечного цикла.

Источники


Что дальше

RAG: чанкинг, эмбеддинги, гибридный поиск, реранжирование, оценка качества — следующий шаг после того, как модель научилась отдавать данные в нужной форме: как дать ей данные, которых нет в её весах. Разберём разбиение документов, векторный и лексический поиск, реранжирование и метрики, по которым качество RAG вообще измеримо.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов