Structured outputs vs JSON mode: что действительно надёжно в проде
Если LLM у вас не чат-ассистент, а звено пайплайна — вам нужна гарантия, что ответ парсится. Невалидный JSON в проде — это не "ошибка модели", это инженерная халатность. Потому что есть три способа это решить, и два из них дают 99.9%+.
Разбираю, в чём разница между ними, где какой работает, и какие грабли у каждого.
Способ 1: "попросить нормально" (JSON prompting)
Самый популярный и самый хрупкий. В системном промпте пишете: "Верни JSON строго в таком формате: { ... }". Иногда добавляете "не пиши ничего кроме JSON".
Работает в 95% случаев. В 5% модель:
- Оборачивает ответ в
json ...блок - Добавляет "Вот ваш ответ:" перед JSON
- Ставит trailing comma
- Кладёт комментарии в JSON (
// this is the name) - На длинных ответах обрезается посередине из-за max_tokens
В продакшне 5% — это катастрофа. Если у вас 1000 запросов в день, 50 ломаются молча, пользователи видят "что-то пошло не так", вы не знаете, почему.
Когда допустимо: prototype, внутренняя тулза, задачи с ретраем без вреда.
Способ 2: tool use (function calling)
Настоящее решение. Описываете желаемую структуру как tool schema, модель вызывает тул, вы получаете аргументы уже в виде JSON.
const resp = await client.messages.create({
model: "claude-sonnet-4-6",
tools: [{
name: "extract_contact",
description: "Extract contact info from text",
input_schema: {
type: "object",
properties: {
name: { type: "string" },
email: { type: "string", format: "email" },
phone: { type: "string" }
},
required: ["name"]
}
}],
tool_choice: { type: "tool", name: "extract_contact" },
messages: [{ role: "user", content: userText }]
});
const args = resp.content.find(b => b.type === "tool_use").input;
// args — уже валидный объект с правильными типами
Ключевые моменты:
tool_choice: { type: "tool", name: "..." }— форсирует вызов именно этого тула. Без этого модель может решить не вызывать и ответить текстом.- Схема валидируется на уровне API — невалидный JSON просто не вернётся.
- Работает на всех Claude 4+ моделях стабильно.
У меня в проде tool use отрабатывает 99.95%+ на тысячах запросов. Оставшиеся 0.05% — это таймауты сети, не ошибки формата.
Когда брать: всегда, когда нужна структурированная экстракция или classification.
Способ 3: strict JSON schema (prefill + validation)
Гибрид для случаев, когда tool use неудобен (например, вам нужно много объектов в массиве).
Техника: prefill ответа { в поле messages, заставляет модель продолжить с этого символа.
const resp = await client.messages.create({
model: "claude-sonnet-4-6",
messages: [
{ role: "user", content: userPrompt },
{ role: "assistant", content: "{" }
]
});
const jsonText = "{" + resp.content[0].text;
const parsed = JSON.parse(jsonText);
Плюс: гарантированно начинается с {, не будет "Вот ваш JSON:" в начале.
Минус: не гарантирует валидности структуры внутри. Нужна Zod / Pydantic валидация сверху.
Надёжность ~99.7% при связке с retry-on-parse-error. Медленнее tool use и требует больше кода.
Когда брать: когда tool schema не выразить (динамические ключи, очень вложенные структуры), либо когда нужно стримить JSON.
Грабли, которые ловят всех
Max_tokens. Самая частая причина "сломанного JSON". Модель честно возвращает валидный префикс, но упирается в лимит и обрывается. Фикс: ставьте max_tokens с запасом x2 от ожидаемого размера, и логируйте stop_reason. Если stop_reason === "max_tokens" — вы не получили полный ответ, даже если он парсится.
Пустые строки там, где должен быть null. LLM часто кладёт "" вместо null или наоборот. Решается схемой: { type: ["string", "null"] } или строгая валидация с coercion.
Числа как строки. "age": "30" вместо "age": 30. Tool use это ловит схемой, JSON prompting — нет.
Юникод и переводы строк. В content-полях модель может вставить \n — это валидный JSON, но ваш frontend может его не обработать. Нормализуйте на стороне сервера.
Стратегия для продакшна
- По умолчанию — tool use. 95% задач ложатся в эту парадигму.
- Валидация после парсинга. Даже tool use не гарантирует, что email действительно email. Прогоняйте через Zod/Pydantic/Joi.
- Retry на невалид. Если парсинг упал — ретрай с сообщением модели: "Предыдущий ответ не прошёл валидацию:
<error>. Попробуй ещё раз." В 90% случаев второй раз попадает. - Метрика невалидных ответов. Если у вас > 0.5% ошибок парсинга — что-то не так с промптом или схемой.
- Никогда не парсьте свободный текст через regex. Это всегда знак архитектурной ошибки.
Что сделать сегодня
Найдите в коде все места, где вы делаете JSON.parse(response.text) или response.json() из LLM-ответа без валидации. Каждое такое место — мина. Переведите на tool use с Zod-валидацией, или добавьте валидацию с retry. Займёт день, избавит от ночных дежурств.
Нужна помощь с архитектурой LLM-пайплайна под ваш случай? Напишите. Входит в "Аудит AI-инфраструктуры".