Привет, Хабр!

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)


  1. Dreams_and_magic
    18.07.2026 00:17

    Разница между strict режимом в обычных моделях и Structured outputs колоссальная. и есть измеренные уважаемыми господами метрики, где Structured outputs выдаёт не 100% точности.
    Если вам важна 100% точность, то надо использовать отдельный парсер для проверки.

    Как вы из этих двух предложений сделали целую статью - ума не приложу:)


  1. rusatch
    18.07.2026 00:17

    Отличная статья!! Многое объяснили что было непонятно, спасибо. Виртуальный плюсик от меня )