CodeRouterの修復器は本当に効いているのか、測ってみた — 自作のコア機能を疑った第23話(改訂版)
TL;DR: CodeRouter の看板機能の一つに「弱いローカルモデルがテキストに吐いた壊れた tool call を、正しい形に直す」修復器があります。ネイティブ `/v1/messages` が普及して「変換プロキシ」の価値が消えた今、残った差別化の核心はこの修復力です。競合の claude-code-router も似た機能を持っている。だとすれば、勝負は「持っているか」ではなく「どこまで効くかを証拠で言えるか」になる。そこで今回、自分のコア機能を疑うことにしました。8 カテゴリ 42 件のコーパスを作って測ったら、総合 80.6% 回収・偽陽性ゼロ。ただし `malformed`(汚れた JSON)だけ 14.3% で全滅していた。そして直せていないのは、一番直したい客層 ── 一番弱いモデルの、一番頻出の失敗形でした。誇れる 80.6% ではなく、直視すべき 14.3% の話をします。第 23 話です。
あらすじ — 23 話目です
連作のタイムスタンプ。初コミットは 2026-04-19、前話(第 22 話・v2.7.0 の全ソースレビュー)から数日。本記事の作業日は 2026-07-04、初コミットから 76 日目です。

これまでの敵は、壊れる Ollama、仕様を変えてくる Claude Code、静かに漏れるトークン、読んでいないコードでした。今回の敵は**「効いているつもりの自分の機能」**です。
きっかけ — 「持っている」だけでは差にならなくなった
第 12 話(tool calling の 3 段階 / Ollama ネイティブ化)で書いたとおり、2026 年に入って wire 変換の価値は消えました。Ollama も LM Studio も llama.cpp も、Claude Code が喋る `/v1/messages` をネイティブで受けられる。「Anthropic 形式を OpenAI 形式に翻訳するだけ」のプロキシは、もう誰の役にも立たない。
翻訳が消えた後、CodeRouter に残った差別化の核心は 3 つ ── tool 修復・ガード・fallback です。その筆頭が修復器。弱いモデルは今でもネイティブに tool call を吐けず(前話でも Qwen2.5-Coder:7B は Level 0 のままでした)、テキストに埋め込まれた壊れた JSON を拾って直すのが唯一の実用経路になる。
ところが、最近あらためて競合を調べ直したら、記録の訂正が必要になりました。claude-code-router(musistudio)は停止していなかった。 社内メモに「2026-05 に停止確定」と書いていたのは誤りで、実際は ★35,557・ほぼ毎日リリース・最新 v3.0.7 の現役最大の直接競合。しかも `enhancetool` という tool call 補正 transformer を持っている。
つまり「壊れた tool call を直す」は、もう CodeRouter だけの武器ではない。だとすれば差になるのは、機能の有無ではなく ──
「修復できる」と「どこまで修復できるか証拠で言える」は別。
後者を持っていない機能は、営業トークではあっても差別化ではありません。正直に言って、これまで私は自分の修復器を「だいたい直る」という体感でしか把握していませんでした。第 2 話で自分の diagnostic ツールに自分が騙されたのに、その反省が一番大事なコア機能に届いていなかった。だから測ることにしました。
どう測ったか — 弱点が出るように作る
やり方はシンプルです。壊れた tool call の実例を 42 件集めて、修復器に通し、直せた率と、直してはいけないものを直してしまった率(偽陽性)を出す。 ネットワークに触れない決定的なテストで、コア機能の回帰ゲートにします。
コーパスは 8 カテゴリに分けました。` ```json ` fence 付き、裸の JSON、複数呼び出し、`<think>` 混入、日本語引数、fence 入れ子 ── そして 汚れた JSON(malformed) と、直してはいけない 6 件(negative)。
ここで一番こだわったのは 2 つです。
① 実装に忖度しない。 「今の修復器が直せないケース」でも、期待結果は「本来は直すべき」のまま書く。そうしないとベンチが現状追認のゴム印になって、弱点が永遠に隠れます。直せていないなら、直せていないと数字に出す。
② negative を必ず混ぜる。 修復器は「拾えたか」だけでは評価できません。「拾ってはいけないものを拾っていないか」を同じ土俵で測らないと意味がない。これは前話(第 22 話)の全ソースレビューで見つかった、**「tool_repair がコードブロックを食べる」**回帰 ── 修復のついでに正規のコードブロックまで消していたバグ ── が念頭にあります。あれは個別バグとして直しましたが、「同じクラスの事故を二度と通さない仕組み」は無かった。negative カテゴリ 6 件は、その恒久的な回帰ゲートです。
結果をどう受け止めたか
出た数字はこうでした。

パッと見は優秀です。8 割回収して、危険な誤修復はゼロ。特に偽陽性ゼロは効きます ── コード例も、素の設定 JSON も、allow-list 外の危険な tool 形も、一切 tool call に化けさせなかった。前話の「コードブロックを食べる」回帰が、二度と起きない状態になったことの証明でもあります。安全側は、白状すると自分でも驚くくらい綺麗でした。
でも、カテゴリを割ると景色が変わります。

クリーン系は全部 100%。その裏で、malformed だけが 14.3% で一人負けしていました。総合の 80.6% は、この一人負けをカテゴリ平均で薄めた数字だったわけです。
正直、ここで一瞬「80.6% で偽陽性ゼロなら十分では」と思いました。でもそれは、弱点を平均で隠す考え方です。第 22 話でレビュー軍団に「テストは通るが想定が間違っている場所」を洗い出させたのと同じ話で ── 総合数字が良いことと、最悪のセルが致命的でないことは、別なんです。
malformed 14.3% が意味すること
直せなかったのは、どういう入力か。
シングルクォートの dict: `{'name': 'Bash', 'arguments': {'command': 'ls'}}` ── Python の dict をそのまま print した形
末尾カンマ: `{"command": "ls",}` の最後のカンマ
無クォートキー: `{name: "Bash", ...}` の JS オブジェクト形
キー名の揺れ: 名前が `tool`、引数が `parameters` / `input`
理由は 2 つに分かれます。前 3 つは標準 JSON パーサ(`json.loads`)が受理しない構文で、現状の修復器は「緩いパース」を持っていないので即破棄。後ろのキー名揺れは、JSON 自体は合法なのに、修復器が知っているシェイプが `name`/`arguments` 系の 2 形しかなくて拾えない。
問題は、これを吐くのがどんなモデルかです。
末尾カンマ・シングルクォート・無クォートキーを吐くのは、小型で弱いモデルです。gemma や qwen の小さいやつが、思考の延長で Python の dict を print してしまう。強いモデル(前話の Gemma 4 のように Level 3 に届くやつ)は、そもそもネイティブに綺麗な `tool_use` を返すので、修復器の出番すらない。
つまり ──
弱いモデルほど汚れた JSON を吐く。だから、一番修復してほしい客層で、一番修復できていない。
これが 14.3% の本当の意味です。80.6% という総合値は、修復器がすでに得意な「そこそこ綺麗な出力を出すモデル」を相手にした数字で、修復器が本当に価値を発揮すべき「Level 0〜1 の弱いローカルモデル」に対しては、malformed の壁でほとんど機能していない。数字は 80.6% でも、実運用インパクトは 14.3% の側にある。
第 10 話で「『対応している』の中身を分解しないと正しい道具を選べない」と書きました。今回はそれが自分に返ってきた形です。「修復に対応している」の中身を分解したら、一番大事な層で穴が空いていた。
次にやること
やることは 2 つです。
① 修復器を直す。 緩い JSON パースを二段構えで足す(`json.loads` で失敗したら、シングルクォート→ダブル・末尾カンマ除去・寛容パースでリトライ)。シェイプ辞書に別名を足す(名前: `tool`/`tool_name`、引数: `parameters`/`input`/`args`)。ただし ── 緩くすると偽陽性が増える方向に圧力がかかるので、negative カテゴリ 6 件を回帰ゲートに固定し、偽陽性ゼロを保ったまま recall を上げるのが絶対条件。ここを外したら「コードブロックを食べる」回帰の再来です。緩めるのと安全なのは、綱引きの関係にあります。
② モデル別に実測する(L2)。 L1 は「修復関数そのもの」の性能でした。実モデルを Ollama 直と CodeRouter 経由で回して率を出す ── で、1 モデル分はもう回しました。qwen2.5-coder:7b、temperature=0、各経路 100 リクエスト。結果は綺麗すぎるくらい極端でした。

直結では、このモデルは一度も構造化 tool_calls を返しませんでした。全部テキストに JSON を書く。修復器のないクライアント(Claude Code はそうです)から見れば全滅です。CodeRouter を挟むと、その全部が修復されて普通の tool call として届く。「修復器は本当に効いているのか」の答えとしては、これ以上ない形でした。
ただし正直に付記します。温度を上げる(デフォルト温度で回す)と経由側でも 17% が fail になり、その失敗形はまさに L1 で名指しした malformed(二重ブレース、XML 風)でした。つまり①の修復器強化は「効いているから不要」ではなく、温度が上がる実運用でこそ必要でした。
── で、①はもうやりました。緩い JSON パース・キー別名・XML 形式対応を入れて、同条件で再計測: fail 17% → 1%。オフライン側も recall 80.6% → 100%(コーパス 55 件に拡張、偽陽性 0/12 維持)。おまけに negative の拡張が、旧実装に残っていた実在の偽陽性 2 件(コード例示を tool call に誤変換 ── 第 22 話と同クラス)を掘り当てました。測るベンチを先に作ったから、直した後も「直った」と数字で言える。残る 1% の失敗形も特定済みで、次のコーパスに入ります。
競合が同じ機能を持っている以上、勝てるのは「うちの修復器は malformed で 14.3% だが、次のバージョンで X% に上げ、偽陽性は 0% を保った」と数字で言える状態を作ることだけです。弱点を隠す会社ではなく、弱点を測って公開して潰す会社でありたい。80.6% を誇るより、14.3% を直視する方が、たぶん長く効きます。
メタ教訓
連作のメタ教訓(抜粋):
(2 話) diagnostic ツール自身も diagnostic され続ける必要がある
(6 話) 自分の検証フローに自分が騙される
(10 話)「対応している」の中身を分解しないと、正しい道具を選べない
(12 話) 自分のプロダクトの価値が「X の不在」に依存しているなら、X は遅かれ早かれ埋まる
(22 話) テストは通るが想定が間違っている場所は、読み直さないと見つからない
第 23 話の教訓:
自分のコア機能ほど、体感でなく数字で疑え。しかも総合値ではなく最悪のセルを名指しせよ。80.6% は 14.3% を平均で隠す。競合が同じ機能を持つ時代、差別化は「持っている」ではなく「どこまで効くか証拠で言える」に移る。証拠は、都合の悪い数字ごと出して初めて信用になる。
修復器は、安全側(偽陽性ゼロ・クリーン系 100%)は完璧でした。だからこそ危なかった ── 完璧に見える部分があると、その隣の穴を見落とす。カテゴリを割らなければ、malformed 14.3% は 80.6% の陰に永遠に隠れていたはずです。
次の話
次回は L2 の残りモデル(qwen3-coder:30b-a3b / gemma4:26b)+ 修復器の改善版で、同じ 42 件コーパスの before/after を出します。malformed 14.3% がどこまで上がるか、偽陽性 0% を保てるか。数字が出たら、良かろうが悪かろうがそのまま書きます。
CodeRouter 本体: https://github.com/zephel01/CodeRouter
uvx --from coderouter-cli coderouter serve --port 8088
ANTHROPIC_BASE_URL=http://localhost:8088 ANTHROPIC_AUTH_TOKEN=dummy claude`pip install coderouter-cli` / `uvx coderouter-cli serve` で動きます。Python 3.12 以上、ランタイム依存 5 個。
いいなと思ったら応援しよう!
サーバー代とコーヒー代になります☕ 役に立ったら応援よろしくお願いします!