Structured output в LLM: как гарантированно получить JSON
Классический сценарий: в промпт дописывается «ответь строго в формате JSON, без лишнего текста», код с надеждой вызывает json.loads() на ответе — и падает с JSONDecodeError, потому что модель заботливо добавила «Конечно! Вот запрошенный JSON:» перед скобкой или обернула объект в блок ``json``. В проде на сотнях запросов эта надежда превращается в процент ошибок, который приходится чем-то закрывать — регулярками, повторными запросами, страхом деплоя. Хорошая новость в том, что задача давно решена не на уровне промпта, а на уровне API: строгий structured output заставляет модель физически не иметь возможности сгенерировать невалидный токен. Разберём, как это работает, где граница между «обещанием» и «гарантией» у разных провайдеров и открытых моделей, и почему валидация на своей стороне всё равно остаётся обязательной.
Содержание
- Почему просьба «ответь в JSON» не гарантирует JSON
- Как работает строгий structured output на уровне API
- Кто и как поддерживает strict-режим: разница между провайдерами
- Открытые модели: не все одинаково хорошо держат схему
- Валидация на своей стороне — обязательная страховка, а не паранойя
- LiteLLM: один интерфейс structured output на разных провайдерах и моделях
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →Почему просьба «ответь в JSON» не гарантирует JSON
LLM в базовом режиме — это предсказатель следующего токена. Инструкция в промпте «выведи только JSON» не накладывает никакого технического ограничения на генерацию — она лишь смещает вероятностное распределение в сторону токенов, которые обычно идут после такой инструкции в обучающих данных. Смещает, но не запрещает всё остальное. Отсюда типичный набор поломок в проде:
- Текст до или после объекта. «Вот результат в формате JSON:» перед скобкой или «Надеюсь, это то, что вам нужно!» после — модель обучена быть вежливой, и эта привычка перебивает инструкцию формата.
- Markdown-обёртка. Ответ приходит как ``
json\n{...}\n`— валидно для чат-интерфейса, но не дляjson.loads()` без зачистки. - Незакрытый JSON. Если модель упёрлась в лимит
max_tokensпосреди генерации, ответ обрывается без закрывающих скобок — для парсера это просто битая строка. - Мелкие синтаксические огрехи. Висячая запятая после последнего элемента массива, одинарные кавычки, комментарии
//— валидно для JS-объекта, невалидно для строгого JSON-парсера. - Отклонение от структуры. Модель выдумывает поле, меняет тип (
"price": "100"вместо100) или пропускает обязательный ключ — синтаксис корректен, но контракт нарушен.
Проблема не в конкретной модели — она системная: инструкция в промпте это просьба, а не ограничение вычислительного процесса. Чтобы получить гарантию, ограничение нужно наложить не на уровне текста, а на уровне того, как модель выбирает следующий токен.
Как работает строгий structured output на уровне API
Современные API решают задачу иначе, чем текстовая просьба: вместе с запросом передаётся JSON Schema, описывающая ожидаемую структуру, а сервер применяет constrained decoding (ограниченную генерацию, иногда называют guided decoding или grammar-based sampling). Схема заранее компилируется в грамматику допустимых продолжений, и на каждом шаге генерации из распределения вероятностей по токенам обнуляются все, что нарушили бы схему в этой позиции. Модель не «старается» соответствовать формату — она физически не может выбрать токен за пределами разрешённого грамматикой.
Практически это отдельный параметр в теле запроса, формат близок к тому, что используют OpenAI-совместимые API:
{
"model": "gpt-4o-mini",
"messages": [
{"role": "user", "content": "Извлеки из отзыва: оценку и тональность"}
],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "review_extraction",
"strict": true,
"schema": {
"type": "object",
"properties": {
"rating": {"type": "integer", "minimum": 1, "maximum": 5},
"sentiment": {"type": "string", "enum": ["positive", "neutral", "negative"]}
},
"required": ["rating", "sentiment"],
"additionalProperties": false
}
}
}
}
Ключевое отличие от промпт-инструкции — поле strict. В нестрогом режиме схема используется скорее как подсказка модели (похоже на просьбу в промпте, только структурированную), а в строгом — как реальное ограничение декодирования на стороне сервера. Разница на практике: при strict: true ответ либо валиден по схеме, либо API вернёт ошибку генерации — но не «почти валидный» JSON с лишним полем.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Развернуть LiteLLMКто и как поддерживает strict-режим: разница между провайдерами
Идея constrained decoding общая, но реализация и гарантии заметно отличаются между провайдерами — этот нюанс легко упустить, если проектировать интеграцию по документации одного вендора.
| Подход | Что гарантирует | Ограничения |
|---|---|---|
Строгий JSON Schema в response_format | Синтаксическую валидность и соответствие схеме на уровне decoding | Не все конструкции JSON Schema поддерживаются (глубокая рекурсия через $ref, сложные oneOf часто урезаны) |
| Structured output через tool/function calling | То же ограничение decoding, но оформленное как «вызов функции» | Ответ приходит в формате вызова инструмента — нужен отдельный код для разбора, подробнее в статье про tool calling в LLM |
| Схема как часть промпта без строгого режима | Ничего технически — только смещение вероятностей | Тот же набор поломок, что и без схемы, просто реже |
| Постобработка регулярками | Ничего гарантированно | Хрупко, ломается на любом отклонении формата |
Не у всех провайдеров строгий режим одинаково зрелый: где-то он доступен только для части моделей линейки, где-то ограничивает глубину вложенности или список поддерживаемых типов схемы. Прежде чем закладывать строгий JSON Schema в архитектуру, стоит проверить в актуальной документации конкретного провайдера и конкретной модели, какие ключевые слова поддерживаются — enum, pattern, minItems, вложенные объекты — список меняется от релиза к релизу, и «почти вся схема» на практике означает, что контракт придётся упрощать под возможности API, а не наоборот.
Открытые модели: не все одинаково хорошо держат схему
С self-hosted моделями та же идея работает иначе, потому что ограничение накладывается не облачным API, а вашим собственным сервером инференса. Движки вроде vLLM умеют constrained decoding через встроенные бэкенды guided-генерации (в экосистеме встречаются реализации на базе Outlines, lm-format-enforcer, XGrammar — конкретный набор зависит от версии движка) — на запрос передаётся параметр вида "guided_json": {...} с той же JSON Schema, что и в облачном API. Механизм тот же, и синтаксическая валидность гарантируется так же жёстко: ограничение работает на уровне сэмплера токенов, а не «понимания» моделью инструкции.
Но здесь стоит быть честным насчёт границы гарантии: constrained decoding гарантирует, что вывод синтаксически соответствует схеме. А вот насколько осмысленными будут значения внутри неё — заполнит ли модель поле status тем, что реально следует из текста, а не первым попавшимся из enum — зависит от качества самой модели. Небольшие и слабо дообученные под инструкции модели заметно отстают от топовых закрытых: чаще «сдаются» в вырожденные ответы (одно значение enum независимо от содержания) или заполняют текстовые поля общими фразами вместо конкретики из контекста. Квантованные версии (Q4 и ниже) на практике держат сложные схемы менее стабильно, чем полновесные — при выборе уровня сжатия полезно свериться с материалом про квантование моделей. Конкретных цифр точности здесь намеренно нет — они быстро устаревают и зависят от задачи; практический вывод один: для не-топовых открытых моделей структурный формат нужно проверять эмпирически на своих данных, а не полагаться на то, что раз схема соблюдена синтаксически — то и по смыслу всё верно.
Валидация на своей стороне — обязательная страховка, а не паранойя
Даже при строгом режиме на стороне API разумная архитектура не убирает валидацию из кода, а оставляет её как последний рубеж: провайдер может поддерживать не весь набор ключевых слов схемы, часть ограничений (pattern, кастомные format) окажется декоративной, а часть пайплайна рано или поздно перейдёт на self-hosted модель послабее. Дешевле держать валидацию как встроенную привычку, чем чинить прод после первого расхождения.
На Python практичнее всего опереться на Pydantic — модель схемы, из которой можно и сгенерировать JSON Schema для запроса, и провалидировать ответ одним и тем же классом:
from pydantic import BaseModel, Field, ValidationError
class ReviewExtraction(BaseModel):
rating: int = Field(ge=1, le=5)
sentiment: str = Field(pattern="^(positive|neutral|negative)Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Развернуть LiteLLM
Кто и как поддерживает strict-режим: разница между провайдерами
Идея constrained decoding общая, но реализация и гарантии заметно отличаются между провайдерами — этот нюанс легко упустить, если проектировать интеграцию по документации одного вендора.
Подход Что гарантирует Ограничения Строгий JSON Schema в response_format Синтаксическую валидность и соответствие схеме на уровне decoding Не все конструкции JSON Schema поддерживаются (глубокая рекурсия через $ref, сложные oneOf часто урезаны) Structured output через tool/function calling То же ограничение decoding, но оформленное как «вызов функции» Ответ приходит в формате вызова инструмента — нужен отдельный код для разбора, подробнее в статье про tool calling в LLM Схема как часть промпта без строгого режима Ничего технически — только смещение вероятностей Тот же набор поломок, что и без схемы, просто реже Постобработка регулярками Ничего гарантированно Хрупко, ломается на любом отклонении формата
Не у всех провайдеров строгий режим одинаково зрелый: где-то он доступен только для части моделей линейки, где-то ограничивает глубину вложенности или список поддерживаемых типов схемы. Прежде чем закладывать строгий JSON Schema в архитектуру, стоит проверить в актуальной документации конкретного провайдера и конкретной модели, какие ключевые слова поддерживаются — enum, pattern, minItems, вложенные объекты — список меняется от релиза к релизу, и «почти вся схема» на практике означает, что контракт придётся упрощать под возможности API, а не наоборот.
Открытые модели: не все одинаково хорошо держат схему
С self-hosted моделями та же идея работает иначе, потому что ограничение накладывается не облачным API, а вашим собственным сервером инференса. Движки вроде vLLM умеют constrained decoding через встроенные бэкенды guided-генерации (в экосистеме встречаются реализации на базе Outlines, lm-format-enforcer, XGrammar — конкретный набор зависит от версии движка) — на запрос передаётся параметр вида "guided_json": {...} с той же JSON Schema, что и в облачном API. Механизм тот же, и синтаксическая валидность гарантируется так же жёстко: ограничение работает на уровне сэмплера токенов, а не «понимания» моделью инструкции.
Но здесь стоит быть честным насчёт границы гарантии: constrained decoding гарантирует, что вывод синтаксически соответствует схеме. А вот насколько осмысленными будут значения внутри неё — заполнит ли модель поле status тем, что реально следует из текста, а не первым попавшимся из enum — зависит от качества самой модели. Небольшие и слабо дообученные под инструкции модели заметно отстают от топовых закрытых: чаще «сдаются» в вырожденные ответы (одно значение enum независимо от содержания) или заполняют текстовые поля общими фразами вместо конкретики из контекста. Квантованные версии (Q4 и ниже) на практике держат сложные схемы менее стабильно, чем полновесные — при выборе уровня сжатия полезно свериться с материалом про квантование моделей. Конкретных цифр точности здесь намеренно нет — они быстро устаревают и зависят от задачи; практический вывод один: для не-топовых открытых моделей структурный формат нужно проверять эмпирически на своих данных, а не полагаться на то, что раз схема соблюдена синтаксически — то и по смыслу всё верно.
Валидация на своей стороне — обязательная страховка, а не паранойя
Даже при строгом режиме на стороне API разумная архитектура не убирает валидацию из кода, а оставляет её как последний рубеж: провайдер может поддерживать не весь набор ключевых слов схемы, часть ограничений (pattern, кастомные format) окажется декоративной, а часть пайплайна рано или поздно перейдёт на self-hosted модель послабее. Дешевле держать валидацию как встроенную привычку, чем чинить прод после первого расхождения.
На Python практичнее всего опереться на Pydantic — модель схемы, из которой можно и сгенерировать JSON Schema для запроса, и провалидировать ответ одним и тем же классом:
from pydantic import BaseModel, Field, ValidationError
class ReviewExtraction(BaseModel):
rating: int = Field(ge=1, le=5)
sentiment: str = Field(pattern="^(positive|neutral|negative)$")
def parse_llm_response(raw_text: str) -> ReviewExtraction:
try:
return ReviewExtraction.model_validate_json(raw_text)
except ValidationError as e:
# логируем raw_text и e для разбора, не проглатываем молча
raise
Для сценариев с высокой ценой ошибки (финансовые данные, действия с реальными последствиями) стоит закладывать не слепой ретрай, а цикл с обратной связью: текст ошибки от Pydantic дописывается в историю сообщений новым запросом («ответ не прошёл валидацию: такое-то поле, исправь и верни JSON заново»), и модель получает шанс поправить конкретную проблему. Дороже одного запроса, но заметно надёжнее «слепого» парсинга — тот же принцип валидации аргументов применяется и при разборе вызовов инструментов в tool calling.
LiteLLM: один интерфейс structured output на разных провайдерах и моделях
Как только в проекте появляется больше одного провайдера — облачный API плюс резервный, или облачный плюс self-hosted модель для части нагрузки — расхождения в том, как каждый оформляет response_format, guided_json и строгость гарантий, превращаются в отдельный слой поддержки в коде. LiteLLM снимает эту боль так же, как и для обычных чат-запросов: единый интерфейс response_format на входе транслируется в нативный параметр конкретного провайдера или движка инференса под капотом, и код выглядит одинаково независимо от того, куда реально уходит запрос:
import litellm
response = litellm.completion(
model="gpt-4o-mini", # или "ollama/llama3", "openrouter/..." — интерфейс тот же
messages=[{"role": "user", "content": "Извлеки оценку и тональность из отзыва"}],
response_format={"type": "json_schema", "json_schema": {
"name": "review_extraction", "strict": True,
"schema": ReviewExtraction.model_json_schema()
}},
)
Практический эффект — смена провайдера или добавление фолбэка на случай перегрузки основного API остаётся конфигурационным изменением в litellm_config.yaml, а не переписыванием слоя парсинга. Это особенно ценно в связке с выводом предыдущего раздела: для критичных полей стоит держать основную модель через провайдера со зрелым strict-режимом, а более слабые открытые модели подключать через тот же шлюз только туда, где расхождение в качестве structured output не критично.
Развернуть такой шлюз проще на собственном сервере — LiteLLM обычное Python-приложение, которому нужны стабильный аптайм и исходящий доступ к API провайдеров, а разница между открытыми моделями и закрытыми API по цене и стабильности вывода разобрана в статье «Открытые LLM против ChatGPT». VPS с оплатой картой или криптой из России закрывает вопрос легальности расчётов — пошаговая установка описана в материале «Как установить и настроить LiteLLM на VPS».
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Развернуть LiteLLM
Нужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
quot;)
def parse_llm_response(raw_text: str) -> ReviewExtraction:
try:
return ReviewExtraction.model_validate_json(raw_text)
except ValidationError as e:
# логируем raw_text и e для разбора, не проглатываем молча
raise
Для сценариев с высокой ценой ошибки (финансовые данные, действия с реальными последствиями) стоит закладывать не слепой ретрай, а цикл с обратной связью: текст ошибки от Pydantic дописывается в историю сообщений новым запросом («ответ не прошёл валидацию: такое-то поле, исправь и верни JSON заново»), и модель получает шанс поправить конкретную проблему. Дороже одного запроса, но заметно надёжнее «слепого» парсинга — тот же принцип валидации аргументов применяется и при разборе вызовов инструментов в tool calling.
LiteLLM: один интерфейс structured output на разных провайдерах и моделях
Как только в проекте появляется больше одного провайдера — облачный API плюс резервный, или облачный плюс self-hosted модель для части нагрузки — расхождения в том, как каждый оформляет response_format, guided_json и строгость гарантий, превращаются в отдельный слой поддержки в коде. LiteLLM снимает эту боль так же, как и для обычных чат-запросов: единый интерфейс response_format на входе транслируется в нативный параметр конкретного провайдера или движка инференса под капотом, и код выглядит одинаково независимо от того, куда реально уходит запрос:
import litellm
response = litellm.completion(
model="gpt-4o-mini", # или "ollama/llama3", "openrouter/..." — интерфейс тот же
messages=[{"role": "user", "content": "Извлеки оценку и тональность из отзыва"}],
response_format={"type": "json_schema", "json_schema": {
"name": "review_extraction", "strict": True,
"schema": ReviewExtraction.model_json_schema()
}},
)
Практический эффект — смена провайдера или добавление фолбэка на случай перегрузки основного API остаётся конфигурационным изменением в litellm_config.yaml, а не переписыванием слоя парсинга. Это особенно ценно в связке с выводом предыдущего раздела: для критичных полей стоит держать основную модель через провайдера со зрелым strict-режимом, а более слабые открытые модели подключать через тот же шлюз только туда, где расхождение в качестве structured output не критично.
Развернуть такой шлюз проще на собственном сервере — LiteLLM обычное Python-приложение, которому нужны стабильный аптайм и исходящий доступ к API провайдеров, а разница между открытыми моделями и закрытыми API по цене и стабильности вывода разобрана в статье «Открытые LLM против ChatGPT». VPS с оплатой картой или криптой из России закрывает вопрос легальности расчётов — пошаговая установка описана в материале «Как установить и настроить LiteLLM на VPS».
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Развернуть LiteLLMОбсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →Частые вопросы
Можно ли просто попросить модель в промпте отвечать в формате JSON без параметра response_format?
Можно, и часто это даже сработает — но без гарантии. Инструкция в промпте смещает вероятности, а не ограничивает их; для критичной интеграции нужен строгий режим на уровне API.
Замедляет ли constrained decoding генерацию?
Заметно замедляет обычно только первый запрос с новой сложной схемой, пока сервер компилирует грамматику; точные цифры зависят от провайдера и движка — стоит измерить на своей нагрузке.
Гарантирует ли строгий JSON Schema, что данные внутри полей будут смысловыми, а не просто валидными по типу?
Нет. Constrained decoding гарантирует только структуру — тип, обязательные поля, допустимые значения enum. Смысловая корректность содержимого зависит от качества модели.
Нужно ли всё ещё валидировать ответ кодом, если провайдер уже поддерживает строгий structured output?
Да. Строгий режим снижает частоту ошибок, но не покрывает все ключевые слова схемы у всех провайдеров и не спасает при переходе на более слабую открытую модель.
Все ли открытые модели одинаково хорошо держат сложную JSON Schema?
Нет: топовые полновесные модели держат схему сопоставимо с закрытыми API, а мелкие или сильно квантованные версии чаще дают синтаксически валидный, но содержательно слабый результат.
Нужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.