Structured Outputs для получения валидного JSON от Claude и GPT
- Три уровня надежности JSON-ответа
- Что понадобится
- Шаг 0. Подготовка окружения
- Шаг 1. Запуск Python и импорт библиотек
- Шаг 2. Создание клиента RouterAI
- Шаг 3. Определяем JSON Schema для классификации обращения
- Шаг 4. Запрос к GPT-4o через RouterAI
- Шаг 5. Тот же запрос к Claude
- Шаг 6. Валидация через Pydantic
- Шаг 7. Обработка отказов и обрезанных ответов
- Где это применяется на практике
- Чек-лист перед продакшном
Если вы хоть раз пытались заставить нейросеть отвечать в JSON, то возможно сталкивались с проблемами. Вы просите вернуть объект, а модель любезно оборачивает его в ```json, добавляет сверху «Вот ваш ответ» или просто забывает поставить запятую. Модель может добавить пояснения, поменять поля местами или вернуть структуру с ошибками. В production-системах, где ответ LLM уходит в базу данных или передается другой функции, одна лишняя запятая ломает весь пайплайн, а вы тратите часы на написание регулярных выражений, чтобы вырезать чистый JSON из текстового ответа.
Проблема в том, что языковые модели по своей природе генерируют текст, а не код. Обычный промпт «Ответь в JSON» для них это просто пожелание, а не строгий контракт. Эта проблема решается с помощью structured outputs. Что это такое на уровне идеи и как устроен JSON Mode, мы разбирали в глоссарии: JSON Mode, Structured Output, 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 pydanticexport 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). Мы проверили это на практике: в нашем тесте 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.