壊れたら自分で直す — 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 つ:
除外: 「不調」じゃなくて「完全除外」。チェーンから消す。中途半端に残してリクエストを食わせない。
再起動: restart_command を設定ファイルに書いておけば、crash 時に自動で再起動を試みる。
回復 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-01Metric 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 話) ネットの評判と実機動作は別物
(2 話) 診断ツール自身も診断され続ける必要がある
(3 話) バグは同じツールの別の場所にも繰り返し現れる
(4 話) 結論には賞味期限がある
(5 話) プロダクトの存在意義は環境変化で書き換わる
(6 話) ドキュメントより実装の挙動が真実
(7 話) 罠を踏むたびに「踏めない仕組み」に変えると、機能追加コストが急激に下がる
(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 個。
いいなと思ったら応援しよう!
サーバー代とコーヒー代になります☕ 役に立ったら応援よろしくお願いします!