これはツールエラーではなく、メッセージ順序のエラーです。Claude では、assistant ターン内のすべての tool_use ブロックに対し、直後の user ターンで tool_result ブロックを返す必要があります。ID は同じで、その間に何も入れてはいけません。通常はツールが例外を投げたため、ループが 1 つ落としています。
エラー
{
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "messages.1: tool_use ids were found without tool_result blocks immediately after: toolu_01A... Each tool_use block must have a corresponding tool_result block in the next message."
}
}原因と対処法の概要
| 原因 | 対処法 |
|---|---|
| ツールが例外を投げたため、何も追加されなかった | is_error: true とエラーテキストを含む tool_result を必ず送信します。 |
| tool_result が後続のメッセージに入った | 直後のメッセージでなければなりません — その間に assistant または user のターンを入れてはいけません。 |
| tool_use_id が一致しない | tool_use ブロックの正確な ID をそのまま返します。新しく生成してはいけません。 |
| 履歴を往復の途中で切り詰めた | ツールの往復全体の境界で切り詰め、1 回の往復を構成する 2 つの部分の間では決して切り詰めないでください。 |
不変条件を一度で説明すると
assistant ターン内のすべての tool_use ブロックには、直後の user メッセージに、同じ tool_use_id を持つ tool_result ブロックが正確に 1 つ必要です。1 つのターンに複数の tool_use ブロックがある場合、その次の 1 メッセージ内に複数の tool_result ブロックが必要です。2 つのターンの間には何も入れてはいけません。
ツールが失敗しても必ず返信する
モデルは失敗したツールを適切に処理できますが、欠落したツールには対応できません。エラーを tool_result として返せば、会話は有効なままになり、通常は 400 ではなく適切な復旧処理が行われます。
results = []
for block in (b for b in resp.content if b.type == "tool_use"):
try:
out = run_tool(block.name, block.input)
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(out),
})
except Exception as e:
# A failed tool still owes the model an answer.
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": f"Tool failed: {e}",
"is_error": True,
})
messages.append({"role": "assistant", "content": resp.content})
messages.append({"role": "user", "content": results})送信前に最後の 2 つのターンを検証する
十数行のアサーションで、ネットワークから 400 が返る前に呼び出し箇所で検出できます。assistant ターンの tool_use ID を走査し、次の user ターンがすべてに応答していることを確認します。
def check_pairs(messages):
for i, m in enumerate(messages):
if m["role"] != "assistant" or not isinstance(m.get("content"), list):
continue
ids = {b.get("id") for b in m["content"]
if isinstance(b, dict) and b.get("type") == "tool_use"}
if not ids:
continue
nxt = messages[i + 1] if i + 1 < len(messages) else None
answered = {b.get("tool_use_id") for b in (nxt or {}).get("content", [])
if isinstance(b, dict) and b.get("type") == "tool_result"}
missing = ids - answered
assert not missing, f"message {i}: unanswered tool_use {missing}"往復の境界で履歴を切り詰める
メッセージ数でコンテキストウィンドウを切り詰めると、最終的には tool_use と tool_result の間で切れてしまいます。削除対象を決めるときは、このペアを分割できない 1 単位として扱います。
Kunavo経由で呼び出している場合
これはペイロード側の問題であり、Kunavo が取り繕うことはありません。400 は再試行不可リストに含まれるため、不正なツールの往復は、同じエラーに至るだけの 2 回目の上流ラウンドトリップの遅延を費やすことなく、1 回で失敗します。拒否されたリクエストのコストは 0 として記録されます。/v1/messages では Messages プロトコルを直接使用するため、tools、tool_use、tool_result ブロックは変換されずに転送されます。そこでは Anthropic API と同様に、400 は型が invalid_request_error のエラーとして返り、上流のメッセージテキストに続いて上流自身の request id が返され、request_id フィールドはありません。2026 年 9 月 24 日までは型が api_error だったため、ログがそれ以前まで遡る場合は HTTP ステータスとメッセージテキストで分岐してください。
よくある質問
応答せずに tool_use ターンを削除してもよいですか?
assistant ターン全体を削除するなら可能です。無効なのは、tool_use を残して tool_result を省略することです。
OpenAI 互換エンドポイントにも同じルールがありますか?
同じペアリングが必要ですが、表記が異なります。assistant メッセージの tool_calls の後に、各呼び出しにつき 1 つの role: "tool" メッセージを置き、tool_call_id を付けます。
拒否されたリクエストに料金はかかりますか?
Kunavo ではかかりません。失敗したリクエストはコスト 0 で記録され、課金対象の上流呼び出しには到達しません。