「moon」と頼んだのに mars を見に行った ― LLM を頭脳に戻す三つの縛りと、全部の部品が正しいのに間違うプランの話

前回の記事を、私はこう締めました。allowlist と deny-by-default は AI の能力を諦めるための柵ではなく、将来もっと賢い頭脳を安心して繋ぐための土台だ、と。今回はその頭脳を載せた話です。「mars のディスク使用量を見て」とチャットに書けば、数秒後に df -h の実出力が返ってくる。ようやく「サーバの様子、見ておいて」が一言で通るようになりました。

ただし、頭脳を返すということは、怖さも一緒に返ってくるということです。実行係からシェルを取り上げたのは、LLM が信用ならないからでした。その LLM を、今度は「自然文をコマンド計画に翻訳する係(planner)」として判断系に戻す。だから今回の主題は翻訳の賢さではなく、翻訳係をどう縛るかです。そして案の定──縛ったつもりの三重の検証を、一つのプランがすり抜けました。「moon のメモリを見て」と頼んだら、planner は mars を見に行くプランを、自信満々で返してきたのです。

頭脳の縛り方、三つ

planner は gemma3:12b に厳格な JSON だけを吐かせる小さなモジュールです(maintenance_planner.py・temperature 0・format=json)。出力はそのまま前回作った承認判定に食わせられる形({node, action_id, params})に固定したので、実行系のコードは一行も変わっていません。縛りは三つ入れました。

一つ目は入力の制限。プロンプトを組み立てる関数の引数は、本人の指示文と静的なカタログ──この二つしか存在しません。サーバのログも、過去のタスク結果も、RAG の検索結果も、混ぜたくても混ぜる口がない。実行系で断った「ログ経由の指示汚染」の通り道を、頭脳側で復活させないための構造的な強制です(テストでは関数シグネチャそのものを検証しています)。

二つ目は量的なガード。一プランのステップ数上限と、アクション別のレート制限です。カタログ照合は「許可された操作か」しか見ないので、「許可された操作の望まない連打」──全ノード再起動を延々繰り返すような形──を止められません。レートは Redis のカウンタを先に消費し、超過したら全量ロールバックして rejected。壊れたプランはカウンタを消費しない順序も、テストで固定しました。

三つ目はfail-closed。JSON が壊れていた、知らないキーが混ざっていた、LLM に到達できなかった──全部「何も実行せず、理由を人に返す」に倒します。推論タスクなら縮退実行にも意味がありますが、サーバ管理の縮退は「推測で何か実行する」ことになる。壊れたプランの正解は実行ゼロです。

初計測は優等生だった

sandbox のテストを 38 ケース緑にしてから、実機の gemma3:12b に自然文を 5 本流しました。JSON 契約の遵守は 5/5。パース失敗も余計なキーも、実在しないアクション ID の捏造もゼロ。「mars で apache の状態を確認して」には、カタログの enum から正しく unit=apache2 を選び──

{
  "status": "ok",
  "steps": [
    { "node": "mars", "action_id": "service.status",
      "params": { "unit": "apache2" } }
  ]
}

「mars を再起動して」には、カタログに無い操作として正しく空プランを返しました。ここまでは優等生です。問題は 4 本目でした。

moon と言ったのに、mars を見に行った

「moon のメモリを見て」。moon はこのクラスタに実在するホストですが、メンテナンス対象としてカタログに載っているのは mars だけです。期待した答えは「対応付けできませんでした」。返ってきたのはこれでした。

{
  "status": "ok",
  "steps": [
    { "node": "mars", "action_id": "mem.usage", "params": {} }
  ]
}

moon への依頼が、音もなく mars にすり替わって、status=ok で通っている。しかも三重の検証は正しく動いた上での結果です。mars は既知のノード、mem.usage は既知のアクション、パラメータも妥当──部品を一つずつ照合する構造検証には、弾く理由が一つもない。全部の部品が正しいのに、プラン全体としては間違っている。意味論の穴は、構造検証の盲点でした。

プロンプトには「一覧に無いノードは絶対に使わない」と書いてありました。gemma はこれを破っていません。mars を使えば違反しないと、字義どおりに充足したのです。「実行できない指示には空プランを返せ」という逃げ道も用意してありましたが、それが効いたのはアクションが無いケース(再起動)だけで、ノードが無いケースでは「近くの既知ノードで代替する」ほうに倒れた。今は read-only なので実害は「moon のつもりで mars のメモリを自信満々に報告する誤答」で済みますが、次のフェーズで書き込みを解禁したら、これは誤ったサーバへの write です。

お願いと、防壁

対処は二段にしました。まずプロンプトに一行足します。「指示が対象とするノードが一覧に無い場合空プランを返す。別のノードへ置き換えることは禁止」。再計測すると moon はきちんと rejected に落ち、正常系も無傷。直りました。

──直った、で終わらせないのが今回の判断です。プロンプトの遵守は gemma への「お願い」であって、防壁ではありません。モデルを更新したら、あるいは将来もっと大きな頭脳に差し替えたら、無言のうちに壊れうる。壊れたことを検知する仕組みも無い。だから二段目として、検証コードに一つガードを足しました。プランが使うノード名は、指示文の中に文字どおり出現していなければならないnode_presence_guard)。

これは LLM のノード選択を「推論」から「転記」に格下げする発想です。指示文に mars と書いてあるから mars のプランが通る。書いていないノードは、どんな推論を経ようと通らない。代償もあります。「公開サーバのディスクを見て」のような役割語・別名での指示は弾かれるようになり、メンテナンス指示ではノード名を明示するのが運用規約になりました。でもこれは deny-by-default の思想からすればむしろ正で、書き込みを解禁する将来には「誤ノードへの write が原理的に成立しない」という実益に化けます。テストは 38 から 42 ケースへ。すり替えプランがレート制限のカウンタを消費しないことまで含めて、コードで固定しました。

チャットに df が返ってくる

頭脳と縛りが揃ったので、最後に配線です。Coordinator に投入エンドポイントを、OpenWebUI に 6 つ目の擬似モデル「🔧 サーバ管理」を足しました。選んで自然文を書くと、planner → 検証 → 承認判定 → 実行 → 結果、が一つのチャットターンに収まります。

配線で意識したのは二つ。一つは rejected を沈黙させないこと。moon の依頼が弾かれたら、その理由がそのままチャットに出ます。もう一つはプランを人に見せること。実行前に「どのノードで・何を・どのパラメータで・承認状態はどうか」をエコー表示します。ガードで塞いだとはいえ、すり替えの一件のあとでは、人間の目という最終防衛線を素通りさせる気になれませんでした。結果の待ち方はカタログのアクション定義にフラグ(sync)で宣言する方式にし、ミリ秒で終わる read はインライン表示、分オーダーになる将来の write は Talk 報告へ──と、これも登録時に人に考えさせる構造にしています。

🔧 サーバ管理プラン plan_36499a1ac821
- mars / disk.usage → 🟢 投入済み・結果待ち

✅ mars / disk.usage(exit 0・0.003s)
Filesystem      Size  Used Avail Use% Mounted on
/dev/sdc1       1.8T  178G  1.6T  11% /home
(以下略)

実行 3 ミリ秒。前回、5 ミリ秒のレースを暴いた「速すぎる read」が、今回はチャットの中で普通に答えを返しています。

おまけ:正本はどこにいる

細かい教訓を二つ。カタログのスキーマを強化(write に失敗時挙動の宣言を必須化)したら、配備済みだった別モジュールのテストが赤くなりました。テスト内に埋め込まれた模擬カタログが新しい必須項目を持っていなかったからです。前回「全緑」と言えたのは古い契約での話で、契約を強化したら、その契約を共有する全テストを同じ組み合わせで再走する──を怠っていました。実機に届く前の sandbox 再検証で捕まえられたのが救いです。

もう一つは正本の話。OpenWebUI の Pipe に擬似モデルを足すパッチが、実機で「アンカーが見つからない」と止まりました。調べると、手元に正本として置いていたファイルと、OpenWebUI の中で実際に動いているコードが別物になっていた。Pipe の真の実行体は OpenWebUI の Functions データベースの中身で、ファイルはその写しに過ぎません。エディタが保存時に自動整形をかけるらしく、写しは静かにズレていく。実行体がファイルと別の場所にあるコンポーネントでは、「ファイルが正本」という前提そのものが崩れる。以後、Pipe は実行体からの export を正本とする規律にしました。

約束はプロンプトに、保証はコードに

今回いちばん残ったのは、優等生の 5 本ではなく、すり替えの 1 本です。JSON 契約は完璧に守り、enum も正しく選び、それでいて「moon」を「mars」に読み替える。LLM は約束を破ったのではなく、約束の文面を私と違う形で満たした。この種のズレは、モデルが賢くなるほど巧妙になりこそすれ、消えはしないと思っています。

だから役割分担はこうなりました。約束はプロンプトに書く。保証はコードに書く。プロンプトの一行は第一防衛として実際に効いていますが、それが将来も効き続けることに賭けはしない。賭けの代わりに、ノード名の転記という機械的に検証できる規則を置く。前回の柵の内側に、今回ようやく頭脳が入りました。頭脳は柵のおかげで、間違えても壊せない。そして人間は、柵のおかげで、頭脳の間違いを安心して観察できる。次のフェーズで write を解禁するときも、この順番──まず柵、それから自由──は変えないつもりです。