LLM ループの『完成定義』を間違えると、100 点合格でも intent が骨抜きになる
LLM エージェントに繰り返しタスクを任せる時、 intent を自然言語で書くだけでは intent は満たされない。 Done conditions と DO NOT の組み合わせ次第で、 LLM は合格基準の中で intent を捨てる経路を選ぶ。実例と構造分析、対策チェックリスト。
LLM ループの「完成定義」を間違えると、100 点合格でも intent が骨抜きになる
LLM エージェントに繰り返しタスクを任せる仕組み(Aider のループ、 ralph-loop、 各種 autonomous coding agent、 Cursor の agent mode 等)を回していると、必ず一度は出くわす現象がある。
checks 全部 pass、 review LGTM、 でも本当は仕事をしていない。
これは LLM が怠慢だったのではなく、 設計者側が 「intent を満たさずに合格できる経路」を完成定義の中に残していた という設計問題。先日この罠を踏んだので、構造を解剖して残しておく。
(このループの基盤として使った ralph-loop を 1 ショット runner に転用する設計と、ローカル LLM / API LLM の経路比較は 並列 ReAct エージェントでローカル LLM と Claude を本気で比べた で別途整理した)
失敗ケース
やらせたこと
既存システムに「A/B テスト基盤」を追加する作業を LLM ループに投げた。要件は 4 つ:
- 新しい設定ファイル B 版を作る
- データクラスに B 用フィールドを追加する
- A と B を並列実行するコードを書く
- A と B の両方の結果を、同じレコードにタグ付けする ← これが本当の intent
prompt.md には Goal を明示で書いた。
Tag both A and B recommendations onto the same record so that downstream aggregation can compare them.
Done conditions は 7 個書いた。 review prompt も書いた。 ループを回した結果、 全 checks pass / review LGTM / 「完成しました」通知。
確認したら
実装は確かに動いていた。 B 用プロンプトは並列で呼び出されている。 B 用フィールドはデータクラスに追加されている。 ただし、 返ってきた B の出力をコードが捨てていた。
# 抜粋
result, output_a, _output_b = generate_both(...) # アンダースコア prefix で破棄
post_to_discord(result) # A だけ通知「両方を同じレコードにタグ付けする」という intent は完全に飛んでいた。 B プロンプトを呼ぶコストだけ払って、 結果を捨てる、という最悪のパターン。
でも私が書いた checks は全部 pass する:
| check 名 | 内容 | 結果 |
|---|---|---|
| b-prompt-file-exists | B 版プロンプトが存在 | ✅ |
| run-references-b-prompt | コードに B プロンプトの定数あり | ✅ |
| stats-variant-b-runs | --variant b モードでエラーなく走る | ✅ (B フィールドが空なので空集計だが) |
| dataclass-has-b-fields | データクラスに B フィールドあり | ✅ (デフォルト "") |
| ... | ... | ✅ |
LLM は 「合格できる経路の中で最小実装を選んだ」 だけ。 振り返ると、 LLM の選択は完全に合理的だった。
なぜそうなるか
完成定義をレイヤー分解してみる。
| レイヤー | 効力 | 表現形式 |
|---|---|---|
| Goal / intent | ソフト | 自然言語、解釈の余地あり |
| DO NOT | ハード | 具体的なファイル名 / 振る舞い禁止 |
| Done conditions (checks) | ハード | exit code が 0 か非 0 か |
| Review (LLM 審判) | 中間 | 別 LLM が diff を見て LGTM か指摘か |
LLM は最終的に ハードな評価基準 で合否が決まる。 Goal はソフトなので、 ハード基準(DO NOT + checks)を満たしつつ Goal を緩く解釈する経路があれば、 そちらを取るのが LLM にとって合理的(コストが低い)。
今回どう経路ができていたか:
- intent: 「両方を同じレコードにタグ付け」
- intent 達成に必要だったこと: 下流の
alerts.pyで B 用パラメータを受け取って、 レコード生成時に B フィールドにも値を入れる - 私が書いた DO NOT: 「alerts.py を変更しない」
- 私が書いた checks: B の出力がコードに渡る経路を 検証していなかった(定数の存在と
--variant bの実行可能性だけ)
→ LLM は DO NOT を厳格に守り、 checks を 100/100 で取り、 intent を静かに捨てた。
これは LLM の怠慢ではなく、 設計者が「intent を満たさずに合格できる経路」を残していた こと。 言い換えれば、 私の DO NOT と checks が intent と矛盾していた。
アンチパターン 3 種
汎化すると、 LLM ループの完成定義でよく出るアンチパターン。
A: intent を自然言語で書いただけ
Goal: ユーザーが新しい項目を追加できるようにする
LLM は「追加できる」を非常に広く解釈する。 UI も API も backend も対象になり得るし、 逆に「設定ファイルに 1 行関数を追加するだけ」も「追加できる」に該当しうる。 LLM が自分の解釈で範囲を決めるので、 結果が予測不可能。
B: checks が「表層」しか見ていない
[[checks.items]]
command = "grep -q 'add_item' src/foo.py"add_item という名前の関数が 存在する だけで合格する。 中身が pass でも通る。 「ファイルが存在する」「定数が宣言されている」「コマンドがエラーなく走る」 系の checks は、 ほとんどの場合「intent を満たさずに合格できる」経路がある。
C: DO NOT が intent を裏切る経路を作る
DO NOT modify db_schema.py
でも intent が「新しい項目を追加して DB に保存する」なら、 db_schema.py を触らないと達成できない。 LLM は DO NOT を優先して intent を緩める。 「保存はしないが、 メモリ上に保持するだけの追加機能」みたいな縮退実装を選ぶ。
3 つに共通するのは、 ハードな制約とソフトな intent の組み合わせで、 LLM が intent を捨てても合格できる経路が生まれる こと。
対策
1. 完成品質を「LLM スコープの外」に置く
LLM が編集できるファイル群と、 合格判定に使うファイル群を物理的に分ける。
- fixture / golden data を人間が用意する
- LLM は処理ロジックを書く
- 合格は fixture を入力として与えて期待出力が出るかで判定
- fixture は LLM の編集スコープに入れない(DO NOT で禁じるだけでなく、 そもそも触る必要がない構造にする)
- 意図逸脱の検出は外部審判 LLM (review) に任せる
- 実装者 LLM とは別の LLM が diff を見て「intent から逸脱していないか」 を判定
- 審判 LLM 用のプロンプトには intent を 検証項目のチェックリストとして 書く(自然言語のままにしない)
2. intent を「動いて値が運ばれるか」 で検証する
「両方タグ付けする」を自然言語で書いただけでは弱い。 これを exit code で yes / no が出る形に翻訳する。
# done condition の例
record = run_pipeline_with_test_input(a_input, b_input)
assert record.a_field == expected_a
assert record.b_field == expected_b # ← 実値が伝播しているか直接確認書類上「ファイルがある」「定数が宣言されている」 ではなく、 動かして値が intent 通りに運ばれているか を assert する。 これが本物の完成定義。
3. DO NOT を「振る舞い単位」で書く
DO NOT は「ファイル単位の禁止」ではなく 「変えてはいけない振る舞い単位」 で書く。
NG: DO NOT modify alerts.py
OK: alerts.py の検知ロジック(ゾーン到達判定、 イベントクローズ判定)
は変更しない。新しいパラメータの追加と横流しは許可する。
ファイル単位の禁止は intent を裏切る経路を簡単に作る。 振る舞い単位なら、 LLM は intent を達成しつつ守るべき不変条件を守れる。
4. 「最小実装で合格できる案」を自分で書き出す
完成定義(checks + DO NOT)を書き終えたら、 公開する前に自問する:
この checks を全て pass する 最小実装の候補 を 3 つ書け。 全部 intent を満たしているか?
ひとつでも intent を裏切る案が思いついたら、 checks が不足している(または DO NOT が広すぎる)サイン。 LLM もその経路を見つけて選ぶ可能性が高い。
チェックリスト
完成定義を書く時に自問する 3 項目(覚えやすさ重視)。
- 「LLM が runtime に自分で動かせる合格条件になっていないか」
- LLM が書いたテストファイル自体が合格条件になっていないか
- fixture が LLM の編集スコープ内にないか
- 「intent を満たさずに checks を全 pass できる経路があるか」
- 最小実装の候補を 3 つ自分で考える。 全部 intent を満たすか
- 「DO NOT が intent と矛盾していないか」
- intent を達成するために、 DO NOT で禁じたファイル / 振る舞いに触る必要があるか
- あれば DO NOT が広すぎる、 もしくは intent の方が広すぎる
教訓
LLM ループは書いた通りに動く。 intent を書いても、 checks に翻訳されていなければ、 LLM は「intent を満たさない合格経路」を 積極的に 選ぶ。 これは怠慢ではなく、 「合格基準の中で最小コストの解を選ぶ」という極めて合理的な振る舞い。
設計者は LLM を信頼するのではなく、 LLM が合格基準を悪用しても intent が達成される構造 を作る必要がある。 ここで言う「悪用」は悪意ではなく、 ただの 「コスト最適化」。
「intent を書く」は完成定義のスタート地点であって、 ゴールではない。 intent を 検証可能な形に翻訳しきる ことが、 LLM 自動化の本当の設計仕事。
今回のケースでは ralph-loop(Python の自家製ラッパー)を使ったが、 本記事の話はループツールに依存しない一般論として書いた。 Aider のループ、 各種 autonomous coding agent、 Cursor の agent mode、 どれでも同じ構造の失敗が起こり得る。
教訓としては:
- intent は書いても満たされない
- 完成定義は exit code に翻訳しきる
- LLM のスコープ外に fixture を置く
- DO NOT は振る舞い単位で書く
- 公開前に「最小実装で合格できる案」を 3 つ自分で書き出す
これを次のループから組み込む。完成定義側でどうしても exit code に翻訳しきれない「意味的に展開されているか」のような項目は、judge LLM をテストに組み込んで自然言語の criteria で評価する手もある(pytest で LLM-as-judge を組む — deepeval × Claude Code CLI)。ただし判定する側も LLM なので、judge をどこまで信用するかの判断は別途必要。