Привет, Хабр!
Strict‑режим появился у OpenAI в августе 2024 года и за полтора года стал способом по умолчанию получать от модели структурированный ответ. Сейчас его в том или ином виде поддерживают все крупные провайдеры, а на self‑hosted моделях ту же роль играет grammar‑constrained decoding в llama.cpp, vLLM и SGLang. Схема пишется первой, промпт строится вокруг неё, а не наоборот.
Разница между strict‑режимом и старым JSON Mode большая. JSON Mode обещает синтаксически корректный JSON и ничего больше: поля могут пропасть, типы поплыть, ключи появиться из воздуха. Strict‑режим компилирует JSON Schema в автомат и на каждом шаге генерации обнуляет вероятность токенов, которые уводят с валидного пути. Модель не может вернуть невалидную структуру не потому, что хорошо обучена, а потому, что нужных токенов у неё в этот момент физически нет.
Гарантия сильная, и именно поэтому вокруг неё столько разочарований. Схема отправлена, strict: true выставлен, а в логах лежит мусор.
Ниже пять ситуаций, в которых код выглядит правильным, а гарантия не работает.
Ограничения из Pydantic Field молча исчезают
Пишем модель для извлечения профиля пользователя и аккуратно проставляем ограничения.
from pydantic import BaseModel, Field from openai import OpenAI class UserProfile(BaseModel): user_id: str = Field(pattern=r"^USR-\d{6}$") name: str = Field(min_length=1, max_length=100) age: int = Field(ge=0, le=120)
Запрос проходит, ответ парсится, всё зелёное. Через неделю в базе обнаруживаются пользователи 250 лет от роду и идентификаторы вида USER123, которые никакому паттерну не соответствуют.
Причина в том, что strict‑режим понимает не весь JSON Schema, а его подмножество. Из него вычеркнуты ровно те слова, которые тут используются: pattern, minimum, maximum, multipleOf, minItems, maxItems, minLength, maxLength. Дальше возможны два исхода: API отвергнет схему с ошибкой валидации, либо (что чаще, потому что современные обёртки санитизируют схему перед отправкой) лишние ключи будут тихо выброшены. Во втором случае никто ничего не заметит: ограничения были в коде, но до декодера не доехали.
Фиксится это разделением ответственности. Структура (типы, обязательность, форма объекта) остаётся в схеме и обеспечивается декодером. Семантика (диапазоны, форматы, бизнес‑правила) уезжает в валидаторы, которые срабатывают уже на разборе ответа.
from pydantic import BaseModel, field_validator import re class UserProfile(BaseModel): user_id: str name: str age: int @field_validator("user_id") @classmethod def check_user_id(cls, v): if not re.match(r"^USR-\d{6}$", v): raise ValueError(f"Invalid user_id format: {v}") return v @field_validator("age") @classmethod def check_age(cls, v): if not 0 <= v <= 120: raise ValueError(f"Age out of range: {v}") return v
В API уходит чистая структурная схема, декодер гарантирует форму, field_validator отсекает бессмыслицу. В проде ко всему этому добавляется повтор запроса: при провале валидации модель получает обратно текст ошибки и генерирует ответ заново. Ровно этот цикл из коробки делает Instructor, поэтому большинство команд рано или поздно к нему и приходит вместо того, чтобы писать свой retry с обратной связью.
Отдельно стоит держать в голове: даже валидная по схеме структура ничего не говорит о корректности содержимого. confidence: 0.51 и confidence: 0.97 одинаково валидны, а решения по ним принимаются разные.
Optional‑поле, которое ломает схему целиком
Вторая ситуация возникает у всех, кто извлекает данные из писем, где часть информации может отсутствовать.
class Address(BaseModel): street: str city: str postal_code: Optional[str] = None class Order(BaseModel): order_id: str customer_name: str shipping_address: Optional[Address] = None notes: Optional[str] = None
API отвечает ошибкой валидации схемы.
Дело в том, что strict‑режим требует, чтобы все свойства объекта были перечислены в required. Опциональности через отсутствие в required в этом мире не существует. Единственный способ сказать «поля может не быть» — это тип‑объединение с null.
Pydantic для Optional[X] = None генерирует схему, где поле в required не попадает. Значение по умолчанию тут работает против нас. Достаточно убрать = None:
class Address(BaseModel): street: str city: str postal_code: Optional[str] # никакого default class Order(BaseModel): order_id: str customer_name: str shipping_address: Optional[Address] notes: Optional[str]
Теперь поле обязательно присутствует в ответе, но его тип допускает null. Семантика опциональности сохранилась, требование strict‑режима выполнено. Модель либо вернёт адрес, либо честно вернёт null, и второе для вас куда лучше, чем молчаливое отсутствие ключа.
Второе требование того же порядка — additionalProperties: false на каждом уровне иерархии, включая вложенные модели в $defs. Pydantic сам по себе ставит этот флаг только в корне.
Хелперы chat.completions.parse и pydantic_function_tool дописывают его по всем уровням автоматически, а вот если вы собираете response_format руками и передаёте сырую схему, обход дерева придётся написать самому. Это первое, что стоит проверять, когда схема выглядит валидной, а API её отвергает.
parsed внезапно равен None
Пайплайн извлечения данных, прогон на большой выборке, всё работает. Примерно один запрос из двух сотен падает так:
invoice = response.choices[0].message.parsed print(invoice.total) # AttributeError: 'NoneType' object has no attribute 'total'
Гарантия схемы вроде бы математическая. Откуда None?
Вместе со strict‑режимом в API приехал новый тип ответа: отказ. Модель может не выполнить запрос, если сочтёт его нарушающим политики. Никакого невалидного JSON при этом не появляется, потому что JSON не появляется вообще: parsed остаётся пустым, а причина отказа лежит в отдельном поле refusal. С точки зрения strict‑логики всё честно, просто ветка отказа в коде обычно не написана.
message = response.choices[0].message if message.refusal: logger.warning("Model refused", extra={"refusal": message.refusal}) raise ModelRefusedError(message.refusal) if response.choices[0].finish_reason == "length": raise IncompleteResponseError() if message.parsed is None: raise UnexpectedResponseError() invoice = message.parsed
Обратите внимание на вторую проверку. Обрыв по лимиту токенов даёт ровно тот же симптом: генерация была валидной ровно до момента, когда её оборвали на середине объекта. Проверять finish_reason нужно так же обязательно, как и refusal.
Отказ имеет смысл считать полноценной ошибкой, а не временным сбоем. Повторять запрос бесполезно: модель откажет ещё раз, разница будет только в счёте за токены. Разумные варианты — логировать для анализа паттернов, эскалировать человеку, если поток критичный, или отдавать fallback, если сценарий это допускает.
На типовом извлечении из документов отказы измеряются долями процента и почти всегда объясняются содержимым самого документа. В агентных сценариях с длинными цепочками рассуждений частота выше.
Алиас модели, который роняет вас в JSON Mode
Ситуация, которую очень сложно диагностировать по логам. Пайплайн отлаживали на закреплённом снапшоте, всё стабильно. Перед выкаткой в прод строку модели заменили на алиас без даты, «чтобы не привязываться к версии». Через пару недель в логах начинают попадаться ответы, не соответствующие схеме, хотя strict: true на месте.
response = client.chat.completions.create( model="gpt-5.6", # алиас, а не снапшот messages=messages, response_format={ "type": "json_schema", "json_schema": {"name": "Invoice", "schema": invoice_schema, "strict": True}, }, )
Strict‑режим доступен не на всех моделях и не на всех их снапшотах. Алиас без даты роутит запрос на ту версию, которую OpenAI считает актуальной прямо сейчас, и эта версия меняется без вашего участия.
В переходные периоды между релизами за алиасом может оказаться снапшот, где strict не поддерживается. Ошибки при этом не будет: запрос выполнится в режиме, эквивалентном обычному JSON Mode. JSON придёт валидный, соответствие схеме гарантировать никто не станет. Узнаете вы об этом по мусору в базе.
Фиксится тем, что версия модели закрепляется в конфигурации так же, как закрепляется версия библиотеки.
STRUCTURED_OUTPUTS_MODEL = "gpt-5.6-2026-XX-XX" # pinned snapshot response = client.chat.completions.parse( model=STRUCTURED_OUTPUTS_MODEL, messages=messages, response_format=InvoiceSchema, )
Переход на новый снапшот становится отдельным решением с прогоном eval‑набора, а не побочным эффектом чужого релиза. Заодно это страхует от прочих изменений в поведении, которые между снапшотами случаются регулярно и в changelog попадают не всегда.
Strict и параллельные вызовы инструментов не дружат
Агент, несколько инструментов, у всех схем выставлен strict: true, параллельные вызовы включены.
tools = [ openai.pydantic_function_tool(SearchQuery, strict=True), openai.pydantic_function_tool(FetchDocument, strict=True), ] response = client.chat.completions.create( model=STRUCTURED_OUTPUTS_MODEL, messages=messages, tools=tools, parallel_tool_calls=True, )
API отвечает отказом: параллельные вызовы несовместимы со strict. Разработчик выключает strict, потому что параллельность жалко, и через неделю ловит tool call, куда модель передала строку вместо числа и лишнее поле в придачу.
Ограничение прописано в документации с самого выхода структурированных выводов и держится до сих пор: при параллельной генерации нескольких блоков tool call соответствие схемам не гарантируется, поэтому parallel_tool_calls требуется выставить в false. Выбирать приходится осознанно, и вариантов три.
Отказаться от параллельности. Инструменты вызываются по очереди, каждый вызов типобезопасен. Для большинства агентов это приемлемо: последовательное поведение предсказуемее, отлаживается легче, а прирост задержки редко оказывается тем, что реально болит.
Оставить параллельность и валидировать аргументы на своей стороне.
def execute_tool_call(tool_call): schema = TOOL_SCHEMAS[tool_call.function.name] try: args = schema.model_validate_json(tool_call.function.arguments) except ValidationError as e: return { "role": "tool", "tool_call_id": tool_call.id, "content": f"Error: invalid arguments. {e}", } return execute(schema.__name__, args)
Ошибка валидации возвращается модели как результат работы инструмента, и на следующем шаге она себя поправляет. Работает, но покупается лишним раундом обмена там, где strict справился бы с первого раза.
Третий путь — отдать эту механику Instructor, который делает то же самое, только с retry и корректировкой промпта внутри. Снаружи похоже на strict для агента, внутри обычный tool calling с валидацией на выходе.
Для агентов ставьте parallel_tool_calls=False по умолчанию и включайте параллельность точечно, там, где она даёт реальный выигрыш по латентности. Обычно это fan‑out чтение из нескольких источников, и обычно там же типы простые настолько, что потеря strict не страшна.
Что из этого стоит унести в свой код
Все пять историй сводятся к небольшому набору привычек.
Структурные ограничения живут в схеме, семантические — в валидаторах с последующим повтором запроса. Опциональные поля описываются как Optional[X] без значения по умолчанию, а additionalProperties: false проставляется на всех уровнях, а не только в корне. refusal и finish_reason проверяются до обращения к parsed, причём отказ логируется как отдельный класс событий, а не тонет в общем счётчике ошибок. Версия модели пинуется явно и обновляется через eval, а не через алиас. Параллельные вызовы инструментов в агентах выключены по умолчанию.
Тем, кто заводит structured outputs с нуля, проще начать с Instructor: там уже собраны и разделение ограничений, и повтор с обратной связью, и единый интерфейс к нескольким провайдерам, и частичный разбор потока через create_partial(). Если структура ответов становится контрактом между сервисами, а не деталью одного пайплайна, стоит посмотреть в сторону BAML с его отдельным DSL и генерацией клиентов под несколько языков.
Structured Outputs решают только часть задачи: чтобы ответы LLM оставались предсказуемыми в проде, нужно понимать устройство самой модели, корректно проектировать агентный цикл и обрабатывать ситуации, которые не покрывает схема.
Продолжить разбираться в теме можно на бесплатных занятиях с преподавателями‑практиками:
22 июля в 20:00. — «Что надо знать про работу LLM моделей». Записаться
Разберём, как модели генерируют ответы, откуда берутся ограничения и почему валидный результат не всегда означает корректный.17 августа в 20:00. — «Создаём первого ИИ‑агента за 90 минут: автоматизируем реальную рабочую задачу с Claude Code». Записаться
Соберём агентный сценарий и посмотрим, как организовать вызовы инструментов, проверку результатов и обработку ошибок.
Больше бесплатных уроков июля смотрите в дайджесте.
Комментарии (2)

rusatch
18.07.2026 00:17Отличная статья!! Многое объяснили что было непонятно, спасибо. Виртуальный плюсик от меня )
Dreams_and_magic
Разница между strict режимом в обычных моделях и Structured outputs колоссальная. и есть измеренные уважаемыми господами метрики, где Structured outputs выдаёт не 100% точности.
Если вам важна 100% точность, то надо использовать отдельный парсер для проверки.
Как вы из этих двух предложений сделали целую статью - ума не приложу:)