見出し画像

壊れたら自分で直す — v2.2.0 で CodeRouter が「再起動しても忘れない」ようになった話

TL;DR: 第 8 話で 6 系統の障害ガードが揃った。でも 「壊れたプロバイダーを人間が手で復旧する」「再起動したら全部忘れる」「切り替えが本当に得だったのか分からない」 という 3 つの問題が残っていた。v2.2.0 で self-healing(自動復旧)、永続化(再起動越し状態保持)、replay(A/B 統計比較)を入れた話。「壊れない」の次は「壊れても自分で直る」だった。

あらすじ — 9 話目です

第 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 話 (本記事)「壊れない」を「壊れても自分で直る」に変える話

1〜7 話は「罠を踏む → 道具に変える」の螺旋。第 8 話で「壊れない」ための 6 系統ガードが揃った。第 9 話は、その次の問い――「壊れた後どうする?」「再起動したら?」「その判断は正しかったのか?」――に答える話です。

「壊れない」の限界

第 8 話で 6 系統のガードが揃って、CodeRouter は「8 時間回しても止まらない」状態になりました。でも実際に 1 週間使い続けると、3 つの「次の問題」が見えてきました。

問題 1: 落ちた Ollama を人間が直している

v2.1.0 の L5 ガードは、Ollama が crash したら検知して「不調」に切り替え、フォールバック先に回す。ここまでは自動。

でも Ollama を再起動するのは人間です。ターミナルに戻って ollama serve を打って、起動を確認して、CodeRouter 側で「あ、復活した」と気づくまで待つ。夜中に crash したら、翌朝まで Ollama なしで回り続ける。

問題 2: CodeRouter を再起動したら全部忘れる

budget tracker の使用量、各プロバイダーの健康状態、self-healing で除外中の情報——全部メモリに載っている。CodeRouter のプロセスを再起動したら、全部ゼロリセット。

月次の予算管理で「今月いくら使った?」を追跡していても、再起動した瞬間にカウンターがゼロに戻る。UNHEALTHY で除外していたプロバイダーが、再起動で何事もなかったかのように復帰する。

問題 3: 切り替えが得だったのか分からない

「ローカル Ollama が遅かったから OpenRouter の無料枠に切り替えた」。でもそれで 本当に速くなったのか? トークン数は? コストは? 体感では良くなった気がする、でも数字がない。

3 つの問題はどれも「ガードの外側」にある。ガードは「壊れた瞬間」を検知して切り替える。でも「壊れた後の復旧」「状態の永続化」「判断の振り返り」は、ガードの仕事じゃない。

3 つの答え

v2.2.0 では、この 3 つの問題にそれぞれ対処しました。

以下、それぞれ何をやったか。

Self-healing: 落ちたら自分で復旧する

v2.1.0 まではこうだった

Ollama が crash → BackendHealthMonitor が UNHEALTHY を検知
                → フォールバックで OpenRouter に切り替え
                → Ollama は死んだまま
                → 人間が ollama serve を打つ
                → 人間が「直ったかな?」とテストする
                → そのうち CodeRouter が気づく

手動復旧は「正しく動く」けど「持続しない」。人間がいない深夜に crash したら、翌朝まで Ollama は死んだままです。

v2.2.0 ではこうなる

Ollama が crash → BackendHealthMonitor が UNHEALTHY を検知
                → SelfHealingOrchestrator が自動除外
                → restart_command で Ollama を自動再起動
                → 30秒後に回復 probe を投げる
                → 失敗 → 60秒後にもう一度
                → 成功 → 元の位置に復帰

ポイントは 3 つ:

  1. 除外: 「不調」じゃなくて「完全除外」。チェーンから消す。中途半端に残してリクエストを食わせない。

  2. 再起動: restart_command を設定ファイルに書いておけば、crash 時に自動で再起動を試みる。

  3. 回復 probe: 再起動後すぐではなく、指数バックオフ(30s → 60s → 120s → 300s)で様子を見る。起動直後のモデルロードが終わるまで待つ。

設定はこれだけ

profiles:
  - name: claude-code
    backend_health_action: exclude    # UNHEALTHY → 完全除外 + 自動復旧
    backend_health_threshold: 3       # 3 回連続失敗で UNHEALTHY

providers:
  - name: ollama-qwen3
    restart_command: "ollama serve"   # crash 時の自動再起動コマンド

backend_health_action を exclude にするだけ。restart_command は任意で、書かなければ「回復 probe だけ」モードになります(手動で Ollama を起動し直せば、probe が拾って自動復帰する)。

踏んだ罠: 二重再起動

最初に踏んだのは 2 つのプロファイルが同じプロバイダーを共有している時の二重再起動。プロファイル A と B が同じ ollama-qwen3 を使っていて、両方で UNHEALTHY になると、再起動コマンドが 2 回走る。

ollama serve が 2 重に起動して、ポートの取り合いでどちらも落ちる。

対処: プロバイダーごとに再起動ロック(_restart_lock)を持たせて、同時に 1 つしか走らないようにしました。2 つ目は「すでに再起動中」と判定してスキップ。

もう 1 つの罠: 回復 probe のタイミング

最初は再起動直後に probe を投げていましたが、Ollama は ollama serve を受け付けてからモデルをメモリにロードし終わるまで数十秒かかる。起動直後に probe を投げると「接続はできるけどモデルが返さない」状態で失敗する。

指数バックオフの初期値を 30 秒にしたのはこのため。7B モデルなら 10〜20 秒で準備できるけど、32B だと 30 秒は欲しい。安全マージン込み。

Persistence: 再起動しても忘れない

なぜ必要だったか

CodeRouter の状態は全部メモリ上にありました:

  • BudgetTracker: 月次の使用量カウンター

  • BackendHealthMonitor: 各プロバイダーの健康状態

  • SelfHealingOrchestrator: 除外中のプロバイダー一覧

  • MetricsCollector: リクエスト統計

プロセス再起動で全部ゼロ。月の途中で再起動すると、budget が「今月まだ $0」に戻って、本来の上限を超えて有料 API を叩く可能性がある。UNHEALTHY で除外していたプロバイダーが、再起動で何食わぬ顔で復帰してまた 3 回失敗してからやっと除外される。

sqlite3 KV store

永続化に使ったのは stdlib の sqlite3。新しい依存を入れないという制約(5 個以上入れない)を守るためです。

# namespace + key でスコープ分離
store.put("budget", "totals", {"anthropic": 1.23, "openrouter": 0.0})
store.get("budget", "totals")  # → {"anthropic": 1.23, "openrouter": 0.0}

設計判断:

  • WAL mode: 読み書き並行。asyncio のスレッドプールから叩いても安全。

  • JSON values: スキーマはテーブル 1 個。各サブシステムが好きな構造を入れる。

  • Graceful degradation: DB が壊れても例外を投げない。永続化は「あったら便利」であって、動作の正しさには影響しない。

JSONL audit log

もう 1 つの永続化は「何が起きたか」の記録。ガードの発火、フォールバックの連鎖、self-healing の除外/復帰を JSONL で記録します。

coderouter audit --tail 20

で直近 20 件のイベントが見える。「昨夜 3 時に何が起きた?」が答えられるようになりました。

仕組みはシンプルで、Python の logging.Handler を実装しただけ。CodeRouter 内部のログイベントのうち「後から振り返る価値がある」ものだけを JSONL ファイルに書き出す。ファイルが大きくなったら .1 にリネームして新しいファイルを作る、1 世代だけの簡易ローテーション。

踏んだ罠: 何を記録しないかの判断

最初は全イベントを記録していましたが、try-provider(プロバイダーにリクエストを投げた)と provider-ok(成功した)は毎リクエスト 2 回発火する。1 日で数千行。ログが膨れて探すのが大変。

「後から振り返る価値がある」だけに絞りました。ガード発火、状態遷移、budget 警告、self-healing のライフサイクル。日常の成功ログは捨てる。「普通」は記録しない、「異常」だけ残す

Replay: 切り替えの効果を数字で見る

何がしたかったか

「Ollama から OpenRouter に切り替えた」「14B から 7B に変えた」。こういう運用判断を体感ではなく数字で評価したかった。

request journal

まず記録。各リクエストのメタデータだけを JSONL で記録します。

{"ts": "2026-05-05T14:32:01Z", "provider": "ollama-qwen3", "input_tokens": 1234, "output_tokens": 89, "cost": 0.0, "streaming": true}

注意: リクエスト本文は記録しない。プライバシー。記録するのは「誰に投げた」「何トークン」「いくらかかった」「ストリーミングか」だけ。コストとパフォーマンスの比較には十分。

統計 A/B 比較

coderouter replay --compare anthropic-api openrouter-free --since 2026-05-01
Metric                    anthropic-api      openrouter-free       Delta
───────────────────────────────────────────────────────────────────────────
Requests                  150                89                      -61
Avg input tokens          1234               1180                    -54
Avg cost (USD)            $0.0082            $0.0000             -0.0082
Total cost (USD)          $1.2300            $0.0000             -1.2300
Cache hit ratio           42.3%              0.0%                 -42.3
Streaming ratio           85.0%              100.0%               +15.0

Per-request: openrouter-free is 100.0% cheaper than anthropic-api

「replay」と名付けたけど、実際にリクエストを再実行するわけじゃない。蓄積したメタデータを集計して並べるだけ。でも「体感で速くなった」を「input_tokens の平均が 54 下がった」「コストが 100% 削減された」に置き換えられる。

踏んだ罠: 何と比較するか問題

最初は「同じ期間の 2 プロバイダー」を比較する設計にしましたが、現実には 同時に 2 プロバイダーを使っていることは少ない。たいていは「先週は Ollama、今週は OpenRouter」のように期間がずれる。

結局、--since フィルタで期間を指定できるようにして、期間横断の比較は operator が手動で 2 回叩いて見比べる運用に落ち着きました。自動で「先週 vs 今週」を検出する仕組みは、実装コストに対して使う場面が少ない。YAGNI。

全部合わせると何が変わったか

数字

テスト数が減っているのは、v2.2 で Unsloth 関連の 3 件(output filter / tool repair の重複排除 / tool loop ハードキャップ)を統合する際に、冗長なテストを整理したため。機能は増えたのにテストが減るのは珍しいけど、テストの網羅性と本数は別の話

「依存 5 個」を守り続ける話

v2.2.0 で新しく入った技術要素:

  • sqlite3 KV store → stdlib

  • JSONL 書き出し → stdlib (json + os)

  • subprocess でプロセス再起動 → stdlib

  • 指数バックオフのタイマー → stdlib (asyncio)

全部 Python の標準ライブラリ。41 sub-release を通じて、ランタイム依存は fastapi / uvicorn / httpx / pydantic / pyyaml の 5 個のまま。

これは意図的な設計制約です。依存を増やすのは簡単だけど、依存を増やした瞬間に「その依存のバージョン管理」「その依存のバグ」「その依存の EOL」を背負う。CodeRouter はインフラ層のソフトウェアなので、「CodeRouter が壊れる理由」は少ないほどいい。

sqlite3 にした理由もここにある。ちゃんとした永続化がしたいなら Redis でも PostgreSQL でも使えるけど、「Ollama と CodeRouter を入れたら動く」という 2 コンポーネント構成を維持したかった。3 つ目のプロセスを足した時点で「CodeRouter の方が面倒」になりかねない。

メタ教訓

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

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

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

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

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

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

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

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

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

第 9 話の教訓:

「壊れないようにする」と「壊れた後を設計する」は別の仕事。

v2.0 / v2.1 でやったのは「壊れないようにする」。Context Budget で溢れないように、Drift Detection で劣化しないように、Continuous Probe で気づかないうちに死なないように。全部「予防」。

v2.2 でやったのは「壊れた後」の設計。壊れたプロバイダーを自動で復旧する。状態を再起動越しに保つ。判断の結果を数字で振り返る。

予防は「壊れる前」にコストを払う。復旧は「壊れた後」にコストを払う。どっちも必要だけど、予防だけでは「予防を突破された時」に何もできない

ソフトウェアを長く動かすなら、「壊れないこと」を目指すだけでなく、「壊れた時にどう振る舞うか」をちゃんと設計する必要がある。v2.2 でようやくその層が入った。

次の話

ここまでで CodeRouter の内部の話はひと段落。

次は視点を変えて、「ローカル LLM で tool calling がちゃんと動くとはどういうことか?」 を整理したい。モデルによって tool calling の対応レベルが全然違う。そもそも対応しているか、フォーマットが正しいか、引数が壊れないか。この 3 段階の話は、CodeRouter を作る動機そのものでもある。

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

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

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

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