← Все статьи
#LLM#JSON#продакшн#интеграция

Structured outputs vs JSON mode: что действительно надёжно в проде

2026-04-27

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 может его не обработать. Нормализуйте на стороне сервера.

Стратегия для продакшна

  1. По умолчанию — tool use. 95% задач ложатся в эту парадигму.
  2. Валидация после парсинга. Даже tool use не гарантирует, что email действительно email. Прогоняйте через Zod/Pydantic/Joi.
  3. Retry на невалид. Если парсинг упал — ретрай с сообщением модели: "Предыдущий ответ не прошёл валидацию: <error>. Попробуй ещё раз." В 90% случаев второй раз попадает.
  4. Метрика невалидных ответов. Если у вас > 0.5% ошибок парсинга — что-то не так с промптом или схемой.
  5. Никогда не парсьте свободный текст через regex. Это всегда знак архитектурной ошибки.

Что сделать сегодня

Найдите в коде все места, где вы делаете JSON.parse(response.text) или response.json() из LLM-ответа без валидации. Каждое такое место — мина. Переведите на tool use с Zod-валидацией, или добавьте валидацию с retry. Займёт день, избавит от ночных дежурств.

Нужна помощь с архитектурой LLM-пайплайна под ваш случай? Напишите. Входит в "Аудит AI-инфраструктуры".