从0打造编码Agent 阅读 + 实验 ≈ 40 分钟 页内实验 ×1
A2 工具系统:模型怎么说清「改哪里」? 第 2 课

old_string 和文件差一个空格,该报错还是容错?

报错还是容错
真实 trace 里,模型给的 old_string 一次在文件里出现 2 处、一次缩进多了 4 格。只查「在不在」的实现改到了 ISO 分支;严格唯一的实现连报两次错,多花 8 千多 token。把五个真 edit 引擎搬进浏览器同时跑:容错能省一轮,也能把多转义的引号和弯引号原样写进代码。
❓

本课一问:上一课选定了「给出要替换的原文」。可模型抄原文会抄错:多一个空格、少一层缩进、引号写成弯的。old_string 和文件差一点点时,harness 该直接报错,还是替它找一个「最像的」位置?

这一课先不碰:行号(第 1 课已排除)、patch 格式本身合不合法(第 3 课)、这次修改该不该被允许(A3)。

从已有出发

第 1 课的结论是按内容锚定:模型给出一段原文 old_string,harness 在文件里找到它,换成 new_string。

从已有出发 · 累计数据流 点已学环节看它回答什么
每一轮循环都经过

回到贯穿任务。本课项目 miniagent 的真实运行 pd-truncate-s1(Qwen3-8B,2026-09-26)里,模型为修第一处 bug 连发了三次 edit_file,miniagent 的规则是 old_string 必须恰好出现一次:

轮模型给的 old_string(首行)miniagent 回给模型的话
4 return datetime.date(year, month, day)Error: old_string appears 2 times in dateparse.py; include more surrounding lines so it matches exactly once.
5 month, day, year = …(缩进 12 格,文件里是 8 格)Error: old_string not found in dateparse.py. Read the file again and copy the exact text, including whitespace and indentation.
6 month, day, year = …(缩进对了)Edited dateparse.py: replaced 1 occurrence.

第 4 轮那一行在文件里真的出现了两次:第 31 行(ISO 分支)和第 36 行(日在前分支),一字不差。第 5 轮只差缩进。这两种情况,就是本课要回答的。

先自己设计:找到就改

最朴素的做法是只问「在不在」:在,就把第一处换掉。learn-claude-code 的 s02 就是这么写的,一共 8 行:

code.py learn-claude-code@0dcafa2:s02_tool_use/code.py:98-107需联网 MIT
98def run_edit(path: str, old_text: str, new_text: str) -> str:
99 try:
100 file_path = safe_path(path)
101 text = file_path.read_text(encoding="utf-8")
102 if old_text not in text:
103 return f"Error: text not found in {path}"
104 file_path.write_text(text.replace(old_text, new_text, 1), encoding="utf-8")
105 return f"Edited {path}"
106 except Exception as e:
107 return f"Error: {e}"
👆 带下划线的行可以点:点哪行,讲哪行。
  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案只查在不在,改第一处(learn-claude-code s02)

s02 对这条 edit 的回应是「Edited dateparse.py」。模型收到成功,会以为自己改的是第 36 行。

严格唯一挡住了改错,但它把代价转给了模型:第 4、5 轮两次报错,模型各多跑一轮,第 5、6 轮一共花掉 8,481 个 token、109.5 秒(trace 里的 usage 与延迟)。其中第 5 轮只是缩进多了 4 格,意图完全明确。能不能对这种小漂移宽容一点?

改进阶梯:三种容错,各自的护栏

Pi:精确优先,归一化兜底,再数一遍唯一

Pi 先做精确匹配;失败时把文件和 old_string 都做一次归一化再找:Unicode NFKC、每行去掉行尾空白、弯引号换成直引号、各种破折号换成 -、各种特殊空格换成普通空格。唯一性也在归一化之后数。

edit-diff.ts pi@31b513e3:packages/coding-agent/src/core/tools/edit-diff.ts:206-255需联网 MIT
206export function fuzzyFindText(content: string, oldText: string): FuzzyMatchResult {
207 // Try exact match first
208 const exactIndex = content.indexOf(oldText);
209 if (exactIndex !== -1) {
210 return {
211 found: true,
212 index: exactIndex,
213 matchLength: oldText.length,
214 usedFuzzyMatch: false,
215 contentForReplacement: content,
216 };
217 }
218
219 // Try fuzzy match - work entirely in normalized space
220 const fuzzyContent = normalizeForFuzzyMatch(content);
221 const fuzzyOldText = normalizeForFuzzyMatch(oldText);
222 const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText);
223
224 if (fuzzyIndex === -1) {
225 return {
226 found: false,
227 index: -1,
228 matchLength: 0,
229 usedFuzzyMatch: false,
230 contentForReplacement: content,
231 };
232 }
233
234 // When fuzzy matching, return offsets in normalized space. Callers can use
235 // the normalized content to compute replacements, then decide how much of
236 // that normalized output should be written back.
237 return {
238 found: true,
239 index: fuzzyIndex,
240 matchLength: fuzzyOldText.length,
241 usedFuzzyMatch: true,
242 contentForReplacement: fuzzyContent,
243 };
244}
245
246/** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it */
247export function stripBom(content: string): { bom: string; text: string } {
248 return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content };
249}
250
251function countOccurrences(content: string, oldText: string): number {
252 const fuzzyContent = normalizeForFuzzyMatch(content);
253 const fuzzyOldText = normalizeForFuzzyMatch(oldText);
254 return fuzzyContent.split(fuzzyOldText).length - 1;
255}
👆 带下划线的行可以点:点哪行,讲哪行。

它还把换行与 BOM 单独处理:匹配前去掉 BOM、把 \r\n 统一成 \n,写回时恢复原来的换行和 BOM(edit.ts:344-350)。这一步不是「容错」,而是不让不可见字符制造假的「找不到」。

OpenCode:9 级 replacer,外加 4 道护栏

OpenCode 的做法更激进。它有 9 个 replacer,每个都是一个产出候选字符串的生成器,驱动函数对每个候选在文件里 indexOf,第一个在文件里只出现一次的候选胜出:

edit.ts opencode@v1.18.32:packages/opencode/src/tool/edit.ts:682-737需联网 MIT
682export function replace(content: string, oldString: string, newString: string, replaceAll = false): string {
683 if (oldString === newString) {
684 throw new Error("No changes to apply: oldString and newString are identical.")
685 }
686 if (oldString === "") {
687 throw new Error(
688 "oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.",
689 )
690 }
691
692 let notFound = true
693
694 for (const replacer of [
695 SimpleReplacer,
696 LineTrimmedReplacer,
697 BlockAnchorReplacer,
698 WhitespaceNormalizedReplacer,
699 IndentationFlexibleReplacer,
700 EscapeNormalizedReplacer,
701 TrimmedBoundaryReplacer,
702 ContextAwareReplacer,
703 MultiOccurrenceReplacer,
704 ]) {
705 for (const search of replacer(content, oldString)) {
706 const index = content.indexOf(search)
707 if (index === -1) continue
708 notFound = false
709 if (isDisproportionateMatch(search, oldString)) {
710 throw new Error(
711 "Refusing replacement because the matched span is much larger than oldString. Re-read the file and provide the full exact oldString for the intended replacement.",
712 )
713 }
714 if (replaceAll) {
715 return content.replaceAll(search, newString)
716 }
717 const lastIndex = content.lastIndexOf(search)
718 if (index !== lastIndex) continue
719 return content.substring(0, index) + newString + content.substring(index + search.length)
720 }
721 }
722
723 if (notFound) {
724 throw new Error(
725 "Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings.",
726 )
727 }
728 throw new Error("Found multiple matches for oldString. Provide more surrounding context to make the match unique.")
729}
730
731function isDisproportionateMatch(search: string, oldString: string) {
732 const oldLines = oldString.split("\n").length
733 const searchLines = search.split("\n").length
734 if (searchLines >= Math.max(oldLines + 3, oldLines * 2)) return true
735 if (oldLines === 1) return false
736 return search.trim().length > Math.max(oldString.trim().length + 500, oldString.trim().length * 4)
737}
👆 带下划线的行可以点:点哪行,讲哪行。

「容错策略」和「在文件里找」被拆成两层:replacer 只负责猜「模型本来想写的是哪段原文」,查找与唯一性检查全部由同一个驱动函数完成。护栏 3 的判定写成公式:

OpenCode 的比例护栏 isDisproportionateMatch(edit.ts:731-737)
⇐ 或
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
这道护栏只在模糊命中时才有意义:精确命中的候选就是 old_string 本身,长度相等,永远不会触发。

Codex:整行比较,四级放宽,取第一处

Codex 的 apply_patch 按行定位:把 patch 里要换掉的那几行,在文件里逐行比较,严格度分四级递减——逐字相等 → 忽略行尾空白 → 忽略首尾空白 → 再把弯引号、长破折号、特殊空格归一到 ASCII。注意两点:它不检查唯一,从当前位置往后找到的第一处就用;命中后用 patch 里的新行原样替换整行,不会替你修正缩进。

seek_sequence.rs codex@8cabf5a:codex-rs/apply-patch/src/seek_sequence.rs:1-70需联网 Apache-2.0
1/// Attempt to find the sequence of `pattern` lines within `lines` beginning at or after `start`.
2/// Returns the starting index of the match or `None` if not found. Matches are attempted with
3/// decreasing strictness: exact match, then ignoring trailing whitespace, then ignoring leading
4/// and trailing whitespace. When `eof` is true, we first try starting at the end-of-file (so that
5/// patterns intended to match file endings are applied at the end), and fall back to searching
6/// from `start` if needed.
7///
8/// Special cases handled defensively:
9/// • Empty `pattern` → returns `Some(start)` (no-op match)
10/// • `pattern.len() > lines.len()` → returns `None` (cannot match, avoids
11/// out‑of‑bounds panic that occurred pre‑2025‑04‑12)
12pub(crate) fn seek_sequence(
13 lines: &[String],
14 pattern: &[String],
15 start: usize,
16 eof: bool,
17 update_file_mode: crate::ApplyPatchFileUpdateMode,
18) -> Option<usize> {
19 if pattern.is_empty() {
20 return Some(start);
21 }
22
23 // When the pattern is longer than the available input there is no possible
24 // match. Early‑return to avoid the out‑of‑bounds slice that would occur in
25 // the search loops below (previously caused a panic when
26 // `pattern.len() > lines.len()`).
27 if pattern.len() > lines.len() {
28 return None;
29 }
30 let search_start = if eof && lines.len() >= pattern.len() {
31 let eof_start = lines.len() - pattern.len();
32 match update_file_mode {
33 crate::ApplyPatchFileUpdateMode::NormalizeToLf => eof_start,
34 crate::ApplyPatchFileUpdateMode::PreserveLineEndings => eof_start.max(start),
35 }
36 } else {
37 start
38 };
39 // Exact match first.
40 for i in search_start..=lines.len().saturating_sub(pattern.len()) {
41 if lines[i..i + pattern.len()] == *pattern {
42 return Some(i);
43 }
44 }
45 // Then rstrip match.
46 for i in search_start..=lines.len().saturating_sub(pattern.len()) {
47 let mut ok = true;
48 for (p_idx, pat) in pattern.iter().enumerate() {
49 if lines[i + p_idx].trim_end() != pat.trim_end() {
50 ok = false;
51 break;
52 }
53 }
54 if ok {
55 return Some(i);
56 }
57 }
58 // Finally, trim both sides to allow more lenience.
59 for i in search_start..=lines.len().saturating_sub(pattern.len()) {
60 let mut ok = true;
61 for (p_idx, pat) in pattern.iter().enumerate() {
62 if lines[i + p_idx].trim() != pat.trim() {
63 ok = false;
64 break;
65 }
66 }
67 if ok {
68 return Some(i);
69 }
70 }
👆 带下划线的行可以点:点哪行,讲哪行。

极小真数字:同一条 edit,五个真引擎

下面五张卡片里跑的,是从各家 pin 源码移植进浏览器的真实匹配逻辑。页面加载时,组件先把 fixture 里的全部输入用浏览器版重跑一遍,与原仓库代码(Python / Node / Rust 实跑)的输出逐条比对,右上角显示一致的条数。

edit 匹配器实验台 · 同一条 edit,六个真引擎 正在用浏览器移植重跑 fixture…

口径:引擎逻辑移植自各 pin 源码(卡片上的链接),期望输出由原仓库代码实跑得到(Python 3.12 / Node 26 / rustc 1.97,脚本 src/data/precomputed/gen_a02_fixtures.py,生成于 2026-09-26);T8 语料来自本课项目真实运行(模型 Qwen/Qwen3-8B-MLX-4bit@383413e,录制于 2026-09-26);「改后测试」是把引擎的结果写回任务仓副本、实跑 python -m unittest 的结果,浏览器里改动过的自定义输入不跑测试。Codex 一栏:apply_patch 以整行为单位,这里把 old/new 各按换行切成一个无 @@ 的 update chunk 交给它——这是构造的转写,不是模型写的 patch。

🎯

试三件事:① 在「重复代码块」上先预测再运行,然后点 learn-claude-code 和 Codex 的卡片:两者都改了第 31 行——前者是「在就改第一处」,后者是「整行比较取第一处」,改后测试里 ISO 也挂了;② 点「整体缩进」,看 OpenCode 卡片的 9 级梯子:第 2 级 LineTrimmed 就接住了,Codex 在 trim 级接住,而 CoreCoder、Pi 都拒绝;③ 在 old_string 里自己删掉一个空格或加一个 Tab,看哪几个引擎还能找到。最后点紫色的「压轴」:弯引号是前面 6 个预置里没有的漂移,先猜谁能接住。

压轴那一题的答案值得停一下:Pi 和 Codex 都接住了弯引号(它们的归一化表里有 “ ”),可它们写回文件的是 new_string 的原样——里面同样是弯引号。写回任务仓实跑测试,结果是 SyntaxError,模块都导入不了。容错只作用在「找」上,不作用在「写」上。

⚠
常见误解一:「模糊匹配越多越好,反正能少报错。」

容错的每一级都在猜「模型本来想写什么」。猜对时省一轮,猜错时就把错的东西写进了代码:「转义差异」那一题,OpenCode 的 EscapeNormalized 级把 \" 还原成 " 去找,找到了,然后把 new_string 里原样的 \" 写回去,结果是 SyntaxError。OpenCode 为此加了比例护栏和歧义跳过;它的 v2(packages/core/src/tool/edit.ts:165-170)眼下只有精确匹配,builtins.ts:26 把「edit fuzzy parity」列为待移植——这不是放弃模糊的表态,但同一个仓库里确实并存着两种选择。

⚠
常见误解二:「唯一匹配就不会改错。」

唯一只说明「这段文字在文件里只有一处」,不说明「这一处就是你想改的」。真实 trace pd-v2-unlimited-s1 第 7 轮:第二个 bug 已经修好了,模型又发了一次同样的修复,old_string 是 month = MONTHS.index(name.lower())。它恰好是已修好那一行 … .lower()) + 1 的前半截,出现 1 次,miniagent 照改,于是那一行变成了 + 1 + 1。写回任务仓实跑:测试从全过变成 FAILED (failures=1)。同一条 edit,按整行比较的 Codex 反而拒绝了(文件里没有和它逐行相等的行)。

🔍 Windows 换行的文件,改一行会怎样?

把 dateparse.py 换成 \r\n 换行再改第 35 行(fixture 里的「CRLF 文件」):learn-claude-code、CoreCoder 和本课项目用的都是 Python 的 read_text / write_text,读的时候 \r\n 被当成 \n,写的时候按 \n 写回——结果整个文件 44 行的换行全变了,diff 里每一行都算改动。Pi 与 OpenCode 记住了原换行并按原样写回,只动 1 行;Codex 在「忽略行尾空白」这一级接住,但被它替换的那两行丢了 \r,文件变成混合换行。这是里程碑 M2 验收第 ⑥ 条的来历。

证据三问

问本课的回答
① 它解决什么问题?模型复述原文时的小漂移(空白、缩进、引号、转义)不再白白浪费一轮
② 为什么可能有效?每一级容错都缩小了「模型写的」与「文件里的」之间的差异;护栏把「猜得太远」的候选挡掉
③ 实验上呢?没有公开的对照实验说明「模糊回退能减少多少失败」:本轮调研没找到 Aider 或任何一家的一手数据 。本课项目的 T8 语料很小(14 条不重复的真实 edit):严格唯一拒绝了其中 2 条,OpenCode 多接住了 1 条 ;实验课逐条看

演进地图:你在这里

模型怎么说清「改哪里」:每一代都被上一代的某个真数字逼出来
  1. 朴素 按行号改 「把第 a–b 行换成……」
    解决了模型不用复述原文,只报行号,输出最短。
    卡在行号是「上一次读文件时」的快照:第一处修复只要多插 2 行,第二次按旧行号 41 改,就落在 if m: 上(第 1 课现算);CoreCoder README 的原话是「Line numbers are a trap」。
  2. 第一代 整文件覆写 write_file;aider 的 whole 格式
    解决了不需要定位,格式最不容易错:aider 同一个 Qwen2.5-Coder-32B,whole 格式合规 99.6%,diff 格式只有 71.6%。
    卡在只改 1 行,也要把 44 行的文件整个再写一遍:参数 487 token,片段替换只要 54(9.0 倍,项目模型 tokenizer 实数);还会覆盖别人在此期间的改动。
  3. 第二代 唯一片段替换(search-replace) CoreCoder、本课项目 miniagent 你在这里
    解决了按内容定位,不怕行号过期;0 处或多处都报错、文件不动,让模型自己补上下文。
    卡在真实 trace pd-truncate-s1:第 4 轮 old_string 在文件里 2 处、第 5 轮缩进多 4 格 0 处,两次报错多跑了两轮,花掉 8,481 token、109.5 秒;唯一也不等于对:pd-v2-unlimited-s1 第 7 轮的 old_string 恰好是已修好那一行的前半截,计数 = 1,于是 + 1 变成了 + 1 + 1。
  4. 第三代 容错匹配:归一化与 9 级 replacer Pi、OpenCode 你在这里
    解决了模型复述时的小漂移不再白费一轮:上面第 5 轮那条缩进多 4 格的 edit,OpenCode 直接接住(LineTrimmed 级)。
    卡在接住 ≠ 改对:多转义一层的引号,OpenCode 的 EscapeNormalized 级照样接住,写回去的是 \",模块导入即 SyntaxError;OpenCode 的 v2(packages/core)眼下只有精确匹配,「edit fuzzy parity」还列在待移植清单里。
  5. 第四代 语法约束解码的 patch Codex apply_patch(19 行 Lark 语法);strict 工具
    解决了语法由解码器保证,不再靠 prompt 教。对照:靠 prompt 教格式时,aider 榜上 47 个 diff 格式条目的「格式正确」比例最低只有 64.4%(官方自报)。
    卡在语法只管格式,定位仍是 seek_sequence:它不检查唯一、取第一处——两处相同的 return datetime.date(year, month, day) 会改到第 31 行 ISO 分支,test_parse_iso 跟着坏;整行比较还会把没写缩进的 new 行原样写进去,得到 IndentationError。
  6. 工具从哪来 工具集随模型变,外部工具经 MCP 接入 OpenCode registry.ts;MCP 2026-07-28
    解决了一套协议接任意外部工具;同一个 harness 给 GPT 换上 apply_patch、给 Claude 用 edit。
    卡在接进来的每个工具每轮都要付上下文:chrome-devtools-mcp 的 30 个工具在项目模型的 chat template 里占 7,047 token,是 miniagent 自己 6 个工具(685)的 10.3 倍。
  7. 岔路 Code Mode:让模型写脚本调工具 Codex exec / wait;OpenCode codemode
    解决了N 次「模型 → 工具 → 模型」往返压成一段脚本。
    卡在省的是往返,不省判断:真实 trace 里模型每一步都要看上一步的结果才知道下一步做什么(岔路课逐轮算)。
数字口径:token 数用项目录制模型 Qwen3-8B 的 tokenizer 与 chat template 算(与服务端 usage 逐轮核对一致);裁决与测试结果是原仓库代码实跑(a02-edit-fixtures.json,2026-09-26);trace 来自本课项目 miniagent 的真实运行(2026-09-26);aider 数字来自 aider 仓库的 polyglot_leaderboard.yml(官方自报);MCP 工具数来自本机实跑 tools/list。

一句话

📌
容错与改错是同一把刀的两面,护栏决定它往哪边切。
🚦

先停在这里:记住三件事:只查在不在会改错位置;严格唯一会多花轮次;容错只管「找」,不管「写」。
再深一层:去实验课,用全部 T8 真实 edit 同时跑五个引擎,看每一条写回任务仓后测试的结果。

下一个缺口

能不能从源头就让模型写出合法格式? → 第 3 课:能不能从源头保证 patch 格式合法?

本课的五个引擎都假设 old_string 已经是一段像样的文本。Codex 走了另一条路:让模型写一种专门的 patch 格式,再在解码时就保证格式合法。第 3 课看它省掉了什么、没省掉什么——你已经在 Codex 卡片上看到,「定位」仍然是这一课的 seek_sequence。

你可能会问