TL;DR
- AI エージェント(Claude Code など)は、与えた検証境界にぴったり最適化して止まる。最小差分・完了宣言バイアス・暗黙不変条件を見ない、という 3 つの性質がある。その結果として、局所的に正しい修正が「変更箇所と無関係な遠い場所」で静かに壊すことがある。
- これは「エージェントがミスした」のではない。指示側が『完了の定義(検証境界)』を狭く与えたために起きる。エージェントは境界の内側では完璧に振る舞う。
- 対策は指示の書き方。共有層を触る作業には「blast radius(変更の影響範囲)を列挙させる」「依存する/破る不変条件を言語化させる」「テストが緑ではなく実際に経路を実行したかで完了とする」を明示的に組み込む。
- 具体的な事故例は別記事「Django の transaction.on_commit と TestCase の相性」で扱った。本記事はそれを指示・プロンプトエンジニアリングの視点で一般化する。
起きたこと(要約)
ある取込フローで「Celery タスクが DB コミット前に .delay() され、未コミット行を掴めずサイレント失敗する」という実バグがあった。AI コード監査がこれを見つけ、.delay() を transaction.on_commit 経由の enqueue に直した。本番として 100% 正しい修正で、新挙動のユニットテストまで足されていた。
ところが 1 日後、日次テスト監査が別の結合テストを赤にした。原因は、それらのテストが高速化のため TestCase(トランザクションを rollback するので on_commit が発火しない)へ移行済みだったこと。これは非局所な副作用だ。修正は「commit されること」を新たな前提に加えたが、commit しないテストの上ではその前提が崩れる。
詳しい技術的経緯は別記事に譲る。ここで注目したいのは、エージェントは何も間違えていないという点だ。では何が問題だったのか。順に見ていく。
エージェントは「完了の定義」に忠実すぎる
修正を生んだ指示は、実質「コードを監査してバグを直せ」だった。エージェントはそれに極めて忠実に応えている。
- 実バグを正しく特定した
- 本番として正しい最小差分を当てた
- 新挙動のユニットテスト(
.delay()をモックして commit 前後を検証)まで足した
つまり**「変更関数+そのユニットテスト」という暗黙の完了ラインの内側では完璧だった。事故はその外側で起きた。ここが本質だ。境界を広げるのは、エージェントではなく指示側の責任**である。
なぜこれが AI エージェント特有の罠なのか
熟練したエンジニアなら「共有ディスパッチ層を触った、遠くで何か壊れそうだ」という嗅覚が働く。エージェントにはそれがデフォルトで備わっていない。むしろ 3 つの性質が事故を後押しする。
1. 最小差分バイアス
エージェントは「差分を局所に絞るほど良い仕事」だと強く方向づけられている。だから blast radius(変更の影響範囲)を自分から広げようとしない。よく訓練されているほど、diff は小さく、レビューは通しやすく、そして影響調査は狭い。
2. 完了宣言バイアス
指定したチェック(新ユニットテストが緑)が通った瞬間に「完了しました」と宣言したがる。指示が「テストを通せ」であれば、エージェントにとってのゴールは文字通り「そのテストが緑になること」であって、「変更が安全であること」ではない。
3. 暗黙不変条件を見ない
「このテスト群は on_commit 非依存という前提で高速化移行した」という契約は、実際にはコードコメントに書かれていた。だが指示の視界に入っていなければエージェントは参照しない。人間なら過去の議論や口伝で知っている不変条件を、エージェントは明示されない限り知らない。
まとめると、AI は与えた検証境界にぴったり最適化して、そこで止まる。放っておいて境界を広げてくれることは期待できない。
指示 Tips(そのまま CLAUDE.md / エージェントプロンプトに入る形)
以下は、共有層やフレームワークのイディオムを触るリファクタ/バグ修正をエージェントに任せるときに、指示へ明示的に組み込む項目である。
1. 変更ではなく blast radius を定義させる
「共有層(signals / dispatch / 基底クラス / middleware / ユーティリティ)を変更したら、その経路を叩く呼び出し元とテストを grep で全列挙し、各テストを実行モデル(commit するか rollback か / 同期か非同期か / mock か real か)で分類せよ」
今回の事故は、これがあれば TestCase 上の結合テストが視界に入り、当日中に気づけた。
2. 依存する/破る不変条件を言語化させる
「この変更が新たに依存する前提、または破る前提を箇条書きで明示せよ」
on_commit 化は「トランザクションが commit されること」を新しい前提に加えた。それを 1 行吐かせるだけで「では commit しないテストは?」という問いに自然に接続する。
3. 「テストが緑」を完了基準にしない
「変更した経路を実際に実行するテストを、ローカルの古い HEAD ではなくマージ先ブランチに対して走らせ、テストが新しいコードを通っていることを確認せよ」
事故当時、ローカルでは緑だった。だが手元の checkout が原因コミットより古く、「実行したつもり」で緑になっていただけだった。この落とし穴を指示側で前倒しに潰す。
4. 「正しいコード」と「安全な変更」を分けて考えさせる
「局所的に正しくても非局所な副作用がありうる。特に framework イディオム(
on_commit/ 非同期 dispatch / キャッシュ / signal / トランザクション境界)はテストと本番で挙動が変わる。テストの実行モデル上でも成立するか確認せよ」
5. 検証は「書いた本人」にやらせない(敵対的レビュー)
修正を書いたエージェントは確認バイアスがかかり、自分の変更を肯定しやすい。別コンテキスト/別エージェントに、「この変更はどこで・どの実行モデルのテストを壊しうるか」だけを探させる。これは、記事の校正で本文を書いたのとは別のエージェントにファクトチェックを回すのと同じ発想だ。マルチエージェント構成なら「実装役」と「破壊役」を分けるのが効く。
ここでよくある問いが「敵対的レビューは別モデルを使うべきか?」だ。結論は、「別モデル」より「別コンテキスト+敵対的プロンプト」の方が本命。バイアスは文脈に宿るので、実装の言い訳を持たない新規コンテキストに「反証せよ/壊れる入力を探せ/デフォルトは有罪」と指示するだけで、同一モデルでも大きく効く。別モデルが要るかは、潰したい盲点の種類で決まる。
- A. 注意・スコープの盲点(見なかった) — 今回の
on_commit事故がこれ。「どのテストを、どの実行モデルで壊すか」とプロンプトで注意を向ければ直る。別モデルはほとんど寄与しない。 - B. 共有された誤信念(見たが、両方とも間違っている) — ライブラリや framework の挙動を思い込みで扱う変更など。同一モデルは新規コンテキストでも同じ誤信念を共有するので気づけない。ここで初めて別モデルファミリーが効く(訓練が違えば誤信念が相関しない)。
つまり別モデルは「共有された誤信念」リスクが高い変更での保険と考えるとよい。なお、弱いモデルを多様性目的で入れるのは逆効果(誤検出で時間を溶かす)。最も弱いのは同一モデル・同一コンテキストの自己レビューで、まずそこから脱するのが最優先だ。
6. 「監査 → 修正 → 横断テストスイープ」を 1 つの手順に固定する
AI 監査が複数のバグを直したら、最後の必須ステップは per-fix のユニットテストではなく、マージ結果に対する横断スイート/全体監査の即時実行にする。安全網(日次監査)は機能したが 1 日遅れた —— これは「横断変更なら PR 時点で全体監査」を手順に組み込めば消える遅延だ。
指示テンプレート例
上記を 1 つのプロンプトブロックにまとめると、たとえばこうなる。
このリファクタ/修正を行う際、以下を必ず満たすこと:
1. 影響範囲: 変更する関数・層を叩く呼び出し元とテストを grep で全列挙し、
各テストを実行モデル(commit/rollback, sync/async, mock/real)で分類して報告。
2. 不変条件: この変更が新たに依存する前提・破る前提を箇条書きで明示。
3. 完了条件: 「テストが緑」ではなく「変更経路を実際に実行するテストが、
マージ先ブランチ上で新コードを通って緑」であること。checkout の鮮度も確認。
4. framework イディオム(on_commit / 非同期 / cache / signal 等)を足す場合、
テストの実行モデル上でも挙動が成立するか個別に検証。
5. 完了報告には「この変更が壊しうる遠い場所」の候補を最低3つ挙げること。
最後の「壊しうる遠い場所を最低 3 つ挙げよ」は特に効く。完了宣言バイアスを、あえて『不安を言語化する』方向に反転させるためのプロンプトだ。
Django プロジェクトでの CLAUDE.md の例
上のテンプレートは 1 タスク分の指示だが、同じ内容はプロジェクトの常設ルールとして CLAUDE.md(エージェントが毎回読む指示ファイル)にも書ける。むしろそちらに置く方が、指示し忘れても毎回効く。
ただしそのままコピーせず、汎用語を自プロジェクトの実名(基底クラス・層・監査ジョブ名)に具体化するのがコツだ。「framework イディオム」より on_commit と名指しし、「TestCase ベース」より実際のクラス名を書く方が、エージェントの注意は確実に向く。今回の事故を踏まえた Django プロジェクトなら、たとえばこうなる。
## 共有層・トランザクション境界を触るとき
- 変更する関数/層を叩く**呼び出し元とテストを grep で全列挙**し、各テストを
実行モデルで分類する:
- `RestTestCase`(= `TransactionTestCase`): commit する → `on_commit` 発火
- `RestFastTestCase`(= `TestCase`): rollback する → `on_commit` 非発火
(`CELERY_TASK_ALWAYS_EAGER=True` の eager 実行でも発火しない)
- `transaction.on_commit` を**新規追加**したら、その経路を叩く `RestFastTestCase`
ベースのテストを `self.captureOnCommitCallbacks(execute=True)` で囲むか、
`RestTestCase` へ戻す。同期経路では `captureOnCommitCallbacks` は no-op なので
sync/async 共通ヘルパにそのまま入れてよい。
- 完了条件は「テストが緑」ではなく「**変更経路を実際に実行するテストが、
マージ先ブランチ上で新コードを通って緑**」であること。手元 checkout の鮮度も確認する。
- `files/` など全アプリ横断の signal / dispatch を変更した場合、マージ後に
日次テスト監査を**手動トリガ**する(当日中に検知するため)。
- 完了報告には「この変更が壊しうる遠い場所」の候補を**最低 3 つ**挙げること。
ポイントは 2 つ。① 高リスクなイディオム(on_commit / 非同期 dispatch)だけ名指しでルール化し、残りは「共有層を触るときは影響範囲を全列挙」の一文に畳んで肥大化を防ぐ。② 完了条件と『壊しうる遠い場所を 3 つ』は普遍的に効くので、イディオムに依らず常設で置く。
なお、リファクタと無関係なリポジトリ(この技術ブログのような Hugo サイト等)にこのルールを足すのは逆効果だ。適用対象のないルールは CLAUDE.md のノイズになり、キャッシュ効率も落とす。ルールは「そこで実際に起きる作業」にひもづけて置く。
まとめ
- AI エージェントの事故の多くは「間違えた」のではなく、与えた完了の定義が狭かったために起きる。
- エージェントは境界の内側では極めて優秀で、最小差分・緑の達成・完了宣言に忠実。だからこそ境界を blast radius に合わせて広げるのは指示側の仕事。
- 具体的には ① 影響範囲の全列挙、② 不変条件の言語化、③ 緑ではなく実行の確認、④ イディオムの実行モデル検証、⑤ 別エージェントによる敵対的検証、⑥ 横断スイープの手順固定。
- これらは人間のチェックリストであると同時に、エージェントへの指示テンプレートとして書ける。むしろ後者に置く方が、毎回確実に実行される。
「AI が正しいコードを書いた」は「変更が安全だった」ことを意味しない。両者を分けて完了条件を設計することが、AI エージェント時代のリファクタリングの肝になる。