Как только вывод модели попадает в базу, форму или следующего Agent, нельзя полагаться на фразу в промпте «верни JSON». OpenAI Structured Outputs через constrained decoding привязывает ответ к вашей JSON Schema: поля не пропадают, enum не выдумывается, типы не прыгают между строкой и числом. Статья для инженеров, которые делают извлечение, классификацию тикетов, скоринг и многошаговые пайплайны: полный путь от JSON Mode к strict: true.
Почему JSON Mode недостаточно стабилен
JSON Mode (type: "json_object") гарантирует только одно: ответ можно прогнать через JSON-парсер. Он не фиксирует имена ключей, обязательные поля, набор enum и числовые типы. В проде чаще всего ломается так: нет severity, вместо 42 приходит "42", или модель изобретает urgent-plus вне enum. Если дальше стоит строгая типизация, пайплайн падает на случайных выборках.
| Возможность | JSON Mode | Structured Outputs |
|---|---|---|
| Валидный JSON | Да | Да |
| Соблюдение вашей JSON Schema | Нет (только промпт) | Да (constrained decoding) |
| Как включать | json_object |
json_schema + strict: true |
| Типичные модели | Ранние GPT-4o / GPT-3.5 и аналоги | gpt-4o-2024-08-06, gpt-4o-mini и более новые снимки |
| Отказ модели | Может выдать «похожий на JSON» отказный текст | Отдельное поле refusal |
Официально Structured Outputs — эволюция JSON Mode: новым проектам лучше сразу идти через Schema, а не трижды ретраить после ошибки парсинга. Если параллельно сравниваете счета за токены у разных моделей, смотрите сравнение стоимости API Kimi K3 и GPT-5.5 — сам структурированный вывод почти не раздувает объём ответа; счёт разъезжается из‑за ретраев и слишком длинного reasoning.
response_format; в Responses API — в text.format. Пути полей разные, а ограничения strict, additionalProperties: false и «все ключи в required» одинаковы.
Минимальный рабочий запрос
Эта Schema для извлечения тикета закрывает около 80% продакшен-кейсов: строка, enum, целое и опциональное поле через union с null. Модель обязана вернуть все ключи; если ID аккаунта нет — явно null, а не пропуск ключа.
{
"model": "gpt-4o-2024-08-06",
"messages": [
{"role": "system", "content": "Извлеките из описания пользователя объект заявки."},
{"role": "user", "content": "На странице оплаты сохранённая карта даёт 500. Началось сегодня. Аккаунт acct_8842."}
],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "support_ticket",
"strict": true,
"schema": {
"type": "object",
"properties": {
"summary": { "type": "string" },
"category": {
"type": "string",
"enum": ["billing", "bug", "account", "other"]
},
"severity": { "type": "integer" },
"account_id": { "type": ["string", "null"] }
},
"required": ["summary", "category", "severity", "account_id"],
"additionalProperties": false
}
}
}
}
Эквивалент в Responses API — та же Schema в text.format, где type, name, schema и strict стоят соседними полями. При первом использовании Schema сервер компилирует ограничения, задержка может быть чуть выше; дальше та же Schema кешируется, и запросы возвращаются к обычной скорости.
Правила, без которых strict Schema не примут
После strict: true вы сдаёте не «любую JSON Schema», а поддерживаемое подмножество. Нарушение даёт 400, а не «постараемся соблюсти».
- Корень — только object: корневой узел не может быть
anyOfили массивом. Если Zod-discriminatedUnionпорождает верхнийanyOf, оберните его объектом, например{ "result": ... }. - У каждого object нужен
additionalProperties: false: включая вложенные. Пропустили один — запрос отклонят. - Все ключи из properties должны попасть в
required: опциональность задают"type": ["string", "null"]илиanyOfсnull. - Поддерживаемые типы: string, number, integer, boolean, object, array, enum,
anyOf. - Ограничения строк:
pattern,format(email,date-time,uuid,ipv4и др.). - Числа и массивы:
minimum/maximum/multipleOf;minItems/maxItems. - Явно не поддерживается:
allOf,not,if/then/else,dependentRequiredи другие комбинаторы. - Лимиты размера: суммарно около 5000 свойств объектов и 10 уровней вложенности; суммарная длина имён свойств, имён определений и строк enum — не больше 120 тысяч символов; всего не более 1000 значений enum.
pattern, format, minLength, minimum, minItems и похожие ограничения пока могут не работать. Сначала проверьте Schema на базовом снимке, потом решайте, нужен ли fine-tune.
Вызов инструмента vs тело ответа
Тот же strict работает и для параметров функций / tools. Разница в намерении: Schema инструмента описывает, «что модель должна вызвать»; response_format / text.format — «какой формы должен быть ответ пользователю». Извлечение, оценка, состояние UI — второе; погода, запись файла, команда в шелле — первое. Если сравниваете терминального Agent и управление GUI, см. связь Claude Code и OpenAI Computer Use — это другой слой «как действовать»; здесь речь о контракте данных до и после действия.
Разбор ответа SDK сразу в объект
Ручную JSON Schema легко забыть дополнить required. Официальный SDK даёт помощники Pydantic / Zod: Schema генерируется из типов, а на стороне ответа сразу приходит разобранный объект. Типичный Python-вариант ниже.
from typing import Literal, Optional
from pydantic import BaseModel
from openai import OpenAI
class SupportTicket(BaseModel):
summary: str
category: Literal["billing", "bug", "account", "other"]
severity: int
account_id: Optional[str]
client = OpenAI()
completion = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "Извлеките заявку."},
{"role": "user", "content": "Страница оплаты: 500, аккаунт acct_8842."},
],
response_format=SupportTicket,
)
ticket = completion.choices[0].message.parsed
if completion.choices[0].message.refusal:
raise RuntimeError(completion.choices[0].message.refusal)
print(ticket.category, ticket.severity)
Ошибки, отказ модели и инженерный чеклист
Перед релизом пройдите этот список — он снимает большинство случаев «локальный curl проходит, пайплайн отвечает 400».
- 400 Unsupported schema: нет
additionalProperties: false, поле не в required, корень —anyOf, или использованallOf. - Отказ модели: при срабатывании политики безопасности контент не запихивают в Schema; читайте
refusalи показывайте его в UI, не ретрайте как ошибку JSON-парсера. - Опциональное поле превратилось в пустую строку: если в контракте
null, принимайтеnull; перед записью в БД мапьте его в SQLNULL, не гадайте второй раз. - Слишком широкий enum: сведите бизнес-статусы к 5–8 значениям; сотни enum жрут контекст и приближают лимит в 1000.
- Порядок ключей: ключи в ответе идут в том же порядке, что в Schema — при стриминге можно разбирать поля инкрементально.
- Промпт всё ещё нужен: Schema задаёт форму, промпт — смысл. Например, «severity 1–5, 5 — полный отказ сайта» пишите в system; диапазон целого дополнительно зафиксируйте
minimum/maximum.
На практике закрепите три вещи: Schema в Git; отдельные метрики на отказ, 400 и таймаут; регрессионный набор из реальных тикетов, а не игрушечный «Hello, верни JSON». Когда слой парсинга стабилен, можно заниматься точностью классификации и задержкой, а не чинить регулярки каждую неделю.
Конвейер извлечения на Mac mini проще отлаживать
Цикл отладки Structured Outputs короткий: поправили Schema, прогнали небольшую выборку, посмотрели разобранный объект. На macOS Python, Node.js, Docker и Homebrew работают сразу, без WSL. Единая память Apple Silicon позволяет одновременно держать редактор, локальные скрипты валидации и оценку с длинным контекстом — без ухода машины в swap на проверке типов.
Mac mini M4 в простое около 4 Вт — удобно гонять регрессионный набор всю ночь; сбои macOS редки, а Gatekeeper и SIP снижают риск «заражённых» скриптов зависимостей. По сравнению с Windows-коробкой той же цены и стабильность, и электричество при круглосуточной оценке Agent заметно дружелюбнее.
Если нужен постоянно онлайн узел именно для JSON-регрессии и сравнения моделей, посмотрите тарифы Kvmzen и перенесите контракт Schema с ноутбука на облачный Mac фиксированной конфигурации.