Все статьи
Артемий Мазаев·· 11 мин чтения

Structured Outputs: как получить от LLM валидный JSON 🔫📋

Constrained decoding: почему «отвечай строго JSON» не работает и как logit masking делает невалидный вывод невозможным — Outlines, XGrammar, vLLM.

Ну чё, малютки, знакомая картина: пишешь в промпте «отвечай СТРОГО валидным JSON, без лишнего текста», а модель отвечает: «Конечно! Вот ваш JSON:», заворачивает его в ```json, ставит лишнюю запятую и добавляет в конце «Надеюсь, помог! 😊». Твой json.loads() падает, ты пишешь регексы, ретраи, стрипаешь бэктики — и всё равно раз в сотню запросов прилетает мусор.

А потом ты включаешь у провайдера галочку «structured outputs» — и мусор пропадает. Не «почти пропадает», а пропадает совсем. Разберём, что за магия под капотом.

🔥Суть за 10 секунд

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


Боль: «отвечай строго JSON» не работает

Почему промптом это не лечится? Потому что LLM — генератор вероятностей, а не исполнитель инструкций. Инструкция в промпте лишь сдвигает распределение в сторону JSON, но у токена Конечно всегда остаётся ненулевая вероятность. А где ненулевая вероятность, там на проде рано или поздно закон больших чисел: миллион запросов — и даже вероятность 0.1% превращается в тысячу упавших парсеров.

Классический народный костыль выглядел так:

for attempt in range(MAX_RETRIES):  # 🙈
    raw = llm(prompt)
    raw = raw.strip().removeprefix("```json").removesuffix("```")
    try:
        return json.loads(raw)
    except json.JSONDecodeError:
        continue

Ретраи жгут токены и латентность, а гарантий по-прежнему ноль. Нужен подход с другой стороны.


Ключевая идея: не проси — запрещай

Вспомним, как модель вообще выбирает слова. На каждом шаге она выдаёт логиты — по числу на каждый токен словаря (у современных моделей это ~150K токенов). Логиты прогоняются через softmax, получается распределение вероятностей, из него сэмплируется следующий токен. Подробнее этот цикл я разбирал в статье про KV-кэш.

Constrained decoding вклинивается ровно между логитами и сэмплированием: всем токенам, которые нарушили бы формат, выставляется логит −∞. После softmax их вероятность — честный ноль, а вероятности выживших токенов ренормализуются. Модель по-прежнему выбирает свободно — но только среди валидных вариантов.

Потыкай — это один шаг генерации, где схема требует целое число:

🎛️ Logit masking на одном шаге генерации
Модель дописала объект до значения поля age и выбирает следующий токен. Включи constraint и смотри, что происходит с распределением.
{"name": "Гоша", "age":
схема: "age": { "type": "integer" }
" · начало строки
36.0%
25
22.0%
null
12.0%
двадцать · словом
9.0%
2
8.0%
[ · массив
5.0%
42
4.0%
примерно
4.0%
Самый вероятный токен — ": модель хочет начать строку «"двадцать пять"». Парсер такого не переживёт, но модели всё равно — она просто генератор вероятностей.

Заметь: модель «хотела» начать строку — токен " был самым вероятным. Constraint не переубеждает её промптом, он просто делает невалидный выбор невозможным. И это работает с любой моделью без дообучения: вся механика живёт на стороне инференса.


Кто решает, что валидно: конечный автомат

Окей, маска обнуляет невалидные токены. Но откуда движок знает, какие токены валидны прямо сейчас? Ответ из теории компиляторов: формат описывается формально (regex или грамматика) и компилируется в конечный автомат (FSM). Автомат идёт по генерации вместе с моделью: текущее состояние автомата = множество разрешённых токенов.

Пошагово на живом JSON:

🤖 Автомат генерирует JSON по шагам
Схема: name — строка, age — целое, оба обязательны. На каждом шаге автомат знает своё состояние и разрешает только легальные токены.
{
START — ждём начало объекта
✅ Разрешено{
⛔ ЗапрещеноВот```json"
Разрешён один токен — выбора нет вообще. Никаких «Конечно, вот ваш JSON:»
Шаг 1/10

Два наблюдения из этой демки, которые стоит унести с собой:

Внутри строки модель свободна. Грамматика держит скелет — скобки, ключи, типы, — а содержимое значений остаётся за моделью. Constraint отвечает за форму, знания — за наполнение.

На многих шагах выбора нет вообще. После ключа может идти только :, после последнего значения — только }. Умные движки (первым — SGLang) такие детерминированные куски вставляют в вывод без прохода модели: это называется jump-forward decoding, и на жёстких схемах оно ещё и ускоряет генерацию — форматные токены достаются бесплатно.

⚠️⚠️ Подвох: токены ≠ символы

Автомат из учебника работает по символам, а модель выдаёт токены — и токенизатор нарезает текст как попало: {“na может быть одним токеном, число 2550 — двумя кусками 25 + 50. Поэтому маску нельзя строить «по буквам»: для каждого состояния автомата надо знать, какие из 150K токенов словаря допустимы целиком, с учётом всех вариантов нарезки. Ключевая придумка Outlines — прекомпилировать эти маски для всех состояний один раз, а в рантайме доставать готовую за O(1).


От regex к JSON Schema

Regex → FSM покрывает простые форматы: телефон, дату, enum из трёх значений. Но JSON с вложенными объектами и массивами — это не регулярный язык: чтобы помнить, сколько скобок открыто, нужен стек. Поэтому для JSON Schema формат компилируется в контекстно-свободную грамматику (CFG), а по генерации шагает автомат со стеком.

Пайплайн целиком:

1Ты отдаёшь JSON Schema (или Pydantic-модель — она конвертится в схему)
2Схема компилируется в грамматику, грамматика — в автомат с масками по словарю токенизатора
3На каждом шаге генерации автомат отдаёт готовую маску, движок накладывает её на логиты
4Выход гарантированно парсится и проходит валидацию по схеме

Компиляция схемы — не бесплатная: у провайдеров первый запрос с новой схемой заметно медленнее, дальше скомпилированная грамматика берётся из кэша (у Anthropic, например, кэш схем живёт 24 часа). Отсюда же ограничения в API: рекурсивные схемы и числовые констрейнты вроде minimum/maximum многие реализации не поддерживают — не всё из JSON Schema удобно ложится в грамматику.


Цена вопроса

Оверхед на маскирование. Наивно проверять 150K токенов о валидности на каждом шаге — дорого, ранние реализации так и тормозили. Современные движки — XGrammar, llguidance — свели оверхед почти к нулю: маски для контекстно-независимых состояний прекомпилированы, а работа автомата перекрывается с вычислениями на GPU. В свежих vLLM и SGLang structured output практически бесплатен, а с jump-forward местами и быстрее обычной генерации.

Качество. А вот тут интересно. Constraint гарантирует валидность, но не правильность: если схема душит модель, она выдаст идеально валидный мусор. Constrained decoding не лечит саму склонность модели выдумывать — про то, откуда берётся галлюцинация на уровне нейронов, я разбирал в статье про H-Neurons. Потыкай:

🧪 Одна модель, один отзыв, две схемы
Задача: извлечь из отзыва товар, цену и тональность. Constraint гарантирует валидность — но не спасает от плохой схемы.
«Наушники огонь, бас качает 🔥 Брал на распродаже, не жалею»
Схема
{
  "product":   { "type": "string" },
  "price":     { "type": ["number", "null"] },
  "sentiment": { "enum": ["positive",
    "negative", "neutral", "mixed"] }
}
Что выдала модель
{
  "product": "наушники",
  "price": null,
  "sentiment": "positive"
}
Цены в отзыве нет — null разрешён, модель честно его ставит
Enum покрывает реальные случаи, «огонь 🔥» уверенно ложится в positive

Это не баг движка — модель обязана попасть в схему, и когда честного ответа в схеме нет, она галлюцинирует ближайший валидный. В научной тусовке даже был спор: статья «Let Me Speak Freely» показала просадку качества рассуждений под констрейнтом, а команда .txt в ответ («Say What You Mean») — что при нормально составленной схеме constrained decoding работает не хуже, а обычно лучше свободной генерации.

Схема — это тоже промпт-инжиниринг

Дай модели пути к отступлению: null для отсутствующих данных, вариант other в enum, необязательные поля для необязательной информации. И не заставляй модель думать внутри схемы: если задача требует рассуждения — сначала свободный chain-of-thought, потом извлечение ответа в JSON вторым шагом (или поле reasoning первым ключом схемы).


Кто это умеет

Шпаргалка по экосистеме:

ИнструментПринимаетГде живёт
Outlinesregex, JSON Schema, Pydanticповерх transformers / vLLM
XGrammarJSON Schema, EBNFдефолтный бэкенд vLLM и SGLang
llguidanceграмматики Guidance, JSON SchemaGuidance, опция в vLLM
llama.cppGBNF-грамматики, JSON Schemaлокальный инференс
OpenAI / Anthropic / GeminiJSON SchemaAPI, strict-режимы

vLLM / SGLang — через OpenAI-совместимый API, схема прикладывается к запросу:

from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="-")

resp = client.chat.completions.create(
    model="Qwen/Qwen3-8B",
    messages=[{"role": "user", "content": f"Извлеки товар и цену: {review}"}],
    extra_body={"guided_json": schema},  # vLLM; в SGLang — json_schema
)

Бэкенды structured output внутри vLLM

Сам vLLM констрейнтом не занимается — он подключает один из бэкендов, и у каждого своя философия:

БэкендПодходОсобенности
XGrammar (дефолт)схема → CFG → автомат со стеком, маски прекомпилированы и кэшируютсяпочти нулевой оверхед на токен, умеет EBNF и structural tags
Outlines (outlines-core, Rust)схема → regex → FSM, прекомпилированный индекс «состояние → валидные токены»O(1) на шаг в рантайме, но индекс сложной схемы компилируется дорого. Та самая библиотека, с которой всё началось
llguidance (guidance)лексер + Earley-парсер, маски считаются лениво на летунулевая компиляция, ~50 мкс на токен, самое широкое покрытие JSON Schema
lm-format-enforcertrie по словарю токеновветеран первой волны; из vLLM V1 выпилен

Классический трейдофф «плати сейчас или плати потом»: Outlines и XGrammar вкладываются в прекомпиляцию, чтобы рантайм был бесплатным, llguidance ничего не компилирует и платит чуть-чуть на каждом шаге. Переключается при запуске:

vllm serve Qwen/Qwen3-8B \
  --structured-outputs-config '{"backend": "xgrammar"}'
# в старых версиях vLLM: --guided-decoding-backend xgrammar

По умолчанию стоит auto: vLLM начинает с XGrammar, а если запрос использует фичу, которую тот не тянет (экзотические форматы строк, хитрые regex), тихо откатывается на llguidance или Outlines. Практический вывод: в 99% случаев дефолт не трогай; лезь в бэкенды, только когда упёрся в неподдержанную фичу схемы или в медленную компиляцию жирной схемы на первом запросе.

llama.cpp — схема или полная GBNF-грамматика прямо при запуске:

llama-server -m model.gguf \
  --json-schema '{"type":"object","properties":{"price":{"type":"number"}}}'
# или своя грамматика: --grammar-file json.gbnf

Облачные API — у OpenAI это response_format с "strict": true, у Anthropic — output_config.format:

resp = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=1024,
    output_config={"format": {"type": "json_schema", "schema": schema}},
    messages=[{"role": "user", "content": f"Извлеки товар и цену: {review}"}],
)

Под капотом у всех одно и то же: схема → грамматика → маска на логиты.


Structured outputs vs function calling

Спойлер: это одна и та же механика. Tool call — это JSON по схеме инструмента: когда агент «вызывает функцию», модель генерирует аргументы, констрейнутые схемой из описания тула (у Anthropic за строгую гарантию отвечает флаг strict: true на инструменте). Весь харнес агента стоит на том, что вызов инструмента можно распарсить со стопроцентной надёжностью — без constrained decoding агентные циклы разваливались бы на каждом кривом вызове.

И не путай с «JSON mode» из старых API: тот гарантировал только «какой-то валидный JSON» без схемы — модель могла вернуть валидный, но бесполезный объект с выдуманными ключами. Structured outputs — это JSON mode, повзрослевший до грамматики по твоей схеме.


Tool-парсеры: как движки читают вызовы инструментов

Об эту деталь спотыкаются все, кто переезжает с облачных API на свой инференс. OpenAI-совместимый API возвращает вызовы инструментов в отдельном структурном поле tool_calls — но модель-то генерирует просто текст. Кто-то должен этот текст распознать и превратить в структуру. Этот кто-то — tool-парсер, и у каждого семейства моделей он свой, потому что единого формата не существует: каждую модель учили на её собственном чат-шаблоне.

Зоопарк форматов в сыром тексте:

СемействоКак выглядит tool call на самом деле
Qwen / Hermes<tool_call>{"name": "get_weather", "arguments": {…}}</tool_call>
Mistral[TOOL_CALLS][{"name": "get_weather", "arguments": {…}}]
Llama 3.1голый JSON: {"name": …, "parameters": {…}}
Llama 4 / pythonic[get_weather(city="Москва")] — вызов на псевдо-Python
DeepSeek V3спец-токены <|tool▁call▁begin|>… вокруг JSON

vLLM — парсер включается флагами при запуске:

vllm serve Qwen/Qwen3-8B \
  --enable-auto-tool-choice --tool-call-parser hermes

Забыл флаги — получил классический тихий облом: модель честно сгенерирует <tool_call>…</tool_call>, но API вернёт это текстом в content, и агентный цикл молча развалится. Встроенных парсеров десятки (hermes, mistral, llama3_json, deepseek_v3, kimi_k2, pythonic…), а для своей модели подключается кастомный — обычный Python-файл плагином:

# my_parser.py
from vllm.entrypoints.openai.tool_parsers import ToolParser, ToolParserManager

@ToolParserManager.register_module(["my_model"])
class MyToolParser(ToolParser):
    def extract_tool_calls(self, model_output, request):
        # нашёл <tool>…</tool>, распарсил JSON →
        # ExtractedToolCallInformation(tools_called=True, tool_calls=[...])
        ...

    def extract_tool_calls_streaming(self, previous_text, current_text,
                                     delta_text, *args, **kwargs):
        # инкрементальный разбор для стриминга — вот тут вся боль
        ...
vllm serve my/model --enable-auto-tool-choice \
  --tool-call-parser my_model --tool-parser-plugin my_parser.py

Нестриминговая ветка тривиальна: дождался конца, нашёл теги, распарсил. Вся боль — в стриминговой: надо по каждому кусочку текста на лету решать «это ещё обычный ответ или уже начался tool call», отдать имя функции одним дельта-чанком, аргументы — следующими, и не упасть на JSON, который ещё не дописан до конца.

SGLang — та же идея, флаг --tool-call-parser (qwen25, mistral, llama3, deepseekv3, pythonic…). Внутри — детекторы, наследники BaseFormatDetector с двумя методами: detect_and_parse() для готового текста и parse_streaming_increment() для стриминга. Бонус SGLang: детектор умеет отдавать грамматику своего формата (EBNF), и тогда аргументы tool call генерируются под constraint — парсер и constrained decoding из первой половины статьи смыкаются в одну систему.

llama.cpp — свой путь: запускаешь llama-server --jinja, и движок берёт формат из чат-шаблона модели. Нативно поддержаны Hermes, Mistral, Qwen, Llama 3.x, DeepSeek R1, Functionary, плюс generic-фолбэк для любой модели. Самое красивое здесь — lazy grammar: GBNF-грамматика спит, пока модель пишет обычный текст, и просыпается по триггеру (например, токену <tool_call>) — дальше аргументы держит грамматика по схеме тула. Свободный текст и жёсткий JSON мирно живут в одном ответе.

💡Куда всё сходится: structural tags

Идея «грамматика активна только внутри тегов» оформилась в XGrammar как structural tags — универсальное описание «здесь свободный текст, а вот отсюда и до закрывающего тега — JSON по схеме». Это снимает главный конфликт: reasoning-модели хотят думать свободным текстом, а инструменты требуют жёсткой структуры. Structural tags дают и то и другое в одном ответе.


TL;DR

1Промпт не гарантирует формат: модель — генератор вероятностей, «строго JSON» лишь сдвигает распределение
2Механика: схема → грамматика → конечный автомат → маска на логиты. Невалидные токены получают −∞ ещё до сэмплирования
3Оверхед почти нулевой (XGrammar, llguidance), а jump-forward местами делает генерацию даже быстрее
4Подвох: валидность ≠ правильность. Схема-душегубка заставит модель галлюцинировать валидный мусор — оставляй null, other и место для рассуждений
5На своём инференсе: не забудь tool-парсер (--tool-call-parser в vLLM/SGLang, --jinja в llama.cpp) — иначе tool calls приедут текстом в content и агент молча сломается

В следующий раз, когда API вернёт тебе идеальный JSON с первой попытки — знай: где-то там конечный автомат только что запретил модели написать «Надеюсь, помог! 😊». 🫡