LLM ハーネスを作って学んだこと
LLM にブラウザを操作させるスクリプトから得た8つの設計原則 ― モデルの周囲を固める決定論的な足場こそが、システム全体の勝負どころだった。
このページの内容 (14)
- LLM ハーネスとは何か
- メンタルモデル
- ルール 1 ― LLM ではなく、検証器こそが真実の源
- ルール 2 ― 正しさをプロンプトの協力に依存させない
- ルール 3 ― 信頼境界:LLM の宣言値は LLM の制御外にあるものと突き合わせる
- ルール 4 ― 認証情報は LLM の世界に入れない
- ルール 5 ― 二段階書き込み:pending → verified
- ルール 6 ― 構造的な修正はプロンプトの指示に勝る
- ルール 7 ― 読み取りと書き込みの権限分離
- ルール 8 ― 推論は一度、出力は最大化
- いま私が嗅ぎ分けるアンチパターン
- 最終的なアーキテクチャの姿
- 一つだけ持ち帰るなら
- おまけ:AI にインストールできる版
些細なものを作るつもりだった:Python スクリプトを一本、自然言語で「HN の servo の記事をアップボートして、自分が何回アップボートしたか教えて」と頼めば、LLM が実際のブラウザを動かして実行する ― そういうものだ。最初のドラフトは約80行。一度動いて、魔法のように感じ、その後の数日で、想像しうるあらゆる形で静かに裏切ってきた。
- ログインページに着地しただけなのに、記事をアップボートしたと主張してきた。
- 依頼したタイトルにタイポがあると、別の記事をアップボートした。
- 同じ間違った操作のバリエーションを試すループで、API を30回無駄に叩いた。
- 私が日本語ローマ字を打ったら、ネパール語で返答してきた。
- 何もしていないのに、上機嫌で「あなたのアップボート数は6回になりました」と言ってきた。
これらすべての失敗は、同じ根本の問いを指していた:LLM 自身の「やった」という報告を、どこまで信じていいのか? 答えは毎回、「あなたが思っているより少ない」だった。
これらの失敗を一つずつ塞いでいく中で立ち上がってきたものがハーネスと呼ばれるものだ。この記事は、初日の自分に知っていてほしかったことだ。
LLM ハーネスとは何か
ハーネスとは、LLM の周りにある決定論的な足場のことで、システム全体を境界づける ― コストに境界を、影響範囲に境界を、LLM が嘘をつける方法に境界を設ける。ハーネスがなければ、それはただの「ツールアクセス付き LLM」であり、セキュリティのバグと、コストのバグと、信頼性のバグをデモに包んだものにすぎない。
具体的に、ハーネスの中では:
- LLM は作業者であり、監督者ではない。推論し、ツールを選び、テキストを出す。
- ハーネスが監督者だ。存在するツールを定義し、あらゆるツール呼び出しを検証し、成功の主張をすべて検証し、諦めるまでに LLM が使える予算に上限を設ける。
- ユーザーは LLM と直接話さない。 ユーザーが話す相手はハーネスで、ハーネスが仲介する。
正しい類比は「AI エージェント」ではない。ブラウザだ ― サンドボックスの中にスクリプトエンジンがあり、サンドボックスが許した範囲の中でスクリプトエンジンは何でもできるが、それ以外のことは一切できない。
メンタルモデル
この記事の他のすべては、たった一つの視点の転換から派生している:
LLM を、有能だが慢性的に信頼できない後輩として扱え。作業はする。だが、どう進んだかについて嘘をつくこともある。あなたの仕事は、それを確認することだ。
そして、その確認を担う場所がハーネスだ。
たどり着いたアーキテクチャは、このアイデアを図にすると次のようになる:
黄色のノードは一つだけ。LLM の推論が行われるのはそこだけだ。それ以外 ― リトライの判断、検証、DB 書き込み、最終レポート ― はすべて、同じ入力に対して常に同じ動きをする Python コードだ。「黄色いノードは一つだけ」というこの規律こそが、ほとんどのハーネスのバグを潰す場所になる。
ルール 1 ― LLM ではなく、検証器こそが真実の源
LLM の最終メッセージは、LLM が言いたい放題を言う。モデルが陽気に振る舞うよう RLHF されていれば、メッセージは陽気になる。自信ありげに聞こえるよう訓練されていれば、メッセージは自信ありげになる。どちらも、タスクが実際に成功したかどうかとは何の関係もない。
実際に動かし始めた瞬間にこれを見た。実行の最後のメッセージ:
"I upvoted story rank 3 successfully!"
このとき「成功」とは、ブラウザが https://news.ycombinator.com/vote?id=… ― つまり HN の「ログインしてください」リダイレクトページに着地したことだった。LLM には、自分が失敗したことを知る手段がなかった。もっともらしい結末を語っただけだ。
対策は、試行のたびに検証器を走らせること。検証器はブラウザの状態を直接読む(URL、body の最初の500字、一覧ページの「ログイン済み?」マーカーなど)、そして構造化された判定を返す:
{"success": False, "reason": "landed on failure url (/vote?)", "terminal": True}
その判定 ― LLM のメッセージではなく ― が終了コードを決める。LLM のメッセージも表示はするが、大きなラベル付きで:
--- Last attempt: LLM claim (UNVERIFIED — may be wrong) ---
I upvoted story rank 3 successfully!
--- Last attempt: deterministic verifier said ---
FAIL: landed on failure url (/vote?)
読者は常に両方を見る。真実は常に2つ目の方だ。
ルール 2 ― 正しさをプロンプトの協力に依存させない
LLM が嘘をつくかもしれないと受け入れると、次の誘惑が現れる ― プロンプトを工夫して嘘を封じよう、というものだ。「アップボート矢印が消えたのを実際に見た場合のみ、成功と主張してください」。「ログインページに着地したらリトライしてください」。「まず X、次に Y、それから Z をしてください」。
これらはすべて、姿を変えたバグだ。
たどり着いた原則:システムプロンプトのある行を LLM が無視したり誤解した場合、ハーネスは正しい結果を出せるか?
- Yes → その行はそのままでよい(スタイル、UX の磨き込みなど、ソフトな指針として)。
- No → その正しさの保証は流砂の上に建てている。
プロンプトは、ツールを説明する場所(ケイパビリティ・マニフェスト)と、応答スタイル(言語、拒否範囲)を設定する場所だ。正しさ ― リトライ、検証、認証情報の扱い、対象照合、順序 ― は Python 側で、テストできる場所に置く。
判定基準:システムプロンプトを丸ごと消してみて、それでもハーネスは間違ったことを拒否できるか? できるなら安全。できないなら、リファクタする。
ルール 3 ― 信頼境界:LLM の宣言値は LLM の制御外にあるものと突き合わせる
もっとも巧妙なバグは「対象一致ガードレール」の初期バージョンにあった。アイデア:ユーザーが "upvote 'The Loss of Bikash Mind'" と言ったら、"the loss of bikash mind" というタイトルの記事のアップボート矢印だけをクリック可能とする。単純な話だ。
ただし ― 対象タイトルが何なのかを教えるのも、LLM の仕事に含まれていた。すると LLM は、対象を例えば "june in servo"(まさにこれからクリックしようとしている記事)と抽出し、それを制約としてインストールし、既に決めていたそのクリックを行うことで、自分で作った制約を満たしてしまう。循環だ。
対策:LLM が宣言する値は、ユーザーの元メッセージと突き合わせて検証する。 部分文字列チェック、正規化のうえで。LLM が宣言したタイトルがユーザーのメッセージに含まれていなければ、宣言を拒否し、LLM は再挑戦するしかなくなる。
一文で言えば、信頼境界とはこういうことだ:LLM は、自分を制約するはずの制約を、自分で発明できてはいけない。LLM に許されるのは、ユーザー自身の言葉から選ぶことだけだ。
同じ原則は、LLM にユーザー入力を構造化させたいあらゆる場面 ― 抽出、分類、曖昧性の解消 ― に当てはまる。LLM の出力は必ず、LLM が改変できないものにアンカーする。
ルール 4 ― 認証情報は LLM の世界に入れない
LLM がログインする必要があるとき、いっそ… LLM にログインさせよう、という誘惑がある。fill_form(selector, value) ツールを渡せばいい。LLM だぞ! フィールドくらいわかるだろう。
やってはいけない。
フォーム入力ツールが存在した瞬間、3つのことが可能になる:
- プロンプトインジェクションによって、認証情報の送り先を書き換えられる。
- LLM のコンテキスト(および下流のあらゆるログ/トレース)に、パスワードが載る。
- その後のすべてのリファクタで、ツール引数をうっかりログに残さない配慮が必要になる。
私が使うパターン:ハーネス自身がログイン処理を行う。 ブラウザアダプタは fill() プリミティブを公開するが、それは意図的に LLM のツールスキーマから外されている。呼び出せるのは Python コード ― 具体的にはログインハンドラだけ。パスワードは環境変数から読み、そのままフォームに打ち込まれ、LLM はその文字列を一度も見ない。
セッションクッキーが実行の途中で切れた場合、ハーネスがそれを検知(検証器の「ログイン済み?」チェックで捕まる)し、試行を止め、ログインハンドラを走らせ、再開する。LLM から見ると、ある試行が「失敗」し、次の試行が「動いた」ように見えるだけ。ログインが起きたことすら知らない。
ルール 5 ― 二段階書き込み:pending → verified
こういう挙動が欲しかった:ユーザーが「1件アップボートして、合計数を教えて」と言う。LLM がアップボートし、件数を問い合わせ、新しい数字を報告する。
素朴な設計:LLM がクリックし、ハーネスが DB に書き、LLM が件数を問い合わせ、その件数には新しい行が含まれている。
バグ:アップボートが実際には成功していなかった場合はどうする? DB の行は成功を前提に書かれた。これで件数は永久に間違ったままだ。
解決策:二段階書き込み。
- フェーズ1(実行中):アップボートが成立した証拠(直後の
get_storiesでalready_voted=true)をハーネスが見たら、verified=NULLの状態で DB 行を挿入する。件数クエリはinclude_pending=Trueでそれを含めるので、LLM は真実のアップボート後の数字を見る。 - フェーズ2(試行終了時):検証器が判定を出したら、ハーネスは pending の行を
verified=True(検証器が成功と言った場合)またはverified=False(失敗と言った場合)に更新する。デフォルトのcount_upvotesはverified=Trueのみを数える。
結果:LLM が実行中に見る件数は常に正確。監査ログは正直。誤検知(false positive)は静かに消されるのではなく、verified=False として記録され、あとから調査可能になる。
全体のシーケンス:
ルール 6 ― 構造的な修正はプロンプトの指示に勝る
私が加えた最大のアーキテクチャ改善は、「LLM さん、こうしてください」に見える問題を、「これがされるまで実行を拒否します」に見える問題へと置き換えたことだった。
具体例。対象一致ガードレールは、ユーザーが要求したタイトルをハーネスが知っている場合にしか機能しなかった。バージョン1:実行の冒頭で補助的な LLM 呼び出しを一つ書いた。「ユーザーメッセージを読み、対象タイトルを JSON で返してください」。1回の実行あたり LLM 呼び出しが1回から2回に増え、加えて余計な呼び出しを避けるための正規表現と LLM 呼び出しの脆いカスケードもできた。
バージョン2は、その余計な呼び出しを丸ごと消した。代わりに、LLM のツールスキーマに set_target(title) というツールを追加し、事前ディスパッチのガードレールにルールを一つ足した:set_target が呼ばれる前のアップボートクリックはすべて拒否する。
こうすると、LLM 自身の推論が、他のすべてを決める同じ推論パスの中で、対象の抽出を行うことになる。もし LLM が set_target を忘れても、拒否メッセージが次に何をすべきかを正確に教えてくれる:
魔法はツールにあるのではない。魔法は構造的な拒否は自己文書化されていることにある。LLM に指示する必要はない ― 前の試行がなぜ失敗したのかの正確な理由が伝えられ、LLM は自分で答えにたどり着く。
経験則:プロンプトに指示を足したくなったら毎回、拒否されるツール呼び出しで同じメッセージをもっと確実に届けられないか自問する。だいたい届けられる。
ルール 7 ― 読み取りと書き込みの権限分離
読み取りは LLM のツール。書き込みはハーネスの副作用。以上。
- 読み取り(LLM 呼び出し可):
goto,get_stories,click,read_page,count_upvotes。 - 書き込み(ハーネス専用):
record_upvote,mark_verified,fill_credentials。
このルールは3層で強制される:
- スキーマ: 書き込みアクションは LLM のツールスキーマにそもそも入っていない。
- 事前ディスパッチガードレール: 万一不正な呼び出しがすり抜けても、読み取り許可リストにないアクションは拒否される。
- ディスパッチャ: 万一ガードレールをすり抜けても、ディスパッチャは読み取りアクションしかルーティングしない。
多層防御(defence-in-depth)。侵害された LLM(プロンプトインジェクション、モデルドリフト)が書き込みまでたどり着くには、独立した3つの層を突破しなければならない。どれか1つが破綻しても、それはバグであって、突破ではない。
ルール 8 ― 推論は一度、出力は最大化
最後の原則は、同じ入力について LLM に二度推論させていたことに気づいた瞬間に生まれた。
バグ:Python 側にタイトル抽出器(正規表現の高速パス + LLM フォールバック)があり、エージェントループの前に走っていた。それはユーザーメッセージを読んで対象を見つけるために推論する。次にエージェントループが走り、そこでも… どのツールを呼ぶかを決めるために同じユーザーメッセージについて推論する。
推論パスが2回。コストが2倍。ロジックが二重化。間違えられる場所が2箇所。
対策(これはルール6の適用だ):抽出をエージェントループの中に、set_target ツール呼び出しとして置く。こうすると LLM はユーザーメッセージについて一度だけ推論し、宣言を出し、同じコンテキストの中で処理を続ける。あらゆる制約は、その一つの宣言に紐づく。
自然な言い回しでの実行における、Before / After のコスト勘定:
| シナリオ | Before | After |
|---|---|---|
| 履歴問い合わせのみ | 追加0 + N ループ反復 | N ループ反復 |
| 引用符付きタイトル | 追加0 + N ループ反復 | N ループ反復 (+1 for set_target) |
| 自然な言い回し | 追加1 + N ループ反復 | N ループ反復 (+1 for set_target) |
合計呼び出し数は同じでも、アーキテクチャの大きさは半分になった。モジュールが一つ減り、LLM の API メソッドが一つ減り、保守すべき正規表現が一つ減り、書くべき検証が一つ減った。シンプルであることが安いのだ ― お金を数える前から。
いま私が嗅ぎ分けるアンチパターン
以下はどれも、少なくとも一度は私を噛んだ。自分がこれをやっていることに気づいたら、止まる:
- 正しさをプロンプトの協力に依存させる。 リトライロジックを Python で書く代わりに、システムプロンプトに「失敗したらリトライしてください」と足す。
- 循環的な信頼。 LLM に自分の制約を定義させる。LLM の世界の外にあるものと突き合わせる。
- テスト対象の下の層をモックする。 ブラウザツールをモックして検証器をテストしても、何の証明にもならない。プロトコル境界でフェイクする。
- バグ修正にリファクタを混ぜる。 「ついでにこれも直そう」。コミットは常に2つに分ける。
- 投機的な抽象化。 「念のため」で Protocol や設定ノブを足す。具体的な必要が現れてから足す。
- サイレントな失敗。 エラー時にも success を返す DB 書き込みは嘘だ。せめてログに出す;通常は再送出する。
- タスク固有のコードをオーケストレータに置く。
Harnessクラスに HN 固有のロジックを書くのは漏れだ。ハーネスが委譲するタスクモジュールに移す。 - モジュール間に散らばったマジック定数。 タイムアウト、リトライ上限、コスト上限 → 一つの設定ファイルに。セレクタ、プロンプト、失敗シグナル → タスクと一緒に置く。
最終的なアーキテクチャの姿
同じことを作る人のために、層状の全体図:
矢印の向きが大事だ。プレゼンテーション → オーケストレーション → ガードレール → アダプタ。上向きも、横向きもない。レコーダーのようなタスク固有のモジュールは、オーケストレーション層が委譲するサービスであり、オーケストレーション層自体はタスク非依存のままだ。HN を Reddit に差し替えるなら、新しいレコーダーとプロンプトを書けばよい。ハーネスは変わらない。
一つだけ持ち帰るなら
これらすべてから一つだけ覚えるなら:
正しさはコードに宿る。ポリシーはプロンプトに宿る。LLM は判断を下し、ハーネスは保証を担う。
上のすべてのルールは、これの具体化にすぎない。新しい振る舞いをどこに置くか迷ったら問う:モデルが悪く振る舞ってもなお成立させたい保証か? Yes ならコード。No なら ― スタイル、トーン、タスク範囲の拒否、応答言語 ― プロンプト。
まず保証を組み立てる。LLM の部分は、保証が滑ったときに受け止めてくれる存在があるとわかっていれば、簡単になる。
おまけ:AI にインストールできる版
この記事を書きながら、これらのルールを Claude Code / エージェントスキル としてもパッケージした。名前は harness-engineer。スキルに対応した AI コーディングアシスタントを使っているなら、自分のプロジェクトにインストールしておくと、ハーネスを設計・レビューする文脈になったときに、同じルールが自動で適用されるようになる ― ブログを再読する必要も、チェックリストをコピペして回る必要もない。
- これは何か: 8つのルールを命令形にした持ち運び可能な版。「これをしていることに気づいたら止まれ」というレッドフラグ付き。
- これは何ではないか: Python、Playwright、HN、この記事の特定コードベースに縛られたものではない。原則は同じ、足場は汎用。
- 入手先: SKILL.md のソース →
- インストール方法:
SKILL.mdファイルを自分のプロジェクトの.claude/skills/harness-engineer/フォルダに置く。次のセッションからアシスタントが自動で拾い、「エージェントを作る」「LLM のツール呼び出し」「ガードレール」などのフレーズが出てくる文脈で自動適用してくれる。
こう考えるとよい:ブログはルールがなぜ存在するかを説明する。スキルはそれを適用しろと思い出させてくれる同僚だ。 自分のワークフローに合う方を使えばよい。