「差分出力でトークン節約」という甘い幻想
自律コーディングエージェントやLLMワークフローを設計するとき、誰もが一度は思いつく「最適化」があります。「500行あるソースコードのうち、修正が必要なのはたったの3行。それならファイル全文をLLMに出力させるのではなく、git diff や patch コマンドで適用できる Unified Diff(ユニファイド差分) のみを出力させればよいのではないか?」という発想です。
計算上、これは極めて合理的に見えます。出力トークン数は500行から15行へと激減し、APIコストは1/30になり、レスポンス速度も圧倒的に跳ね上がるはずです。入門チュートリアルやプロンプト集でも、「AIにdiff形式で修正差分を出力させ、OSのpatchコマンドでファイルに自動適用する」という設計が当たり前のように紹介されています。
しかし、このアーキテクチャを現場のプロジェクトに投入した瞬間、開発者は冷徹な現実に叩き落とされます。
$ patch -p1 < agent_change.diff
patching file src/services/billing.py
Hunk #1 FAILED at line 48.
patch: **** malformed patch at line 62: @@ -85,6 +85,8 @@
1 out of 1 hunk FAILED -- saving rejects to file src/services/billing.py.rej
「パッチ適用失敗(Hunk failed)」「不正なフォーマット(malformed patch)」。何度エージェントを回しても、まともにパッチが当たりません。失敗したエラーログをプロンプトに戻して「パッチの適用に失敗したから修正して」とリトライさせると、今度はさらに行番号がズレて別の関数を破壊し始めます。
「トークン代をケチるために差分出力を選んだ結果、リトライの嵐で5倍以上のトークンと時間を浪費し、最後は人間が手動でコピペ修正する羽目になった」
これは使用しているLLMの性能不足ではありません。「人間とGitのために設計された差分フォーマット(Unified Diff)」と「自己回帰型LLMの生成メカニズム」との間に横たわる、情報構造的な決定論的矛盾が原因なのです。
なぜLLMはUnified Diffを出力できないのか?2つの構造的欠陥
LLMがどれほど賢くなっても、Unified Diffの直接生成において失敗をゼロにできない理由は、主に2つの構造的欠陥に集約されます。
欠陥①:自己回帰モデルにおける「因果律の逆転(行数ヘッダーの先出し問題)」
Unified Diffの最も基本的な構文であるハンクヘッダー(Hunk Header)を思い出してください。
@@ -48,7 +48,11 @@ def calculate_total_amount(items: list[Item]) -> Decimal:
subtotal = sum(item.price for item in items)
- tax = subtotal * Decimal('0.08')
+ # 軽減税率の適用
+ tax_rate = Decimal('0.10') if not is_food else Decimal('0.08')
+ tax = subtotal * tax_rate
+ logger.info(f"Calculated tax: {tax}")
return subtotal + tax
ここで注目すべきは @@ -48,7 +48,11 @@ という宣言です。この記号は「削除前は48行目から7行分、追加後は48行目から11行分」という行数の厳密な数値を宣言しています。
しかし、LLMのアーキテクチャは「左から右へ、1トークンずつ確率的に生成する自己回帰モデル(Auto-regressive Model)」です。つまり、「自分がこれから何行のコードを出力するか」を、コード本文を1トークンも出力していないヘッダー生成の時点で完全に予知していなければならないのです。
人間でさえ、修正後のコードをすべて書き終えた後に差分行数を数え直さなければこの数値を正しく書けません。それを「思考しながら文章を紡ぐ」自己回帰型LLMに強制するのは、因果律の逆転以外の何物でもありません。結果として、ヘッダーに書かれた行数と実際に出力された行数が1行ズレ(行番号ドリフト)、パッチパーサーは「形式不正」として即座にクラッシュします。
欠陥②:BPEトークナイザと「空白・インデントの揺らぎ」
2つ目の致命的な問題は、トークナイザ(Byte Pair Encodingなど)の挙動です。PythonやGo、YAMLなどのコードにおいて、スペース4個、タブ、改行コードは、前後の文字列と組み合わさって異なるトークンIDに圧縮されます。
Unified Diffでは、変更のない文脈行には先頭に半角スペース1個(" ")、追加行にはプラス("+")、削除行にはマイナス("-")を厳密に配置しなければなりません。しかしLLMは、行頭の「スペース1個+インデント4個」と「インデント4個」を容易に取り違えます。トークナイザの境界線がインデントの途中で切れることにより、先頭の識別記号が欠落したり、余分なスペースが挿入されたりするのです。
patch コマンドに --fuzz オプションを付けて曖昧マッチを許可すると、今度はさらに悲惨な事態が起きます。「1文字ズレた空白」を許容した結果、ファイル内の別の場所にある似たようなIF文や関数定義にパッチが誤爆して刺さり、既存のコードを破壊したままサイレントに正常終了するという最悪のデータ破損を引き起こします。
現場が辿り着いたコード適用アーキテクチャの進化史
AI駆動開発の最前線で戦ってきたオープンソースプロジェクトや商用ツール(Aider、Cursor、Claude Code、Antigravityなど)は、この「差分の罠」とどのように戦い、克服してきたのでしょうか?その進化の系譜を整理します。
┌─────────────────────────────────────────────────────────────┐
│ 第1世代: Unified Diff 直接生成(2023初頭) │
│ - git diff / patch コマンド依存 │
│ - 成功率: 30% 未満(行番号ドリフトとインデント破損で全滅) │
└──────────────────────────────┬──────────────────────────────┘
▼ 行番号依存の完全破棄
┌─────────────────────────────────────────────────────────────┐
│ 第2世代: Search / Replace ブロック(2023〜2024) │
│ - Aider方式: <<<<<<< SEARCH ... ======= ... >>>>>>> REPLACE │
│ - 成功率: 80%〜90%(一意なコンテキストマッチング) │
└──────────────────────────────┬──────────────────────────────┘
▼ 決定論的検証とツール化
┌─────────────────────────────────────────────────────────────┐
│ 第3世代: 構造化ツール呼び出し & 全文上書きフォールバック │
│ - 精密な行範囲指定+ターゲット文字列照合(Tool Calling) │
│ - 変更率30%超は躊躇なく「全文再生成」に切り替えるハイブリッド│
└─────────────────────────────────────────────────────────────┘
第1世代:Unified Diff(全滅)
2023年初頭の初期のエージェント実装は、ほぼ例外なくUnified Diffを採用し、そして全滅しました。LLMに「必ず正しいパッチ形式で出力してください」といくら懇願(プロンプトチューニング)しても、行番号ドリフトとコンテキスト行の欠落を抑え込むことは数学的に不可能だったからです。
第2世代:Search / Replace Block(Aider方式のブレイクスルー)
この泥沼に革命を起こしたのが、Aiderの開発者Paul Gauthierらが提唱した Search / Replace ブロック方式 です。「そもそも行番号なんて数えさせるから失敗するのだ」という発想の転換でした。
<<<<<<< SEARCH
subtotal = sum(item.price for item in items)
tax = subtotal * Decimal('0.08')
return subtotal + tax
=======
subtotal = sum(item.price for item in items)
tax_rate = Decimal('0.10') if not is_food else Decimal('0.08')
tax = subtotal * tax_rate
return subtotal + tax
>>>>>>> REPLACE
エージェントには「置換前のコード(SEARCH)」と「置換後のコード(REPLACE)」を出力させます。適用エンジン側は、ファイルの中からSEARCHブロックと完全に一致する箇所を文字列検索で見つけ出し、REPLACEブロックに置き換えるだけです。行番号の概念を完全に消去したことで、パッチ適用成功率は一気に80%を超えました。
第3世代:構造化ツール呼び出し(Tool Calling)と全文フォールバック
現在、CursorやClaude Code、Antigravityなどの最新エージェントが採用しているのが、構造化されたツール呼び出し(replace_file_content など)です。単なるフリーテキストのパッチではなく、APIパラメータとして「対象ファイル」「検索対象の厳密な文字列」「置換後文字列」「おおよその行番号ヒント」を渡させます。
システム側は置換を実行する前に、「指定された行範囲に対象の文字列が存在するか」「一意に特定できるか(重複マッチがないか)」を静的に検証します。不一致があれば即座に決定論的なエラーメッセージをエージェントに返し、ハルシネーションによるコード破壊を物理的に遮断する設計です。
【実践実装】堅牢なSearch/Replaceパッチハンドラーの設計
では、自前のAIエージェントやワークフローでコード適用パイプラインを組む場合、どのようなコードを書くべきでしょうか?現場で実績のある「インデント正規化ファジィマッチ付きSearch/Replaceハンドラー」のコアロジックを提示します。
import re
from typing import Tuple
def apply_search_replace(file_content: str, search_block: str, replace_block: str) -> Tuple[bool, str, str]:
"""
Search/Replaceブロックをファイルに適用する。
1. 完全一致
2. インデント・改行を正規化したフォールバック一致
の2段構えで堅牢に置換を行う。
"""
# 1. 完全一致(最も安全)
if search_block in file_content:
# 複数箇所にマッチした場合は危険なので弾く
if file_content.count(search_block) > 1:
return False, file_content, "SEARCHブロックがファイル内に複数存在し、一意に特定できません。文脈行を増やしてください。"
updated = file_content.replace(search_block, replace_block, 1)
return True, updated, "Successfully applied (exact match)."
# 2. 改行コードや末尾スペースの揺らぎを吸収する正規化マッチ
search_lines = [line.rstrip() for line in search_block.strip().splitlines()]
file_lines = file_content.splitlines()
match_start = -1
for i in range(len(file_lines) - len(search_lines) + 1):
window = [file_lines[i + j].rstrip() for j in range(len(search_lines))]
if window == search_lines:
if match_start != -1:
return False, file_content, "正規化後もSEARCHブロックが一意に特定できませんでした。"
match_start = i
if match_start != -1:
# マッチした範囲を置換
new_file_lines = (
file_lines[:match_start] +
replace_block.splitlines() +
file_lines[match_start + len(search_lines):]
)
# 元の改行コードを保持して結合
return True, "\n".join(new_file_lines), "Successfully applied (normalized whitespace match)."
return False, file_content, "SEARCHブロックに一致するコード片が見つかりませんでした。"
このハンドラーの肝は、「完全一致」を最優先としつつ、トークナイザが削りがちな行末の空白(rstrip())のみを正規化して再試行する点です。全体のインデント構造を破壊することなく、LLM特有の空白ノイズを高い精度で救済できます。
開発者が直面する3つの疑問と現場のトレードオフ
Q1: 「小規模なファイルなら全文上書き(Whole File Write)で十分では?」
その通りです。実は200〜300行以下のファイルであれば、差分パッチなど使わずに「全文上書き」させるのが現場では最も安全で最速です。
最近のモデル(Claude 3.7 Sonnet、GPT-4o、Gemini 2.0 Flash等)は出力トークン速度が極めて高速です。200行程度のコードなら3〜5秒で全文生成できます。全文上書きの適用成功率は100%であり、パッチ適用失敗によるリトライリスクが完全にゼロになります。「差分適用はファイルが500行を超えた時だけ使う」という割り切りが、現場では最も障害を減らします。
Q2: 「変更量が全体の何割を超えたら全文上書きに切り替えるべきか?」
現場の実務的な閾値は「変更量がファイル全体の30%を超える場合」です。1ファイルの中にSearch/Replaceブロックが4つも5つも散らばると、置換の順序依存(前の置換によって後ろの置換箇所の行番号や文脈がズレる現象)が発生し、後半のブロックが高確率で衝突します。変更箇所が多岐にわたる場合は、最初から「このファイル全体を書き直せ」と指示する方が圧倒的に堅牢です。
Q3: 「1万行を超える巨大なレガシーファイルはどう修正すべきか?」
そもそも1万行のファイルをエージェントにそのまま読み書きさせようとする設計自体が破綻しています。コンテキストウィンドウを無駄に圧迫し、注意機構の焦点がボケてハルシネーションを起こすからです。
この場合の正解は、「Tree-sitter等の構文解析ツールを用いて、修正対象のクラスや関数(例えば150行分)だけを仮想ファイルとして切り出し、エージェントにSearch/Replaceさせた上で、ホスト側が元のファイルに再統合する」というサンドボックス・スライシング構造を取ることです。
まとめ:トークン代をケチるな、決定論的堅牢性を買え
AI駆動開発や自律エージェントの設計において、最も高くつくコストは何でしょうか?それは「APIの出力トークン代」ではありません。「安く済ませようとして壊れた差分パッチを、LLMに何往復もリトライさせて浪費するトークンと時間、そして最終的に壊れたコードを人間が血眼になって直すデバッグコスト」です。
- Unified Diffの直接出力を捨てる: 自己回帰モデルに行番号を予測させるのは因果律に反する。
- Search / Replace または 構造化Tool Callingを採用する: 行番号ではなく、決定論的なテキストマッチングで適用する。
- 短いファイルは潔く「全文上書き」する: 適用成功率100%の確実性に勝る最適化はない。
人間にとって便利なフォーマット(diff)が、AIにとっても便利であるとは限りません。LLMの生成特性を深く理解し、確率的な出力と決定論的なファイルシステムを接続する強固なブリッジを設計すること——それこそが、本番で壊れない自律エージェントを創り出すエンジニアの必須スキルなのです。