MAATRIX / Блог / Structured output в LLM: как гарантированно получить JSON

Structured output в LLM: как гарантированно получить JSON

Structured output в LLM: как гарантированно получить JSON

MAATRIX

Классический сценарий: в промпт дописывается «ответь строго в формате JSON, без лишнего текста», код с надеждой вызывает json.loads() на ответе — и падает с JSONDecodeError, потому что модель заботливо добавила «Конечно! Вот запрошенный JSON:» перед скобкой или обернула объект в блок ``json``. В проде на сотнях запросов эта надежда превращается в процент ошибок, который приходится чем-то закрывать — регулярками, повторными запросами, страхом деплоя. Хорошая новость в том, что задача давно решена не на уровне промпта, а на уровне API: строгий 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 — десятки моделей в одном окне. Оплата картой РФ и по СБП.