Ну чё, малютки, знакомая картина: пишешь в промпте «отвечай СТРОГО валидным JSON, без лишнего текста», а модель отвечает: «Конечно! Вот ваш JSON:», заворачивает его в ```json, ставит лишнюю запятую и добавляет в конце «Надеюсь, помог! 😊». Твой json.loads() падает, ты пишешь регексы, ретраи, стрипаешь бэктики — и всё равно раз в сотню запросов прилетает мусор.
А потом ты включаешь у провайдера галочку «structured outputs» — и мусор пропадает. Не «почти пропадает», а пропадает совсем. Разберём, что за магия под капотом.
Модель не просят выдать валидный 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 их вероятность — честный ноль, а вероятности выживших токенов ренормализуются. Модель по-прежнему выбирает свободно — но только среди валидных вариантов.
Потыкай — это один шаг генерации, где схема требует целое число:
age и выбирает следующий токен. Включи constraint и смотри, что происходит с распределением.": модель хочет начать строку «"двадцать пять"». Парсер такого не переживёт, но модели всё равно — она просто генератор вероятностей.Заметь: модель «хотела» начать строку — токен " был самым вероятным. Constraint не переубеждает её промптом, он просто делает невалидный выбор невозможным. И это работает с любой моделью без дообучения: вся механика живёт на стороне инференса.
Кто решает, что валидно: конечный автомат
Окей, маска обнуляет невалидные токены. Но откуда движок знает, какие токены валидны прямо сейчас? Ответ из теории компиляторов: формат описывается формально (regex или грамматика) и компилируется в конечный автомат (FSM). Автомат идёт по генерации вместе с моделью: текущее состояние автомата = множество разрешённых токенов.
Пошагово на живом JSON:
name — строка, age — целое, оба обязательны. На каждом шаге автомат знает своё состояние и разрешает только легальные токены.Два наблюдения из этой демки, которые стоит унести с собой:
Внутри строки модель свободна. Грамматика держит скелет — скобки, ключи, типы, — а содержимое значений остаётся за моделью. Constraint отвечает за форму, знания — за наполнение.
На многих шагах выбора нет вообще. После ключа может идти только :, после последнего значения — только }. Умные движки (первым — SGLang) такие детерминированные куски вставляют в вывод без прохода модели: это называется jump-forward decoding, и на жёстких схемах оно ещё и ускоряет генерацию — форматные токены достаются бесплатно.
Автомат из учебника работает по символам, а модель выдаёт токены — и токенизатор нарезает текст как попало: {“na может быть одним токеном, число 2550 — двумя кусками 25 + 50. Поэтому маску нельзя строить «по буквам»: для каждого состояния автомата надо знать, какие из 150K токенов словаря допустимы целиком, с учётом всех вариантов нарезки. Ключевая придумка Outlines — прекомпилировать эти маски для всех состояний один раз, а в рантайме доставать готовую за O(1).
От regex к JSON Schema
Regex → FSM покрывает простые форматы: телефон, дату, enum из трёх значений. Но JSON с вложенными объектами и массивами — это не регулярный язык: чтобы помнить, сколько скобок открыто, нужен стек. Поэтому для JSON Schema формат компилируется в контекстно-свободную грамматику (CFG), а по генерации шагает автомат со стеком.
Пайплайн целиком:
Компиляция схемы — не бесплатная: у провайдеров первый запрос с новой схемой заметно медленнее, дальше скомпилированная грамматика берётся из кэша (у Anthropic, например, кэш схем живёт 24 часа). Отсюда же ограничения в API: рекурсивные схемы и числовые констрейнты вроде minimum/maximum многие реализации не поддерживают — не всё из JSON Schema удобно ложится в грамматику.
Цена вопроса
Оверхед на маскирование. Наивно проверять 150K токенов о валидности на каждом шаге — дорого, ранние реализации так и тормозили. Современные движки — XGrammar, llguidance — свели оверхед почти к нулю: маски для контекстно-независимых состояний прекомпилированы, а работа автомата перекрывается с вычислениями на GPU. В свежих vLLM и SGLang structured output практически бесплатен, а с jump-forward местами и быстрее обычной генерации.
Качество. А вот тут интересно. Constraint гарантирует валидность, но не правильность: если схема душит модель, она выдаст идеально валидный мусор. Constrained decoding не лечит саму склонность модели выдумывать — про то, откуда берётся галлюцинация на уровне нейронов, я разбирал в статье про H-Neurons. Потыкай:
{
"product": { "type": "string" },
"price": { "type": ["number", "null"] },
"sentiment": { "enum": ["positive",
"negative", "neutral", "mixed"] }
}{
"product": "наушники",
"price": null,
"sentiment": "positive"
}Это не баг движка — модель обязана попасть в схему, и когда честного ответа в схеме нет, она галлюцинирует ближайший валидный. В научной тусовке даже был спор: статья «Let Me Speak Freely» показала просадку качества рассуждений под констрейнтом, а команда .txt в ответ («Say What You Mean») — что при нормально составленной схеме constrained decoding работает не хуже, а обычно лучше свободной генерации.
Дай модели пути к отступлению: null для отсутствующих данных, вариант other в enum, необязательные поля для необязательной информации. И не заставляй модель думать внутри схемы: если задача требует рассуждения — сначала свободный chain-of-thought, потом извлечение ответа в JSON вторым шагом (или поле reasoning первым ключом схемы).
Кто это умеет
Шпаргалка по экосистеме:
| Инструмент | Принимает | Где живёт |
|---|---|---|
| Outlines | regex, JSON Schema, Pydantic | поверх transformers / vLLM |
| XGrammar | JSON Schema, EBNF | дефолтный бэкенд vLLM и SGLang |
| llguidance | грамматики Guidance, JSON Schema | Guidance, опция в vLLM |
| llama.cpp | GBNF-грамматики, JSON Schema | локальный инференс |
| OpenAI / Anthropic / Gemini | JSON Schema | API, 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-enforcer | trie по словарю токенов | ветеран первой волны; из 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 мирно живут в одном ответе.
Идея «грамматика активна только внутри тегов» оформилась в XGrammar как structural tags — универсальное описание «здесь свободный текст, а вот отсюда и до закрывающего тега — JSON по схеме». Это снимает главный конфликт: reasoning-модели хотят думать свободным текстом, а инструменты требуют жёсткой структуры. Structural tags дают и то и другое в одном ответе.
TL;DR
--tool-call-parser в vLLM/SGLang, --jinja в llama.cpp) — иначе tool calls приедут текстом в content и агент молча сломаетсяВ следующий раз, когда API вернёт тебе идеальный JSON с первой попытки — знай: где-то там конечный автомат только что запретил модели написать «Надеюсь, помог! 😊». 🫡

