# Structured Outputs для получения валидного JSON от Claude и GPT

Если вы хоть раз пытались заставить нейросеть отвечать в JSON, то возможно сталкивались с проблемами. Вы просите вернуть объект, а модель любезно оборачивает его в ```json, добавляет сверху «Вот ваш ответ» или просто забывает поставить запятую. Модель может добавить пояснения, поменять поля местами или вернуть структуру с ошибками. В production-системах, где ответ LLM уходит в базу данных или передается другой функции, одна лишняя запятая ломает весь пайплайн, а вы тратите часы на написание регулярных выражений, чтобы вырезать чистый JSON из текстового ответа.&nbsp;

Проблема в том, что языковые модели по своей природе генерируют текст, а не код. Обычный промпт «Ответь в JSON» для них это просто пожелание, а не строгий контракт. Эта проблема решается с помощью structured outputs. Что это такое на уровне идеи и как устроен JSON Mode, мы разбирали в глоссарии: [JSON Mode](/pages/yavnoe-ukazanie-formata-vyvoda-json-mode "JSON Mode"), [Structured Output](/pages/structured-output "Structured Output"), [JSON Response Format](/pages/json-response-format "JSON Response Format"). В этой статье расскажем как получать от GPT и Claude чистую структуру с первого раза.

## Три уровня надежности JSON-ответа

Прежде чем писать код, важно понимать, что не все способы получить JSON одинаково надежны, это разные уровни гарантий.

**Уровень 0.** Попросить в промпте. «Ответь в формате JSON» в тексте запроса. В среднем работает, но ничего не гарантирует. Модель может добавить преамбулу, обернуть ответ в markdown или ошибиться в структуре. Не используйте это в продакшне.

**Уровень 1.** JSON Mode (response\_format: {"type": "json\_object"}). Гарантирует, что ответ будет синтаксически валидным JSON. Не гарантирует, что в нём окажутся нужные поля нужного типа: модель может вернуть {"error": "не понял вопрос"} вместо ожидаемой структуры, и это тоже будет валидный JSON.

**Уровень 2.** Structured Outputs / JSON Schema (response\_format: {"type": "json\_schema", ...} с strict: true). Гарантирует не только валидный JSON, но и соответствие конкретной схеме: обязательные поля будут на месте, типы будут правильными, значения перечислений (enum) не выйдут за рамки списка. Это и есть production-стандарт на 2026 год. OpenAI прямо называет старый json\_object устаревшим для задач, где важна не просто синтаксическая валидность, а точное соответствие схеме.

В статье работаем именно с уровнем 2 и разбираемся, что меняется, когда вместо GPT вы обращаетесь к Claude через тот же самый эндпоинт RouterAI.

## Что понадобится

- API-ключ RouterAI: Настройки → API-ключи.

- Python 3.9+ и установленные пакеты:

`pip install openai pydantic`

- Баланс от 100 ₽. Тестовые запросы из этой статьи обойдутся в несколько рублей.

## Шаг 0. Подготовка окружения

Откройте терминал (на macOS – Терминал, на Windows – PowerShell или WSL) и выполните:

`pip3 install openai pydantic`  
`export ROUTERAI_API_KEY="ваш_ключ_здесь"`

## Шаг 1. Запуск Python и импорт библиотек

Введите python3 и внутри интерпретатора выполните импорт нужных модулей:

`import os`

`from openai import OpenAI`

`from pydantic import BaseModel, Field, ValidationError`

`from typing import Literal`

`import json`

Результат: появляется приглашение` >>>`.

## Шаг 2. Создание клиента RouterAI

Клиент, который будет отправлять запросы к RouterAI:

`client = OpenAI(`

`    api_key=os.getenv("ROUTERAI_API_KEY"),`

`    base_url="https://routerai.ru/api/v1",`

`)`

Ключ читается из переменной окружения, а не хранится в коде, так он не попадёт в git или в скриншот по ошибке.

## Шаг 3. Определяем JSON Schema для классификации обращения

В качестве примера возьмём обработку обращения в поддержку. Из текста письма нужно извлечь тональность, категорию, срочность и краткую суть. Схема:

`schema = {`

`    "type": "object",`

`    "properties": {`

`        "sentiment": {`

`            "type": "string",`

`            "enum": ["positive", "neutral", "negative"],`

`            "description": "Общая тональность обращения"`

`        },`

`        "category": {`

`            "type": "string",`

`            "enum": ["billing", "technical", "account", "other"],`

`            "description": "Категория проблемы"`

`        },`

`        "urgency": {`

`            "type": "integer",`

`            "description": "Срочность от 1 (не срочно) до 5 (критично)"`

`        },`

`        "summary": {`

`            "type": "string",`

`            "description": "Одно предложение с сутью обращения"`

`        }`

`    },`

`    "required": ["sentiment", "category", "urgency", "summary"],`

`    "additionalProperties": False`

`}`

## Шаг 4. Запрос к GPT-4o через RouterAI

Отправляем запрос с параметром response\_format и нашей схемой:

`response = client.chat.completions.create(`

`    model="openai/gpt-4o",`

`    messages=[`

`        {"role": "system", "content": "Ты классифицируешь обращения в поддержку."},`

`        {"role": "user", "content": "Уже третий день не могу зайти в личный кабинет, "`

`                                     "пароль не принимает, а поддержка молчит. Это невыносимо!"}`

`    ],`

`    response_format={`

`        "type": "json_schema",`

`        "json_schema": {`

`            "name": "support_ticket",`

`            "strict": True,`

`            "schema": schema`

`        }`

`    }`

`)`

` `

`print(response.choices[0].message.content)`

Ответ:

`{"category":"account","sentiment":"negative","summary":"Пользователь не может войти в личный кабинет, так как его пароль не принимается.","urgency":4}`

Чистый JSON, без лишнего текста, все поля на месте, типы соблюдены, модель вернула именно то, что мы просили.

Два нюанса, из-за которых схема может не пройти валидацию:

- В строгом режиме (strict: true) все поля из properties обязаны быть перечислены в required, даже если по смыслу поле опциональное. Для настоящих опциональных полей делайте тип ["string", "null"] вместо того, чтобы просто убрать поле из required.

- "additionalProperties": false нужен на каждом уровне вложенного объекта, а не только на верхнем уровне. Самая частая причина ошибки заключается в том, что забывают указать additionalProperties: false на вложенном уровне, если в схеме есть вложенный объект.

## Шаг 5. Тот же запрос к Claude

Меняем только модель на anthropic/claude-sonnet-4.5:

`response_claude = client.chat.completions.create(`

`    model="anthropic/claude-sonnet-4.5",`

`    messages=[`

`        {"role": "system", "content": "Ты классифицируешь обращения в поддержку."},`

`        {"role": "user", "content": "Уже третий день не могу зайти в личный кабинет, "`

`                                     "пароль не принимает, а поддержка молчит. Это невыносимо!"}`

`    ],`

`    response_format={`

`        "type": "json_schema",`

`        "json_schema": {`

`            "name": "support_ticket",`

`            "strict": True,`

`            "schema": schema`

`        }`

`    }`

`)`

` `

`print(response_claude.choices[0].message.content)`

Результат:

`{"category":"account","sentiment":"negative","summary":"Пользователь не может войти в личный кабинет три дня из-за проблемы с паролем, поддержка не отвечает.","urgency":4}`

У Anthropic structured outputs на уровне собственного API устроены иначе, чем у OpenAI. Это отдельный параметр output\_config.format, либо принудительный вызов инструмента через tool\_choice (подробнее в [официальной документации Claude по structured outputs](https://platform.claude.com/docs/en/build-with-claude/structured-outputs "официальной документации Claude по structured outputs")). Мы проверили это на практике: в нашем тесте RouterAI принял точно такой же OpenAI-совместимый response\_format для модели Claude и вернул валидный по схеме JSON без какого-либо дополнительного кода с нашей стороны.

Схема в нашем примере простая. Для более сложных случаев или других версий Claude результат может отличаться. Если прямой способ не сработает, ниже приведён запасной вариант через вызов инструментов. Он работает практически всегда.

### Обходной путь через принудительный вызов инструмента (tool calling), если прямой response\_format не сработал

Способ работает практически на любом OpenAI-совместимом шлюзе для любой модели, которая поддерживает вызов инструментов (function calling). Вместо того чтобы просить модель ответить в определённом формате, вы описываете нужную структуру как функцию, которую модель обязана вызвать, и получаете данные из аргументов этого вызова:

`response = client.chat.completions.create(`

`    model="anthropic/claude-sonnet-4.5",`

`    messages=[`

`        {"role": "system", "content": "Ты классифицируешь обращения в поддержку."},`

`        {"role": "user", "content": "Уже третий день не могу зайти в личный кабинет, "`

`                                     "пароль не принимает, а поддержка молчит. Это невыносимо!"}`

`    ],`

`    tools=[{`

`        "type": "function",`

`        "function": {`

`            "name": "classify_ticket",`

`            "description": "Вернуть структурированную классификацию обращения в поддержку",`

`            "parameters": schema`

`        }`

`    }],`

`    tool_choice={"type": "function", "function": {"name": "classify_ticket"}}`

`)`

` `

`tool_call = response.choices[0].message.tool_calls[0]`

`print(tool_call.function.arguments)`

Данные придётся доставать не из message.content, а из tool\_calls, зато способ надежнее. Он не зависит от того, поддерживает ли конкретная модель прямую трансляцию response\_format, вызов инструмента работает даже там, где такой трансляции нет.

## Шаг 6. Валидация через Pydantic

Строгий режим (strict: true) гарантирует структуру, но не проверяет числовые диапазоны, например, что urgency лежит между 1 и 5. JSON Schema в строгом режиме понимает не весь свой словарь: ключевые слова вроде minimum, maximum, pattern, minLength могут быть проигнорированы моделью или молча выброшены ещё до отправки запроса. Формально ответ останется «валидным по схеме», а urgency вполне может оказаться вне ожидаемого диапазона. Поэтому вторая проверка на стороне приложения — не паранойя, а обязательный шаг.

Pydantic-модель с теми же полями, но с ограничением ge=1, le=5 для urgency:

`class SupportTicket(BaseModel):`

`    sentiment: Literal["positive", "neutral", "negative"]`

`    category: Literal["billing", "technical", "account", "other"]`

`    urgency: int = Field(ge=1, le=5)`

`    summary: str`

Проверяем корректный JSON, полученный от Claude:

`raw = response_claude.choices[0].message.content`

`try:`

`    ticket = SupportTicket.model_validate_json(raw)`

`    print("Валидация пройдена:", ticket)`

`except ValidationError as e:`

`    print("Ошибка валидации:", e)`

Вывод:

`Валидация пройдена: sentiment='negative' category='account' urgency=4 summary='Пользователь не может войти в личный кабинет три дня из-за проблемы с паролем, поддержка не отвечает.'`

Теперь симулируем плохой ответ, например, если модель по ошибке вернёт urgency: 12:

`bad_json = '{"sentiment":"negative","category":"account","urgency":12,"summary":"Тест"}'`

`try:`

`    ticket = SupportTicket.model_validate_json(bad_json)`

`    print("Валидация пройдена:", ticket)`

`except ValidationError as e:`

`    print("Ошибка валидации (как и ожидалось):")`

`    print(e)`

Вывод:

`Ошибка валидации (как и ожидалось):`

`1 validation error for SupportTicket`

`urgency`

`  Input should be less than or equal to 5 [type=less_than_equal, input_value=12, input_type=int]`

`    For further information visit https://errors.pydantic.dev/2.13/v/less_than_equal`

Pydantic поймал ошибку, и дальше можно реагировать по обстоятельствам: переспросить модель, записать в лог или выбросить исключение выше по стеку. Это защита, которая дополняет строгую схему, а не заменяет её.

## Шаг 7. Обработка отказов и обрезанных ответов

Даже при строгой схеме есть два сценария, которые нужно ловить отдельно, иначе однажды код упадёт в проде без объяснений.

Отказ (refusal). Если модель сочтёт запрос небезопасным, она не станет подгонять отказ под вашу схему, а вернёт отдельное поле refusal вместо content. Проверяйте его первым.

Обрезанный ответ. Если у модели закончился лимит токенов на середине JSON, finish\_reason будет равен "length", а content окажется половиной объекта, которую json.loads не разберёт. Проверяйте это до попытки парсинга.

`message = response_claude.choices[0].message`

`if getattr(message, "refusal", None):`

`    print("Модель отказалась отвечать:", message.refusal)`

`elif message.content:`

`    ticket = SupportTicket.model_validate_json(message.content)`

`    print("Валидация прошла успешно, отказ отсутствует")`

`else:`

`    raise Exception("Пустой ответ без content и без refusal")`

` `

`if response_claude.choices[0].finish_reason == "length":`

`    print("Ответ обрезан по лимиту токенов, увеличьте max_tokens")`

`else:`

`    print(f"Ответ полный, finish_reason: {response_claude.choices[0].finish_reason}")`

Ожидаемый вывод:

`Валидация прошла успешно, отказ отсутствует`

`Ответ полный, finish_reason: stop`

## Где это применяется на практике

Structured Outputs используется не только для того, чтобы достать данные из текста. JSON давно стал языком общения между нейросетью и вашим кодом. Модель возвращает не данные для человека, а инструкции, команды и состояния, которые приложение сразу выполняет.

**Извлечение данных.** Вы загружаете в модель резюме кандидата, счёт-фактуру или договор, а на выходе получаете готовый объект для базы данных: ФИО, сумма, дата, список позиций. Если документ приходит в виде PDF, его сначала прогоняют через обработку, а уже извлеченный текст отправляют по схеме.

**Классификация и роутинг.** Тикеты в поддержку, отзывы, комментарии в соцсетях сортируются по тональности, теме и приоритету. А в агентных системах LLM-оркестратор вообще решает, кому передать задачу: {"target\_agent": "billing", "reason": "payment\_issue"}.

**Преобразование текста в команды.** Пользователь пишет боту: «хочу заказать пиццу на завтра к 19:00». Схема с enum для action (order, cancel, status) превращает этот свободный текст в команду, которую можно сразу передать в обработчик, без написания собственного NLP-парсера.

**Диалоговые менеджеры и машины состояний.** Модель возвращает не только ответ пользователю, но и обновленное состояние сессии: какой интент распознан, какой слот заполнен, какое действие выполнить следующим. Это позволяет строить сложные голосовые ассистенты и ботов.

**Генерация UI.** Модель возвращает описание интерфейса в JSON, которое рендерится на фронтенде (паттерн server-driven UI). Динамические email-рассылки собираются из блоков: заголовок, список товаров, кнопка. SEO-контент размечается для schema.org: FAQ, HowTo, Product.

**Модерация и безопасность.** Модель возвращает структурированный отчет с категориями нарушений, уровнем уверенности и рекомендуемым действием. Это позволяет строить защитные ограждения (guardrails) для LLM-приложений.

## Чек-лист перед продакшном

- Все поля из properties перечислены в required.

- additionalProperties: false стоит на каждом уровне вложенности.

- Числовые и строковые ограничения (minimum, pattern и подобные) продублированы валидацией на своей стороне (например, через Pydantic).

- Обработка refusal идёт раньше попытки распарсить content.

- Проверка finish\_reason == "length" стоит раньше парсинга JSON.

- API-ключ читается из переменной окружения.

Мы прошли путь от установки пакетов до получения гарантированно валидного JSON от двух разных моделей GPT и Claude Sonnet, через один и тот же API RouterAI и код. Убедились, что схема соблюдается на обеих моделях без дополнительной адаптации кода под конкретного провайдера, а вторая проверка через Pydantic ловит то, что пропускает строгий режим JSON Schema.

&nbsp;
