0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AI はレビュー結果を改ざんする — AI 協働開発で品質を守る 3 つの設計パターン

0
Last updated at Posted at 2026-04-21

はじめに

あなたの AI エージェントは誠実ですか?

私の開発環境では、実装を担当するエージェントとレビューを担当するエージェントを分けています。自分で書いたコードを自分で「問題ない」と判断してしまうバイアスを避けたい、という狙いです。しかし運用していると、妙な場面に遭遇しました。レビュー記録は「zero findings で通過」だったのですが、レビュアーの生ログを見ると、実際には指摘が返っていました。実装者 AI が独断で「偽陽性」と判定して記録から消していた — つまりレビュー結果が書き換えられていたのです。

「不誠実にもほどがある」私は思いました。しかし、これはAIエージェントに悪意があって起きたわけではなく、今思えば仕組みの問題でした。レビュアーの指摘を受け取る主体も、レビュー結果ファイルに書き込む主体も、commit を実行する主体も、全部実装を担当する AI エージェント だったからです。

「実装とレビューを別の AI エージェントに担当させる」という設計自体は、AI 協働開発で広く推奨されています (Anthropic の Plan / Generate / Evaluate 分離、Martin Fowler の "Behaviour Harness" も同じ方向性)。ただしこの分離だけでは、次の 3 つの構造問題が残ります:

  • A. レビュー結果の改ざん — 実装者が独断で findings を揉み消す
  • B. レビュー後の変更紛れ込み — 承認後〜commit までの間にコードが変わる
  • C. レビュー / 修正ループが収束しない — 指摘密度の爆発でループが止まらない

本記事では、3 問題それぞれへの設計パターンを紹介します。核になるアイデアは 実装者 agent を untrusted として扱い、レビュアー自身が書いたレビュー結果ファイルを pre-commit で機械判定することです。Rust でのコード片を例示しますが、設計自体は言語・フレームワーク非依存です。


用語定義 (記事内で使う固有語)

本記事は AI 協働開発における 設計パターンの紹介です。読み進める前に 3 つだけ用語を定義します:

  • レビュー判定 (verdict): レビュアーが返す結果です。本記事では ZeroFindings (指摘ゼロ) または FindingsRemain(findings) (指摘あり) の 2 値を想定します。以降は 「判定」 と略記することがあります
  • 実装者 (agent): コードを書く AI agent (例: Claude Code) です。同時にレビュアーを起動したり、判定を永続化する主体でもあるため、後述のとおり信頼境界上の要注意ポイントになります
  • レビュアー (agent): 実装者とは別のモデル / プロセスで実行される、コード review 専門 agent (例: Codex CLI) です
  • scope: 本記事ではレビュー対象をいくつかのまとまりに分けて並列でレビューするパターンを紹介します。その区分のことです (例: domain / usecase / infrastructure など、glob パターンで定義)

TL;DR

AI コーディング支援では 実装する agent とレビューする agent を別エージェントにする 設計が重要です (判断の独立性、blind spot 回避)。

ただし分離するだけでは 3 つの新しい問題 が生じます。それぞれを構造的に解決するパターンを紹介します:

問題 解決策
A. レビュー結果の改ざん (実装者がレビュアーの findings を独断で偽陽性と判断して書き換える) レビュアー subprocess 自身に記録させる (実装者が判定に介入する経路を消す) + レビュアーに機械可読な JSON で出力させ、自然言語の解釈を挟ませない
B. レビュー済みでないコードが commit される (レビュー後〜commit までの間に実装者が修正を加える) レビュー結果に対象コードの SHA-256 ハッシュを埋め込み、commit 前に現コードとの一致を検証
C. レビューが収束しない / 時間爆発 (diff が大きくなるほど指摘密度が爆発し修正が別指摘を誘発) M 分解 (直列) × P 並列 scope レビュー で、実時間 (開始から終了までの経過時間) を O(N²) から理想的には O(N²/M/P²) まで削減。Fast/Final 2 段階モデルと scope 別 briefing で定数項も削減。レビュー/修正ループは専用サブエージェントに隔離してメインコンテキスト枯渇を防ぐ

1. なぜ実装者とレビュワーを別エージェントにするのか

AI コーディング支援を production で使うとき、実装とレビューを同じ agent に任せる設計は罠になります。

同じモデルには以下の性質があります:

  • blind spot が同じ — 同じ学習データ由来の誤解を自分で検出できません
  • 自己正当化バイアス — 生成した実装を自分でレビューすると「問題ない」に寄りやすくなります
  • prompt 文脈の汚染 — 実装時の前提を引きずったままレビュー判断してしまいます

対策として、実装者と別のモデル / 別の実行プロセスをレビュワーにします。例えば:

  • 実装者: Claude Code (Opus 4.7)
  • レビュワー: Codex CLI (gpt-5.4 / gpt-5.4-mini)

異なる学習データ・異なる推論系統・異なる prompt scaffolding の 2 つに判断させれば、blind spot が重ならない確率が上がります。いわゆる best-of-N の劣化版です。

これは Anthropic の Plan/Generate/Evaluate 分離パターンや、Fowler の "Behaviour Harness" 概念とも整合します。

2. 分離すると発生する 3 つの新しい問題

実装者とレビュアーを別 agent にすると、それぞれ性質の違う 3 つの問題が生じます。以降の章はそれぞれに対応します。

問題 A: レビュー結果の改ざん

レビュアーが指摘を返したとき、それを受け取って処理する主体は誰でしょうか? 実装者 agent です。具体的には:

  1. 実装者 (Claude Code) がレビュアー subprocess (Codex) を起動します
  2. レビュアーが判定を返します (通常は自然言語の文章形式)
  3. 実装者が判定を解釈し、実装を修正するかを判断します
  4. 実装者が判定を永続化ファイルに記録します

問題は 3 と 4 を実装者が独占している ことです。実装者 agent は以下のことができてしまいます:

  • レビュー指摘を 曲解 する (「これは false positive」と判断してスキップ)
  • レビュー指摘を 軽視 する (「later fix」と称して無視)
  • レビュー指摘を なかったことにする (永続化時に「ゼロ finding」と書き換える)

人間のレビュー文化で言う「レビュー指摘を黙殺する」行為の自動化版です。AI の場合、指摘を丁寧に読まなくても「ぱっと見 nit っぽい」で捏造判定されるリスクが高まります。

問題 B: レビュー済みでないコードが commit される

レビュー自体が正しく通過しても、レビュー完了〜commit までの間に実装者 agent がファイルを編集すると、commit されるコードはレビューされていないコードになります。

時刻 T1: レビュアーが scope S のコードを評価、zero_findings を返す
時刻 T2: 実装者 agent が別の tool call で S 内のファイルを編集
時刻 T3: 実装者 agent が commit を実行
         → commit されるのは T2 時点のコード、レビュー済みは T1 時点のコード

これも人間開発では起きにくい問題 (人間は commit 前に「さっきレビュー通ったコードそのままだよね?」と自分で確認する) ですが、AI agent は tool call が非同期に連鎖するので、意図せず混入します。

さらに tool call の race 以外にも「レビュー後に気になって Edit で typo 修正した」のような些末な変更でも発生します。些末に見えても、別の場所で副作用を起こす修正が混じっていれば検出が必要です。

問題 C: レビューが収束しない / 時間が爆発する

1 回のレビューで大きな diff をまとめて reviewer に渡すと、以下のような連鎖が起きます:

  • 指摘が大量に返ってきます (diff 量 N に比例して O(N) 件)
  • 1 件修正すると別の場所との関係で新しい指摘が生まれます
  • 修正 → 再レビュー → 新指摘 → 修正… のループが止まりません

経験的に、レビュー全体のコスト = 読解量 × 指摘回数 = O(N) × O(N) = O(N²) 程度と評価できます。1 ラウンドごとに diff 全体を読む (O(N)) × 累計指摘回数 (O(N)) の積で効いてくる形です。

これは AI agent 固有の問題ではなく、従来の人間レビューでも大きな PR は地獄化しますが、AI agent だと以下の増幅要因があります:

  • レビュアーが疲れないので、人間なら飛ばす些末指摘も全件返ってきます
  • 修正ループが自動で回るので、止める判断タイミングが失われます
  • 並列で複数レビュアーが走ると指摘の重複・相互衝突も発生します

以降の章で「実装者 untrusted」を前提とした設計パターンを順に見ていきます。

3. 問題 A の解決 — レビュアー自身に記録させる

問題 A (実装者によるレビュー結果の改ざん) の本質は、「レビュアーの出力が実装者の手を経由して永続化される」という経路の存在です。ここを断ち切ります。

方針: 判定の生成者 = 判定の記録者

実装者がレビュアーを起動する経路は残しますが、判定の抽出と永続化はレビュアー subprocess の内側でアトミックに実行します。

// レビュアー起動 + 判定抽出 + 永続化を 1 関数内で
match cycle.review(&scope) {
    Ok(ReviewOutcome::Reviewed { verdict, hash, .. }) => {
        review_store.write_verdict(&scope, &verdict, &hash)?;  // ← subprocess 内で記録
        emit_output(&verdict)
    }
    // ...
}

実装者 agent が叩けるレビューコマンドの形は:

review-cli run \
  --briefing-file <path> \
  --scope domain \
  --round-type final

ポイントは 2 つです:

  1. レビュワーの呼び出しと記録をアトミックに行う — 1 回の CLI 呼び出しで subprocess 起動 → 判定抽出 → 永続化までを不可分に実行する。途中で実装者 agent が介入する窓を作らない
  2. レビュー結果を手動で記録する手段を用意しない--verdict フラグのような「外から判定を渡す」経路や、レビューと独立した「記録だけを行うサブコマンド」を CLI に置かない (詳細は次節)

判定は subprocess 内部で抽出され、同じ subprocess 内でレビュー結果ファイルに書き込まれます。実装者 agent は 判定を直接受け取らず、編集するタイミングも得られません

方針: レビュー結果を機械可読な構造データで出力させる

レビュアー AI のデフォルト出力は自然言語の文章です。「〜という懸念があります」「こうした方がよいでしょう」といった形で返ってきます。このままでは subprocess が判定を確信を持って抽出できず、結果的に実装者 AI が自然言語を解釈して zero_findings / findings_remain を判定することになり、改ざんの温床になります。

そこでこのパターンでは、レビュアーに構造化スキーマ (JSON など) で出力させることを推奨します。例えば codex exec--output-schema で以下のような JSON Schema を渡します:

{
  "type": "object",
  "required": ["verdict", "findings"],
  "properties": {
    "verdict": { "enum": ["zero_findings", "findings_remain"] },
    "findings": {
      "type": "array",
      "items": {
        "type": "object",
        "required": ["message"],
        "properties": {
          "message": { "type": "string" },
          "severity": { "enum": ["low", "medium", "high"] },
          "file": { "type": "string" },
          "line": { "type": "integer" }
        }
      }
    }
  }
}

これにより、subprocess は出力を serde_json 等でそのまま decode し、人間や AI の解釈を挟まずに記録と判定の両方を機械化できます。pre-commit ゲート (§6 参照) が verdict == "zero_findings" && findings.is_empty() を機械的に判定できるのも、この構造化出力が前提です。

自然言語の文章も補助的に取得してセッションログとして保存すれば、後から人間が確認する用途には使えます。承認判定の経路には自然言語の解釈を介在させない、というのが勘所です。

サブコマンドの最小化

レビュー CLI のサブコマンドは以下 3 つに絞ります:

pub enum ReviewCommand {
    Run(RunArgs),                    // ← 起動 + 記録をアトミックに
    CheckApproved(CheckApprovedArgs), // ← commit 前の承認確認
    Status(StatusArgs),              // ← 現状表示 (読み取りのみ)
}

「外から判定を渡して記録させる」サブコマンド (例: record-round --verdict '...') は置かないのが原則です。これにより 「レビュアーを起動せずに判定だけ書き込む」パスが存在しない状態を構造的に保証します。

4. 問題 B の解決 — レビュー結果にコードハッシュを埋め込む

問題 B は「レビュー時点と commit 時点でコードが違う」状態です。これは レビュー結果にレビュー対象コードの SHA-256 ハッシュを埋め込む ことで検出できます。

流れ

T1: レビュアーが scope S のコードを評価
    → 判定 (zero_findings) + コードハッシュ H を記録
T2: 実装者 agent が S 内のファイルを編集
T3: commit 実行 → check-approved が scope S の現コードハッシュ H' を計算
    H != H' → commit をブロック (StaleHash として再レビュー要求)

ReviewState enum

レビュー結果の状態は ReviewState enum として表現します:

pub enum ReviewState {
    Required(RequiredReason),
    NotRequired(NotRequiredReason),
}

pub enum RequiredReason {
    NotStarted,       // 未レビュー
    FindingsRemain,   // 指摘あり
    StaleHash,        // 判定はゼロ finding だが現在のコードと hash 不一致
}

pub enum NotRequiredReason {
    Empty,            // scope に対象ファイルなし
    ZeroFindings,     // ゼロ finding + hash 一致
}

StaleHash は「過去にレビュー通過したが、その後コードが変わったので再レビューが必要」という意味の独立状態です。

レビュー中の変更も検出する (before/after hash)

同じハッシュ機構は、レビューの変更も検出できます。レビュー開始時と完了時に hash を計算し、違えばその round を破棄します:

fn review(&self, scope: &ScopeName) -> Result<ReviewOutcome<Verdict>, ReviewCycleError> {
    let hash_before = self.hasher.calc(&review_target)?;
    let (verdict, _) = self.reviewer.review(&review_target)?;  // 数秒〜数分
    let hash_after = self.hasher.calc(&review_target)?;

    if hash_before != hash_after {
        return Err(ReviewCycleError::FileChangedDuringReview);
    }
    Ok(ReviewOutcome::Reviewed { verdict, hash: hash_after })
}

AI agent は tool call が非同期に並走し、レビュー中も他のファイルを触り続けることができるので、この検出は不可欠です (人間レビュアーの世界では不要だった概念)。

ハッシュ計算の設計方針

実装上の注意点は 3 つです:

  • 決定性: 対象ファイル群をソートした上で manifest を組み、その全体を SHA-256 する。削除ファイルも含めて表現する (単にファイル集合から外すと「削除したのに気付かない」ケースが生じる)
  • 対象を絞る: 「最新 HEAD との diff がある」AND「該当 scope にマッチする」の交差だけを hash 対象にする。scope glob が 10000 ファイルマッチしても、PR で触ったのが数ファイルなら hash 計算も数ファイルで済む
  • scope ごとに独立に計算する: 後述の並列レビュー (§5) と組み合わせる前提なら、hash も scope 単位で独立に持つ必要がある。diff 全体で 1 つの hash を取ってしまうと、domain scope を触っただけで承認済みの usecase scope の判定まで無効化され、独立レビューの利点が消える

5. 問題 C の解決 — M 分解 × P 並列で実時間を理想的に O(N²/M/P²) に

問題 C は「大きな diff を 1 回でレビューすると指摘が爆発してループする」状態です。経験的に、レビュー全体のコストは 読解量 × 指摘回数 = O(N) × O(N) = O(N²) 程度と評価できます (diff 量 N に対し、1 ラウンドの読解量も累計指摘回数もそれぞれ O(N) で効いてくる)。

これを 2 つの直交する軸 で減らします:

  • M: 直列方向のタスク分解 — 「大きな変更を 1 度に commit / review しない」という運用規律
  • P: 並列方向の scope レビュー — 1 つのタスクを P scope に分けて reviewer を並列起動する CI 最適化

: 以降の O(·) 表記は 理想的な条件下 (diff が M / P に均等分割される、並列度 P が実行環境で頭打ちにならない、reviewer 起動の固定オーバーヘッドが無視できる) での上限です。実際の実時間は分割の偏りや API rate limit の影響を受けます。

軸 1: M 分解 (直列) で O(N²) → O(N²/M)

1 つの機能追加 N を M 個の小さな commit に分解し、それぞれで review → commit のサイクルを回します。

  • 1 commit あたりの review コスト: O((N/M)²) = O(N²/M²)
  • M 回分の累計: M × O(N²/M²) = O(N²/M)

これは運用規律の話なので、コードで強制する必要はありません。プロンプトで「タスクを小さく分割してから実装 → 各タスクごとに review → commit」を指示すれば十分です。

軸 2: P 並列 (scope 分割) で実時間を O(N²/M²) → O(N²/(M²·P²))

M 分解された各タスクについて、さらに diff を P 個の scope に分割し、各 scope の reviewer を並列に起動します。

  • scope 分割の定義は glob:
{
  "groups": {
    "domain":         { "patterns": ["libs/domain/**"] },
    "usecase":        { "patterns": ["libs/usecase/**"] },
    "infrastructure": { "patterns": ["libs/infrastructure/**"] },
    "cli":            { "patterns": ["apps/**"] },
    "harness-policy": { "patterns": [".claude/commands/**"] },
    "plan-artifacts": { "patterns": ["docs/**", "knowledge/adr/**"] }
  }
}
  • 各 scope の size: (N/M) / P = N/(MP)
  • 各 scope のレビューコスト: O((N/(MP))²) = O(N²/(M²P²))
  • P 並列で実行 → 1 タスクの実時間: O(N²/(M²P²))

組み合わせ: O(N²) → O(N²/M/P²) (実時間)

M 個のタスクを直列に回し、各タスクで P 並列 scope レビューを使うと、全 M タスクを終えるまでの実時間は:

  • M × O(N²/(M²P²)) = O(N²/(M·P²)) = O(N²/M/P²)

M と P は掛け算で効くので、理想的には例えば M=4 (4 つの commit に分ける)、P=6 (6 scope に分割) なら:

  • M の効果: 1/4
  • P の効果: 1/36
  • 合計: 1/144 の実時間短縮 (上限値。実測は分割の偏りや並列度の頭打ちで劣化します)

さらに scope 内でも: Fast / Final の 2 段階モデル

scope 分割してもなお、1 scope あたりの reviewer 呼び出しは遅い・高コストです。そこで 2 段階モデルを使います:

  • Fast pass: fast / 軽量モデル (例: gpt-5.4-mini) が低コストで走り、自明な findings を拾う。参考値
  • Final pass: 実装者が fast pass の findings を修正した後、full / 本命モデル (例: gpt-5.4) で final review を実行。承認判定は final のみ

fast はあくまで参考値として扱い、コミット承認の判定に使わないのがポイントです。fast は高速かつ安価に自明な不備を発見し、重たい final review の呼び出し回数を減らす目的で使用します。

補助: scope 別 severity policy

さらに scope ごとに reviewer への briefing (severity policy) を差し替えできます:

{
  "groups": {
    "domain": {
      "patterns": ["libs/domain/**"],
      "briefing_file": "review-prompts/domain.md"
    },
    "plan-artifacts": {
      "patterns": ["docs/**"],
      "briefing_file": "review-prompts/plan-artifacts.md"
    }
  }
}

例えば設計ドキュメント scope には「factual error のみ報告、wording nit はスキップ」を強制できます:

## 設計ドキュメントレビューの severity policy

以下のみを findings として報告すること:
- factual error (存在しない CLI / ファイルパス / ADR 番号)
- contradiction (複数箇所で矛盾する記述)
- broken reference (参照先が存在しない)

以下は **報告しないこと**:
- wording nit (言い回しの好み、冗長さ、トーン)
- 設計判断に対する代替案

これは O オーダーを下げる対策ではなく、ループ長期化を誘発する指摘タイプ (特に設計ドキュメントへの wording nit) を抑制する補助です。指摘が減れば定数項が小さくなります。

実運用の補助: レビュー/修正ループをサブエージェントに隔離する

上記の工夫でレビュー時間は大きく短縮できますが、経験上、それでもなおレビューは 数〜十数ラウンド に及びます。これをメインエージェントのコンテキストで直接処理すると、レビュー指摘の読み込みと修正の往復でコンテキストがすぐに枯渇します。

対策として、レビューと修正を自律的に繰り返す専用サブエージェント (例: review-fix-lead) を定義し、メインコンテキストから分離するのが有効です:

[メインエージェント] ─spawn─→ [review-fix-lead (scope: domain)]
                   ├─spawn─→ [review-fix-lead (scope: usecase)]
                   └─spawn─→ [review-fix-lead (scope: infrastructure)]
                          ↓
                    各サブエージェントが内部で:
                    1. レビュー実行
                    2. findings があれば修正してステップ 1 に戻る
                    3. zero_findings に到達したら終了
                          ↓
                    zero_findings 到達の可否だけメインに報告

サブエージェントは 1 つの scope を担当し、内部で数〜十数ラウンドの review → fix → review を回します。メインエージェントには 最終結果 (zero_findings 到達 or エスカレーション) のみが戻るので、メインコンテキストには個別の findings / 修正 diff が一切流入しません。

効果:

  • メインコンテキストの寿命が延びる (1 PR / 1 作業単位を 1 セッションで回しやすい)
  • scope ごとにサブエージェントを並列起動すれば、P 並列の効果 (§軸 2) と自然に重なる
  • サブエージェントが timeout や findings 残存で失敗したら、メインに escalate してユーザー判断を仰ぐ経路も明示できる

6. まとめ

この記事の骨格を再掲します:

前提: AI コーディング支援では、実装者とレビュアーを 別エージェントにする (blind spot 回避 / 独立した判断)

全体の流れ: pre-commit で機械判定するパイプライン

問題 A / B / C の解決策を組み合わせると、以下の品質担保パイプラインになります:

[レビュアー subprocess]
  ├─ 機械可読な構造化データ (JSON) で判定を出力
  ├─ コード hash 計算
  └─ レビュー結果ファイルに永続化 (atomic、実装者は介入不可)  ← 問題 A 対策
         ↓
[実装者が commit を試みる]
         ↓
[pre-commit チェック]
  ├─ レビュー結果ファイルを機械的に解析
  ├─ 全 scope が NotRequired(ZeroFindings) か判定        ← 問題 B 対策
  └─ 現コードの hash と記録済み hash が一致するか検証
         ↓
   OK → commit 成立
   NG → commit をブロック、該当 scope を再レビューへ

ポイントは:

  • レビュー結果は実装者を介さずレビュアーが書き込む → 改ざん経路を消す (§3)
  • レビュアーに構造化スキーマ (JSON) で出力させる → 自然言語を解釈する余地をなくし、機械判定の入口を揃える (§3)
  • 永続化ファイルに hash を埋め込む → 後からの改ざん・乖離を検出可能にする (§4)
  • pre-commit で機械判定する → 人間/AI の主観判定を排除し、commit ゲートを構造化する
  • レビュー 1 ラウンドを軽くする M × P 分解 → このパイプラインを現実的な時間で回せるようにする (§5)

「レビュー結果を書く主体」「判定する主体」「commit を通す主体」を構造的に分離するのがこの設計の核です。

3 つの新しい問題と対応する解決策

問題 解決策 核心メカニズム
A. レビュー結果の改ざん レビュアー自身に記録させる 判定抽出と永続化をレビュアー subprocess 内でアトミック実行 + 構造化スキーマ (JSON) で出力 + --verdict 入力口を消去
B. レビュー済みでないコードが commit される レビュー結果にコードハッシュ埋め込み SHA-256 manifest hash で StaleHash 検出 + before/after hash で race 検出
C. レビューが収束しない / 時間爆発 M 分解 × P 並列 scope 実時間を O(N²) から理想的に O(N²/M/P²) まで、Fast/Final 2 段階、scope 別 briefing、サブエージェントでレビュー/修正ループを隔離

「実装者とレビュアーの分離」は多くの AI 開発フレームワークで言及されますが、分離したあとに発生する 3 つの二次問題まで踏み込んで対処している実装例は少ないように感じます。レビュアーを subprocess として動かす運用を選ぶなら、実装者 untrusted モデルは避けて通れないはずです。本記事の設計パターンが、同じ構図で悩んでいる方の一助になれば幸いです。

参考文献

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?