見出し画像

自分の検証フローに自分が騙された話 — v1.9.0 release 前に踏んだ 3 つの罠

TL;DR: CodeRouter v1.9.0 をリリース直前に実機検証していたら、3 種類の罠を順番に踏んだ。
(罠 1) Qwen3.6-35B-A3B は「35B」って書いてあるけど MoE で active 3.8B、9B モデルと同じ速度だったので「重いモデルに切り替えたら速度差がつく」という前提が崩れた。
(罠 2) 計測の比較対象を作るには「事前に他のモデルにもリクエストを投げておく」必要があった(rolling window という時間制約付きの観測バッファのため、後述で「養殖」と命名)。
(罠 3) 自分が書いた docstring が「400 を返す」と約束していたのに、実装は 500 を返していた(try/except 漏れ、検証中に発覚して当日修正)。3 つとも「机上で動くはず」を実機が裏切ってきた話で、verification は仕様の正しさより実装の生々しい挙動を見ないと意味がないことを再確認した。
連作 6 話目。

あらすじ — 連作 6 話目になります

第 1 話 (v1.8.1)ガチで動かしてみたら 3 連敗した話 — note 推奨の Qwen3.6 / Qwopus3.5 / Gemma 4 を Ollama 経由で動かして 3 連敗
第 2 話 (v1.8.2)自分が作った診断ツールに自分が騙された話 — Gemma 4 は実は完全動作、CodeRouter の doctor probe が偽陽性を出していた
第 3 話 (v1.8.3)Ollama で詰んだ Qwen3.6 を llama.cpp で動かしたら、診断ツールがもう 1 つ偽陽性を出してた話 — tool_calls probe にも同じ thinking-aware バグ
第 4 話 (v1.8.4)「フレームワーク待ち」の前提が翌日崩れた話 — LM Studio 0.4.12 で Qwen 3.5/3.6 + Anthropic 互換が一気に公式化
第 5 話 (v1.8.5)Claude Code とローカル LLM を繋ぐ 5 通りの経路 — LM Studio 直結が成立した状況下で、CodeRouter 自身の存在意義を経路ごとに整理
第 6 話 (本記事 v1.9.0)「自分の検証フローに自分が騙された話」 — v1.9 系の最終 verification で踏んだ 3 つの罠

連作の通低音は「机上で動くはず → 実機が裏切る → 自分のツール / コードを直す」のループ。第 5 話で「翻訳という存在意義は終わり、観測 / 動的最適化 / capability gate に重心移動」と書いたまさにその v1.9 系の最終 verification で、今度は 検証フローそのもの が机上で組まれていた、という変奏です。

この記事で使う 5 つの言葉 (用語の準備)

技術用語をまとめて先に置きます。読み飛ばして、後で必要になった時に戻ってきても大丈夫です。

profile (プロファイル) CodeRouter の providers.yaml で名前付けする「使う provider の並び (chain) + 動作設定」のひとかたまり。例えば coding profile なら 1 番目に local Ollama、2 番目に OpenRouter free、みたいな並びを 1 つの名前で呼ぶ。リクエスト時にヘッダで切り替えできる。

chain / fallback chain (チェーン) profile 内の provider の 並び順。1 番目から順に試して、エラーなら次へ落ちる「フォールバック (代替経路)」の rope。

adaptive routing (適応ルーティング、v1.9-C で追加) profile に adaptive: true を付けると、CodeRouter が 「いま遅くなってる provider」を自動で後ろに送る 動的な並び替えを開始する機能。静的な chain 順は変えず、内部で観測した latency / error rate に基づき効果的順序を計算し直す。「いつもは速いけど今日は重い」を勝手に避けてくれる。

rolling window (転がる窓 / 観測バッファ) adaptive routing が「現在の latency」を判断するために覗いている、直近 60 秒 の観測サンプル群。60 秒より古い観測は自動で捨てられる (ずっと前のデータで判定すると古い情報で誤判定するため)。「窓」が時間軸の上をズレながら現在を切り取り続けるイメージ。

break action (v1.9-E で追加) agent が「同じツール (例: Read) を同じ引数で 3 回連続呼び出す」みたいな stuck loop に陥ったとき、CodeRouter がリクエスト処理を 400 エラーで打ち切る policy。warn (ログだけ) / inject (system に注意ヒント追加) / break (打ち切り) の 3 段階のうち最も強い対応。

これだけ頭に入れて先に進みます。

検証環境

  • マシン: M3 Max 64GB (Apple Silicon)

  • ローカル LLM: LM Studio 0.4.12+ (port 1234) + Ollama 0.21+ (port 11434)

  • CodeRouter: v1.9.0a6 → v1.9.0 GA

  • 検証目的: v1.9 系で実装した 3 機能 (adaptive routing / cache observability / tool-loop guard) を実機で動作確認 してから release tag を切る

罠 1 — 「35B」と書いてあっても遅いとは限らない (MoE の罠)

きっかけ

docs/inside/verification.md(内部資料) に書いていた adaptive routing の検証手順は、こんな構成でした:

profile に lmstudio (qwen3.5-9b) と ollama-gemma4-26b を並べる、adaptive: true を付ける通常時は両方そこそこ速いので並び替え発生しないことを確認LM Studio 側を Qwen3.6-35B-A3B に切り替えて意図的に遅くする (「5-10 秒/turn」と書いていた)→ adaptive が lmstudio を自動降格するログ (adaptive-routing-applied) が出る

3 番が肝で、「重いモデルに切り替えれば速度差がつくから、adaptive が demote (降格) を判断するはず」という前提でした。

実機で 3 番を実行:

$ lms unload qwen3.5-9b
$ lms load qwen/qwen3.6-35b-a3b --identifier qwen3.5-9b
$ lms ps
IDENTIFIER    MODEL                    STATUS    SIZE
qwen3.5-9b    qwen/qwen3.6-35b-a3b     IDLE      22.07 GB

22GB の 35B モデルがちゃんと load された。よし重くなったはず、と思って実測:

$ time curl ... -d '{"max_tokens":16,...}' >/dev/null
real  0m1.108s

1.1 秒。9B のときと変わらない。

なぜか

providers.yaml のコメントに答えがありました:

# (c) Qwen3.6 35B-A3B — qwen35moe architecture、MoE active 3.8B / total 35B

MoE (Mixture of Experts、専門家混合) というアーキテクチャだったんです。総パラメータは 35B あるけど、1 トークン推論するときに実際に動いているのは 3.8B 分だけ。残りの 31B 分は「使われない expert」として待機。雑な比喩でいうと「会社に 35 人エンジニアがいるけど、1 つの質問には 4 人だけが答える」みたいな感じ。総人数では大きいけど、1 質問あたりの処理量はその 4 人分。

ディスク占有 22GB は本物 (全 expert を全部メモリに乗せる必要がある) なので、「サイズ感では 35B」「推論速度は 4B 級」という分離が起きる。Apple Silicon が GPU で expert routing をうまく扱うので、9B dense (全パラメータが毎回動く) と速度がほぼ並んでしまった。

教訓

モデルのサイズ ≠ 推論の重さ」。MoE か dense か、active params がいくつか、を見ないと「重くなる」前提は崩れる。/v1/models の API でも lms ps でも、ここの情報は出てこない (HuggingFace の model card を読みに行かないと分からない)。

検証台にしたかったのは「lmstudio が他より明確に遅い」という状況なので、最終的には dense 27B (qwen/qwen3.6-27b) に切り替えて再試行。それでも 1 秒前後で大差つかず、最終的には provider chain の方を「サイズ差を作る側」に寄せ直すことになりました (罠 2 の続編で出てきます)。

罠 2 — 観測を貯めるのに「手元の試験リクエストだけ」では足りなかった

Sequential fallback の構造

CodeRouter の chain は 逐次フォールバック: 1 番目の provider が成功したら 2 番目以降は呼ばれない (節約のため)。

[lmstudio (1番目)] → 成功 → 終了
                ↓ 失敗のときだけ
                [ollama (2番目)] → 成功 → 終了
                                ↓ 失敗のときだけ
                                [openrouter-free (3番目)]

この構造の 副作用 に気づいていませんでした。adaptive routing は「全 provider の median latency を比較して、1.5 倍以上遅い provider を降格」なんですが、lmstudio が成功し続ける限り、ollama も openrouter も呼ばれないので latency サンプルが 0 件のまま

サンプル 0 だと、MIN_SAMPLES_FOR_LATENCY = 3 (最低 3 件ないと median 信用しない) という閾値で 比較対象から除外 されます。比較対象が lmstudio 1 個だと、global_median = lmstudio_median そのもの、lmstudio_median ≥ lmstudio_median × 1.5 という比較になり、永遠に False。降格しない。

つまり「lmstudio が単独で遅くなった」を検出するには、**「他の provider にも事前に何らかの方法で観測サンプルを入れておく」**必要がある、という設計になっていた。

「養殖」とは

これを解決する手として、別の profile を経由して、比較対象にしたい provider を意図的に呼んでサンプルを貯める作戦を採用。これを「養殖 (ようしょく)」と呼びました。魚を池に放しておくと将来食べ頃になる、みたいな比喩です。

具体的には:

# 1. 養殖フェーズ — multi profile (1 番目 = ollama-gemma4-26b) で 5 リクエスト
for i in $(seq 1 5); do
  curl ... -H 'X-CodeRouter-Profile: multi' ...
done
# → ollama-gemma4-26b に sample 5 個積まれる

# 2. すぐ次に試験フェーズ — test-adaptive profile で 5 リクエスト
for i in $(seq 1 5); do
  curl ... -H 'X-CodeRouter-Profile: test-adaptive' ...
done
# → ここで初めて lmstudio と ollama-gemma4-26b の比較が成立する

CodeRouter の _adaptive_adjuster は engine 単位で 1 個 なので、profile を跨いでもサンプルは共有される。multi で gemma4-26b を呼ぶと、その観測は test-adaptive の判定計算でも使える。

Rolling window のタイミング制約

ただしここで rolling window 60 秒 の制約が効いてきます。

養殖 → 60 秒以上空けて → 試験、をやると、養殖したサンプルが既に window から落ちて消えている。これでまた lmstudio 単独問題に戻ってしまう。

実際 1 回目の試行で、養殖と試験の間に 3 分空けてしまい、adaptive-routing-applied ログが出ない事態に。原因に気づいて、&& で養殖と試験を直結する 1 行コマンドに書き換えて再試行:

for i in $(seq 1 5); do curl ... multi ... ; done && for i in $(seq 1 5); do curl ... test-adaptive ... ; done

これで 6-8 秒以内に養殖→試験を完了できる。

サイズ差を作って実機 demote 観察

罠 1 で 35B でも 27B でも 1 秒前後で「速度差が作れない」問題に当たっていたので、比較対象側を意図的に小さくするアプローチに切り替え。test-adaptive の chain を:

profiles:
  - name: test-adaptive
    providers:
      - lmstudio-qwen3-5-9b-anthropic    # 27B dense ≈ 1.0 秒
      - ollama-qwen-coder-1_5b           # 1.5B ≈ 0.2 秒    ← ここを 1.5B に
      - openrouter-free
    adaptive: true

「26B 同士の兄弟」ではなく「27B vs 1.5B (約 18 倍差)」にすると速度比が大きくなり、threshold 1.5 倍を確実に超える。これでようやく実機で adaptive-routing-applied が発火:

{
  "msg": "adaptive-routing-applied",
  "static_order":    ["lmstudio-...", "ollama-qwen-coder-1_5b", "openrouter-free"],
  "effective_order": ["ollama-qwen-coder-1_5b", "openrouter-free", "lmstudio-..."],
  "stats": {
    "lmstudio-qwen3-5-9b-anthropic":  {"median_latency_ms": 474.28, "sample_count": 3},
    "ollama-qwen-coder-1_5b":          {"median_latency_ms": 134.38, "sample_count": 5},
    "openrouter-free":                 {"median_latency_ms": null,   "sample_count": 0}
  }
}

数学的には:

  • global_median = median([474, 134]) = 304ms

  • threshold = 304 × 1.5 = 456ms

  • lmstudio (474ms) ≥ 456ms → demote (降格) +1

  • effective_order で lmstudio が末尾に移動 → 次のリクエストから ollama-qwen-coder-1_5b に飛ぶ

きれいに想定通り。

教訓

観測ベースの adaptive 判定は、観測対象を作る作業 (= 養殖) が必要」。Sequential fallback だと「実トラフィックだけでは比較サンプルが揃わない」というのは実装されてみないと気づかない盲点で、verification doc には養殖手順を後追いで追記しました。Apple Silicon みたいな高速ハードウェアでは「サイズ差ある provider を chain に混ぜる」もセットで必要。

罠 3 — docstring が約束していた 400 が、実装では 500 になっていた

確認のはじまり

最後に検証したのが tool-loop guard の break action (用語の準備で説明したやつ)。同じツールを 3 回連続呼んだら 400 で打ち切られる、という挙動を確認するパート。

curl で意図的に「Read を 3 回連続」のリクエスト履歴を組み立てて投げたら:

$ curl -i -X POST http://localhost:8088/v1/messages ...

HTTP/1.1 200 OK     ← !? 400 のはずなのに
content-type: application/json
{"id":"msg_...","content":[{"type":"text","text":"...response..."}],...}

200 OK で普通の応答が返ってきた。break action で打ち切られたなら 400 が返るはずなのに。

何が起きていたか

ソースコードを追うと、coderouter/guards/tool_loop.py の ToolLoopBreakError の docstring にこう書いてありました:

class ToolLoopBreakError(CodeRouterError):
    """Raised when a loop is detected and the configured action is ``break``.

    The engine catches this at the top of generate_anthropic /
    stream_anthropic and converts it into a ``400`` response so
    the client sees a structured failure rather than a 5xx.
    """

「engine が catch して 400 に変換する」と書いてある。じゃあ engine 側 (fallback.py) を見ると:

async def generate_anthropic(self, request):
    request = _apply_tool_loop_guard(request, config=self.config)
    # ↑ ここで break action のとき raise ToolLoopBreakError
    chain = self._resolve_anthropic_chain(request)
    ...

_apply_tool_loop_guard が raise してくる、と。そして ingress (anthropic_routes.py) を見ると:

try:
    anth_resp = await engine.generate_anthropic(anth_req)
except NoProvidersAvailableError as exc:
    raise HTTPException(status_code=502, detail=str(exc)) from exc

return anth_resp.model_dump(exclude_none=True)

NoProvidersAvailableError しか catch していない。ToolLoopBreakError は素通りして FastAPI のデフォルト 500 ハンドラに落ちる。docstring が約束していた 400 ではなく、500 Internal Server Error が返る経路だった。

(実際は私の手元では 200 OK が返っていた — これは別の問題で、リクエスト本文を意図的に壊した影響でループ検出に到達する前に短絡してしまっていた。サーバの起動 mode 切り替え後に正しく組み直したら 500 が出た。)

docstring と実装の乖離は「言っているのに、やってない」事故

これは Anthropic API でいうところの「invariant が break している」事故: モジュールの interface ドキュメントが contract を約束しているのに、実装側に対応コードが無い。テストもなかった。docstring を信じて downstream で「400 だったら branch する」みたいなコードを書いた人が居たら、その分岐は永遠に発火しない。

修正

その場で 3 ファイル直しました:

  1. coderouter/guards/tool_loop.py: ToolLoopBreakError に threshold / window をキーワード必須で追加 (ingress で 400 detail を構築するときに必要)

  2. coderouter/routing/fallback.py: raise 箇所で threshold / window を渡す

  3. coderouter/ingress/anthropic_routes.py: try/except ToolLoopBreakError → HTTPException(400, detail=...) を追加 (非 streaming) + streaming パス側にも対称な error event 発行を追加

加えて tests/test_ingress_anthropic.py に 2 件 追加: 非 streaming で 400 + 構造化 detail、streaming で SSE error event。これで全 823 件 green

修正後の挙動 (実機):

$ curl -i ... -H 'X-CodeRouter-Profile: test-loop-break' ...

HTTP/1.1 400 Bad Request
content-type: application/json
{"detail":{"error":"tool_loop_detected",
  "message":"tool loop detected on profile='test-loop-break': tool 'Read' repeated 3 times consecutively.",
  "profile":"test-loop-break","tool_name":"Read","repeat_count":3,"threshold":3,"window":5}}

期待通り。docstring が約束していた構造化 400 detail がちゃんと出るようになった。

教訓

連作 第 2 話 (doctor probe 偽陽性) と第 3 話 (tool_calls probe 偽陽性) が「自分の診断ツールに自分が騙された」だったのに対し、今回は「自分の docstring が約束していた挙動を、自分の実装が出していなかった」。ドキュメントが嘘をつく より、実装がドキュメントを裏切る のほうが根が深い。テストでガードしていなかった (両 ingress 経路の ToolLoopBreakError catch を強制するテストが無かった) のがチェック漏れの根本原因。

メタ教訓 — 検証は実機で初めて意味を持つ

3 つの罠は全部「机上では成立するけど、実機では成立しない」の variant でした:

  • 罠 1: モデルのスペック表が前提を保証しない (35B と書いてあっても MoE で速い)

  • 罠 2: コードのロジックが前提を保証しない (sequential fallback は単独 provider で adaptive 判定が成立しないという設計上の盲点)

  • 罠 3: docstring が前提を保証しない (実装が docstring を裏切ることもある)

3 つに共通するのは「実機で動かさないと露見しない」点。unit test は通っていたんです (823 件 green)。tests/test_guards_tool_loop.py も tests/test_adaptive_routing.py も書かれていた。それぞれの単位では検証されていたけど、

  • ingress + engine + adapter + LM Studio + ollama を 全部繋いだとき に何が起きるか

  • 60 秒の rolling window と 1 秒/req の処理時間の 時間スケール の相互作用

  • MoE モデルの実速度が OS / hardware の最適化 とどう絡むか

これらは unit test だと模擬しきれない。verification.md の手順を実機で 3 回踏み直して、ようやく「ここはこういう罠がある」が見える。整備された検証手順 vs 実機の差 は、ドキュメントが詳細であればあるほど見えにくくなる (= 詳細だから「こうなるはず」を信じてしまう)。

v1.9.0 GA はこれらを全部修正 + 検証済みで release しました。MoE の罠と養殖の必要性は verification.md に追記、break action の catch 漏れは ingress の修正 + テスト 2 件で防御。「verification を実機で完走できなかった」が結果的に v1.9.0 を強くした、というのが今回の収穫。

v1.9.0 release で出荷した 5 機能 (ざっくり)

最後にリリース内容を 5 行で:

  1. 観測 (v1.9-A) — Anthropic prompt cache の hit/miss を全リクエスト log + dashboard で可視化

  2. 透過 (v1.9-B) — OpenAI 互換経路でも cache_control 等を可能な限り透過、不可能なら明示的に warn

  3. 動的最適化 (v1.9-C) — adaptive routing で「いま遅い provider」を自動降格

  4. コスト把握 (v1.9-D) — providers.yaml に cost: 宣言、cache savings を別計算で dashboard 表示

  5. 信頼性ガード (v1.9-E) — tool-loop 検出の 3 段階 policy (warn / inject / break)

詳細は CHANGELOG [v1.9.0] と各 sub-release entry (v1.9.0a1〜a6) を参照。

連作の続き

次は v1.10 系で「Claude Code を 8 時間連続で回したら起きた何かしらの障害」の話を書く予定 (v1.9-E phase 2 = Memory pressure / Backend health の動機付け)。本当に 8 時間回す必要があるので、書けるのは検証 session が走り切った後。

それまでは GitHub repo で「自分のローカル環境で v1.9.0 を試した結果」のフィードバックお待ちしてます。issue でも note コメントでも歓迎です。

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

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