自作AI基盤を、AI自身に診断させた話 ― 観測を作って露見した11日間の無音バグと、LLMの所見を黙らせる決定論の檻

自宅で動かしている分散推論基盤「Coordinator」を、OpenWebUI のチャットから「見て・直せる」ようにした。「ai-coreで基盤のヘルスチェックをして」と打てば、どのワーカーが生きていて、どのモデルが常駐していて、キューが詰まっていないかが一枚で返る。「macbookairにqwen3をwarmして」と言えば承認を挟んでモデルを常駐させられる。やりたかったのは、ただそれだけの単純な話だった。

ところが単純な話ほど、作っている途中で足元の穴が見える。今回も例外ではなかった。観測する仕組みを作った瞬間に、観測の経路そのものが11日前から壊れていたことが露見し、次に、状態にひとこと所見を添えさせた LLM が、よりによってその LLM 自身が動いているノードを「故障の可能性がある」と書いた。この記事は、その二つの「静かな嘘」と、最後にそれを黙らせるために作った決定論の檻の話だ。第45回・第46回の続きにあたる。

1. 「状態を見る」を作る

基盤側の状態は、実はほとんどが中央(ai-core)に集まっている。どのワーカーが登録しているかは Redis のレジストリと coordinator の /workers が持っているし、推論キューの滞留は Redis の LLEN で読める。各ノードの Ollama に何が常駐しているかは、ai-core から各ノードの HTTP API を叩けば分かる。つまり各ノードに新しい実行体を置かなくても、ai-core から読むだけで基盤全体の状態は見える。read-only・外向きの HTTP と Redis だけ。既存の保守基盤で守ってきた「公開サーバに inbound を開けない・依存を増やさない」という制約を一切崩さずに済む。

そう見立てて、状態確認アクションを一式こしらえた。ワーカー一覧、推論キューの滞留、各ノードの常駐モデル、RAG(Qdrant)のコレクション、coordinator の版と死活。全部 read で、承認は要らず、結果はチャットにそのまま整形して返す。カタログ(許可リスト)に登録して、いざ最初の一本を叩いた。

2. 最初の一本が、90秒沈黙した

「ai-coreの登録ワーカーを一覧して」。プランは正しくエコーされ、承認も要らず、実行に入った——そのまま90秒沈黙して縮退した。Talk 通知すら来ない。前回・前々回で散々見た「無音の実行役」の顔つきだ。

疑ったのは、まず新しく足したコードだった。だが単体で叩くと /workers は 0.018 秒で即返る。API も新コードも健全だ。詰まりは別の場所にある。切り分けは、いつもの Redis のキュー長を一発見るだけで済んだ。

tasks:maintenance:ai-core       = 0
tasks:maintenance:ai-core:read  = 1   ← ここに溜まっている
maintenance:results             = 0

read レーンにタスクが1件、誰にも拾われずに静止していた。理由はすぐに分かった。ai-core の実行役は通常レーン(tasks:maintenance:ai-core)だけを消費している。ところが coordinator は、承認済みの read アクションを :read という別レーンに振り分ける設計になっていた。mars には read 専用の第二の実行役がいるので成立するが、ai-core にはその read 専用実行役がいない。だから ai-core 宛の read は全部 :read に落ちて、そこには消費者がいない。永久に。

ここで背筋が寒くなったのは、これが今日作ったバグではない、という点だった。read レーン分離を入れたのは11日前。それ以来ずっと、ai-core 宛の read アクションは静かに :read へ落ち続けていた。今日まで露見しなかったのは、たまたま誰も ai-core の read を叩かなかっただけだ。実行役の死活を確認する既存のアクションも、キューの長さを見るアクションも、全部この穴に落ちる状態で、11日間そこにあった。

「基盤を観測できるようにしよう」と手を動かして、初めて「観測の経路が壊れていた」ことに気づいた。観測を作らなければ、観測の穴は見えない。これはこの一年、何度も踏んできた型そのものだ。ProtectHome が df に嘘をついた話も、reaper が「成功しました」と嘘のログを出していた話も、根は同じで——平常時に緑が出ていることは、異常時の正しさを何も保証しない

直し方は、実行役を増やすことではなかった。ai-core は write の対象が薄く、read を直列でさばいて困らない。だから「このノードは read を分離しない」と宣言できる仕組みを coordinator に足した。除外リストに ai-core を載せれば、ai-core 宛の read は通常レーンへ入り、既存の実行役がそのまま消化する。mars の分離はそのまま。既定は空——つまり何も宣言しなければ従来どおりで、挙動は1ミリも変わらない。この「機能はコードに入れておくが、既定では効かせず、宣言(環境変数一つ)で点火する」形は、ロールバックが一変数で済むので、この基盤ではもう手癖になっている。

3. AIに所見を言わせたら、自分の居場所を「故障」と言った

状態確認の総仕上げとして、複合ヘルスチェックを一本作った。実行役の死活、mars の外付けディスクのマウント、全キューと取りこぼし、各ノードの生死——第46回で停電後に手で流した点検を、まるごと一枚にまとめたものだ。

ここで欲を出した。「せっかく LLM を積んでいるのに、点検結果を機械的に並べるだけではもったいない。ひとこと総評を添えさせよう」。ただし絶対に譲れない一線がある。この基盤の原則は「実行結果を LLM に戻さない」——サーバの生の出力を LLM の文脈に入れた瞬間、実行役の側で塞いだはずの注入経路が頭脳側で復活する。だから総評は「判断には一切フィードバックしない、ただの読み物」に限る、と決めた。総評を書く LLM は、実行を決めるプランナーとは完全に別経路。入力は生ログではなく、機械的に列挙済みのフィールドだけ。

モデルは二つ試した。片方(14B)は事実に忠実だが、一回の総評に95〜106秒かかって実用外。もう片方(8B の日本語モデル)は19秒で速いが、最初は down しているノードを片方しか拾わなかった。そこで「機械が確定させた事実サマリを、LLM 入力の冒頭に下敷きとして置く」ことにした。重要点は決定論が保証し、LLM は肉付けだけする。これで取りこぼしはほぼ消えた——ように見えた。

本番で叩いてみると、8B はこう書いた。

現在、状態は正常です。デーモンは正常に動作しています。ただし、登録されているワーカーは4件ありますが、macbookair のノードに問題がある可能性があります。

macbookair は落ちていない。むしろ生きている。決定論サマリ(事実の側)は「down しているのは moon と rtx3070ti」とはっきり書いていて、macbookair は UP 側にいた。実際に /api/tags を叩けば即座にモデル一覧が返る。にもかかわらず、LLM は渡された事実と矛盾する不安を、推測の「可能性があります」という語尾で書いた。

いちばん皮肉なのは、その所見を書いた 8B モデルは、macbookair の上で動いているということだ。落ちていたら、この文章は生成すらできない。自分が今まさに動いている足元を「故障の可能性」と報告する——自己矛盾がそのまま反証になっている。

これは、この基盤で何度も見てきた「全部品が妥当なのに、答えだけが間違っている」型だ。プロンプトでは「確定サマリと矛盾するな」と明示していた。下敷きも与えていた。それでも 8B の器は逸脱する。プロンプトは第一の防壁にはなるが、モデルの気分や更新で無言に破れる

4. 檻を作る ― LLMの選択を、コードで検問する

直し方は、より賢いモデルでも、より丁寧なプロンプトでもなかった。LLM の出力を、決定論の事実で検問して、矛盾していたら人に見せずに捨てることにした。

ロジックは単純だ。決定論サマリが「UP」または「active」と確定した固有名(ノード名・ワーカー名)を持っている。LLM の所見を文単位に割り、否定的な語(ダウン・停止・問題・異常・故障…)と、UP 確定の固有名が同じ文に同居していたら、その所見は破棄する。さらに——今回の本命だが——確定サマリに存在しない固有名を所見が勝手に持ち出したら、それも破棄する。破棄したら定型文に差し替える。「所見は確定サマリと矛盾したため非表示にしました」。

今回の「macbookair のノードに問題がある可能性」は、macbookair が UP 確定なのに否定語「問題」と同居している。だから破棄される。一方「moon と rtx3070ti はダウンしている」は、両者が down 確定なので通る——それは事実だからだ。

大事なのは役割分担で、重要な事実は必ず決定論が保証し、LLM はその上に読みやすい一言を足すだけという構図になっている。だから LLM が黙っても(あるいは黙らされても)、一段目の事実は必ず正しく出る。総評は所詮おまけで、おまけが嘘をつくくらいなら、消えていい。

そして実際、その後もう一度叩いたら、今度は 8B はこう書いた。「Ollama ノードの moon が down 状態であることが確認されました。デーモンは正常に動作しており、キューに未処理のタスクはありません。概ね安定しています」。確定サマリと完全に整合している。プロンプト強化で逸脱の頻度は下がり、それでも逸脱したら檻が捕まえる。二段構えが実データで噛み合った。

これは新しい発想ではない。この基盤は一貫して「LLM の選択を、推論から転記に格下げする」ことをやってきた。プランナーが勝手にノードを他所に置き換えないよう、指示文に書かれたノード名しか通さない検証を入れたのも同じ思想だ。LLM に事実判断を委ねない。委ねた部分は、コードで検算する。総評ガードは、その原則を「AI に語らせる」という一番緩みやすい場所に当てただけだ。

5. 直す側の権限 ― sudoではなく、最小の鍵で

状態を見られるようになったら、次は直す側だ。モデルの常駐/退避は ai-core から各ノードの Ollama を HTTP で叩くだけなので、特権は要らない。ここで一つ安全弁を入れた。warm する前に対象ノードの常駐一覧を確認し、そのノードに実在するモデルだけを許可する。存在しなければ拒否する——うっかり巨大なモデルの pull を走らせないためだ。許可リストは注入面を塞ぎ、実行時チェックは pull 暴走を塞ぐ。二段で締める。

最後の一本、結果集約役(reaper)の再起動だけは、特権が要る。ここで素直に sudo を使おうとして、壁にぶつかった。ai-core の実行役は堅牢化のため NoNewPrivileges=yes で動いている。この設定は sudo の特権昇格を構造的に遮断する。sudoers に許可行を足しても、そもそも sudo が昇格できない。

選択肢は二つあった。実行役全体の堅牢化を一段下げて sudo を通すか、あるいは polkit のルールで「この一本のユニットの再起動だけ」をピンポイントに許すか。前者は reaper 一本のために実行役全体に特権昇格の口を開くことになる。後者は堅牢化を保ったまま、最小の鍵だけを渡せる。迷わず後者にした。hogehoge が、reaper サービスの restart だけを、polkit 経由で許可される。sudo は使わない。NoNewPrivileges=yes は最後まで一度も崩さなかった。

「ai-coreでreaperを再起動して」。承認を挟み、実行役は sudo を使わずに素の systemctl を叩き、polkit が認可し、exit 0 で返ってきた。公開サーバ側で write を解禁したときは NoNewPrivileges=no + sudo という別解を採ったが、今回は polkit という別の鍵で、より狭く握った。同じ「最小権限」でも、置かれた文脈で正解は変わる。

おわりに ― 見えるものと、嘘をつくもの

できあがったのは、チャットから「ai-coreでできることを一覧して」と打てば全アクションが使い方付きで出てきて、状態を見て、モデルを常駐させ、詰まった集約役を再起動できる、そういう入口だ。読む系は自動、直す系は承認制。許可リストと承認と決定論ガードで、AI には「見る・提案する」までを任せ、「実行するかどうか」は人が握る。

この一年、この基盤で作ってきたものの多くは、結局「静かに嘘をつくものを、檻に入れる」作業だった。ProtectHome は df に嘘をつき、reaper は「成功しました」と嘘をつき、read レーンは「配達済み」の顔で11日間タスクを捨て続け、そして今回、LLM は自分の足元を「故障の可能性」と語った。どれも、部品は全部妥当に見える。だから検算する。事実は機械が保証し、LLM はその上でだけ喋る。緑が出ていることを信じない。

観測を作ったら観測の穴が見えた、というのは、たぶん悪い話ではない。穴は、覗こうとした者にしか見えないからだ。