Проблема: LLM ломают JSON
Кто выводил в продакшен фичу на LLM, тот эту боль знает. Просишь модель «вернуть JSON с полями name, email, score», и в 99% случаев получаешь ровно это. Остальной 1% выглядит так:
- Ответ начинается с ограждения
```json, и парсер спотыкается. - В конце висит комментарий вроде
// note: score estimated. - После последнего элемента стоит запятая, и
JSON.parseпадает. - Внутри строки кавычка осталась без экранирования.
- Поле превратилось в
e_mail, потому что модели так показалось красивее. - Половина ответа ушла в markdown с пояснением, что этот JSON означает.
Миллион вызовов в месяц превращает 1% в десять тысяч тихих сбоев в проде. Свободная генерация текста плохо подходит для контрактов между машинами.
OpenAI в 2024 году выпустила response_format: { type: "json_schema", strict: true } и на своём стеке вопрос по сути закрыла: декодирование ограничено на уровне токенов, поэтому вывод по грамматике гарантированно проходит схему. Anthropic пошла другим путём. На 2026 год в нативном Messages API по-прежнему нет флага json_mode. Вместо него Anthropic предлагает использовать возможность, которая у неё уже была: tool use.
Эта статья описывает паттерн, который мы рекомендуем, чтобы получать от Claude 100% валидный JSON, соответствующий схеме. Разберём нативный API с форсированным tool_use, OpenAI-совместимую обёртку response_format, генерацию схем из Pydantic и Zod, цикл валидации с ретраем и сравнение с нативным режимом json_schema у GPT-4o.
Все примеры работают через шлюз codexkey с базовым URL https://api.codexkey.ru/v1. Он отдаёт и нативный Anthropic Messages API, и OpenAI-совместимый Chat Completions. Цены и список моделей смотрите в статье Цены Claude API 2026.
Решение Anthropic: форсированный tool_use
Tool use в Claude создавали для агентных сценариев: чтобы модель могла вызвать get_weather или search_db. Но механизм, которым она отдаёт вызовы инструментов, как раз подходит для структурированного вывода. Модель возвращает блок tool_use, и его поле input всегда валидный JSON-объект, соответствующий input_schema инструмента.
Это гарантирует декодер Anthropic. Модель не уговаривают вернуть JSON: вход инструмента и есть JSON. Добавьте tool_choice: { type: "tool", name: "..." }, чтобы форсировать вызов ровно одного инструмента, и получите структурированный API во всём, кроме названия.
Рецепт:
- Опишите целевую форму как модель Pydantic (Python) или схему Zod (TypeScript).
- Преобразуйте её в JSON Schema.
- Оберните в один инструмент с
input_schema = <ваша JSON Schema>. - Отправьте запрос с
tools=[этот_инструмент]иtool_choice={"type":"tool","name":"<имя инструмента>"}. - Прочитайте
response.content[0].input. Это и есть проверенный объект.
Python: Pydantic → JSON Schema → tool_use
import anthropic
from pydantic import BaseModel, Field
from typing import Literal
class InvoiceLineItem(BaseModel):
description: str
quantity: int = Field(ge=1)
unit_price_cents: int = Field(ge=0)
class Invoice(BaseModel):
invoice_number: str
issued_on: str = Field(description="ISO-8601 дата, например 2026-04-03")
currency: Literal["USD", "EUR", "RUB", "GBP"]
vendor_name: str
line_items: list[InvoiceLineItem]
total_cents: int
client = anthropic.Anthropic(
base_url="https://api.codexkey.ru/v1",
api_key="sk-cxa-...",
)
extract_tool = {
"name": "record_invoice",
"description": "Записать распарсенный инвойс в учётную систему.",
"input_schema": Invoice.model_json_schema(),
}
resp = client.messages.create(
model="claude-sonnet-5",
max_tokens=2048,
tools=[extract_tool],
tool_choice={"type": "tool", "name": "record_invoice"},
messages=[
{"role": "user", "content": f"Извлеки инвойс:\n\n{raw_invoice_text}"}
],
)
raw = resp.content[0].input # уже dict, уже валиден против схемы
invoice = Invoice.model_validate(raw) # дополнительная Pydantic-проверка
resp.content[0].input приходит как словарь Python, который уже чисто разбирается и уже удовлетворяет присланной вами JSON Schema. Вызов Invoice.model_validate(raw) нужен для подстраховки: он приводит типы по правилам Pydantic (строка "42" станет числом 42) и запускает ваши собственные валидаторы.
TypeScript: Zod → JSON Schema → tool_use
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
const SupportTicket = z.object({
category: z.enum(["billing", "bug", "feature_request", "abuse", "other"]),
urgency: z.enum(["low", "normal", "high", "critical"]),
summary: z.string().max(280),
suggested_owner_team: z.enum(["payments", "platform", "growth", "trust_safety"]),
contains_pii: z.boolean(),
});
const client = new Anthropic({
baseURL: "https://api.codexkey.ru/v1",
apiKey: process.env.CODEXKEY_KEY!,
});
const resp = await client.messages.create({
model: "claude-sonnet-5",
max_tokens: 1024,
tools: [{
name: "classify_ticket",
description: "Классифицировать входящий тикет поддержки.",
input_schema: zodToJsonSchema(SupportTicket, { target: "openAi" }) as any,
}],
tool_choice: { type: "tool", name: "classify_ticket" },
messages: [{ role: "user", content: ticketBody }],
});
const block = resp.content.find((b) => b.type === "tool_use");
const ticket = SupportTicket.parse(block?.input);
Обратите внимание на target: "openAi" в zodToJsonSchema. Он выдаёт вариант JSON Schema, который принимают и Claude, и OpenAI. Целевой формат Zod по умолчанию порождает конструкции $ref, и Anthropic API их отклоняет.
OpenAI-совместимый путь: response_format через шлюз codexkey
Если код у вас уже написан под OpenAI SDK, шлюз codexkey принимает формат OpenAI Chat Completions и внутри превращает response_format в форсированный tool_use. Переписывать ничего не придётся.
from openai import OpenAI
client = OpenAI(
base_url="https://api.codexkey.ru/v1",
api_key="sk-cxa-...",
)
resp = client.chat.completions.create(
model="claude-sonnet-5",
messages=[{"role": "user", "content": resume_text}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "Resume",
"strict": True,
"schema": Resume.model_json_schema(),
},
},
)
resume = Resume.model_validate_json(resp.choices[0].message.content)
Это самый короткий путь при уходе с OpenAI. SDK тот же, семантика response_format та же, качество вывода как у Claude.
Три конкретных примера
1. Извлечение данных из инвойса
На входе шумный текст инвойса, вытащенный из PDF. На выходе модель Invoice из примера на Python выше. Форсированный tool_use избавляет от регулярных выражений для разбора позиций: модель возвращает типизированный список, а последующий код напрямую читает invoice.line_items[0].unit_price_cents.
2. Классификация тикета поддержки
Берём Zod-схему SupportTicket. Энумы category и urgency ограничивают модель, поэтому результат можно маршрутизировать без защитных .toLowerCase() по всему коду. Булев флаг contains_pii сразу уходит в конвейер редактирования персональных данных.
3. Разбор резюме
class WorkExperience(BaseModel):
company: str
title: str
started_on: str = Field(description="YYYY-MM")
ended_on: str | None = Field(description="YYYY-MM или null, если текущая работа")
highlights: list[str]
class Resume(BaseModel):
full_name: str
email: str | None
phone: str | None
years_of_experience: int = Field(ge=0, le=60)
skills: list[str]
experience: list[WorkExperience]
Подключаем ту же обвязку на tool_use и получаем API для разбора резюме.
Как проектировать схемы
После нескольких десятков таких конвейеров в проде важнее остальных оказались несколько приёмов.
- Активно применяйте
enum. Если значения поля берутся из конечного набора, делайте его энумом. Выбирать из списка модель умеет заметно точнее, чем придумывать значение свободно. - Описания работают как промпты. Claude читает
descriptionкаждого поля. Пометки «ISO-8601 дата» или «телефон в формате E.164» меняют вывод. - Не уходите глубоко. Два уровня вложенности оптимальны. На трёх-четырёх модель начинает путаться, в какой
{она находится, даже при форсированном tool use. - Для трёх состояний берите строковый энум, а не булевы флаги.
status: "approved" | "rejected" | "needs_review"модель обрабатывает лучше, чем два независимых булевых поля. - Явно отмечайте необязательные поля.
Optional[str]в Pydantic или.optional()в Zod. Тогда модель аккуратно пропустит поле, а не выдумает"unknown". - Не просите свободный текст внутри структурных полей. Поле
summary: strв самом конце допустимо. Если же послеsummaryидут ещё структурные поля, проза обычно протекает в соседние.
Цикл валидации и ретрай
Даже при форсированном tool_use ваши бизнес-валидаторы (например, «total_cents должен равняться сумме позиций») могут не пройти. Паттерн прямолинеен:
def call_with_retry(messages, max_attempts=3):
for attempt in range(max_attempts):
resp = client.messages.create(
model="claude-sonnet-5",
max_tokens=2048,
tools=[extract_tool],
tool_choice={"type": "tool", "name": "record_invoice"},
messages=messages,
)
raw = resp.content[0].input
try:
return Invoice.model_validate(raw)
except ValidationError as e:
messages.append({"role": "assistant", "content": resp.content})
messages.append({
"role": "user",
"content": f"Этот вывод не прошёл валидацию: {e}. Вызови инструмент ещё раз с исправленными значениями.",
})
raise RuntimeError("Валидация схемы не прошла после ретраев")
Обычно хватает одного повтора. Во второй попытке Claude видит в контексте свой прежний вывод и ошибку валидации и надёжно исправляется сам.
GPT-4o json_schema против форсированного tool_use в Claude
Оба подхода в проде дают JSON, соответствующий схеме. На практике они отличаются так:
- Чем держится гарантия.
strict: trueу OpenAI обеспечивает ограниченное декодирование на уровне токенов, и модель буквально не может выдать невалидный JSON. Форсированныйtool_useу Anthropic обеспечивает валидатор входа инструмента. На уровне API разницы не видно (оба каждый раз возвращают валидный JSON), но если Claude всё же ошибётся, сбой проявится как «вызов вернул ошибку», а не как «JSON сломан». - Какие возможности схемы поддержаны. Строгий режим OpenAI запрещает
oneOf,not, рекурсивные ссылки и ещё несколько возможностей JSON Schema. Claude вtool_useпринимает более широкий поднабор, включая часть паттернов$ref(если их выдал правильный целевой формат Zod) и более богатые строкиpattern. - Роль описаний. Claude придаёт описаниям полей больше веса, чем GPT-4o. Если у схемы развёрнутые
description, ждите, что Claude использует их полнее. - Задержка. Форсированный
tool_useдобавляет небольшие постоянные накладные расходы по сравнению со свободной генерацией, но на наших замерах идёт вровень с режимомjson_schemaу GPT-4o. - Цена миграции. Через OpenAI-совместимый эндпоинт шлюза codexkey вы меняете одну строку, параметр
model, иresponse_formatпродолжает работать.
Итого
Claude не нужен флаг json_mode, потому что tool use уже даёт больше: типизированный контракт, который проверяет сам API. Опишите схему один раз в Pydantic или Zod, форсируйте один инструмент, и структурированный вывод у вас не хуже, чем у любого игрока на рынке. OpenAI-совместимая обёртка на шлюзе codexkey позволяет подставить Claude в существующий код на response_format, не трогая бизнес-логику. Добавьте цикл ретрая для собственных валидаторов, и JSON-конвейер перестанет ломаться.