見出し画像

「動く」と「壊れない」は別の話だった — ローカル LLM を 8 時間回すと起きる 4 つの障害と、その対処

TL;DR: 第 7 話で「v1.x の機能が揃った」と書いた。でも Claude Code + ローカル LLM を半日ぶっ通しで回すと、短時間のテストでは見えなかった壊れ方が 4 つ出てきた。v2.0.0 と v2.1.0 でその 4 つを対処した話。「動く」を「壊れない」に変えるには、動かし続けないと見えない問題を潰す必要があった。

あらすじ — 8 話目です

第 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 話 (本記事)「動く」を「壊れない」に変える話

1〜7 話は「罠を踏む → 道具に変える → 道具が成熟して罠が踏めなくなる」の上昇螺旋でした。第 8 話は、その道具を 「半日動かしてもセッションが死なない」 方向に使い切る話です。

「長時間使うと壊れる」とは

Claude Code をローカル LLM(Ollama + Qwen3:32b 等)経由で使うとき、最初の 1〜2 時間は快適に動くのに、4〜5 時間くらいから不思議なことが起き始める経験をしたことはないでしょうか。

たとえば:

  • 急にエラーが出て、セッション全体がフリーズする

  • 応答が妙に短くなる。「はい」「了解」だけで tool を使わなくなる

  • 途中まで良い応答を書いていたのに、ストリーミング中に接続が切れて全部消える

  • 朝イチの最初のリクエストだけ必ず失敗する

これらは短時間のテスト(coderouter doctor 等)では再現できない。長時間稼働して初めて浮き彫りになる障害です。

CodeRouter では、こうした障害を 6 つのパターンに分類しています。v1.x で 4 つを対処済みで、残りの 2 つ + 既存対策の強化を v2.0 / v2.1 で実装しました。

6 つの障害パターン(ざっくり)

v2.0 / v2.1 で太字の 4 つに取り組みました。以下、それぞれで何が起きて、どう対処したかを書きます。

障害 1: 会話が長くなりすぎ問題(L1 → v2.0.0)

どんな症状か

Claude Code を長時間回すと、system prompt + tool の呼び出し結果 + 応答が messages にどんどん溜まっていきます。1 ラウンドで数千トークン使うので、50 ラウンドもやれば簡単に 3 万トークンを超えます。

ローカルモデルの context window は 32K(Qwen3:32b の場合)。これを超えた瞬間、Ollama は 400 エラーを返します。CodeRouter は「エラーが出たら次のプロバイダーに切り替える」機能がありますが、全プロバイダーが同じ messages を受け取るので、全員同時にエラーになって全滅します。

対処: 自動トリミング

リクエストが来た時点で「今、context window の何パーセント使っている?」を推定する仕組みを入れました。

  • 80% を超えたら警告(ログに記録、ヘッダーでクライアントに通知)

  • 90% を超えたら自動で古い messages を削除して枠内に収める

トークン数の推定は 文字数 ÷ 4 という簡易式。完璧ではありませんが、ちょっと過小評価する方向に倒れるので、実際には 72% くらいの時点で 90% トリミングが発動する計算。安全側に倒れるなら十分実用的です。

踏んだ罠: 消してはいけないペア

ここで v2.0 系最大の罠を踏みました。

Claude Code の messages には「tool を呼んだ(tool_use)」と「tool が結果を返した(tool_result)」がペアで入っています。古い messages を先頭から消す時に、片方だけ消してしまうと API が 400 エラーを返すんです。

「tool_use は残っているのに、対応する tool_result が消えた」状態。Anthropic API はこの不整合を許しません。

最終的に、消す時は必ずペアでまとめて消すロジックに書き換えて解決しました。テストは「片方だけ残る」「境界をまたぐ」等のパターンを 8 本書いて全部潰しました。

障害 2: 応答品質が静かに劣化する問題(L4 → v2.1.0)

どんな症状か

Ollama + Qwen3:32b を 4〜5 時間連続稼働させると、体感で応答が短くなる。tool を使わなくなる。空の応答が増える。

厄介なのは、エラーは出ないこと。ステータスコードは 200。応答としては正しい形式。ただ中身がスカスカになっていく。いわゆる「ゆでガエル」状態で、気づいた頃にはまともな応答がなくなっています。

原因は Ollama 側の内部状態(KV cache の劣化等)と推測されますが、CodeRouter から内部は見えません。

対処: 5 つの数字を見張る

見えない原因を直すのではなく、見える症状を数えるアプローチにしました。各プロバイダーの直近の応答から 5 つの指標を計測:

  1. 空応答率 — 中身のない応答がどれくらい増えたか

  2. 長さの急落 — 応答が以前の平均の 30% 以下に縮んだか

  3. tool 沈黙率 — tool を呼ぶべき場面で呼ばなくなったか

  4. 異常な終了 — 応答がおかしな理由で途切れていないか

  5. エラー率 — 5xx やタイムアウトの頻度

これらを直近 20 件の rolling window で追跡して、閾値を超えたら「品質劣化」と判定します。

踏んだ罠: ゆでガエルを検知できない

最初は「直近 20 件全体の中央値と比較」で実装しましたが、全体が徐々に短くなると中央値も一緒に下がって、いつまでも異常を検知できない

対策として、20 件の前半(古い方 10 件)の中央値を「普段の長さ」の基準にする設計に変えました。これで「最初は普通に答えていたのに、後半から短くなった」パターンを捕まえられるようになりました。

品質劣化を検知したらどうする?

Ollama に「キャッシュを一回捨てて」というリクエストを送ります(keep_alive=0)。理屈上は、汚れた内部状態をリセットして、フレッシュな状態から再開できます。

ただし、本当に効くかは Ollama の内部実装次第。なので「off / 警告だけ / リロードも実行」の 3 段階設定にして、operator が自分の環境で試してから上げる運用にしました。警告だけでも、ダッシュボードで劣化が可視化されるので十分有用です。

障害 3: 途中まで書いた応答が消える問題(L6 → v2.1.0)

どんな症状か

ストリーミング応答の途中で Ollama が crash すると、CodeRouter は event: error を返して安全に止めます。これ自体は正しい動作で、2 つのプロバイダーの応答を繋ぎ合わせた「フランケン応答」を防ぐための設計。

でも問題は、crash するまでに 300 トークン分のテキストが画面に表示されていること。CodeRouter の内部にもそのテキストは蓄積されている。なのにエラー時は全部捨てていた。

「途中まで書いたコードの説明」とか「途中まで書いたリファクタリング案」が、接続断で消えるのはもったいない。

対処: 途中テキストの救出

ストリーミング中にテキストを内部で蓄積しておき、途中で失敗した場合は event: coderouter_partial という特別なイベントで、そこまでの蓄積テキストをクライアントに返す仕組みにしました。

ポイントは tool 呼び出しの途中 JSON は除外すること。テキストは途中でも読めますが、途中で切れた JSON を渡しても使い物にならないので、テキストブロックだけ救出する判断にしました。

障害 4: 朝イチの失敗問題(L5 強化 → v2.1.0)

どんな症状か

v1.10.0 で作った backend 健康監視は、ユーザーのリクエストが来て初めてプロバイダーの生死を確認する「受動監視」でした。

つまり、深夜に Ollama が crash しても、朝イチのリクエストまで誰も気づかない。最初の 2〜3 リクエストが連続失敗して、やっと「不調」に切り替わってフォールバックが効き始める。

「朝イチだけ必ず 2〜3 回失敗する」 という UX になる。

対処: 60 秒おきの生存確認

バックグラウンドで 60 秒おきに、各プロバイダーに 1 トークンだけ生成させる超軽量リクエストを投げるようにしました。1 トークンなので一瞬で終わり、コスト(有料プロバイダーの場合)もほぼゼロ。

成功/失敗を健康監視に流すだけなので、ユーザーリクエストがない時間帯でも状態が更新され続けます。深夜に crash しても、60 秒以内に「不調」に切り替わり、朝イチの最初のリクエストからフォールバックが効く。

踏んだ罠: 終了処理の難しさ

バックグラウンドで無限ループを回すので、CodeRouter を止める時にちゃんと終了させる必要があります。最初は強制キャンセルで実装しましたが、通信中にいきなり打ち切ると HTTP クライアントの後始末ができない。

結局、「停止してくれ」というシグナルを送って、ループが自然に抜ける設計に変更。細かい話ですが、バックグラウンド処理の終了は開始より難しいという学び。

もう 1 つの罠: モデル名が一致しない

生存確認の応答に含まれるモデル名を、設定ファイルの名前と比較して「すり替え」を検知する機能も入れました。ところが Ollama は qwen3:32b と設定しても qwen3:32b-fp16 と返すことがある。tag のエイリアス解決結果が返るため。

最初は完全一致で比較 → 偽陽性の嵐。完全一致のまま、不一致時に「この名前は既知のモデルか?」を追加情報として出す方向にしました。判断は operator に委ねる設計。

4 つの対処で何が変わったか

6 系統の障害すべてに対処が入りました。「8 時間動かしても止まらない」の実装が揃った状態です。

数字

メタ教訓

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

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

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

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

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

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

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

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

第 8 話の教訓:

「観測する」と「介入する」は質的に違う。

v1.x でやってきたことは基本的に「観測」でした。ログを取る、メトリクスを集める、異常を検知する。v2.0 系で初めて「介入」に踏み込みました。messages を能動的に削る、プロバイダーにリロードを要求する、途中テキストを独自イベントで返す、背景で勝手にリクエストを投げる。

観測は安全です。何も壊さない。でも介入は、それ自体が新しい問題を生む可能性がある。messages を削れば API の不変条件(tool_use/tool_result ペア)を壊しうる。リロードは本当に効くか分からない。途中テキストの返し方はプロトコルの想定外。バックグラウンド処理は終了が難しい。

v1.x の「型にはめるだけ」が効いたのは、全部が観測だったから。v2.0 系で介入に踏み込んだ結果、それぞれ固有の罠を踏むことになりました。

でもこれも、いずれ「型」に変わるはず。

次の話

「実装が揃った」と「実際に壊れない」は、まだ別の話です。v2.1.0 のコードが揃った状態で、実際に 8 時間ぶっ通しのセッションを走らせたとき:

  • Context budget のトリミングは実際に何回発動する?

  • Drift detection は false positive を出さない?

  • Continuous probe は Ollama crash をちゃんと拾う?

その観察データが集まったら、次の記事を書きます。

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

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

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

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