Skip to content

Ошибка Claude API «tool_use ids were found without tool_result blocks immediately after»: как исправить ​

Запрос вашего агента завершается ошибкой HTTP 400 invalid_request_error: tool_use ids were found without tool_result blocks immediately after. Проблема кроется в истории сообщений, а не в самой модели. Ниже разобраны правила порядка сообщений, которые проверяет API, типичные сценарии в коде, нарушающие эти правила, а также готовый цикл обработки и валидатор для поддержания корректной структуры диалога.

Короткий ответ

На каждый блок tool_use в сообщении ассистента должен быть дан ответ в виде блока tool_result с соответствующим tool_use_id в самом следующем сообщении (с ролью user) — без каких-либо промежуточных сообщений, причём блоки tool_result должны идти первыми в массиве контента, до любого текста. Эта ошибка означает, что хотя бы для одного идентификатора tool_use в нужном месте не оказалось результата. Типичные причины: инструмент выбросил исключение и был пропущен, дан ответ только на один из нескольких параллельных вызовов, перед результатами вставлен пользовательский текст или обрезка истории отделила вызов от его результата. Исправьте историю — повторная отправка того же запроса приведёт к той же ошибке.

Симптомы и смысл ошибки ​

Ключевая фраза приведена в документации Anthropic по работе с инструментами, а полное сообщение, встречающееся в публичных баг-репортах, указывает индекс проблемного сообщения и идентификаторы вызовов:

json
{
  "type": "error",
  "error": {
    "type": "invalid_request_error",
    "message": "messages.3: `tool_use` ids were found without `tool_result` blocks immediately after: toolu_01A09q90qw90lq917835lq9. Each `tool_use` block must have a corresponding `tool_result` block in the next message."
  }
}

Префикс messages.N — это индекс (начиная с нуля) в вашем массиве messages (в известных случаях он указывает на ход ассистента, чей вызов остался без ответа), поэтому вы можете сразу найти нужное место в логах.

Родственные ошибки из того же семейства:

  • Зеркальная ситуация (формулировка из отчётов): unexpected `tool_use_id` found in `tool_result` blocks: toolu_... — результат есть, но запросивший его ход ассистента отсутствует или находится не непосредственно перед ним.
  • Незавершённые серверные инструменты (официальная формулировка): `web_search` tool use with id `srvtoolu_...` was found without a corresponding `web_search_tool_result` block — вы добавили контент после результатов, пока вызов серверного инструмента ещё ожидал выполнения.
  • Изменённые блоки рассуждений (официальная формулировка): `thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified — блоки thinking в вызывающем ходе ассистента были изменены или удалены.

Правила, которые проверяет API ​

Согласно разделам документации Anthropic «Handle tool calls», «Parallel tool use» и «Stop reasons»:

  1. Результаты идут сразу после вызовов. Между сообщением ассистента с tool_use и сообщением пользователя с tool_result не должно быть других сообщений.
  2. Результаты идут первыми. В этом сообщении пользователя блоки tool_result должны располагаться в самом начале массива content, а любой текст — после всех них.
  3. Один результат на каждый вызов. Каждому блоку tool_use должен соответствовать один блок tool_result с тем же tool_use_id.
  4. Параллельные вызовы объединяются в одно сообщение. Возвращайте все результаты вместе в одном сообщении пользователя; по данным Anthropic, раздельные сообщения «отучают» модель вызывать инструменты параллельно.
  5. Ожидающие серверные инструменты требуют сообщения только с результатами. Если в том же ходе был вызван серверный инструмент (например, веб-поиск), результат которого ещё не получен, сообщение пользователя должно содержать только блоки tool_result, а массив tools должен оставаться неизменным.
  6. Блоки рассуждений передаются вместе с вызовом. Передавайте блоки thinking из хода ассистента без изменений, включая подпись (signature).

Один нюанс: Messages API автоматически объединяет идущие подряд сообщения с ролью user в одно. Поэтому само по себе разделение результатов на два пользовательских сообщения может и не вызвать ошибку, но текстовое сообщение пользователя, вставленное перед результатами, при слиянии окажется впереди них и нарушит правило 2.

Почему это происходит ​

  1. Инструмент выбросил исключение, и цикл пропустил его вместо того, чтобы вернуть результат с флагом ошибки.
  2. Обработан только первый из нескольких параллельных вызовов — код прочитал response.content[0] или остановился на первом совпадении.
  3. Пользователь отправил реплику, пока выполнялся инструмент, и приложение добавило этот текст перед результатами.
  4. Обрезка истории удалила сообщение ассистента, но оставила его результаты, или наоборот.
  5. Был сохранён обрезанный ответ. При stop_reason: "max_tokens" последний блок может оказаться незавершённым tool_use; Anthropic рекомендует повторить запрос с увеличенным max_tokens, а не сохранять частичный вызов.
  6. Прерванный запуск сохранился посреди цикла — сбой, отмена или обрыв потокового соединения произошли после сохранения хода ассистента, но до записи результатов.
  7. Ошибки конвертации форматов между сообщениями в стиле OpenAI (tool_calls / role: "tool") и блоками Anthropic привели к потере, перестановке или несовпадению идентификаторов.

Решение 1: цикл обработки инструментов, который всегда отвечает на каждый вызов ​

Возвращайте результат для каждого tool_use, включая сбои, в одном сообщении пользователя:

python
import json
import anthropic

client = anthropic.Anthropic()

def run_loop(messages, tools, model="claude-sonnet-5-5", max_tokens=4096):
    while True:
        resp = client.messages.create(model=model, max_tokens=max_tokens,
                                      tools=tools, messages=messages)
        # Незавершённый вызов инструмента: не сохраняем, повторяем с большим лимитом
        if (resp.stop_reason == "max_tokens" and resp.content
                and resp.content[-1].type == "tool_use"):
            max_tokens *= 2
            continue
        # Сохраняем все блоки (включая thinking) в точности как получено
        messages.append({"role": "assistant", "content": resp.content})
        if resp.stop_reason != "tool_use":
            return resp
        results = []
        for block in resp.content:
            if block.type != "tool_use":
                continue
            try:
                output = run_tool(block.name, block.input)   # ваш диспетчер
                results.append({"type": "tool_result", "tool_use_id": block.id,
                                "content": json.dumps(output)})
            except Exception as e:
                results.append({"type": "tool_result", "tool_use_id": block.id,
                                "content": f"Error: {e}", "is_error": True})
        # Одно сообщение пользователя, только блоки tool_result
        messages.append({"role": "user", "content": results})

Примечания:

  • is_error: true — документированный способ сообщить о сбое; Claude прочитает его и сможет повторить попытку или объяснить ситуацию. Пишите информативные сообщения ("Rate limit exceeded. Retry after 60 seconds."), а не просто "failed".
  • Если пользователь что-то написал во время выполнения инструментов, добавьте этот текст после результатов или отправьте следующим ходом пользователя.
  • В продакшене ограничьте сверху удвоение max_tokens.
  • В официальных SDK также есть Tool Runner, который берёт управление этим циклом на себя; используйте его, если вам не нужна нестандартная логика.

Решение 2: валидация истории перед каждым запросом ​

Быстрая проверка позволяет перехватить повреждённую историю до отправки в API (работает с обычными словарями; для сохранённых объектов SDK вызовите .model_dump()):

python
def blocks(msg):
    c = msg["content"]
    return [] if isinstance(c, str) else c

def check_tool_pairs(messages):
    problems = []
    for i, msg in enumerate(messages):
        if msg["role"] != "assistant":
            continue
        ids = {b["id"] for b in blocks(msg) if b.get("type") == "tool_use"}
        if not ids:
            continue
        nxt = messages[i + 1] if i + 1 < len(messages) else None
        if nxt is None or nxt["role"] != "user":
            problems.append(f"messages.{i}: no user message with results follows")
            continue
        leading = []
        for b in blocks(nxt):
            if b.get("type") != "tool_result":
                break               # результаты должны идти первыми
            leading.append(b["tool_use_id"])
        missing = ids - set(leading)
        extra = set(leading) - ids
        if missing:
            problems.append(f"messages.{i}: unanswered {sorted(missing)}")
        if extra:
            problems.append(f"messages.{i + 1}: results for unknown ids {sorted(extra)}")
    return problems

Запускайте эту функцию в тестах и логируйте её вывод в продакшене. Пустой список означает, что все правила порядка соблюдены.

Решение 3: восстановление уже повреждённого диалога ​

Если сохранённая сессия застряла и каждое новое сообщение вызывает ошибку 400, пропатчите историю вместо того, чтобы сбрасывать её целиком:

  • Отсутствующие результаты: для каждого неотвеченного идентификатора вставьте tool_result с "is_error": true и текстом вроде "Tool call was cancelled." в самое начало сообщения пользователя, идущего сразу за соответствующим ходом ассистента.
  • Осиротевшие результаты: удалите блоки tool_result, чьи идентификаторы отсутствуют в непосредственно предшествующем сообщении ассистента.
  • Текст перед результатами: переместите его в конец, после всех блоков tool_result.
  • Обрезка по длине: разрезайте историю только на обычных текстовых ходах пользователя; на странице об ошибке prompt is too long приведена вспомогательная функция для этого.

При восстановлении оставляйте блоки thinking в сообщении ассистента побайтово неизменными — их редактирование приведёт к другой ошибке 400.

Решение 4: правильное использование OpenAI-совместимого формата ​

На OpenAI-совместимом эндпоинте то же самое правило записывается иначе. Каждому элементу в массиве tool_calls ассистента требуется отдельное сообщение с role: "tool" и соответствующим tool_call_id, и все они должны идти до следующего сообщения пользователя или ассистента:

json
[
  {"role": "assistant", "content": null, "tool_calls": [
    {"id": "call_1", "type": "function",
     "function": {"name": "get_weather", "arguments": "{\"city\": \"Paris\"}"}},
    {"id": "call_2", "type": "function",
     "function": {"name": "get_weather", "arguments": "{\"city\": \"Tokyo\"}"}}
  ]},
  {"role": "tool", "tool_call_id": "call_1", "content": "18°C, cloudy"},
  {"role": "tool", "tool_call_id": "call_2", "content": "24°C, clear"}
]

Пропущенное сообщение tool или вклинившееся между ними сообщение user — это та же самая ошибка, но в формате OpenAI.

Если вы вызываете Claude через byesu ​

byesu — это AI API-шлюз с OpenAI-совместимым эндпоинтом (https://byesu.com/v1) и нативным эндпоинтом Anthropic (/v1/messages на том же хосте) с оплатой по мере использования (pay-as-you-go) без подписок. В контексте этой ошибки важны два момента:

  • Оба формата требуют соблюдения одного и того же правила. На /v1/messages следуйте правилам для блоков, описанным выше; на /v1/chat/completions — правилам связки tool_calls / role: "tool" из Решения 4. Через OpenAI-совместимый эндпоинт сообщение об ошибке может приходить обёрнутым в объект ошибки формата OpenAI — ищите в нём подстроку tool_use ids were found without tool_result blocks.
  • Будьте готовы к параллельным вызовам. Модели claude-opus-5-5, claude-sonnet-5-5 и claude-haiku-5-5 поддерживают параллельный вызов инструментов, поэтому отвечайте на все вызовы одного хода в едином сообщении. Обратите внимание, что Claude Opus 5.5 и Sonnet 5.5 отклоняют принудительный вызов инструментов (tool_choice со значением any или именем конкретного инструмента) — используйте auto.

Чтобы начать работу, создайте токен в консоли, укажите ваш SDK на приведённый выше эндпоинт и запустите цикл из Решения 1. Полная инструкция по настройке: Быстрый старт.

Часто задаваемые вопросы (FAQ) ​

Что означает «tool_use ids were found without tool_result blocks immediately after»? ​

Это означает, что в истории диалога есть сообщение ассистента с блоком tool_use, на который нет ответного блока tool_result с тем же tool_use_id в самом начале следующего сообщения пользователя. API проверяет всю историю при каждом запросе, поэтому запрос будет завершаться ошибкой, пока вы не добавите недостающий результат, не удалите лишнее промежуточное сообщение или не перенесёте текст, поставленный перед результатами.

Можно ли в сообщении Claude API ставить текст перед блоками tool_result? ​

Нет. В сообщении пользователя, отвечающем на вызов инструмента, блоки tool_result должны идти первыми в массиве content, а любой текст — после всех них. Текст в начале приводит к ошибке 400. Если в ходе ассистента также был вызван серверный инструмент, результат которого ещё ожидается, сообщение должно содержать только блоки tool_result.

Как возвращать результаты при параллельном вызове инструментов? ​

Отправьте по одному блоку tool_result для каждого блока tool_use из хода ассистента — все вместе в одном сообщении пользователя, с соответствующими tool_use_id. Anthropic рекомендует именно такой формат: отправка каждого результата отдельным сообщением снижает частоту параллельных вызовов со стороны Claude, а ответ только на первый вызов приводит к этой ошибке.

Что возвращать, если инструмент завершился с ошибкой или был отменён? ​

Всё равно верните блок tool_result, указав is_error равным true и поместив в content краткое информативное сообщение об ошибке. Claude прочитает ошибку и сможет повторить попытку или объяснить сбой пользователю. Пропуск результата из-за неперехваченного исключения в инструменте — одна из самых частых причин этой ошибки 400.

Что означает «unexpected tool_use_id found in tool_result blocks»? ​

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

Возникла проблема? Напишите в поддержку или в наш чат.