apply-patch,给模型设计一种 diff
模型填一份没有行号的补丁,人看的是事后算出来的 unified diff。同一处修改,两套格式各自会在哪一步翻车。
@@ -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 会改哪一段,为什么?
进阶一问:若希望单独命中第二段,锚点应该写在哪一行前面?同一份补丁里若先成功新增一个文件,再更新一个不存在的路径,磁盘上会留下什么?