Назад в блог

API и SDKИнструкция

Как получить валидный JSON от LLM API: response_format и ретрай

Готовый запрос на Python, который возвращает разбираемый JSON, обёртка над json.loads для пустых и обрезанных ответов и чек-лист причин поломки.

Автор: CheapTokens TeamОпубликовано 5 мин чтения

Терминал с запросом chat completion и параметром response_format json_object
Содержание
  1. Задача: письмо на входе, строка на выходе
  2. Почему пример важнее флага
  3. Безопасный разбор: пусто, обрезано или просто не JSON
  4. Проверяйте форму, а не только синтаксис
  5. Сколько стоит пачка писем
  6. Быстрая проверка

Чтобы получить JSON от чат-модели, нужно три вещи: передать response_format={"type": "json_object"}, написать в промпте слово «json» с примером структуры и поставить max_tokens с запасом на весь объект. А дальше не верить ответу на слово: проверить, что он не пустой, посмотреть finish_reason и только потом звать json.loads.

Это краткая версия. Остальное — один законченный скрипт для частой задачи: достать поля из письма. В примерах — адрес https://api.cheaptokens.dev/v1 и id модели deepseek-ai/DeepSeek-V4-Flash-0731 (документация).

Задача: письмо на входе, строка на выходе#

Допустим, скрипт читает письма в поддержку и превращает каждое в запись: отправитель, дата, сумма и чего он хочет. Возьмём такое письмо:

Здравствуйте, это Анна Петрова из Lumen Ltd. 14 октября я оплатила счёт №3381 на 1 250 EUR, но в квитанции он всё ещё «не оплачен». Исправьте, пожалуйста, до пятницы.

Назад нужно получить вот это и ничего больше:

json
{"sender": "Anna Petrova", "date": "2026-10-14", "amount": 1250, "currency": "EUR", "action": "fix receipt status"}

На простое «достань поля» модель может ответить вежливой фразой, блоком кода или и тем и другим. json.loads спотыкается на первой же обратной кавычке. Эту беду вместе лечат response_format и нормальный промпт.

Один и тот же запрос на Python и curl

Python

python
import json
import os

from openai import OpenAI

client = OpenAI(
    base_url="https://api.cheaptokens.dev/v1",
    api_key=os.environ["CHEAPTOKENS_API_KEY"],
)
MODEL = "deepseek-ai/DeepSeek-V4-Flash-0731"

SYSTEM = """Extract fields from the email and answer with json only.
Example of the json format:
{"sender": "Jane Roe", "date": "2026-01-31", "amount": 99.5, "currency": "USD", "action": "short phrase"}
Use null for a field that is not in the email."""

resp = client.chat.completions.create(
    model=MODEL,
    messages=[
        {"role": "system", "content": SYSTEM},
        {"role": "user", "content": email_text},
    ],
    response_format={"type": "json_object"},
    max_tokens=1000,
    temperature=0,
)
print(resp.choices[0].message.content)

curl

bash
curl https://api.cheaptokens.dev/v1/chat/completions \
  -H "Authorization: Bearer $CHEAPTOKENS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "deepseek-ai/DeepSeek-V4-Flash-0731",
    "messages": [
      {"role": "system", "content": "Extract fields from the email and answer with json only. Example: {\"sender\": \"Jane Roe\", \"amount\": 99.5}"},
      {"role": "user", "content": "Hi, this is Anna Petrova from Lumen Ltd. I paid invoice #3381 for 1,250 EUR..."}
    ],
    "response_format": {"type": "json_object"},
    "max_tokens": 1000
  }'

В справке DeepSeek API про JSON Output (проверено 27 октября 2026; даты публикации на странице нет) описаны правила, которым следует этот запрос: response_format со значением json_object, слово «json» в системном или пользовательском промпте и пример нужной структуры. Без примера модели приходится угадывать названия полей, и на следующем письме она угадает иначе.

Почему пример важнее флага#

response_format просит API вернуть JSON-объект. Но не говорит, какой именно. Это как сказать коллеге «пришли таблицу» и не назвать колонки: таблица будет, только не ваша.

Пример в промпте — это строка заголовков. Работает лучше, если держаться двух привычек:

  • Показывать настоящую форму вместе с типами: 1250 — число, "2026-10-14" — строка в фиксированном формате даты.
  • Сказать, что делать, если поля в письме нет (выше — null). Иначе модель придумает значение, чтобы закрыть пробел.

response_format — сильная подсказка, а не гарантия. Модель всё равно может вернуть пустую строку или обернуть объект в пояснения. Для этого и нужна следующая часть.

Безопасный разбор: пусто, обрезано или просто не JSON#

На практике встречаются три поломки, и у каждой свой след:

  • Пустой ответ. В документации DeepSeek сказано, что в JSON-режиме API иногда возвращает пустое содержимое, и советуют переформулировать промпт. У вас это выглядит как message.content равный "" или None, а json.loads("") падает с Expecting value: line 1 column 1 (char 0).
  • Обрезано. max_tokens закончился посреди объекта. finish_reason равен length, текст обрывается на середине строки. Подробнее — в статье почему ответ ИИ обрывается на полуслове.
  • Не JSON. Фраза перед объектом или блок кода вокруг него.
json.loads на пустом ответе падает с JSONDecodeError
Как в Python выглядит пустой ответ в JSON-режиме

Первую и третью поломку лечит повтор запроса с более строгим промптом. Со второй повтор с тем же лимитом ничего не даст, поэтому обёртка в этом случае вызывает ошибку и просит поднять max_tokens:

python
def ask_json(email_text: str, retries: int = 1) -> dict:
    user = email_text
    for attempt in range(retries + 1):
        resp = client.chat.completions.create(
            model=MODEL,
            messages=[
                {"role": "system", "content": SYSTEM},
                {"role": "user", "content": user},
            ],
            response_format={"type": "json_object"},
            max_tokens=1000,
            temperature=0,
        )
        choice = resp.choices[0]
        if choice.finish_reason == "length":
            raise ValueError("JSON cut off by max_tokens: raise the limit")
        text = (choice.message.content or "").strip()
        if text:
            try:
                return json.loads(text)
            except json.JSONDecodeError:
                pass
        # empty or not JSON: ask again, more firmly
        user = email_text + "\n\nReply with one json object only, in the format of the example."
    raise ValueError("no valid JSON after retry")

Для скрипта хватает одного повтора. Если не вышло дважды, запишите id письма в лог и идите дальше: третий такой же запрос редко помогает.

Найдите пары

Какой симптом указывает на какую причину?

  • finish_reason равен «length»max_tokens мал для этого объекта
  • content — пустая строкаJSON-режим ничего не вернул; переформулируйте промпт и повторите
  • JSONDecodeError на символе обратной кавычкимодель завернула JSON в блок кода
  • Названия полей отличаются от письма к письмув промпте нет примера структуры

Проверяйте форму, а не только синтаксис#

json.loads доказывает лишь то, что текст — корректный JSON. Что нужные поля на месте, он не доказывает. Добавьте дешёвую проверку сразу после разбора:

python
REQUIRED = {"sender", "date", "amount", "currency", "action"}

data = ask_json(email_text)
missing = REQUIRED - data.keys()
if missing:
    raise ValueError(f"missing fields: {sorted(missing)}")

После этого словарь можно класть прямо в строку базы или таблицы. Если же модель должна вызывать ваши функции, а не заполнять фиксированную запись, это другой инструмент: см. вызов функций на Python.

Путь от письма до проверенной записи: промпт с примером, json_object, разбор, проверка полей
Весь путь в пяти шагах

Сколько стоит пачка писем#

Короткое извлечение — самый дешёвый тип запроса: несколько сотен токенов на входе и несколько десятков на выходе. Допустим, 1 000 писем в день, около 400 входных токенов (промпт, пример и письмо) и около 80 выходных (сам JSON). На DeepSeek-V4-Flash это $0.014 в день, а таблица показывает ту же нагрузку на других моделях:

Модель За запрос 1 000 запросов OpenRouter, 1 000 запросов
GLM-5.3-Flash $0.000011 $0.0112 $0.0226
DeepSeek-V4-Flash $0.000014 $0.014 $0.0315
MiniMax-M2.7 $0.000032 $0.032 $0.216

400 входных и 80 выходных токенов на запрос. Цены за 1M токенов актуальны и меняются вместе с нашими тарифами; столбец OpenRouter показывает справочные цены для той же нагрузки.

В сумме, с учётом входа и выхода, DeepSeek-V4-Flash на этой нагрузке выходит дешевле референсной цены OpenRouter. Кроме того, ответ может занимать до 32 768 токенов, поэтому длинный список позиций не обрежется так, как при маленьком лимите, а цена одинакова в любое время суток. GLM-5.3-Flash (zai-org/GLM-5.3-Flash) — ещё один вариант по тому же адресу API: ответ до 8 192 токенов, для одной записи на письмо этого с запасом. Актуальные цены — на странице тарифов, а подробности про модель из примера — на странице DeepSeek.

Быстрая проверка#

Быстрая проверка

JSON обрезан посреди строки. Куда смотреть в первую очередь?

Показать ответ

Ответ: На finish_reason и max_tokens

У обрезанного объекта finish_reason равен length. Лимит исчерпан, поэтому поднимайте max_tokens; temperature и адрес тут ни при чём.

Если вы ещё не отправляли ни одного запроса, начните с первого запроса к API, а потом вернитесь и подставьте код отсюда.