old_string 和文件差一个空格,该报错还是容错?
本课一问:上一课选定了「给出要替换的原文」。可模型抄原文会抄错:多一个空格、少一层缩进、引号写成弯的。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 行:
- ① 朴素方案
- ② 它在哪失败
- ③ 改进方案
s02 对这条 edit 的回应是「Edited dateparse.py」。模型收到成功,会以为自己改的是第 36 行。
CoreCoder v0.7.0 先数出现次数(content.count(old_string)),0 处报「not found」并附上文件开头,多处报「appears N times」要求补上下文,恰好 1 处才替换(corecoder/tools/edit.py:59-73)。本课项目 miniagent 用的是同一条规则,所以第 4 轮被拒,文件没动。
FAILED (failures=2),原本通过的 test_parse_iso 也挂了。工具回给模型的是「Edited dateparse.py」。 严格唯一挡住了改错,但它把代价转给了模型:第 4、5 轮两次报错,模型各多跑一轮,第 5、6 轮一共花掉 8,481 个 token、109.5 秒(trace 里的 usage 与延迟)。其中第 5 轮只是缩进多了 4 格,意图完全明确。能不能对这种小漂移宽容一点?
改进阶梯:三种容错,各自的护栏
Pi:精确优先,归一化兜底,再数一遍唯一
Pi 先做精确匹配;失败时把文件和 old_string 都做一次归一化再找:Unicode NFKC、每行去掉行尾空白、弯引号换成直引号、各种破折号换成 -、各种特殊空格换成普通空格。唯一性也在归一化之后数。
它还把换行与 BOM 单独处理:匹配前去掉 BOM、把 \r\n 统一成 \n,写回时恢复原来的换行和 BOM(edit.ts:344-350)。这一步不是「容错」,而是不让不可见字符制造假的「找不到」。
OpenCode:9 级 replacer,外加 4 道护栏
OpenCode 的做法更激进。它有 9 个 replacer,每个都是一个产出候选字符串的生成器,驱动函数对每个候选在文件里 indexOf,第一个在文件里只出现一次的候选胜出:
「容错策略」和「在文件里找」被拆成两层:replacer 只负责猜「模型本来想写的是哪段原文」,查找与唯一性检查全部由同一个驱动函数完成。护栏 3 的判定写成公式:
Codex:整行比较,四级放宽,取第一处
Codex 的 apply_patch 按行定位:把 patch 里要换掉的那几行,在文件里逐行比较,严格度分四级递减——逐字相等 → 忽略行尾空白 → 忽略首尾空白 → 再把弯引号、长破折号、特殊空格归一到 ASCII。注意两点:它不检查唯一,从当前位置往后找到的第一处就用;命中后用 patch 里的新行原样替换整行,不会替你修正缩进。
极小真数字:同一条 edit,五个真引擎
下面五张卡片里跑的,是从各家 pin 源码移植进浏览器的真实匹配逻辑。页面加载时,组件先把 fixture 里的全部输入用浏览器版重跑一遍,与原仓库代码(Python / Node / Rust 实跑)的输出逐条比对,右上角显示一致的条数。
口径:引擎逻辑移植自各 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,文件变成混合换行。
证据三问
| 问 | 本课的回答 |
|---|---|
| ① 它解决什么问题? | 模型复述原文时的小漂移(空白、缩进、引号、转义)不再白白浪费一轮 |
| ② 为什么可能有效? | 每一级容错都缩小了「模型写的」与「文件里的」之间的差异;护栏把「猜得太远」的候选挡掉 证据等级 · 数学可证明 在写明的前提下可以严格推导出来,不依赖实验。只要前提成立,换数据、换规模仍然成立。 本条算法性质:同一输入下各引擎的裁决是确定的,本页五张卡片由原仓库代码实跑的 fixture 校验。「会不会改错」则取决于容错猜得对不对,算法本身不保证。 出处OpenCode@v1.18.32 edit.ts:682-737(源码直证) ↗需联网 |
| ③ 实验上呢? | 没有公开的对照实验说明「模糊回退能减少多少失败」:本轮调研没找到 Aider 或任何一家的一手数据 证据等级 · 有理论动机 有数学或机制上的理由说明它可能有效,但没有证明它一定更好。最后是否采用,仍然要看实验。 本条调研记录 U6:Aider edit format 的一手实验证据为零,二手转述不采信。 出处待补 。本课项目的 T8 语料很小(14 条不重复的真实 edit):严格唯一拒绝了其中 2 条,OpenCode 多接住了 1 条 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条项目实测:单一模型(Qwen3-8B)、单一任务、样本极小,只能说明现象存在,不能说明比例。 出处待补 ;实验课逐条看 |
演进地图:你在这里
- 解决了模型不用复述原文,只报行号,输出最短。卡在行号是「上一次读文件时」的快照:第一处修复只要多插 2 行,第二次按旧行号 41 改,就落在
if m:上(第 1 课现算);CoreCoder README 的原话是「Line numbers are a trap」。 - 解决了不需要定位,格式最不容易错:aider 同一个 Qwen2.5-Coder-32B,whole 格式合规 99.6%,diff 格式只有 71.6%。卡在只改 1 行,也要把 44 行的文件整个再写一遍:参数 487 token,片段替换只要 54(9.0 倍,项目模型 tokenizer 实数);还会覆盖别人在此期间的改动。
- 解决了按内容定位,不怕行号过期;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。 - 解决了模型复述时的小漂移不再白费一轮:上面第 5 轮那条缩进多 4 格的 edit,OpenCode 直接接住(LineTrimmed 级)。卡在接住 ≠ 改对:多转义一层的引号,OpenCode 的 EscapeNormalized 级照样接住,写回去的是
\",模块导入即 SyntaxError;OpenCode 的 v2(packages/core)眼下只有精确匹配,「edit fuzzy parity」还列在待移植清单里。 - 解决了语法由解码器保证,不再靠 prompt 教。对照:靠 prompt 教格式时,aider 榜上 47 个 diff 格式条目的「格式正确」比例最低只有 64.4%(官方自报)。卡在语法只管格式,定位仍是 seek_sequence:它不检查唯一、取第一处——两处相同的
return datetime.date(year, month, day)会改到第 31 行 ISO 分支,test_parse_iso 跟着坏;整行比较还会把没写缩进的 new 行原样写进去,得到 IndentationError。 - 解决了一套协议接任意外部工具;同一个 harness 给 GPT 换上 apply_patch、给 Claude 用 edit。卡在接进来的每个工具每轮都要付上下文:chrome-devtools-mcp 的 30 个工具在项目模型的 chat template 里占 7,047 token,是 miniagent 自己 6 个工具(685)的 10.3 倍。
- 解决了N 次「模型 → 工具 → 模型」往返压成一段脚本。卡在省的是往返,不省判断:真实 trace 里模型每一步都要看上一步的结果才知道下一步做什么(岔路课逐轮算)。
一句话
先停在这里:记住三件事:只查在不在会改错位置;严格唯一会多花轮次;容错只管「找」,不管「写」。
再深一层:去实验课,用全部 T8 真实 edit 同时跑五个引擎,看每一条写回任务仓后测试的结果。
下一个缺口
能不能从源头就让模型写出合法格式? → 第 3 课:能不能从源头保证 patch 格式合法?
本课的五个引擎都假设 old_string 已经是一段像样的文本。Codex 走了另一条路:让模型写一种专门的 patch 格式,再在解码时就保证格式合法。第 3 课看它省掉了什么、没省掉什么——你已经在 Codex 卡片上看到,「定位」仍然是这一课的 seek_sequence。
你可能会问
- 「Pi 为什么让一次调用带多条 edit?」 Pi 的 edit 参数是
edits[]数组,每条都对着原始文件匹配,不是一条改完再匹配下一条;所有命中先按位置排序、检查区间是否重叠,重叠就整体报错(edit-diff.ts:345-354)。一次调用改多处,就不会出现第 1 课那种「前一处改动挪动了后一处的位置」。 - 「报错之后,模型真的会改正吗?」 pd-truncate-s1 里会:第 4 轮被告知「2 处」,第 5 轮带上了上一行作为上下文(可惜缩进抄错),第 6 轮成功。报错信息写得越具体,模型越容易改对——这也是 A2 里「工具描述与报错是 prompt 资产」的一部分,见岔路课。
- 「为什么不直接让模型用 sed?」 可以,bash 工具本来就能跑 sed。代价是 harness 看不到「改了哪里」:没法做唯一性检查、没法生成 diff 给人审,也没法在 A3 按文件路径做权限判断。