API и SDKИнструкция
Как получить валидный JSON от LLM API: response_format и ретрай
Готовый запрос на Python, который возвращает разбираемый JSON, обёртка над json.loads для пустых и обрезанных ответов и чек-лист причин поломки.

Содержание
Чтобы получить JSON от чат-модели, нужно три вещи: передать response_, написать в промпте слово «json» с примером структуры и поставить max_tokens с запасом на весь объект. А дальше не верить ответу на слово: проверить, что он не пустой, посмотреть finish_reason и только потом звать json.loads.
Это краткая версия. Остальное — один законченный скрипт для частой задачи: достать поля из письма. В примерах — адрес https:/ и id модели deepseek- (документация).
Задача: письмо на входе, строка на выходе#
Допустим, скрипт читает письма в поддержку и превращает каждое в запись: отправитель, дата, сумма и чего он хочет. Возьмём такое письмо:
Здравствуйте, это Анна Петрова из Lumen Ltd. 14 октября я оплатила счёт №3381 на 1 250 EUR, но в квитанции он всё ещё «не оплачен». Исправьте, пожалуйста, до пятницы.
Назад нужно получить вот это и ничего больше:
{"sender": "Anna Petrova", "date": "2026-10-14", "amount": 1250, "currency": "EUR", "action": "fix receipt status"}На простое «достань поля» модель может ответить вежливой фразой, блоком кода или и тем и другим. json.loads спотыкается на первой же обратной кавычке. Эту беду вместе лечат response_format и нормальный промпт.
Один и тот же запрос на Python и curl
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
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. Фраза перед объектом или блок кода вокруг него.

Первую и третью поломку лечит повтор запроса с более строгим промптом. Со второй повтор с тем же лимитом ничего не даст, поэтому обёртка в этом случае вызывает ошибку и просит поднять max_tokens:
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. Что нужные поля на месте, он не доказывает. Добавьте дешёвую проверку сразу после разбора:
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.

Сколько стоит пачка писем#
Короткое извлечение — самый дешёвый тип запроса: несколько сотен токенов на входе и несколько десятков на выходе. Допустим, 1 000 писем в день, около 400 входных токенов (промпт, пример и письмо) и около 80 выходных (сам JSON). На DeepSeek-V4-Flash это $0.014 в день, а таблица показывает ту же нагрузку на других моделях:
| Модель | Вход $/1M | Выход $/1M | За запрос | 1 000 запросов | OpenRouter, 1 000 запросов |
|---|---|---|---|---|---|
| GLM-5.3-Flash | $0.02 | $0.04 | $0.000011 | $0.0112 | $0.0226 |
| DeepSeek-V4-Flash | $0.015 | $0.10 | $0.000014 | $0.014 | $0.0315 |
| MiniMax-M2.7 | $0.05 | $0.15 | $0.000032 | $0.032 | $0.216 |
400 входных и 80 выходных токенов на запрос. Цены за 1M токенов актуальны и меняются вместе с нашими тарифами; столбец OpenRouter показывает справочные цены для той же нагрузки.
В сумме, с учётом входа и выхода, DeepSeek-V4-Flash на этой нагрузке выходит дешевле референсной цены OpenRouter. Кроме того, ответ может занимать до 32 768 токенов, поэтому длинный список позиций не обрежется так, как при маленьком лимите, а цена одинакова в любое время суток. GLM-5.3-Flash (zai-) — ещё один вариант по тому же адресу API: ответ до 8 192 токенов, для одной записи на письмо этого с запасом. Актуальные цены — на странице тарифов, а подробности про модель из примера — на странице DeepSeek.
Быстрая проверка#
Быстрая проверка
JSON обрезан посреди строки. Куда смотреть в первую очередь?
Показать ответ
Ответ: На finish_reason и max_tokens
У обрезанного объекта finish_reason равен length. Лимит исчерпан, поэтому поднимайте max_tokens; temperature и адрес тут ни при чём.
Если вы ещё не отправляли ни одного запроса, начните с первого запроса к API, а потом вернитесь и подставьте код отсюда.



