← Блог

Модель вернула не JSON: как получать структурированный ответ

· 8 минут чтения

Коротко

Просьба «верни JSON» в промпте - это не гарантия, а вежливое пожелание. Модель может обернуть ответ в объяснение, добавить ```json, поставить запятую в конце или подставить в числовое поле текст вроде <ОСТАТОК>. Парсер после этого падает в проде, а не на тестах.

Надёжность набирается уровнями, и каждый следующий дороже предыдущего в работе:

  1. Промпт - бесплатно, ловит половину случаев.
  2. JSON mode - параметр запроса, гарантирует синтаксис, но не структуру.
  3. Строгая схема (structured output) - гарантирует и поля, и типы.
  4. Валидация у себя - то, что нужно делать всегда, независимо от трёх предыдущих.

Ниже - код каждого уровня, что именно он гарантирует и где всё равно рванёт.

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

Structured output (структурированный вывод) - режим, в котором вы передаёте модели описание формата ответа, а сервис гарантирует, что ответ ему соответствует. JSON mode - упрощённый вариант: гарантируется только то, что ответ будет синтаксически корректным JSON, без обещаний про поля.

Сравнение двух колонок, где строгая схема контролирует больше параметров, чем обычный JSON-режим.
Базовый режим спасает от сломанного синтаксиса, но только строгая схема гарантирует нужные поля.

Уровень 1: попросить в промпте

Так делают все и на этом же обжигаются.

resp = client.chat.completions.create(
    model="claude-haiku-4-5",
    messages=[
        {"role": "system", "content": "Верни только JSON вида {\"category\": str, \"amount\": number}. Без пояснений."},
        {"role": "user", "content": "Такси до аэропорта, 1450 рублей"},
    ],
)
data = json.loads(resp.choices[0].message.content)   # рано или поздно упадёт

Что здесь ломается на практике:

  • Обёртка в markdown. Ответ приходит внутри ```json … ```, и json.loads спотыкается о первую же строку.
  • Вежливое вступление. «Конечно! Вот результат:» перед объектом.
  • Плейсхолдер вместо числа. Модель не хочет считать и подставляет "amount": "<СУММА>". Формально JSON валидный, а в базу такое не ложится.
  • Лишние поля. Модель решает, что вам полезно знать её уверенность, и добавляет confidence, которого вы не просили.

Уровень имеет право на жизнь ровно в двух случаях: одноразовый скрипт и прототип, который вы смотрите глазами.

Уровень 2: JSON mode

Один параметр в запросе - и сервис гарантирует, что ответ разберётся как JSON.

resp = client.chat.completions.create(
    model="claude-haiku-4-5",
    messages=[...],
    response_format={"type": "json_object"},
)

Что вы получаете: синтаксис. Никаких ```, никаких вступлений, ни одной висящей запятой.

Что вы не получаете: обещаний про содержимое. Поле может называться sum вместо amount, число может приехать строкой, вложенный объект может оказаться массивом. Проверено на любом достаточном объёме: из тысячи вызовов десяток приедет с другой формой.

🔴 Грабля: в JSON mode модель обязана вернуть JSON - и если она не понимает запрос, она вернёт корректный JSON с мусором внутри вместо честного «не могу». Отсутствие ошибки перестаёт быть признаком успеха.

Уровень 3: строгая схема

Здесь вы передаёте не пожелание, а описание формата - и сервис не выпускает ответ, который ему не соответствует.

SCHEMA = {
    "type": "object",
    "properties": {
        "category": {"type": "string", "enum": ["еда", "транспорт", "жильё", "прочее"]},
        "amount":   {"type": "number"},
        "currency": {"type": "string", "enum": ["RUB", "USD"]},
    },
    "required": ["category", "amount", "currency"],
    "additionalProperties": False,
}

resp = client.chat.completions.create(
    model="claude-sonnet-5",
    messages=[{"role": "user", "content": "Такси до аэропорта, 1450 рублей"}],
    response_format={
        "type": "json_schema",
        "json_schema": {"name": "expense", "schema": SCHEMA, "strict": True},
    },
)

Три вещи, которые делают этот уровень рабочим:

  • enum вместо свободной строки. Категория больше не может приехать как «Транспорт», «транспортные расходы» или «taxi» - список закрыт.
  • additionalProperties: false. Модель не дописывает поля от себя.
  • required. Поле не может тихо исчезнуть, когда модель не уверена.

Именно enum даёт основной выигрыш. Свободная строка в поле категории - это ваша будущая работа по нормализации: полгода спустя в базе окажется семь написаний одного и того же.

Поддержка режима зависит от модели, а не только от сервиса. Строгую схему держат современные модели OpenAI и Gemini, у остальных придётся спускаться на уровень 2 плюс валидация. Проверять это надо на своей модели до того, как схема попадёт в прод.

Уровень 4: валидация на своей стороне

Единственный уровень, который делается всегда, какой бы режим вы ни включили.

from pydantic import BaseModel, Field, ValidationError
from typing import Literal

class Expense(BaseModel):
    category: Literal["еда", "транспорт", "жильё", "прочее"]
    amount: float = Field(gt=0)
    currency: Literal["RUB", "USD"]

def parse(raw: str) -> Expense | None:
    try:
        return Expense.model_validate_json(raw)
    except ValidationError as e:
        log.warning("модель вернула нерабочий ответ: %s", e)
        return None

Что это ловит из того, что не поймала схема:

  • Бессмысленные, но валидные значения. amount: 0, amount: -1450 - тип верный, смысл нулевой. Отсюда gt=0.
  • Ответ от модели, которая не поддерживает схему, - вы всё равно разбираетесь.
  • Смену поведения после обновления модели. Вендор выкатывает новую версию, формат немного едет, и падение вы видите в логе, а не в поддержке.

Ключевой момент - что делать при провале валидации. Рабочих сценариев два: повторить запрос с добавленным текстом ошибки или отправить случай на модель посильнее. Второе дешевле, чем кажется: если строгая схема на лёгкой модели даёт 97% пригодных ответов, то оставшиеся 3% на флагмане почти не двигают счёт.

Сколько это стоит

Считаем на реальной задаче: 50 000 чеков в месяц, примерно 300 входных и 60 выходных токенов на штуку. Цены - по каталогу на 23.08.2026.

СхемаСтоимость в месяц
Всё на Claude Sonnet 5$50.84
Всё на Claude Haiku 4.5$23.58
Haiku + повтор 3% случаев на Sonnet 5$25.11

Разница между «сразу флагман» и «лёгкая модель плюс подстраховка» - вдвое, при этом непригодных ответов в обеих схемах примерно поровну.

Столбчатая диаграмма, сравнивающая стоимость чистого флагмана, легкой модели и гибридного подхода.
Дешевая модель с локальной валидацией и перевызовом флагмана экономит половину бюджета.
Это и есть аргумент в пользу того, чтобы вкладываться в схему и валидацию, а не в более дорогую модель.

🔴 Оговорка про длину. Строгая схема не спасает от обрыва: если ответ упёрся в max_tokens, вы получите оборванный JSON. Смотрите finish_reason - при length ответ нужно не парсить, а перезапрашивать. Через шлюз запросы без явного max_tokens режутся на 4096 токенов, и на длинных выгрузках это заметно.

С чего начать

  1. Опишите формат схемой, а не фразой в промпте. Даже если пока используете JSON mode - схема пригодится как источник правды для валидатора.
  2. Закройте все перечислимые поля через enum. Это самая дешёвая правка с самым большим эффектом.
  3. Поставьте валидацию на своей стороне и логируйте каждый провал с текстом ответа.
  4. Прогоните сотню реальных примеров на лёгкой модели и посмотрите долю брака. Обычно она такая, что флагман не нужен.
  5. Проверьте finish_reason перед разбором ответа.

Все 44 модели с ценами и параметрами - на витрине. Про то, как раздавать задачи разным моделям, - отдельный разбор, а про остальные способы не переплачивать - десять приёмов с расчётами.

FAQ

Что такое structured output простыми словами?

Это режим, в котором вы передаёте модели описание нужного формата ответа - какие поля, каких типов, какие обязательны, - а сервис не выпускает ответ, который этому описанию не соответствует. В отличие от просьбы в промпте, это гарантия, а не пожелание.

Чем JSON mode отличается от строгой схемы?

JSON mode гарантирует только синтаксис: ответ точно разберётся как JSON. Названия полей, их типы и состав он не контролирует - поле может приехать под другим именем или числом-строкой. Строгая схема контролирует и структуру тоже.

Почему модель возвращает JSON внутри markdown-блока?

Потому что её обучали отвечать человеку, а человеку код показывают в блоке. Просьба «без пояснений» уменьшает частоту, но не убирает её. Убирает - response_format в запросе, а не формулировка промпта.

Что делать, если модель подставляет плейсхолдер вместо числа?

Это происходит, когда модель не хочет считать. Лечится двумя вещами сразу: типом number в схеме, который не пропустит строку, и валидацией с ограничением вроде «больше нуля» на своей стороне. Полагаться только на формулировку промпта здесь нельзя.

Все ли модели поддерживают строгую схему?

Нет. Современные модели OpenAI и Gemini держат её штатно, у части остальных доступен только JSON mode. Проверять поддержку нужно на конкретной модели до выката, а код писать так, чтобы он работал и без неё - за счёт валидации у себя.

Нужна ли валидация, если схема строгая?

Да. Схема проверяет типы, но не смысл: сумма -1450 пройдёт как валидное число. Плюс валидация - ваша страховка на случай смены версии модели и единственный способ узнать о поломке из лога, а не из обращения пользователя.