Structured Outputs, или structured decoding, это способ заставить LLM возвращать ответ в заранее заданном формате: валидный JSON, соответствующий JSON Schema. На уровне генерации это обычно означает constrained decoding: на каждом шаге модели запрещаются токены, которые привели бы к нарушению схемы. Хорошее техническое объяснение есть в статье vLLM: [Structured Decoding in vLLM: a gentle introduction].
Для продакшн-систем это важно, потому что ответ модели часто становится входом для следующего шага: API-вызова, записи в базу, бизнес-правила или другого LLM-запроса.
Промпт в стиле «ответь строго валидным JSON» помогает, но не даёт инженерной гарантии. Модель может поменять имя ключа, добавить пояснение перед JSON или выбрать значение вне допустимого enum. Structured Outputs должен решать именно эту задачу: провайдер принимает схему и ограничивает вывод так, чтобы ответ соответствовал формату.
К Structured Outputs можно относиться по-разному. Жёсткая схема иногда ухудшает качество ответа: модель может хуже рассуждать или зациклиться в длинном строковом поле до лимита токенов. Но в корпоративных системах это всё равно востребованная функция: формат ответа часто важен не меньше, чем сам текст ответа. С деградацией качества приходится работать отдельно — через evals, подбор схемы, разбиение задачи на шаги и проверку поведения конкретной модели.
Важно: Structured Outputs не снимает все проверки на стороне приложения. В документации OpenAI отдельно описаны случаи, когда ответ может не быть обычным успешным JSON по вашей схеме: safety refusal, incomplete response / truncation и другие edge cases. Их всё равно нужно обрабатывать в коде, а результат валидировать после получения. См. [Structured model outputs].
На практике остаётся вопрос: какие ограничения схемы провайдер действительно применяет, а какие только принимает в JSON Schema. Это особенно важно для [Schema-Guided Reasoning], где структура Pydantic-модели используется не только для парсинга, но и для управления ходом ответа. Чтобы проверить это, мы прошлись по трём провайдерам (OpenAI, Gemini, xAI) с конфликтными промптами: просили модель нарушить конкретное ограничение и смотрели, сможет ли провайдер удержать ответ в рамках схемы.
Как тестировали
Идея проверки простая: если провайдер действительно применяет ограничение из схемы, модель не сможет вернуть значение, которое это ограничение нарушает. Даже если в промпте прямо попросить её так сделать.
Для каждого ограничения мы делали минимальную JSON Schema и отдельный промпт, который просит вернуть некорректное значение.
Например, схема требует строку длиной от 5 до 8 символов:
{
"type": "object",
"properties": {
"word": {
"type": "string",
"minLength": 5,
"maxLength": 8
}
},
"required": ["word"],
"additionalProperties": false
}
А в промпте мы просим нарушить это ограничение:
“Return word=’hi’ — exactly 2 characters. Use that exact short word.”
Дальше смотрим на результат:
-
если вернулось
"hi"—minLengthне enforced; -
если вернулась строка длиной 5–8 символов — провайдер не дал модели нарушить схему.
Полный список проверенных ограничений:
|
Ограничение |
Пример в схеме |
Пример нарушения в промпте |
|
|
|
вернуть |
|
|
|
вернуть |
|
|
|
вернуть |
|
|
строка длиной от 5 до 8 символов |
вернуть |
|
|
число должно быть кратно |
вернуть |
|
|
строка должна совпадать с |
вернуть |
|
|
массив из 2–3 элементов |
вернуть пустой массив или 5 элементов |
|
Обязательные поля |
поле |
пропустить |
|
|
разрешены только поля из |
добавить поле |
|
Вложенные объекты |
|
вернуть |
|
|
|
вернуть объект, нарушающий подмодель |
|
|
|
вернуть число |
|
|
|
вернуть |
|
|
|
вернуть объект без |
|
|
|
вернуть только поля |
Каждый кейс запускался 3–5 раз. Все тесты шли через единый OpenAI-совместимый API. Модели: gpt-4o-mini, gemini-2.0-flash, grok-3-mini.
OpenAI
Для OpenAI ключевым параметром оказался strict: true.
Если передать схему с enum, maxItems, pattern и обязательными полями, но не включить strict: true, ограничения не применяются. В тестах модель возвращала значения из промпта, даже если они нарушали схему.
С strict: true все простые ограничения из теста применялись: литералы, числовые границы, длина строк и списков, multipleOf, pattern, обязательные поля, запрет лишних полей, вложенные объекты и $defs / $ref.
Для composition keywords есть отдельное ограничение. oneOf и allOf с strict: true приводят к 400 Bad Request: API отклоняет запрос на этапе валидации схемы.
# Это вызовет 400 при strict=True
{"oneOf": [{"$ref": "#/$defs/Cat"}, {"$ref": "#/$defs/Dog"}]}
Вероятная причина в том, что при constrained decoding схема должна быть однозначно разрешимой на каждом шаге генерации. oneOf и allOf усложняют этот процесс, поэтому такие схемы не принимаются в strict-режиме.
Поэтому, если вам нужна дискриминированная объединённая схема под OpenAI — используйте отдельные массивы на каждую ветку, а не oneOf внутри одного поля.
Gemini
Gemini через OpenAI-совместимый endpoint (/v1beta/openai/) применяет часть ограничений без отдельного параметра strict: true. В тестах работали enum, обязательные поля, additionalProperties, maxItems, вложенные объекты, anyOf и oneOf.
Основной риск в том, что Gemini принимает некоторые ограничения в схеме, но не применяет их при генерации. Это относится к minLength / maxLength, exclusiveMinimum / exclusiveMaximum, multipleOf и pattern. allOf в тесте возвращал пустой объект, а const внутри $defs работал нестабильно.
Например, для схемы с minLength: 5, maxLength: 8 и промптом «верни слово hi» Gemini вернул {"word": "hi"} во всех трёх прогонах.
Аналогично для exclusiveMinimum: 0 и промпта «верни -5.0»: модель вернула -5.0, хотя значение нарушает схему.
Отдельно стоит учитывать max_tokens. Если схема содержит поле reasoning: str или другие открытые строковые поля, при низком лимите токенов ответ может оборваться на середине JSON. В наших проверках при max_tokens=200 это происходило часто, а при max_tokens=800 проблема исчезала. Для схем с длинными строковыми полями лучше закладывать запас по токенам.
xAI (Grok)
В тестах xAI применял большинство проверенных ограничений без параметра strict: true.
Исключение — allOf: как и у Gemini, запрос возвращал пустой объект {}.
Отдельно стоит отметить, что в отличие от Gemini, xAI применял pattern, multipleOf, строгие числовые границы и ограничения длины строк. В отличие от OpenAI strict-режима, oneOf не приводил к 400 ошибке.
Итоговая таблица
|
Ограничение |
OpenAI без strict |
OpenAI strict |
Gemini |
xAI |
|
|
нет |
да |
да |
да |
|
|
нет |
да |
да |
да |
|
|
нет |
да |
нет |
да |
|
|
нет |
да |
нет |
да |
|
|
нет |
да |
нет |
да |
|
|
нет |
да |
нет |
да |
|
|
нет |
да |
частично |
да |
|
Обязательные поля |
нет |
да |
да |
да |
|
|
нет |
да |
да |
да |
|
Вложенные объекты |
нет |
да |
да |
да |
|
|
нет |
да |
частично |
да |
|
|
нет |
да |
да |
да |
|
|
нет |
да |
да |
да |
|
|
нет |
400 |
да |
да |
|
|
нет |
400 |
возвращает |
возвращает |
Что делать с ограничениями, которые не принудительны
С ограничениями, которые конкретный провайдер не применяет при генерации, можно работать двумя способами.
Первый — валидировать на своей стороне после получения ответа:
from pydantic import BaseModel, Field, ValidationError
class Response(BaseModel):
word: str = Field(min_length=5, max_length=8)
raw = call_llm(schema=Response.model_json_schema())
try:
result = Response.model_validate_json(raw)
except ValidationError as e:
# обработать или повторить запрос
...
Такую проверку стоит оставлять даже для провайдеров с хорошей поддержкой Structured Outputs.
Второй — проектировать схему так, чтобы ключевые ограничения выражались через структуру, а не через дополнительные JSON Schema keywords. Это один из приёмов Schema-Guided Reasoning: вместо свободной строки с ограничением длины — Literal["low", "medium", "high"]; вместо oneOf с дискриминатором — отдельные контейнеры для разных веток.
Например, вместо:
# Наследование может привести к allOf в JSON Schema
class Finding(AnimalBase):
type: Literal["finding"]
severity: str # нет ограничения
Лучше:
# Плоская модель без allOf
class Finding(BaseModel):
model_config = ConfigDict(extra="forbid")
reasoning: str
type: Literal["finding"]
severity: Literal["low", "medium", "high"]
Порядок полей здесь тоже важен. Если поле reasoning стоит перед итоговым решением, модель сначала фиксирует промежуточное объяснение, а затем выбирает ограниченное значение. Это не заменяет валидацию, но делает структуру ответа более предсказуемой.
Про allOf и наследование
allOf не сработал ни у одного из тестированных провайдеров. OpenAI возвращает 400, Gemini и xAI возвращают пустой объект {}.
Это значит, что если вы используете наследование Pydantic-моделей:
class Base(BaseModel):
id: int
class Child(Base):
label: str
Схема для Child может содержать allOf: [Base, {properties: {label: ...}}]. Для Structured Outputs надёжнее явно перечислить все поля в одной модели.
Несколько практических выводов
Для OpenAI нужно включать strict: true. Без этого протестированные ограничения не применялись. При этом oneOf и allOf с strict=True приводят к 400 ошибке.
Gemini принимает числовые и строковые bounds, но не применяет их при генерации. minLength, maxLength, exclusiveMinimum, multipleOf, pattern нужно проверять в своём коде.
xAI применял большинство проверенных ограничений, но allOf также не сработал.
allOf / наследование лучше не использовать для Structured Outputs. Практичнее делать плоскую схему без наследования.
Если приложение работает с несколькими провайдерами, одной схемы может быть недостаточно. Разные провайдеры по-разному применяют одни и те же JSON Schema keywords. Практичнее держать каноническую смысловую модель и отдельные provider-specific варианты схем, а затем тестировать каждую из них на реальных эндпоинтах.
Всегда валидируйте результат на своей стороне. Даже когда провайдер применяет ограничения схемы, это относится только к успешным генерациям. Refusal, incomplete response и truncation нужно обрабатывать отдельно.
Skill для кодовых агентов
Во время работы с Structured Outputs я столкнулся с отдельной практической проблемой: кодовые агенты часто не знают, как эта функция устроена в API. На просьбу «напиши код вызова, чтобы модель вернула JSON по схеме» агент может просто положить JSON Schema в текст промпта и не использовать response_format или аналогичные параметры провайдера. Такой код выглядит правдоподобно, но не даёт гарантий формата. Даже когда агент использует нужный API, он может предложить схему, которая плохо подходит под конкретного провайдера: где-то забудет strict: true, где-то использует allOf, где-то положится на pattern в Gemini.
Сначала это приходилось каждый раз объяснять в промпте. В итоге я вынес правила, provider-specific caveats и практические выводы из этого исследования в отдельный skill: [schema-guided-reasoning-pydantic].
После установки skill’а Codex, Claude Code и Cursor лучше понимают, какие схемы предлагать для OpenAI, Gemini и xAI, когда нужна отдельная provider-specific схема, и какие ограничения обязательно проверять на практике. Все основные выводы из этой статьи также лежат в этом репозитории.
Предупреждение о свежести данных
Это срез состояния на май 2026 года, модели gpt-4o-mini, gemini-2.0-flash, grok-3-mini. Провайдеры могут менять реализацию Structured Outputs без отдельного объявления. Если для вашей задачи гарантия критична, проверьте своё конкретное ограничение adversarial-тестом перед тем как на него положиться. Минимальный тест выглядит так: схема с нужным ограничением + промпт с явной просьбой нарушить его + несколько прогонов. Если хотя бы один ответ нарушил схему, это ограничение нельзя считать надёжным без дополнительной валидации.
Автор: Mentalitet


