見出し画像

ローカル LLM の tool calling には 3 段階ある — 「対応している」と「ちゃんと動く」は別の話

TL;DR: ローカル LLM で tool calling を使おうとすると「対応しているはずなのに動かない」にぶつかる。調べてみると、tool calling の「対応」には 3 段階あって、モデルによって到達点がバラバラだった。
① そもそも呼ぶか
② フォーマットは正しいか
③ 引数は壊れないか

この 3 段階のどこで止まるかで「動く / 動かない」の意味が変わる。CodeRouter を作る動機そのものだった話。

あらすじ — 10 話目です

第 1 話 (v1.8.1)ガチで動かしてみたら 3 連敗した話
第 2 話 (v1.8.2)自分が作った診断ツールに自分が騙された話
第 3 話 (v1.8.3)Ollama で詰んだ Qwen3.6 を llama.cpp で動かしたら、もう 1 つ偽陽性を出してた話
第 4 話 (v1.8.4)「フレームワーク待ち」の前提が翌日崩れた話
第 5 話 (v1.8.5)Claude Code とローカル LLM を繋ぐ 5 通りの経路
第 6 話 (v1.9.0)自分の検証フローに自分が騙された話
第 7 話 (v1.10.0)見積 3〜4 週間 → 実 2 日の話
第 8 話 (v2.0.0/v2.1.0)「動く」を「壊れない」に変える話
第 9 話 (v2.2.0)壊れたら自分で直す話
第 10 話 (本記事)tool calling の「対応」には 3 段階ある話

1〜9 話は CodeRouter の開発を追いかけた連作。第 10 話は視点を変えて、そもそも CodeRouter がなぜ必要なのかの根っこにある問題——ローカル LLM の tool calling——を整理します。

「tool calling 対応」は嘘じゃないけど足りない

HuggingFace のモデルカードや Ollama のドキュメントに「tool calling 対応」と書いてあるモデルは増えてきました。でもその「対応」の意味が、モデルによって全然違います。

Claude や GPT-4 の tool calling に慣れた状態で、ローカルの 7B〜32B モデルに同じことをやらせると、「対応しているはずなのに動かない」 が頻発します。エラーが出るならまだいい。エラーが出ずに、ただ tool を呼ばないことがある。

この現象を自分なりに整理した結果、tool calling の「対応度」には 3 つの段階があることに気づきました。

3 つの段階

Level 1: 呼ぶか呼ばないか

一番基本的な話。tool のスキーマをプロンプトに渡して「この tool を使え」と言ったとき、そもそも tool を呼ぶ応答を返すかどうか

Level 1 で止まるモデルの典型的な症状:

  • tool のリストを渡しても、テキストで「ファイルを確認しましょう」と書くだけで実際には呼ばない

  • たまに呼ぶけど、5 回に 1 回くらい。残りはテキストで「結果は以下の通りです」と空想を書く

  • system prompt が長い(Claude Code の 15〜20K トークン)と、tool の存在自体を忘れる

これはモデルの訓練データに tool calling のパターンが十分含まれていないことが原因。小さいモデル(7B 以下)や、coding 特化でない汎用モデルでよく起きます。

CodeRouter 側でできることは少ない。モデル自体が「呼ぶ」判断をしないので、後から直しようがない。プロバイダーのフォールバック(呼ばないモデル → 呼ぶモデル)で対処するしかありません。

Level 2: フォーマットが正しいか

tool を呼ぶ判断はできる。でも 呼び方が間違っている

Level 2 で止まるモデルの典型的な症状:

  • tool_calls フィールドに入れず、テキスト本文に JSON を書く

  • OpenAI 形式の function_call ではなく、独自のタグ(<tool_call>...</tool_call>)で出力する

  • JSON として壊れている(引用符が抜けている、末尾カンマがある)

これが CodeRouter の tool-call repair が対処する問題。モデルは「この tool を呼びたい」という意図を持っている。ただ出力フォーマットがフレームワークの期待と合わない。

CodeRouter は応答テキストの中から tool-call っぽい JSON を探し出して、正しい tool_calls 構造に組み直します。テキストに埋め込まれた JSON、コードブロック内の JSON、連続する複数の JSON——それぞれのパターンを検出して修復。

モデルの応答(テキスト本文):
  "ファイルを確認します。```json{"name":"Bash","arguments":{"command":"ls"}}```"

CodeRouter の修復後:
  tool_calls: [{function: {name: "Bash", arguments: {command: "ls"}}}]
  content: "ファイルを確認します。"

このレベルの問題は、実は一番多い。Qwen3-Coder や DeepSeek-Coder-V2 など、coding に強いモデルの多くが「tool を呼ぶ意図はあるがフォーマットが非標準」の状態にあります。

Level 3: 引数が壊れないか

tool を呼ぶ。フォーマットも正しい。でも 引数の中身がおかしい

Level 3 で止まるモデルの典型的な症状:

  • file_path に存在しないパスを入れる

  • command に複数のコマンドを改行なしで詰め込む

  • 必須引数を省略する

  • 型が違う(文字列を期待しているところに数値を入れる)

これはモデルの「推論能力」の問題で、フォーマットの問題ではない。CodeRouter の wire-level 修復では対処できない領域。引数の中身の正しさは、モデルの知識と推論の質に依存します。

3 段階の分布

実際にローカルモデルを試した体感(CodeRouter の doctor --check-model + 実セッションの観察)での大まかな分布:

上の表のうち Gemma 4 と Qwen2.5-Coder の数字は、Ollama v0.23.1 の Anthropic API 互換エンドポイントに対して 10 回ずつ自動テストした実測値です(scripts/verify_ollama_0_23.py)。他は体感ベースの観察です。

Gemma 4 が印象的なのは、最小の e4b (9.6GB) ですら Level 3 に完全到達していること。従来は 32B 以上でないと Level 2 すら安定しなかったのが、Gemma 4 + Ollama v0.23.1 の組み合わせで一気にハードルが下がりました。

ただし傾向として モデルサイズと Level は概ね相関する ことは依然として言えます。Qwen2.5-Coder:7B が Level 0 に沈んでいるのは、7B クラスの限界の一例です。Gemma 4 e4b が例外的に優秀なのは、Google が tool calling を訓練段階で重視した結果だと推測します。

CodeRouter が対処できる範囲

CodeRouter が本当に価値を発揮するのは Level 2 です。モデルの意図は正しいのにフォーマットだけが合わない——この「翻訳可能なズレ」を修復するのが CodeRouter の中核機能。

Level 1 はフォールバックで回避できるけど、それは「別のモデルに逃げる」だけ。Level 3 は CodeRouter の守備範囲外。

なぜこの話を整理したかったか

CodeRouter を作り始めた頃(v0.3〜v0.7)、「ローカル LLM の tool calling が壊れる」という問題を一枚岩で捉えていました。「壊れる → 直す」。シンプル。

でも実際にいろんなモデルで試すと、「壊れる」の中身がモデルごとに違うことに気づきました。ある人が「tool calling が動かない」と言うとき、Level 1 の話をしているのか Level 2 の話をしているのか Level 3 の話をしているのかで、対処が全然違う。

Level 1 の問題を抱えている人に tool-call repair を勧めても意味がない。Level 3 で困っている人にフォーマット修復は的外れ。

3 段階に分けて考えることで、「自分の問題はどこにあるか」「どの道具で対処できるか」が見えやすくなる。CodeRouter の README に「Tool-call repair」と書いているけど、それが効くのは Level 2 の話であって、全部を魔法のように直すわけじゃない、という正直な整理。

フレームワーク側の事情

もう 1 つ複雑なのは、フレームワーク(Ollama / llama.cpp / LM Studio)ごとに tool calling の実装が違うこと。

同じモデル(例えば Qwen3-Coder:32B)を Ollama で動かすのと LM Studio で動かすので結果が変わる。これは第 4 話(LM Studio 0.4.12 で Qwen3.5 が動いた話)で体験しました。

理由は、フレームワークが モデルの tool calling 出力をどう解釈するか が違うから。モデルは生トークンとして <tool_call>{"name":"..."} みたいな文字列を出力しているだけ。それを構造化された tool_calls フィールドに変換するのはフレームワークの仕事。

  • Ollama: chat template に従って tool calling をパースする。template が不完全だと壊れる。

  • LM Studio: 独自の tool calling パーサーを持っている。モデルによっては Ollama より品質が良い。

  • llama.cpp (直接): tool calling のパースは自分で書く必要がある。自由度は最大。

つまり「モデルの Level」と「フレームワークの解釈品質」の掛け算で最終結果が決まる。同じ Level 2 のモデルでも、フレームワークの解釈がうまければ Level 3 に近い動作になるし、解釈が雑だと Level 1 に落ちることもある。

CodeRouter は フレームワークの後ろに座っている ので、フレームワークが解釈しきれなかった分を追加で修復する立場です。

2026-05 追記: Ollama v0.23.1 の Anthropic API 互換

この記事を書いた後、状況が 1 つ大きく動きました。Ollama v0.23.1 が Anthropic Messages API のネイティブエミュレーションを追加したのです。

これまで Ollama は OpenAI 互換 API (/v1/chat/completions) だけを提供していて、Claude Code と繋ぐには CodeRouter のような翻訳レイヤーが必須でした。v0.23.1 からは /v1/messages エンドポイントで Anthropic 形式のリクエスト/レスポンスを直接受け付けます。

実機検証した結果:

  • Gemma 4 + Ollama v0.23.1: 全サイズ (e4b/26b/31b) で type: "tool_use" ブロックが正規に返る。Level 3 完全到達。CodeRouter の翻訳も修復も不要

  • Qwen2.5-Coder:7B + Ollama v0.23.1: Anthropic API 経由でもテキスト本文に JSON を書く(Level 0)。CodeRouter の tool-call repair を通すと 5/5 PASS

つまり、フレームワーク側の事情がもう 1 段階複雑になりました。「モデルの Level」×「フレームワークの解釈品質」に加えて、「API 形式(OpenAI / Anthropic)」 という軸が加わった。同じ Ollama でも、OpenAI 互換経由と Anthropic 互換経由で結果が変わりうる。

これは第 4 話(LM Studio が Anthropic 互換を追加した話)と同じパターンの環境変化です。そしてまた第 5 話の教訓——プロダクトの存在意義は環境変化で書き換わる——が効いています。

メタ教訓

連作 1〜9 話のメタ教訓:

  1. (1 話) ネットの評判と実機動作は別物

  2. (2 話) 診断ツール自身も診断され続ける必要がある

  3. (3 話) バグは同じツールの別の場所にも繰り返し現れる

  4. (4 話) 結論には賞味期限がある

  5. (5 話) プロダクトの存在意義は環境変化で書き換わる

  6. (6 話) ドキュメントより実装の挙動が真実

  7. (7 話) 罠を踏むたびに「踏めない仕組み」に変えると、機能追加コストが急激に下がる

  8. (8 話) 「観測する」と「介入する」は質的に違う

  9. (9 話) 「壊れないようにする」と「壊れた後を設計する」は別の仕事

第 10 話の教訓:

「対応している」の中身を分解しないと、正しい道具を選べない。

「tool calling 対応」は Yes/No じゃなく、少なくとも 3 段階のスペクトラムだった。問題を「一枚岩の壊れ」として見ている限り、間違った層に間違った道具を当ててしまう。

これは tool calling に限った話じゃなくて、「X に対応」「Y をサポート」と書いてある機能は、たいてい内部に段階がある。その段階を分解して初めて「自分の問題はどこにあるか」が見える。

次の話

連作 10 話を通じて CodeRouter の開発過程を追いかけてきました。次は一歩引いて、「CodeRouter とは結局何なのか」 を 1 記事で俯瞰します。連作を最初から読んでいない人向けに、なぜこの道具が生まれて、何を解決するのかを整理する話。

CodeRouter のリポジトリ: https://github.com/zephel01/CodeRouter

uvx --from coderouter-cli coderouter serve --port 8088 で v2.2.0 が動きます。Python 3.12 以上、依存 5 個。

いいなと思ったら応援しよう!

zephel01 サーバー代とコーヒー代になります☕ 役に立ったら応援よろしくお願いします!