我让模型吐 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%1131
B+ 全文件精确搜索40.0%681
C+ 忽略行尾空白 / CR60.0%751
D+ 缩进归一化(tab ↔ 空格)66.7%741
E+ 已应用检测73.3%831
F+ 空行宽容(最松)73.3%831

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();] 漂移-7

S9 那个错贴,我试过所有档位,没有一个能拦住——因为拦不住的不是我,是那份补丁本身的信息量。我加了个对照 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,换回来的是再也不用猜模型能偏多少。真正会咬人的是相似性,不是性能。

我的判断

这份东西现在是我那条链路的默认落地层,我的几条结论:

  1. 别信任行号,但要留着它。行号在多数场景是噪声,在相似块场景是唯一的消歧信息——我的策略是"全文件找,多个候选时按离声明位置最近取,并列则报歧义拒绝"。
  2. 放宽匹配可以,改写原文不行。匹配放宽到什么程度都行,写入时上下文行必须一字不改地还给文件。这条是我今天最有价值的收获,也是 git 文档里一句话的事。
  3. 失败要 fail-closed,且要带诊断。一个说不清哪一行对不上的失败,模型重试十次也是瞎猜。
  4. 放宽到没有收益就停手。我从 E 到 F 多放了一层,一个场景都没多救,只多了一批潜在脏改动。
  5. 真正的烂补丁在应用层救不回来。S9 那种,该做的是改提示词让模型多带上下文,不是继续调匹配器。

最后补一句没测的:我那个"已应用检测"有假阳性风险——万一补丁的新内容恰好已经在文件里、但语义不是一回事,它会被我当成"打过了"跳过。我只在"旧内容完全匹配不上"时才启用它,并且把返回状态跟正常成功区分开,方便上层记日志。但我没为它单独造反例测过,不敢说它是干净的。

添加新评论

文章目录