AIエージェントの「ツール呼び出しが時々失敗する」を、1,894回の実測で切り分けた
社内で提案資料を生成するAIエージェントを運用していたところ、ツール呼び出しが不定期に失敗する事象が続きました。エラーメッセージは Your tool call was malformed and could not be parsed. というものです。再現条件が掴めず、会話が長いせいだ、日本語のせいだ、といった説が現場で飛び交っている状態でした。
そこで全セッションのログを集計し、1,894回のツール呼び出しを横断で分析しました。結果として通説はすべて否定され、原因は入力の内容ではなく特定モデルバージョンの挙動にあることが分かりました。AIエージェントを業務で運用する際のデバッグ手順として、そのまま使える形で記録します。
症状: 再現条件が掴めない失敗
AIエージェントにファイル操作やコマンド実行を任せていると、ツール呼び出しが失敗して処理が止まることがあります。厄介なのは、同じ作業を繰り返しても毎回落ちるわけではない点です。数十回に一度、脈絡なく発生します。
この手の事象は、原因を推測した順に対策を試すと時間を消費します。「長い会話が悪いのだろう」と会話をリセットし、「日本語がまずいのだろう」と英語で書き直し、それでも再発する。推測ベースの対策は、効いたかどうかの判定もできません。発生が確率的なので、しばらく起きなければ直った気になってしまうためです。
やるべきことは1つで、全数を数えることです。
まず全数を数える
使っていたエージェント基盤(Claude Code)は、セッションのやり取りをJSONL形式でローカルに保存します。ここにはモデル名・ツール呼び出し・停止理由がすべて残るため、後から集計できます。
# セッションログ(JSONL)から tool_use の回数とモデルを集計する
cat ~/.claude/projects/<project>/*.jsonl \
| jq -r 'select(.message.content != null)
| .message as $m
| ($m.content[]? | select(.type=="tool_use")) as $t
| [$m.model // "unknown", $t.name] | @tsv' \
| sort | uniq -c | sort -rnここで重要なのは、失敗した回数だけを見ないことです。失敗率を出すには成功も含めた全呼び出し数が要ります。「今日は5回落ちた」は情報量がありません。5回/50回なのか、5回/2,000回なのかで、話がまったく変わります。
集計対象は12セッション、ツール呼び出し1,894回。うち失敗47回でした。全体では2.5%程度ですが、この数字自体には意味がありません。分解して初めて構造が見えます。
実測データ
モデル別に分けたところ、分布が偏っていました。
失敗が0件だった側について、サンプル数が少ないのではないかという疑いが残ります。ここは確率で確認できます。仮に両者の失敗率が同じ2.8%だとすると、203回連続で失敗しない確率はおよそ0.3%です。偶然でこの偏りが出るとは考えにくい水準と判断しました。
翌日、対象を広げて再集計しました。サブエージェントの実行分も含め、モデルを4種類に分けています。
Sonnet 4.6 が792回で0件だったのは実務上ありがたい結果でした。前バージョンの Opus 4.7 も0件です。失敗は特定の1バージョンに閉じていました。
通説を1つずつ否定する
原因が分かった後より、分かる前にどう潰していったかの方が再利用できるので、否定した仮説を残しておきます。
文脈長の項目は特に意外でした。直感に反して、大きな文脈のセッションほど失敗率が低い。もし長さが原因なら逆の傾向が出るはずで、この1点だけで「会話をリセットすれば直る」という対処が無意味だと分かります。
失敗時に何が起きていたか
ツール呼び出しは、モデルが直接プログラムを起動しているわけではありません。モデルはタグ形式のテキストを生成し、それを実行環境(ハーネス)が解析して実際のツール実行に変換します。この解析に失敗すると malformed になります。
失敗したケースのログを見ると、共通の形がありました。
# 落ちたケースの記録 (要約)
{
"stop_reason": "tool_use", # ツールを呼ぼうとしている
"content": ["text"] # しかし中身はテキストブロックのみ
}
# 期待される正常形
{
"stop_reason": "tool_use",
"content": ["text", "tool_use"] # 構造化されたツール呼び出しが入る
}停止理由はツール呼び出しになっているのに、記録された中身はテキストブロックだけでした。つまりモデルはツールを呼ぼうとしていて、その出力が構造として成立しないままこぼれていた、ということです。入力の内容ではなく生成側のほころびなので、決定論的な再現条件が見つからなかったのも筋が通ります。
対処: バージョンを固定する
原因が特定バージョンに閉じている以上、対処は明快で、そのバージョンを経路から外すことです。ただし1点、見落としやすい穴がありました。
サブエージェントの定義でモデルをエイリアス指定していると、そこだけ問題のバージョンに解決されます。主セッションのモデルを切り替えても、エージェント定義に model: opus のような別名で書いてあると、それは「最新のOpus」と解釈され、当時の最新=問題のバージョンに繋がってしまいます。主セッションだけ直して安心していると、サブエージェントの中で再発します。
--- name: conceptor description: 提案のコンセプトと章立てを作る model: claude-fable-5 # ← エイリアス(opus)ではなくフルモデル名で固定 ---
あわせて、チーム共有の設定ファイルは触らず、個人の設定でモデルを上書きする形にしました。共有設定のモデルは実測で問題が出ていないものだったため、変更する理由がないからです。影響範囲を最小にして、必要な箇所だけ固定するのが安全です。
運用として持ち帰ったこと
(1) 確率的な不具合は、分母を数えるまで議論しない。 「よく落ちる」「最近増えた」といった体感は、発生が確率的な事象では当てになりません。全呼び出し数を分母に置いて初めて、比較できる数字になります。ログが残る基盤を使っているなら、集計は数分で終わります。
(2) 変数を1つずつ分離する。 今回はモデル別に分けた瞬間に答えが出ました。逆に言えば、モデルという軸で分けるまでは、文脈長・言語・引数サイズといった軸をいくら調べても構造は見えませんでした。手当たり次第に条件を変えるより、ログにある属性で機械的に層別する方が早いです。
(3) AIエージェントを業務で使うなら、モデルのバージョンは固定する。 エイリアス指定は「常に最新を使う」という意味になり、モデル更新のたびに挙動が変わる余地を抱えることになります。品質を保証したい処理では、フルモデル名でピン留めし、更新は検証してから行うのが安全です。今回の件は、その運用ルールが無かったために調査が必要になった、とも言えます。
(4) 観測結果は提供元に報告する。 バージョン間の差分が数字で出ている場合、報告する価値があります。自社で回避策を打つのと並行して、デバッグログ付きで報告しました。
私たちはこうした検証をふまえて、AIを業務プロセスに組み込む設計・実装を行っています。AIエージェントの導入で同種の運用課題に当たっている場合は、AI・自動化の導入もご覧ください。