apply-patch,给模型设计一种 diff
一份没有行号的补丁语言:定位靠上下文锚点,应用靠四级匹配,单文件对不上就不写盘
本页解决的问题
先给结论「apply-patch,给模型设计一种 diff」要解决的关键问题是什么?
一份没有行号的补丁语言:定位靠上下文锚点,应用靠四级匹配,单文件对不上就不写盘
跟着交接处走,不要只看 Demo。 系统是否可靠,往往取决于模型、工具、状态、权限和人的交接处。把每次交接都当成可以观察、测试和恢复的地方。
为一个自动化步骤写清输入、负责人、审批和恢复动作。
一次运行成功了,却说不清发生了什么,也无法安全重放。
@@ -l,s +l,s 那四个数字;以及上下文对不上时,单文件为什么整份不落盘,跨多个文件时这个保证为什么不成立。
greet 里的 pass 换成 return 123- 文法里的
@@只有锚点,没有起始行和跨度parser.rs L20 - 更新文件的 chunks 必须按在文件中出现的先后排列parser.rs L74
- 有
change_context时,从line_index往下搜这一行file_update.rs L99 - 先整行精确比,再抹掉行尾空白,再两边 trimseek_sequence.rs L40
- 锚点找不到,立刻报 Failed to find context,不按附近行猜file_update.rs L109
- 单文件全部 chunk 在内存里算完,才调用 write_filelib.rs L695
- 跨文件失败时带着已经提交的 delta 返回,没有回滚lib.rs L453
- 给人看的 unified diff 是事后用 TextDiff 另算的file_update.rs L328
@@ 头要同时填对旧起始、旧跨度、新起始、新跨度。文件上面插两行,这四个数字一起废。@@ def greet():,运行时现搜。插两行也能对上,因为行号根本没进这份格式。你让模型改一个函数:把 greet 里的 pass 换成 return 123。它吐出标准 unified diff,头一行写成 @@ -47,3 +47,3 @@。
文件刚才被另一处编辑插了两行,greet 已经在第 49 行。模型是在带行号的摘录里数的,写补丁时还要自己加算起始行和跨度。这四个数字一起错,是常态。
patch(1) 会按行号去找,找不到就 fuzz。fuzz 再失败,整份补丁作废。更麻烦的是它可能把变更贴到邻近的另一个函数上。测试还是绿的,只是改错了函数。
Codex 把填写和阅读拆开。模型填的那份没有行号。更新一段时,头一行只写成 @@ def greet():。单独一个 @@ 表示从当前位置继续搜。定位交给运行时的 seek_sequence。
人看变更时,界面上再另外用 similar::TextDiff 生成一份标准 unified diff。工具参数里那份 Codex 格式到这里已经用完了。
codex-rs/apply-patch/src/file_update.rs 第 328 至 329 行
这份文法把没有行号写进了产生式。两种 @@ 写法都只带文本锚点:
//! change_context: ("@@" | "@@ " /(.+)/) LF
//! change_line: ("+" | "-" | " ") /(.+)/ LF
//! eof_line: "*** End of File" LF
openai/codex,核对文件 codex-rs/apply-patch/src/parser.rs,commit 4f39251a01,核对日期 2026-08-22。代码块保留源码原文,这三行就是给模型的 diff 头:有锚点,没有行号。发给模型的说明书把这套语言写成「stripped-down, file-oriented diff format designed to be easy to parse and safe to apply」。Add、Delete、Move 在文法里是三种标记,解析器按标记分发。模型不用记 ---、+++、/dev/null 和 rename 头怎么拼。
坐标靠加算,内容靠识别。模型数行号这件事,换一个模型、换一种语言都好不到哪去。把找到哪一段从填写时的算术,改成应用时的字符串搜索,这个分工不依赖 Rust,也不依赖 unified diff 这个具体格式。
模型写补丁时,行尾多一个空格,或者文件里是 en-dash、它写成了减号,都是高频事故。每次都整份失败,模型只能重写。按行号附近再试几行,又会回到 fuzz 贴错函数的老路。
seek_sequence 按四档从紧到松搜。第一档整行精确相等。第二档去掉行尾空白再比。第三档两边都 trim()。第四档把常见 Unicode 短横和弯引号收成 ASCII。四级都失败就返回空,报「Failed to find context」或「Failed to find expected lines」。没有按附近几行再试这个循环。
codex-rs/apply-patch/src/seek_sequence.rs 第 40 至 114 行
早期有一次事故,专门为奇怪的 Unicode 字符加了第四级。它只放宽空白和标点,不放宽位置。中文全角引号不在归一化表里,模型写了全角左引号,文件里是半角引号,四级都会失败。
容错要分清两类差异。行尾空格、弯引号是无意义的字节差,可以归一。行号偏了两行,是贴错地方,应该报错让模型重写。这个分界换语言重写也成立。
一份补丁里有两个 chunk。第二个对不上,第一个已经改进去了,文件会变成半成品。排查的人看到的是一份应用成功了一半的文件,比整份失败更难修。
单文件内部,compute_replacements 把每个 chunk 先收成替换列表,某一个对不上就立刻返回错误,还没走到 write_file。这个文件保持原样。
codex-rs/apply-patch/src/file_update.rs 第 109 至 113 行
跨文件是另一回事。apply_hunks_to_files 按 hunk 顺序写盘,失败时带着已经提交的 AppliedPatchDelta 返回,循环里没有回滚。测试 015 把这个钉死了:先成功新增 created.txt,再更新一个不存在的文件,磁盘上 created.txt 还在。
codex-rs/apply-patch/src/lib.rs 第 453 行,以及第 504 行起的 hunk 循环
算完再提交的范围,要和你能原子处理的单位对齐。一个文件可以先在内存里算完全部替换再写一次。多个文件已经落到磁盘上,回滚就要再写一遍,还要处理 Move 这种源和目标都动过的半成功。要不要跨文件事务,是产品选择,不是格式本身的承诺。写给模型的说明里,别把单文件的保证说成全局保证。
DeepSeek Harness:先读过,才能改
DSH 的 editIntent 查的是这个 session 有没有观测过这个文件。没观测过就抛 FS_NOT_OBSERVED。观测记录的是 dev:ino:size:mtimeNs:ctimeNs 拼出来的版本,不是内容 hash。
即便过了这道门,applyLiteralEdit 默认还要求 old_string 只出现一次,多处命中就抛 FS_AMBIGUOUS_EDIT。防错挂在事件门禁和字面量唯一上。Codex 没有先读约束,定位信息写在补丁里,运行时现搜;old_lines 出现两次时取第一处,没有歧义报错。
Claude Code:没读过就拒绝,多处命中也拒绝
FileEditTool 同时要两件事。文件必须先读过,没读过报 errorCode 6,原文是 File has not been read yet。 old_string 在文件里多于一处且 replace_all 为假时,报 errorCode 9,要求补更多上下文,把这一处单独标出来。
模糊只覆盖引号。findActualString 先精确搜,再把弯引号收成直引号搜。没有 Codex 那种行尾空白三级,也没有短横归一化。想少一次工具往返,抄 Codex 的格式;想对模型错误信息更具体,抄这几条带 errorCode 的拒绝文案。
两段相同的 old_lines,改哪一段
文件里有两段完全相同的 old_lines,模型只想改第二段,却没有给足够的 @@ 锚点。seek_sequence 会改哪一段,为什么?
进阶一问:若希望单独命中第二段,锚点应该写在哪一行前面?同一份补丁里若先成功新增一个文件,再更新一个不存在的路径,磁盘上会留下什么?
「先玩一遍 · 同一处修改,两种写法」的能力藏在每次交接里
「你让模型改一个函数:把 greet 里的 pass 换成 return 123 。它吐出标准 unified diff,头一行写成 @@ -47,3 +47,3 @@」说明,Agent 的表现不只由模型决定。模型、上下文、工具、状态、权限和人之间的每次交接,都会改变任务能否继续以及出了问题能否恢复。
先写清状态,再增加能力
从「文件刚才被另一处编辑插了两行, greet 已经在第 49 行。模型是在带行号的摘录里数的,写补丁时还要自己加算起始行和跨度。这四个数字一起错,是常态」出发,可以把流程拆成起始状态、下一步动作、工具返回、状态更新和停止条件。这样调试时找的是第一处丢失信息或权限的位置,而不是笼统地说“模型变笨了”。
- 文法里的 @@ 只有锚点,没有起始行和跨度 parser.rs L20
- 更新文件的 chunks 必须按在文件中出现的先后排列 parser.rs L74
- 有 change_context 时,从 line_index 往下搜这一行 file_update.rs L99
成功路径不能代表系统可靠
用「进阶一问:若希望单独命中第二段,锚点应该写在哪一行前面?同一份补丁里若先成功新增一个文件,再更新一个不存在的路径,磁盘上会留下什么」重放一次成功和一次失败,记录每一轮真正传入的上下文、工具结果和负责人;只要第二个人能复述这条链,系统才有可维护性。
从「先玩一遍 · 同一处修改,两种写法」走到「思路一 · 模型填一份格式,人看另一份」
「先玩一遍 · 同一处修改,两种写法」先把问题落在「把 greet 里的 pass 换成 return 123 播放 单步 重置 磁盘上的文件 原样 上面插了两行 锚点行被改掉 切到插了两行,左边四个数字会偏。切到锚点丢了,右边会停笔。 unified diff 四个数字 要填对 等待开始。 apply-patch 一行锚点 现搜 @@ def greet(): 等待开始。 逻辑轨迹 · 动画每一步对应源码里的哪一段 文法里的 @@ 只有锚点,没有起始行和跨度 par…」上;到了「思路一 · 模型填一份格式,人看另一份」,讨论继续推进到「你让模型改一个函数:把 greet 里的 pass 换成 return 123 。它吐出标准 unified diff,头一行写成 @@ -47,3 +47,3 @@」。两段连起来,重点就不只是记住一个结论,而是看清它成立所依赖的条件。
把这条判断带到下一个场景
分析 Agent 时,沿着状态、动作、工具结果和下一步的顺序走一遍;每次交接都要能说明信息从哪里来、由谁确认、失败时停在哪里。
- 「先玩一遍 · 同一处修改,两种写法」:把 greet 里的 pass 换成 return 123 播放 单步 重置 磁盘上的文件 原样 上面插了两行 锚点行被改掉 切到插了两行,左边四个数字会偏。切到锚点丢了,右边会停笔。 unified diff 四个数字 要填对 等待开始。 apply-patch 一行锚点 现搜 @@ def greet(): 等待开始。 逻辑轨迹 · 动画每一步对应源码里的哪一段 文法里的 @@ 只有锚点,没有起始行和跨度 par…
- 「思路一 · 模型填一份格式,人看另一份」:你让模型改一个函数:把 greet 里的 pass 换成 return 123 。它吐出标准 unified diff,头一行写成 @@ -47,3 +47,3 @@
- 「最后的要点」:锚点找不到,立刻报 Failed to find context,不按附近行猜 file_update.rs L109
最后的「最后的要点」把讨论落到「锚点找不到,立刻报 Failed to find context,不按附近行猜 file_update.rs L109」。回看这条线索时,最值得保留的是:当输入、规模或风险改变,哪些判断需要重新做一遍。
我把这篇文章里的一个判断改写成了今天可以验证的小实验。比记住结论更有用的是,知道下一步要观察什么。
读完以后我先回头找它成立的条件,而不是直接把方法搬进项目。这个顺序让后面的取舍清楚很多。
如果把这个判断放到真实工作里,最先需要补的约束是什么?我想知道从阅读到第一次实践之间,哪一步最值得先做。
还没有这篇文章的讨论。