我让模型吐 unified diff 直接落盘:15 个场景里 13 个打不上
我在给一条「模型改代码」的链路写最后一步:模型输出 unified diff,我负责把它写回磁盘。我一直以为这是整条链路里最省心的一环——diff 是个几十年前就定死的老格式,git apply 一把梭的事,能出什么问题。
我按教科书写法实现了第一版:读 hunk 头里的 @@ -10,7 +10,8 @@,跳到第 10 行,逐字节比对上下文,对上就替换。然后我造了 15 个场景压它,13 个打不上。
我以为是模型菜,摊开看才发现一大半是我的锅
失败原因摊开之后,我看到的分四类:
| 场景 | 是什么情况 | 这份补丁该不该打上 |
|---|---|---|
| S1 | 行号对、字节也对 | 该成功 |
| S2 / S3 | 文件上方多了 6 行 / 37 行,行号漂了 | 该成功 |
| S4 | 文件是 CRLF,我按 \n 切的行 | 该成功 |
| S5 / S11 | 行尾多了空格、空行其实是「两个空格」 | 该成功 |
| S6 | 文件用 tab,补丁写的是 4 个空格 | 该成功 |
| S10 | 这个补丁已经打过一次了 | 该成功(幂等跳过) |
| S12 / S13 | 多 hunk、删除型 hunk,行号是旧版本的 | 该成功 |
| S15 | 两个相似块,行号指错,但上下文带了函数名 | 该成功 |
| S7 / S14 | 补丁基于的版本跟文件已经对不上了 | 该拒绝 |
| S8 | 两个相似块,行号正好落在中间,无法消歧 | 该拒绝 |
| S9 | 两个相似块,行号指到了第二个 | 没有足够信息,谁都救不了 |
15 个里 11 个本来就应该成功,3 个应该被拒绝。我的第一版只做到了 1 个。
真正是「模型不行」的只有 S9 那一个:它给的 4 行上下文在文件里出现了两次,行号又指到了第二个,我没有任何依据判断它想改的是哪个。剩下那些,模型给的补丁内容全是对的,是我的应用器太脆。
第一版里最蠢的一条是我自己写出来的 bug:diff 结尾那个换行被我当成了一行空上下文,于是每个 hunk 都多出一行,永远匹配不上。这是「跑得起来、结果全错」型的问题,不报错,只会让你怀疑模型。
一路放宽到 73%,然后发现自己顺手改坏了无关行
我开始逐级放宽,每加一档就重跑一遍 15 个场景:
| 档位 | 做法 | 落地 | 逐字节干净 | 拒绝 | 贴错 |
|---|---|---|---|---|---|
| A | 认行号 + 逐字节 | 6.7% | 1 | 13 | 1 |
| B | + 全文件精确搜索 | 40.0% | 6 | 8 | 1 |
| C | + 忽略行尾空白 / CR | 60.0% | 7 | 5 | 1 |
| D | + 缩进归一化(tab ↔ 空格) | 66.7% | 7 | 4 | 1 |
| E | + 已应用检测 | 73.3% | 8 | 3 | 1 |
| F | + 空行宽容(最松) | 73.3% | 8 | 3 | 1 |
E 到 F 是平的,多放宽一层一个场景都没多救回来。这条我记下来了:往下松没收益的时候,就别再松了。
但我盯着 E 档的结果越看越不对:有 3 个场景虽然"应用成功",输出却跟我想要的不一样——S5 里我改动了 7 行,而补丁其实只想加 1 行。
原因很直白:我是拿补丁里的文本整段替换掉文件里那一段。文件里 const cfg = JSON.parse(raw); 行尾带两个空格,补丁里没有,模糊匹配让它过了,然后我顺手把那两个空格删了。补丁没让我改的行,被我改了。
我去翻了 git apply 的文档想看看别人怎么做,看到 --ignore-whitespace 底下这句(核对日期 2026-10-16,git-scm.com/docs/git-apply):
Context lines will preserve their whitespace
一句话点醒我:git 是匹配时放宽、写入时保留原文。我搞反了。于是我改成逐行重建——上下文行一律写回文件原文,新增行按文件自己的缩进风格重排:
function rebuild(h, fileLines, at) {
const out = [];
let fi = at;
let patchRefIndent = null, fileRefIndent = null;
for (const l of h.lines) {
if (l.tag === ' ') {
out.push(fileLines[fi]); // 上下文:写回磁盘上的原文
patchRefIndent = indentOf(l.text);
fileRefIndent = indentOf(fileLines[fi]);
fi++;
} else if (l.tag === '-') {
patchRefIndent = indentOf(l.text);
fileRefIndent = indentOf(fileLines[fi]);
fi++;
} else {
// 新增行:只搬运「相对缩进了几级」,绝对宽度沿用文件风格
const delta = widthOf(indentOf(l.text)) - widthOf(patchRefIndent);
out.push(renderIndent(fileRefIndent, delta) + l.text.slice(indentOf(l.text).length));
}
}
return out;
}G 档的结果:落地 11/15,逐字节干净 11,副作用 0。S6 那个 tab 文件,新增行自动变成了 \tvalidate(cfg);,而不是混进一行 4 空格。
这一档的收益不是"多救回几个",而是"救回的那几个不再脏"。我宁可少成功一个,也不想在 diff 里看到一堆跟需求无关的红绿条。
剩下那 3 个拒绝和 1 个错贴
S7、S8、S14 三个失败,我认为是好事,一个都不该救:
- S7:补丁上下文写的是
export function loadConfig(file) {,文件里已经是loadConfig(file, opts)。这是补丁基于旧版本,打上去就是错的版本。 - S14:补丁要改的函数文件里根本没有。
- S8:
@@ -28,4正好落在两个一模一样的代码块中间,两边距离相等。我的实现在这种情况下直接报ambiguous: 候选行 24,32,一个字节都不动。
失败信息要能拿去喂给模型重试,所以我让它带上定位:
S7 -> no-context-match hunk第1行 期望[export function loadConfig(file) {]
实际[export function loadConfig(file, opts) {] 漂移0
S8 -> ambiguous 候选行 24,32
S14 -> no-context-match hunk第1行 期望[export function loadUser(id) {]
实际[ ctx.fill();] 漂移-7S9 那个错贴,我试过所有档位,没有一个能拦住——因为拦不住的不是我,是那份补丁本身的信息量。我加了个对照 S15:同样是两个相似块、行号同样指错,但上下文里多带了函数名 export function renderA(ctx) {,六档全过。行号可以错,上下文不能少,这句话我从今天的实验里是真的信了。
Aider 的 Paul Gauthier 在 2023 年就把话说死了(核对日期 2026-10-16,aider.chat/2023/12/21/unified-diffs.html):
GPT is terrible at working with source code line numbers.
所以他的做法是让模型根本别写行号,hunk 头写成 @@ ... @@,每个 hunk 当一次搜索替换。同一篇里还有个数字我看了有点后背发凉:把容错逻辑关掉,编辑错误率是 9 倍。也就是说我第一版那个 6.7% 的落地率,才是这类系统的常态水平。
顺手量了一下搜索窗口该开多大
全文件搜索听起来很贵,我拿 20000 行和 100000 行的文件量了一下单次 apply(20 次平均):
行号真偏 -9 行 -> win=0:N win=20:Y win=50:Y win=200:Y win=1000:Y win=∞:Y
行号真偏 51 行 -> win=0:N win=20:N win=50:N win=200:Y win=1000:Y win=∞:Y
行号真偏 991 行 -> win=0:N win=20:N win=50:N win=200:N win=1000:Y win=∞:Y
行号真偏 4991 行 -> win=0:N win=20:N win=50:N win=200:N win=1000:N win=∞:Y
100000 行文件:win=5000 32.12ms win=∞ 63.06ms结论很无聊但有用:直接开全文件搜索。10 万行文件上多花 30ms,换回来的是再也不用猜模型能偏多少。真正会咬人的是相似性,不是性能。
我的判断
这份东西现在是我那条链路的默认落地层,我的几条结论:
- 别信任行号,但要留着它。行号在多数场景是噪声,在相似块场景是唯一的消歧信息——我的策略是"全文件找,多个候选时按离声明位置最近取,并列则报歧义拒绝"。
- 放宽匹配可以,改写原文不行。匹配放宽到什么程度都行,写入时上下文行必须一字不改地还给文件。这条是我今天最有价值的收获,也是 git 文档里一句话的事。
- 失败要 fail-closed,且要带诊断。一个说不清哪一行对不上的失败,模型重试十次也是瞎猜。
- 放宽到没有收益就停手。我从 E 到 F 多放了一层,一个场景都没多救,只多了一批潜在脏改动。
- 真正的烂补丁在应用层救不回来。S9 那种,该做的是改提示词让模型多带上下文,不是继续调匹配器。
最后补一句没测的:我那个"已应用检测"有假阳性风险——万一补丁的新内容恰好已经在文件里、但语义不是一回事,它会被我当成"打过了"跳过。我只在"旧内容完全匹配不上"时才启用它,并且把返回状态跟正常成功区分开,方便上层记日志。但我没为它单独造反例测过,不敢说它是干净的。