ПОЧЕМУ «ПОПРОСИТЬ JSON В ПРОМПТЕ» — ЭТО НЕ РЕШЕНИЕ
Классика 2023 года: «Ответь строго в формате JSON, без markdown, без пояснений». Модель кивает и в 95% случаев слушается. Проблема в оставшихся 5%. На потоке из миллиона запросов это десятки тысяч упавших пайплайнов: то обернёт ответ в ```json ... ```, то добавит «Конечно, вот ваш JSON:», то поставит trailing comma, то оборвёт объект на середине из-за лимита токенов.
Свободный текст — вероятностный процесс. Пока декодер волен выбрать любой следующий токен, он рано или поздно выберет тот, что ломает парсер. Единственный надёжный путь — забрать у модели саму возможность сгенерировать невалидный вывод. Именно это и делает современный structured output.
ТРИ УРОВНЯ КОНТРОЛЯ ФОРМАТА
Не путайте эти вещи — их часто валят в кучу под словом «JSON mode», но гарантии у них разные.
- JSON mode — движок гарантирует, что вывод будет синтаксически валидным JSON. Но какие в нём поля и типы — не гарантирует. Модель может вернуть
{"result": 42}вместо ожидаемого{"score": 42, "reason": "..."}. - Structured output / schema-guided — вы передаёте JSON Schema, и движок гарантирует соответствие структуре: те поля, те типы, те enum-значения. Это то, что нужно в 90% продовых задач.
- Constrained decoding — низкоуровневый механизм под капотом. На каждом шаге генерации грамматика (обычно скомпилированная из схемы в конечный автомат) маскирует логиты запрещённых токенов. Модель физически не может выйти за грамматику.
Верхний слой удобен, нижний — мощен. В идеале вы пользуетесь высокоуровневым SDK, а constrained decoding работает незаметно.
КАК ЭТО РАБОТАЕТ ПОД КАПОТОМ
Механика проще, чем кажется. JSON Schema компилируется в грамматику (регулярную или контекстно-свободную), из неё строится автомат. На каждом шаге генерации известно текущее состояние автомата — а значит, известно множество токенов, которые допустимы дальше. Логиты всех остальных токенов зануляются (маска), softmax считается только по разрешённым.
Результат: если схема требует после ключа "age" двоеточие и число, модель просто не имеет в распределении токена " или буквы. Битый JSON становится невозможен в принципе, а не маловероятен.
Библиотеки вроде XGrammar, Outlines и llguidance довели накладные расходы почти до нуля — маску можно предвычислять параллельно с forward-проходом. По замерам сообщества оверхед на структурированную генерацию сегодня в пределах единиц процентов, а не 2-3x, как было пару лет назад. Свежие релизы движков и грамматик-компиляторов удобно отслеживать в каталоге REDDYX.
СРАВНЕНИЕ ПОДХОДОВ
| Подход | Гарантия структуры | Где работает | Оверхед | Когда брать |
|---|---|---|---|---|
| Промпт «верни JSON» | Нет | Везде | 0 | Прототип, one-off скрипт |
| JSON mode | Только валидность | Большинство API | ~0 | Когда поля не критичны |
| Structured output (schema) | Полная (поля + типы) | OpenAI, Anthropic, vLLM, Ollama | Низкий | Прод, дефолтный выбор |
| Function / tool calling | Полная (аргументы = схема) | Все крупные провайдеры | Низкий | Агенты, вызов инструментов |
| Constrained decoding локально | Полная, вплоть до грамматики | vLLM, llama.cpp, TGI | Единицы % | Свои модели, сложные грамматики |
PYDANTIC КАК ЕДИНЫЙ ИСТОЧНИК ПРАВДЫ
Главный сдвиг мышления в 2026: вы не пишете JSON Schema руками. Вы описываете Pydantic-модель, а схема генерируется из неё автоматически. Одна и та же модель служит и контрактом для LLM, и валидатором на выходе, и типом для остального кода. DRY в чистом виде.
Рабочий пример на OpenAI SDK с Pydantic — модель гарантированно вернёт объект нужной формы, а parse отдаст уже типизированный питоновский объект:
from pydantic import BaseModel, Field
from openai import OpenAI
client = OpenAI()
class Ticket(BaseModel):
priority: str = Field(description="one of: low, medium, high, urgent")
category: str
summary: str = Field(max_length=120)
needs_human: bool
completion = client.chat.completions.parse(
model="gpt-4.1",
messages=[
{"role": "system", "content": "Классифицируй обращение в саппорт."},
{"role": "user", "content": "Всё упало, платёж списался дважды!!!"},
],
response_format=Ticket,
)
ticket = completion.choices[0].message.parsed
print(ticket.priority, ticket.needs_human) # -> urgent True
print(type(ticket)) # -> Ticket, не dict, не строка
Хотите следить за такими инструментами первыми — подпишитесь на Telegram-канал REDDYX AI: новые репозитории и разборы каждые 30-60 минут.