AIエージェントの作り方、その先――ツール選択の正解率を実モデルで数えた

目次
以前このサイトで、ollama のローカルLLMと標準ライブラリだけでエージェントループを自作し、壊れ方を観察した。あの記事の結論は、使った qwen2.5-coder 系が tool_calls を一度も返さず、content に生JSONを書く逸脱をパーサで吸収する必要があった、というものだった。残った問いは逆側だ。tool_calls をちゃんと返すモデルなら、あとは安心なのか。 ツールを増やしたときの選択正解率、ツール不要な質問への反応、応答が返らないケースまでは誰も数えていない。数えずに載せれば、「ツールを呼べるはずのモデル」を前提にした判断を検証なしですることになる。
今回は前回と同じ最小ループを使い、tool_calls を返すモデルと返さないモデルの両方で、ツール選択を定量化した。
最小エージェントと、tool_callsを返すモデル
環境は次のとおり。
$ bash -c 'sw_vers -productVersion; uname -m; python3 -V; ollama --version'
26.6.1
arm64
Python 3.14.7
ollama version is 0.31.1
# exit=0 (77 ms)
ループは前回と同じ構造だ。Ollama の /api/chat に tools として関数スキーマを渡し、返答の tool_calls があればローカルの関数を実行して結果を履歴に戻し、なければ最終回答として終了する。
for step in range(1, max_steps + 1):
resp = chat(model, messages, tool_names)
msg = resp["message"]
tcs = msg.get("tool_calls")
if not tcs:
return content
messages.append(msg)
for tc in tcs:
result = FUNCS[tc["function"]["name"]](**tc["function"]["arguments"])
messages.append({"role": "tool", "content": json.dumps(result)})
まず手元にあった huihui_ai/qwen3-abliterated:14b で (12+7)*3 を計算させた。ツールは add と multiply の2個だけ登録した。
$ python3 agent.py huihui_ai/qwen3-abliterated:14b '(12+7)*3 を計算して。ツールを使って' add,multiply
--- step 1 (model=huihui_ai/qwen3-abliterated:14b)
tool_calls: [{"id": "call_ht7jje1t", "function": {"index": 0, "name": "add", "arguments": {"b": 7, "a": 12}}}, {"id": "call_dhy8s1gq", "function": {"index": 1, "name": "multiply", "arguments": {"a": 19, "b": 3}}}]
...
exec add({"b": 7, "a": 12}) -> 19
exec multiply({"a": 19, "b": 3}) -> 57
--- step 2 (model=huihui_ai/qwen3-abliterated:14b)
tool_calls: null
content: (12+7)*3 の計算結果は 57 です。
FINAL (steps=2)
# exit=0 (19286 ms)
前回のモデルでは一度も見られなかった tool_calls が、最初の応答から構造化されて返った。「tools対応モデル」の中にも返す系と返さない系がある、という対比が最初の発見だ。
ただし1つ気になる点がある。step 1 でモデルは add と multiply を同時に呼んでいて、multiply の引数 19 は add がまだ実行されていない時点で決め打ちされている。12+7=19 をモデルが頭の中で先に計算し、それを引数にした形だ。今回は合っていたが、ここを読み違えた場合、ツール結果を待たずに誤った中間値で次のツールを呼ぶ。並列の tool_calls を素直に信頼するループは、この「先読み」に対して無防備だ。
前回の壊れ方はそのまま再現した
確認のため、同じコード・同じタスクのままモデルだけ qwen2.5-coder:7b に替えて実行した。
$ python3 agent.py qwen2.5-coder:7b '(12+7)*3 を計算して。ツールを使って' add,multiply
--- step 1 (model=qwen2.5-coder:7b)
tool_calls: null
content: {
"name": "multiply",
"arguments": {
"a": 19,
"b": 3
}
}
FINAL (steps=1)
# exit=0 (6174 ms)
tool_calls は null で、呼び出したいツールのJSONが contentに書かれた。前回記事と同じ症状で、if tool_calls: ... else: 最終回答 というループはこれを回答完了と誤認し、生のJSONを最終回答として返す。ツールは1回も実行されていないのに exit code は 0 だ。このモデル系の逸脱は前回記事の扱いなのでここでは再現確認に留め、本題は次の定量測定に進む。
ツールを2→4→8個に増やして選択正解率を数えた
登録するツール数を変えて選択ミスを数えた。ツールは add multiply get_weather read_file word_count list_files reverse_string get_time の8個で、タスク6件それぞれに正解ツールが1つある。登録数 k(2/4/8)に応じて囮ツールを混ぜて渡し、応答を「nativeのtool_calls」「contentのJSON救出」「未登録名の幻覚」などに分類した。temperature は0に固定している。
なお測定のやり方でも1回つまずいた。最初は3モデル一括のスイープを1コマンドで回そうとして、実行側の既定時間300.0秒で打ち切られた。
$ python3 bench_select.py
[timeout] 300.0 秒を超えたため打ち切りました
# exit=124 (300014 ms)
モデル別・登録数別に分割して再実行した結果がこちら。
$ python3 -u bench_select.py huihui_ai/qwen3-abliterated:14b 2,4,8 --no-tool
...
SUMMARY huihui_ai/qwen3-abliterated:14b k=2 n=6 native_ok=6 rescued_ok=0 wrong_tool=0 hallucinated=0 args_missing=0 no_call=0 mean_s=18.4
...
SUMMARY huihui_ai/qwen3-abliterated:14b k=4 n=6 native_ok=6 rescued_ok=0 wrong_tool=0 hallucinated=0 args_missing=0 no_call=0 mean_s=17.3
...
# exit=1 (461137 ms)
$ python3 -u bench_select.py huihui_ai/qwen3-abliterated:14b 8 --no-tool
...
!! TimeoutError: timed out
[huihui_ai/qwen3-abliterated:14b k=8] task5: error (180.0s) tools=['list_files', 'reverse_string', 'add', 'multiply', 'get_weather', 'get_time', 'read_file', 'word_count']
SUMMARY huihui_ai/qwen3-abliterated:14b k=8 n=6 native_ok=5 rescued_ok=0 wrong_tool=0 hallucinated=0 args_missing=0 no_call=0 error=1 mean_s=36.2
...
# exit=0 (306941 ms)
$ python3 -u bench_select.py qwen2.5-coder:7b 8 --no-tool
...
SUMMARY qwen2.5-coder:7b k=8 n=6 native_ok=0 rescued_ok=6 wrong_tool=0 hallucinated=0 args_missing=0 no_call=0 error=0 mean_s=5.4
...
# exit=0 (37707 ms)
$ python3 -u bench_select.py qwen2.5-coder:1.5b 8 --no-tool
...
SUMMARY qwen2.5-coder:1.5b k=8 n=6 native_ok=0 rescued_ok=6 wrong_tool=0 hallucinated=0 args_missing=0 no_call=0 error=0 mean_s=1.5
...
# exit=0 (10897 ms)
数えて分かったことは2つある。1つ目は、tool_callsを返す14bモデルでもツールを8個にした時点で応答自体が返らないケースが出たこと(error=1。mean_s=36.2 はこのタイムアウト1件を含む平均で、正常応答だけの体感速度とは別の数字)。2つ目は、coder系2モデルが native_ok=0 なのに rescued_ok=6 だった点だ。8個の中から選ぶ力はあるのに、呼び出しを tool_calls フィールドに置かない。選択の正しさと、応答フォーマットへの準拠は別物だった。前回記事が示した逸脱はランダムな故障ではなく、このモデル系の応答形式として一貫している。
contentのJSONをパースする救出処理を入れれば、この2モデルでもエージェントとして動かせる計算になる。ただしこれは「モデルが仕様通りに返さなかったときの拾い方」であって、最終回答の文中にJSON例が混ざったときにツール呼び出しと誤認するリスクは残る(前回記事でも地の文との混在は観測済み)。救出に頼り切るより、tool_calls を正しく返すモデルを選ぶのが第一選択だと私は考える。
ツール不要の質問でも呼び出す・稀に応答しない
逆のケースも測った。ツールを8個全部登録したまま、ツール不要な質問を4件投げたところ、どのモデルでも不要な呼び出しが出た。
$ python3 -u bench_select.py huihui_ai/qwen3-abliterated:14b 8 --no-tool
...
[huihui_ai/qwen3-abliterated:14b no-tool] q3: called_tool -> reverse_string(via native) (9.0s)
SUMMARY huihui_ai/qwen3-abliterated:14b no-tool n=4 called_tool=1 clean_answer=3 error=0
# exit=0 (306941 ms)
$ python3 -u bench_select.py qwen2.5-coder:7b 8 --no-tool
...
[qwen2.5-coder:7b no-tool] q1: called_tool -> get_weather(via rescued) (1.2s)
[qwen2.5-coder:7b no-tool] q2: called_tool -> add(via rescued) (2.0s)
...
SUMMARY qwen2.5-coder:7b no-tool n=4 called_tool=2 clean_answer=2 error=0
# exit=0 (37707 ms)
qwen2.5-coder:1.5b も同じく4件中2件で不要な呼び出しをした(verify/logs/011-bench-coder15b-v2.txt)。「日本の首都はどこ?」に get_weather を呼ぶような挙動で、ツールを登録しただけで「使ってみたくなる」傾向がある。エージェントループはこの呼び出しを忠実に実行して結果を返すので、答えがおかしい方向に進む入口になる。
なおこの no-tool プローブは各モデル4件の探索的なもので、母数は小さい。率そのものより「0件ではなかった」という事実が要点で、頻度を議論するには件数を増やす必要がある。私がモデルを選ぶ一次情報としては十分だったが、一般化はしていない。
もう1つつまずいた。qwen3-14b の get_time タスクは、ベンチ中に2回連続で180.0秒のタイムアウトに掛かった。だが同じプロンプトを単発で3回投げたプローブでは、全て tool_calls で正常に応答した。
$ python3 -u probe_gettime.py
try0 tools=['add', 'multiply', 'get_weather', 'read_file', 'word_count', 'list_files', 'reverse_string', 'get_time'] -> tool_calls=[{'id': 'call_8b9x2ili', 'function': {'index': 0, 'name': 'get_time', 'arguments': {}}}] content='起来,用户问的是现在的时刻。我需要调用get_time这个函数,因为它可以返回当前时间,格式是HH:MM:SS。其他函数比如加法、乘法或者天气之类的都不相关。所以直接使用get_time,不需要任何参数。应该生成一个tool_call,名字是get_time,参数是空对象。\n</think>\n\n' (11.9s)
...
# exit=0 (44296 ms)
なお実行側の時限は今回 180.0 秒に置いていたが、対話用途ならもっと短く切ってリトライする設計が要るだろう。原因は切り分けられていないが、少なくとも「この組み合わせでは必ず応答しない」わけではない。稀に応答が返らないことがある、という扱いが妥当で、呼び出し側にタイムアウトが要る理由になる。
あと些細な点として、このモデルの content には中国語の思考文と </think> の残滓が混ざることがある。上の引用にも出ている。最終回答として画面に出すなら、ここも処理対象になる。
公開前に自分のエージェントへ掛ける4つの確認
前回は「tool_callsが返らないモデルをどう救うか」が主題だった。今回の計測で分かったのは、返すモデルでも残る確認項目で、全部で4つある。
tool_callsがそのモデルで空にならないか。 coder系2モデルは全12回で一度もtool_callsを返さなかった。対応モデルの表示を信用せず、載せる前に1回叩いてフィールドを確認する。- ツールを増やした状態で選択率と応答率を数えたか。 今回の測定では k=8 で選択はほぼ正しかったが、応答自体が返らないケースが出た。ツール2個のスモークだけでは見えない。
- 不要呼び出し率を数えたか。 ツールを登録しただけで不要な呼び出しが出る(今回は4件中1〜2件)。「ツールがあるなら呼ぶ」副作用がある。
- 呼び出しにタイムアウトを掛けたか。 再現しない応答遅延が実際に起きた。ループの上限とは別に、1回の推論に時限が要る。
限界も書いておく。今回測ったのは単発のツール選択で、複数ターンの計画やツール数十個の規模は対象外だ。呼び出し1回に17.3〜36.2秒掛かった14bモデルの体感速度も、対話用途では別の制約になる。それでも「tool_calls を返すモデルなら大丈夫」は今回の計測で部分的にしか支持できなかった。作り方をなぞって動いたところが出発点で、載せるモデルを決める前に数えるのが、今回確かめた順番だ。
関連書籍
