Назад в блог

Решение проблемИнструкция

400 после вызова инструмента: верните reasoning_content

Почему второй запрос цикла с инструментами даёт 400 на модели с рассуждением, как исправить код на Python и когда поле возвращать не нужно.

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

Обложка: цикл с инструментом, где reasoning_content идёт из сообщения ассистента в следующий запрос
Содержание
  1. Что происходит между первым и вторым запросом
  2. Исправление: добавляйте сообщение как есть
  3. Частый виновник: сообщение, собранное вручную
  4. Когда это не нужно
  5. Если 400 всё равно приходит

Ваш агентный цикл отрабатывает первый запрос и падает на втором с ошибкой 400. На модели с рассуждением одна из документированных причин — пропавшее поле: у сообщения ассистента, которое вы отправляете обратно, нет reasoning_content. Если дело в этом, поможет добавление сообщения в историю целиком, как его вернул API.

Руководство DeepSeek по режиму рассуждения (документация, проверено 8 ноября 2026) формулирует правило одной фразой: если в запросе есть параметр tools, то reasoning_content нужно полностью передавать во всех следующих запросах, в том числе на ходах без вызова инструмента. Иначе API вернёт 400. Там же сказано, что рассуждение включено по умолчанию, с уровнем high. Поэтому цикл, написанный под модель без рассуждения, может сломаться, хотя вы не изменили ни строчки.

Что происходит между первым и вторым запросом#

В режиме рассуждения цепочка мыслей приходит в reasoning_content — поле на одном уровне с content. Вот как идёт цикл:

  1. Вы отправляете вопрос и список tools.
  2. Модель отвечает tool_calls (а рядом с ними — reasoning_content).
  3. Вы запускаете инструмент и отправляете всё обратно: историю, сообщение ассистента и сообщение tool с результатом.

Этот запрос из шага 3 и получает 400, если в сообщении ассистента нет reasoning_content.

Три шага цикла с инструментом: запрос, сообщение ассистента с reasoning_content, второй запрос
Путь поля: из сообщения ассистента в следующий запрос

DeepSeek описывает 400 только для случая с вызовом инструмента и текст ошибки не приводит. Поэтому проверяйте, та ли это проблема, по HTTP-статусу, а не по конкретной формулировке.

Исправление: добавляйте сообщение как есть#

Самое надёжное — вообще не пересобирать сообщение ассистента. Берёте его из ответа и добавляете целиком. Ниже полный цикл на OpenAI Python SDK, направленном на CheapTokens. Инструмент погоды — заглушка, так что код можно запускать как есть.

python
import json, 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"

tools = [{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "Current weather for a city",
        "parameters": {
            "type": "object",
            "properties": {"city": {"type": "string"}},
            "required": ["city"],
        },
    },
}]

def get_weather(city):
    return {"city": city, "temp_c": 7}  # stub

messages = [{"role": "user", "content": "Do I need a jacket in Oslo today?"}]

for _ in range(5):  # hard step limit
    resp = client.chat.completions.create(model=MODEL, messages=messages, tools=tools)
    msg = resp.choices[0].message
    messages.append(msg.model_dump(exclude_none=True))  # the whole message
    if not msg.tool_calls:
        print(msg.content)
        break
    for call in msg.tool_calls:
        args = json.loads(call.function.arguments)
        messages.append({
            "role": "tool",
            "tool_call_id": call.id,
            "content": json.dumps(get_weather(**args)),
        })

Главная здесь одна строка: messages.append(msg.model_dump(exclude_none=True)). SDK сохраняет поля, которых не знает, поэтому всё, что API положил в сообщение, включая reasoning_content, уходит обратно вместе с ним. Итоговый ответ тоже добавляется в историю — этого и требует правило: после первого вызова инструмента каждый следующий запрос несёт рассуждения прошлых ходов ассистента.

Устройство самих вызовов (схема, tool_call_id, разбор аргументов) разобрано в статье Function calling в Python с OpenAI-совместимым API. Базовый адрес, ключ и id модели — на странице документации CheapTokens; модель из примера описана на странице DeepSeek: контекст 380K и до 32 768 токенов в ответе, так что хватает и на длинное рассуждение, и на сам ответ.

Частый виновник: сообщение, собранное вручную#

Один из частых способов потерять поле — код, который добавляет в историю не объект ответа, а новый словарь. Выглядит безобидно: роль, текст, вызовы инструментов. Так же делают фреймворки и обёртки, когда хранят историю в своём формате. Единственное поле, о котором они не знают, как раз и теряется.

Слева сообщение ассистента, собранное из role, content, tool_calls. Справа то же сообщение как вернул API, с reasoning_content
Разница между двумя сообщениями ассистента — одно поле

Сравнение

Собрано вручную и добавлено целиком

Собрано вручную (ломается)

python
messages.append({
    "role": "assistant",
    "content": msg.content,
    "tool_calls": [c.model_dump() for c in msg.tool_calls],
})

Добавлено целиком (работает)

python
messages.append(msg.model_dump(exclude_none=True))

Если вы работаете через фреймворк и эту строку не поправить, посмотрите, как он хранит ходы ассистента. История, проходящая через сериализатор, который оставляет только content и tool_calls, ломается так же, только этажом ниже.

Когда это не нужно#

Не каждый вызов модели с рассуждением требует такой аккуратности. Для многоходового чата без инструментов руководство DeepSeek говорит, что reasoning_content возвращать не обязательно; если всё же отправить, оно игнорируется и прошлые рассуждения в контекст не попадают. Обычный чат может и дальше обходиться без этого поля — ничего не сломается.

Выберите свой случай

Какой у вас случай?

В запросе есть tools, модель рассуждает

Добавляйте сообщение ассистента целиком на каждом ходу — и с вызовом инструмента, и с итоговым ответом.

Обычный чат, параметра tools нет

Исправление не нужно. reasoning_content в истории можно не хранить, а можно отправлять — оно игнорируется.

Историю собирает фреймворк

Найдите, где он сериализует сообщения ассистента, и убедитесь, что reasoning_content не теряется. Если не получается, часть с инструментами вызовите своим циклом.

И ещё одно: историю вы отправляете заново, а это входные токены на каждом запросе, так что длинные рассуждения в длинном цикле складываются. Как считаются токены рассуждения — в статье Reasoning effort и токены рассуждения, а что делать, когда запрос вырос за пределы модели, — в Request too long: 400 и 413.

Если 400 всё равно приходит#

Проверьте по порядку:

  • Выведите messages прямо перед упавшим вызовом. У каждого сообщения ассистента от модели есть reasoning_content, если оно было в ответе?
  • Не пересобираете ли вы сообщение где-то ещё: во вспомогательной функции, в классе истории, в json.dumps и обратно с фиксированным списком ключей?
  • Есть ли сообщение tool для каждого tool_call_id, с тем же id?
  • Может, дело в длине? Запрос больше лимита контекста — это другая 400, она разобрана в статье выше.

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

Что добавлять в историю после того, как модель попросила инструмент?

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

Ответ: Сообщение ассистента целиком, как вернул API, вместе с reasoning_content

На tool_calls отвечают ваши сообщения tool, а при tools в запросе рассуждение должно идти вместе с ними.

Вот и всё исправление: один append, без пересборки. А в любой цикл, который уходит в работу, добавьте лимит шагов из примера: тогда плохой ход обойдётся в пять запросов, а не в пятьсот.