5 月の頭、壁紙アプリのレビュー文要約バッチを動かしていた最中に Tool result could not be submitted because the previous turn was not a tool use というエラーが本番ジョブを止めました。1 日 8,000 件ほどのレビュー要約を回しているなかでの停止だったので、まず焦って公式ドキュメントを読み返したのですが、原因の入り口が複数あって切り分けが面倒なエラーです。同じ症状で動いている方の参考になるように、私が落ち着くまでの順序を残しておきます。
廣川政樹と申します。アーティスト・個人開発者として 2014 年から壁紙アプリ群(累計 5,000 万ダウンロード超)の運営をしており、Claude API は記事生成と AdMob レビュー対応の自動化に使っています。本記事の話は、その AdMob レビュー要約バッチを実装したときの実体験です。
このエラーが出る 3 つのパターン
最初に押さえておきたいのは、このエラーは「ツール呼び出しの直後ではない場所に tool_result ブロックが置かれた」という 1 種類の状況に集約される、ということです。ただし、発生の入り口が 3 つあるので、それを混同していると延々と直しても消えません。
- A. 履歴の編集ミス: 過去のターンを途中で削った結果、
tool_useだけが消えてtool_resultだけが残った - B. リトライ実装のバグ: API エラーで再送するとき、
tool_use抜きでtool_resultから送り直してしまった - C. 並列リクエストの順序崩れ: 非同期で 2 本走らせていて、
tool_use側のレスポンスが先に書き戻る前にtool_resultを送信した
私が今回踏んだのは B でした。レート制限の 429 を見たときに、messages 配列の末尾だけを再送する素朴な再試行を入れていたのが原因です。Anthropic SDK のリトライ機構に任せていればよかったところを、自分でコントロールしたかったあまり巻き込み事故を起こした、というのが正直な反省点です。バッチが停止したのが朝 7 時で、AdMob のレビュー応答が滞留したまま午前中いっぱい原因を追っていたので、本番影響もそれなりに発生しました。
切り分けの順序
エラーが出たらまず、その時点で送ろうとした messages 配列をログに丸ごと出力します。Anthropic SDK の messages.create() を呼ぶ直前で json.dumps するだけで十分です。確認するのは次の 2 点だけです。
- 末尾 N 件を逆順に見て、
tool_resultブロックの直前に必ずtool_useブロックがあるか - その
tool_useとtool_resultのtool_use_idが一致しているか
この 2 点が満たされていればエラーは出ません。逆に言うと、満たされない原因が A・B・C のどれかを特定できれば修正は一直線です。私の場合、ログの末尾を逆順に追って 3 ターン目で tool_use_id が抜けていることに気付き、その時点で B 確定でした。最初のうちは何時間か無駄にしましたが、慣れてくると 5 分以内に切り分けられるようになります。
リトライ層で会話履歴を自動修復する小さな実装
私が壁紙アプリのレビュー要約バッチで結局採用したのは、リトライ層で会話履歴の整合性を自分で検証してから再送するパターンです。最小限の Python 実装はこの程度の量で済みます。
from anthropic import Anthropic, APIError
from anthropic.types import MessageParam
import time
client = Anthropic()
def ensure_tool_pairing(messages: list[MessageParam]) -> list[MessageParam]:
"""tool_result の直前に対応する tool_use がない要素を捨てる。"""
safe: list[MessageParam] = []
last_tool_use_ids: set[str] = set()
for msg in messages:
content = msg.get("content", [])
if not isinstance(content, list):
safe.append(msg)
continue
new_blocks = []
for b in content:
t = b.get("type")
if t == "tool_use":
last_tool_use_ids.add(b["id"])
new_blocks.append(b)
elif t == "tool_result":
if b.get("tool_use_id") in last_tool_use_ids:
new_blocks.append(b)
else:
new_blocks.append(b)
if new_blocks:
safe.append({**msg, "content": new_blocks})
return safe
def call_with_repair(messages, max_retry=3):
for attempt in range(max_retry):
try:
return client.messages.create(
model="claude-sonnet-4-6",
max_tokens=2048,
messages=ensure_tool_pairing(messages),
tools=[...],
)
except APIError as e:
if "tool_result could not be submitted" in str(e):
# 履歴を再正規化してから 1 回だけ再送
messages = ensure_tool_pairing(messages)
time.sleep(0.5 * (2 ** attempt))
continue
raise
raise RuntimeError("repair retry exhausted")ポイントは ensure_tool_pairing を「リトライ前」と「初回送信前」の両方で必ず通すことです。私の最初の実装はリトライ前にしか通していなかったので、A のパターン(履歴編集側のバグ)で初回から落ちる場面を取りこぼしていました。
並列リクエストで踏むときに気をつけたいこと
C のケースを踏んだ知人の話で記録しておきたい点があります。彼は asyncio.gather で 2 本走らせたうち、片方が tool_use を含むレスポンスを返す前に、もう片方が tool_result を送ってしまっていました。Anthropic SDK 側は責められず、自分の状態管理がスレッド/タスク間で透けていることが原因です。
私の個人的な推奨は、tool_use を含む会話は 1 つのコルーチンに閉じ込めることです。並列化したいなら、独立した会話単位(=独立した messages 配列)として動かすほうが結局速く、デバッグも楽になります。共有状態を持ち込まないことが、結果的にスループットの上限も引き上げてくれるという経験則です。
私が今後採用しない選択肢
参考までに、検討したものの今回採用しなかった対処をいくつか挙げます。
ひとつは「tool_result ブロック自体を諦めて、ツール結果を user メッセージのプレーンテキストに埋め込む」というやり方です。これはエラーは消えますが、Claude が tools の戻り値を構造化したまま使う場面で精度が下がるのと、長期的な保守で何のためにツール定義を書いたのか分からなくなります。私自身、5,000 万ダウンロードの壁紙アプリ群を運営しながら検証ツールを少しずつ整えてきた経験から、構造を捨てる選択は最後の手段に置いておくのが賢明だと考えています。
もうひとつは「会話履歴を毎回 1 ターン目から再送する」やり方です。これはエラーの予防には効きますが、トークン消費が線形に増えるので、レビュー要約のような長期ジョブには向きません。Claude API のコストは無視できない規模になり始めると、こうした素朴な対処はすぐ採算の壁にぶつかります。
私は最終的に、上記のリトライ層を共通モジュールに切り出して、claudelab.net の記事生成パイプラインでも同じものを使っています。共通化したことで、別ジョブで同じエラーが再発したときの解析時間が体感で 1/3 に減りました。
このバッチを止めた経験から得た一番の学びは「Anthropic SDK の挙動を読み切れていないのに自前リトライを足してはいけない」ということでした。SDK の messages.create() は内部で 5xx のいくつかを自動再試行してくれていて、ユーザー側で再送する必要があるのは主に 429 と 529 だけです。私はそこを誤解して全部の例外で再送する実装を入れていたので、tool_use の途中で切れた応答に対しても再送してしまっていました。SDK のドキュメントの「Automatic Retries」のセクションを読み直したのは、エラーを潰したあとの落ち着いてからでした。
もう少し広い観点で言えば、AI 関連の API 連携で起きる障害は、純粋なネットワーク問題よりも「状態の流れ方を自分で誤解していた」という形のバグの方が圧倒的に多いと感じます。私自身、レビュー要約バッチに限らず、別の壁紙アプリで AdMob のリワード判定を組んだときも、似た形の「想定していなかった呼び出し順序」で半日溶かしたことがあります。今回はその経験があったぶん、3 つのパターンに分けて整理する発想にすぐたどり着けました。
次に試すべき 1 つのこと
エラーが出ているコードを今から触るなら、まず messages.create を呼ぶ直前で tool_use_id の対応状況を出力する print を 1 つ入れてみてください。それだけで A・B・C のどれを踏んでいるかが 5 分で分かるはずです。
5,000 万ダウンロード規模の壁紙アプリ群でも、API 連携のミスは最後まで残る種類のバグだと感じます。共に学んでいけたら嬉しいです。