55 Structured Output
Контекст
LLM — это распределение над текстом, но приложениям (агенты, tool use, API-конвейеры) нужен машиночитаемый вывод: валидный JSON точно по схеме. Просто попросить «ответь JSON» — ненадёжно: модель забывает запятую, добавляет прозу, путает типы. На больших схемах prompt-only даёт порядка трети валидных ответов — для продакшена неприемлемо. Нужна гарантия, а не надежда.
Идея и механизм
1. Function calling (2023). Модель дообучают: по описанию функции и её схеме параметров выдавать {"name": …, "arguments": {…}}. Сделало tool use практичным — но валидность всё ещё вероятностная: обученная модель обычно попадает в схему, но не обязана.
2. Constrained / guided decoding (2023–24). Гарантию дают не обучением, а на этапе декодирования. Ведём автомат, который отслеживает уже сгенерированный префикс относительно грамматики; на каждом шаге маскируем логиты всех токенов, которые сделали бы вывод невалидным, и семплируем только из допустимых. Структура валидна по построению. Outlines сводит regex/грамматику к конечному автомату и предвычисляет индекс: состояние → множество допустимых токенов (иначе пришлось бы на каждом шаге перебирать весь словарь). XGrammar берёт контекстно-свободную грамматику, компилит в byte-level pushdown-автомат и кэширует маски. OpenAI Structured Outputs (2024) компилит JSON-схему в грамматику и так гарантирует 100% соответствие (плюс обучение под схемы).
автоматы · декодирование Маскирование логитов и почему токен-алайнмент — это сложно
Ядро. Пусть автомат в состоянии s, а \( \mathcal{A}(s) \subseteq V \) — множество токенов, после которых ещё возможно валидное завершение. Модель даёт логиты \( z \in \mathbb{R}^{|V|} \); маскируем и семплируем только из допустимого:
Запрещённые токены получают \(-\infty\) → нулевую вероятность после softmax; распределение ренормируется по валидным. Токен переводит автомат в новое состояние \(\delta(s,x_t)\). Какой автомат нужен, зависит от грамматики:
Плоский формат описывается регулярным языком (DFA конечен). Но JSON рекурсивен — вложенные, сбалансированные {} и [] не образуют регулярный язык, поэтому нужен pushdown-автомат: стек считает глубину вложенности.
Сложная часть — token alignment. Грамматика определена над символами/байтами \(\Sigma\), а модель генерирует токены V (BPE-сабворды). Один токен покрывает несколько символов, а одна строка имеет много токенизаций — прямого соответствия нет. Поэтому нельзя просто «разрешить нужные символы»: надо для каждого состояния вычислить допустимые токены. Отсюда предвычисленный индекс:
Стоимость. Наивно проверять валидность каждого токена — \(O(|V|)\) на шаг (словарь ~10⁵). Индекс Outlines делает это \(\approx O(1)\) (плюс дешёвое векторное применение маски). XGrammar идёт дальше: делит токены на контекстно-независимые (валидность видна по позиции — предвычислимы, >99%) и контекстно-зависимые (нужен весь стек), кэшируя маски по вершине стека PDA. Итог — <50 мкс/токен, ничтожно против 10–50 мс инференса.
Python Constrained decoding: маска логитов по автомату
def constrained_decode(model, automaton, idx): # idx: state -> set(token_id)
s, out = automaton.start, []
while not automaton.is_accept(s):
z = model.logits(out) # логиты по словарю V
allowed = idx[s] # предвыч. индекс: допустимые токены из s
z = mask_fill(z, allowed, NEG_INF) # запрещённые -> -inf (нулевая вероятность)
tok = sample(softmax(z)) # семплируем ТОЛЬКО валидное
out.append(tok)
s = automaton.step(s, tok) # переход автомата (regex->DFA, JSON->PDA+стек)
return detokenize(out) # гарантированно валидная структура
Почему это важно
Это фундамент, на котором стоит весь современный tool use, агенты и надёжные API-конвейеры: без гарантии валидного вывода нельзя строить системы, где ответ LLM парсится программой. «Structured output», которым сегодня пользуются все (OpenAI, Anthropic tool use, vLLM/TGI/llama.cpp с грамматиками), — это ровно эта линия: обучение под схемы + constrained decoding. Урок шире: надёжность LLM-систем часто рождается не в самой модели, а в слое декодирования вокруг неё.
Связи
Constrained decoding вклинивается ровно туда, где авторегрессионный трансформер превращает логиты в токен — в softmax над словарём. Маскирование логитов возможно именно потому, что генерация идёт токен-за-токеном: перед каждым семплом можно обнулить запрещённые варианты. Без пошаговой природы #32 такого рычага бы не было.
Function calling — это выравнивание под инструмент: модель дообучают следовать схемам вызовов (родня SFT/RLHF из #44). Обучение делает вывод обычно валидным и осмысленным; constrained decoding добавляет гарантию. Полноценный structured output — их сумма: обученная модель + маска.
CoT хочет свободного рассуждения вслух; строгий формат его подавляет. Исследование «Let Me Speak Freely?» (2024) показало: жёсткий JSON-режим роняет reasoning — модель вынуждают выдать ответ до того, как она «подумала». Практический выход — сначала свободный CoT-филд, потом формат (порядок полей схемы решает).
Вопросы пытливого ума
Если маска гарантирует валидный JSON — почему не включать constrained decoding всегда?
Потому что маска искажает распределение: обнуляя часть токенов и ренормируя, она может выталкивать модель в маловероятные для неё области и навязывать структуру раньше, чем модель «додумала». «Let Me Speak Freely?» (2024) показал измеримое падение reasoning под строгим форматом — особенно из-за перестановки: ответ приходится назвать до рассуждения. Митигации: сначала свободный reasoning-филд, потом строгие поля (порядок в схеме), либо генерировать в natural language и конвертировать в формат отдельным шагом. Гарантия формата ≠ бесплатно.
Почему это сложнее, чем «разрешать только нужные символы»?
Из-за token alignment. Грамматика живёт над символами/байтами, а модель — над токенами BPE: один токен склеивает несколько символов, и одна и та же строка может быть токенизирована по-разному. Значит, «разрешить символ {» не переводится напрямую в «разрешить токен» — надо для каждого состояния автомата вычислить, какие токены (многосимвольные) не ломают грамматику. Отсюда предвычисленный индекс (Outlines) и byte-level PDA с делением на контекстно-независимые (>99%, предвычислимы) и контекстно-зависимые токены (XGrammar). Это и есть настоящая инженерная глубина темы.
Regex-хватает для многих форматов — зачем для JSON именно стек (PDA)?
JSON рекурсивен: объект может содержать объекты, массивы вложены произвольно, скобки должны быть сбалансированы. Язык сбалансированных скобок — классический пример нерегулярного (его не распознаёт конечный автомат: нужно «помнить» произвольную глубину). Поэтому нужен pushdown-автомат со стеком, который считает открытые скобки и требует их закрыть. Для плоских форматов (дата, enum, число) хватает regex/DFA — но как только структура вложенная, без стека не обойтись.
Что читать в оригинале
Читать выборочно. Outlines (Willard & Louf, arXiv 2307.09702) — ключевая идея индекса «состояние FSM → допустимые токены» и сведение к автомату. XGrammar (arXiv 2411.15100) — byte-level PDA, деление токенов на контекстно-(не)зависимые, кэш масок. Пост OpenAI Structured Outputs — как JSON-схема компилится в грамматику для 100% соответствия. И обязательно «Let Me Speak Freely?» (arXiv 2408.02442) — честная цена строгого формата для reasoning.