派手な機能追加の裏で、小さな穴が4つ溜まっていた
Phase 1 で汎用推論プールを8B級に絞り、土台を .43(MacBook Air M1)1台に集約したあたりから、副作用の小さなバグが溜まっていた。どれも単独では記事1本にならないが、放っておくと「静かに壊れる」たちのものばかりだ。今回はそれを4つ、同じ道具立て ― count==1 のアンカーパッチ、py_compile 自動ロールバック、そして「計測してから信じる」 ― で順に潰した記録を書く。派手さはないが、この基盤がいちばん時間をかけているのは、実はこういう地味な作業だ。
穴1・2:Reaper が、生きている順番待ちを「幽霊」として殺していた
Reaper は、担当 Worker が死んだり、pending のまま放置されたタスクを回収して別 Worker へ再投入するスレッドだ。Phase 1 で土台が .43 単独になった結果、2つの問題が表面化した。
#1 get_all_workers が壊れたハッシュで500を出す。 sanity_models=[] で退役した Worker が、register はスキップするのに running_tasks のカウンタだけ Redis に残す ― worker_id フィールドを持たない壊れたハッシュができる。指名経路 route_to_named_worker がこれを素の添字で触って KeyError → 500。対処は素直に、欠落エントリを warning 付きで弾く。silent に捨てず、気づける形にする。
#2 ghost_pending 誤殺のほうが厄介だった。 .43 の単一モデルに2本連投すると、2本目は1本目が終わるまで pending のまま待つ。これは正常な順番待ちだ。ところが Reaper の ghost 判定の猶予が短く(dead 検出と共有の15秒)、順番待ちの pending を「担当が動いていない幽霊タスク」と誤判定して別 Worker へ蹴り出そうとする。代替がいなければ fail-fast する。
安易な対処は「猶予を伸ばす」だが、それだと dead Worker の検出まで鈍る。そこで猶予を分離した。
REAPER_GHOST_GRACE_SEC = int(os.getenv("REAPER_GHOST_GRACE_SEC", 60))
# v6.9: ghost専用grace(dead_grace=15 から分離)
さらに「本当に幽霊か」を PG で確かめる。_worker_has_running_task が、対象 Worker が running のタスクを抱えているかを PG(権威。Redis の running_tasks カウンタは信用しない)で見て、抱えていれば「単一 Worker の順番待ち」とみなして ghost 除外する。判定不能なときは busy 扱い=誤殺回避を優先する。取得フロアも min(dead, ghost) にして、将来 ghost 猶予を下げても取りこぼさないようにした。v6.3 本来の保護 ― Worker は生きていて idle なのに動かない真の孤児 pending ― はそのまま温存される。
穴3:「います」が「いま」に化けて、Web検索が誤発火する
純粋な技術質問なのに Web検索へ誤ルーティングされる事象が、journal に3件記録されていた。当初は「『もう一度考えて』『正しくは』のような対話語が誤発火させている」と仮説を立てていた。だが、この基盤には [web_search_classify] keyword= というログがある。3件を調べると、全件が keyword='いま' だった。
発火源は対話語ではなく、丁寧語の語尾だった。「命名しています」「間違っています」― います ⊃ いま の部分文字列衝突だ。対話語ゼロの純技術質問が誤発火した1件は、対話語仮説では説明できない。仮説は反証された。可観測性(keyword= ログ)が真因を確定させた瞬間だった。
対処は、Web検索キーワードから部分文字列ハザードを持つ語を除くこと。ネガティブ語(対話語)の追加は採用しなかった ― 観測3件を1件も説明しないうえ、「もう一度調べて」のような正当な再検索を殺すリスクだけが増えるからだ。
WEB_SEARCH_KEYWORDS = [
"今日", "今週", "今月", "今年", "現在", "いまの",
"最新", "最近", "ニュース", "速報", "新着", "今朝", "今夜",
"現状", "今どう", "いま何", "何時", "天気",
...
]
# 除去: いま(⊂います) / 何度(⊂何度も) / now(⊂know)
# 温存: いまの / いま何 ← 「いまの為替」「いま何時」の web 導線は生かす
「いま」を消しても「いまの」「いま何」は残すことで、為替や時刻の Web 導線は死なない。substring 照合ゆえの再追加禁止を、コードのすぐ隣にコメントで刻んだ。既知の残ハザード(何時 ⊂ 何時間、current ⊂ concurrent)は未観測なので据え置き、同じ keyword= ログで監視を続ける。
穴4:num_ctx を固定しないと、指名経路でコンテキストが爆発する
M1 で指名経路のタスクが 300秒 timeout する事象があった。原因は Worker が /api/generate をモデル既定の context 長(qwen3:8b なら 32768)でロードし、KVキャッシュごと 10GB に膨れていたこと。16GB の M1 には重すぎた。指名経路と通常経路で context が違えば二度ロードも起きる。
対処は Worker 側(worker_base v6.4)で、全ての /api/generate 呼び出しに options.num_ctx を明示付与すること。解決順は「設定ファイル → 環境変数 OLLAMA_NUM_CTX → 未設定なら従来どおり」で、既存 Worker を無改修のまま完全オプトインにした。sanity check にも同じ値を付けて二度ロードを防ぐ。起動ログに [num_ctx] fixed num_ctx=8192 が出れば効いている。
この穴には macOS ならではの罠が付いていた。launchd の環境変数は launchctl kickstart -k では再読込されない。bootout → bootstrap しないと反映されず、「設定したのに num_ctx 行が出ない」で一度ハマった。systemd の daemon-reload に相当する手順が要る、というのを実地で確認した。
余談だが、今回の sarashina 権威ルーティング(第37回)で .43 の同時 resident を測ったとき、私自身がこの num_ctx を手動 warmup で渡し忘れて、8B を 32768/10GB で測ってしまった。本番の Worker は 8192 固定で動いているのに、別条件を測って「16GBに入らない」と早合点しかけた。num_ctx を渡して測り直すと 6.2GB で、あっさり同時 resident できた。爆発対策そのものが、計測条件の落とし穴として跳ね返ってきた格好だ。
おまけ:検証の規律をワンコマンドにした ― route_probe.py
これらの修正のたびに「ルーティングの回帰が起きていないか」を手で確かめるのは苦痛だ。そこで固定14問(専門知識・二羽・一般知識対照・Web正/負・RAG正/誤爆・指名・pipelineスモーク)を投げて、/route の着地(worker / model / queue から tier を導出)を自動アサートする route_probe.py を作った。
設計上のこだわりが2つある。1つは、分類・選択のアサートは読み取り専用の /route/preview を主に使い、PG を汚さないこと。実タスクを作った行は着地アサート直後に既定で DELETE する(品質スコアラーの汚染を避ける)。もう1つは、8Bフロアの期待値(worker ∈ {macbookair, rtx3070ti} × model ∈ {qwen3:8b, elyza-jp-8b})を環境変数で更新可能にして、Phase 構成が変わってもコード改変不要にしたこと。
初回の実機 run は6問 FAIL したが、全て同一署名(client read timeout)で、ルーティングの回帰はゼロだった。FAIL/PASS の分かれ目が「ドメインキーワードの有無」と完全一致していたことから、真因を特定できた ― キーワード非該当のプロンプトは classify_domain が CPU 上の LLM フォールバックに落ち、冷ロード込みで15〜40秒かかって後続が連鎖 timeout していた。client timeout の裏でサーバは全問完走しており、孤児行の着地を見れば全て正着だった。v1.1 で timeout を60秒に伸ばし、domain を明示指定して LLM 分類を経路から排除(決定論化)したら、14/14 PASS になった。
まとめ ― 静かな失敗を、可観測性と一次情報で潰す
4つの穴に共通するのは「静かに壊れる」ことだった。壊れたハッシュ、順番待ちの誤殺、部分文字列衝突、context 爆発 ― どれもエラーを吐かずに品質や可用性をじわじわ削る。潰し方も共通していた。keyword= ログや PG という一次情報で真因を確定し、仮説を反証し、count==1 アンカーと py_compile 自動ロールバックで安全にパッチを当て、route_probe で回帰を機械的に確認する。 「もう一度考えて系の対話語だろう」という もっともらしい仮説を、ログ3件が全て「いま」だと言って覆したのが、今回いちばん気持ちよかった瞬間だった。計測してから信じる ― この基盤の一番の背骨は、やっぱりこれだと思う。