OpenAI Codex · 代码模式 apply-patch,给模型设计一种 diff 模型填一份没有行号的补丁,人看的是事后算出来的 unified diff。同一处修改,两套格式各自会在哪一步翻车。 一句话速览 一份没有行号的补丁语言:定位靠上下文锚点,应用靠四级匹配,单文件对不上就不写盘
课程目标 读完能说清两件事:给模型的 diff 为什么只写一行上下文锚点,不写 @@ -l,s +l,s 那四个数字;以及上下文对不上时,单文件为什么整份不落盘,跨多个文件时这个保证为什么不成立。
先玩一遍 · 同一处修改,两种写法
把 greet 里的 pass 换成 return 123
播放 单步 重置
磁盘上的文件
原样 上面插了两行 锚点行被改掉
切到插了两行,左边四个数字会偏。切到锚点丢了,右边会停笔。
unified diff 四个数字 要填对
等待开始。
apply-patch 一行锚点 现搜
@@ def greet():
等待开始。
逻辑轨迹 · 动画每一步对应源码里的哪一段
文法里的 @@ 只有锚点,没有起始行和跨度 parser.rs L20 更新文件的 chunks 必须按在文件中出现的先后排列 parser.rs L74 有 change_context 时,从 line_index 往下搜这一行 file_update.rs L99 先整行精确比,再抹掉行尾空白,再两边 trim seek_sequence.rs L40 锚点找不到,立刻报 Failed to find context,不按附近行猜 file_update.rs L109 单文件全部 chunk 在内存里算完,才调用 write_file lib.rs L695 跨文件失败时带着已经提交的 delta 返回,没有回滚 lib.rs L453 给人看的 unified diff 是事后用 TextDiff 另算的 file_update.rs L328
点播放,看同一处修改在两种 diff 写法下怎么定位。
四个数字 unified diff 的 @@ 头要同时填对旧起始、旧跨度、新起始、新跨度。文件上面插两行,这四个数字一起废。 一行锚点 apply-patch 只写 @@ def greet(): ,运行时现搜。插两行也能对上,因为行号根本没进这份格式。 对不上就停 锚点行被改掉时,右边报 Failed to find context,这个文件保持原样。左边那种格式会在行号附近 fuzz,有可能贴到邻近函数。
教学示意:文件行块与行号是课程化设定,用来对照两种格式的定位方式。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 模型填一份格式,人看另一份
它解决什么问题 你让模型改一个函数:把 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 行 这份文法把没有行号写进了产生式。两种 @@ 写法都只带文本锚点:
codex-rs/apply-patch/src/parser.rs 第 20 至 22 行
//! 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 头怎么拼。
模型填写
模型 写一份补丁
apply-patch @@ 锚点,没有行号
seek_sequence 在磁盘上现搜,算出新内容 人阅读
已经算好的新旧文本 工具参数里那份格式到此用完
TextDiff 事后生成 unified diff
界面 给人看的那一份
教学化结构图:同一处修改,模型填锚点,人看行号。
为什么长期成立 坐标靠加算,内容靠识别。模型数行号这件事,换一个模型、换一种语言都好不到哪去。把找到哪一段从填写时的算术,改成应用时的字符串搜索,这个分工不依赖 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 字符加了第四级。它只放宽空白和标点,不放宽位置。中文全角引号不在归一化表里,模型写了全角左引号,文件里是半角引号,四级都会失败。
精确相等
trim_end
两边 trim
normalise
四级都失败,返回空 没有按行号上下挪几行再试
立刻报错
教学化流程图:空格和短横可以过,行号偏移不在这四级里。
为什么长期成立 容错要分清两类差异。行尾空格、弯引号是无意义的字节差,可以归一。行号偏了两行,是贴错地方,应该报错让模型重写。这个分界换语言重写也成立。
思路三 · 单文件算完再写,跨文件没有事务
它解决什么问题 一份补丁里有两个 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 循环
同一个文件里的两个 chunk
chunk 1 在内存里算完
chunk 2 对不上,返回
write_file 还没走到,文件原样 两个文件级 hunk
Add File 已经写盘
Update 一个不存在的路径
delta 留下,created.txt 还在
教学化对照:没有部分成功这个保证,只对单个文件成立。
模型填锚点。人看行号。单文件对不上就不写。
为什么长期成立 算完再提交的范围,要和你能原子处理的单位对齐。一个文件可以先在内存里算完全部替换再写一次。多个文件已经落到磁盘上,回滚就要再写一遍,还要处理 Move 这种源和目标都动过的半成功。要不要跨文件事务,是产品选择,不是格式本身的承诺。写给模型的说明里,别把单文件的保证说成全局保证。
横向对比 · 同一道题的另一种答法
DeepSeek Harness:先读过,才能改 DSH 的 editIntent 查的是这个 session 有没有观测过这个文件。没观测过就抛 FS_NOT_OBSERVED 。观测记录的是 dev:ino:size:mtimeNs:ctimeNs 拼出来的版本,不是内容 hash。 即便过了这道门, applyLiteralEdit 默认还要求 old_string 只出现一次,多处命中就抛 FS_AMBIGUOUS_EDIT 。防错挂在事件门禁和字面量唯一上。Codex 没有先读约束,定位信息写在补丁里,运行时现搜; old_lines 出现两次时取第一处,没有歧义报错。 两侧均已核对源码 · 2026-08-22 · DSH · 文件编辑的工程学
Claude Code:没读过就拒绝,多处命中也拒绝 FileEditTool 同时要两件事。文件必须先读过,没读过报 errorCode 6,原文是 File has not been read yet。 old_string 在文件里多于一处且 replace_all 为假时,报 errorCode 9,要求补更多上下文,把这一处单独标出来。 模糊只覆盖引号。 findActualString 先精确搜,再把弯引号收成直引号搜。没有 Codex 那种行尾空白三级,也没有短横归一化。想少一次工具往返,抄 Codex 的格式;想对模型错误信息更具体,抄这几条带 errorCode 的拒绝文案。 两侧均已核对源码 · 2026-08-22
课堂练习
01
两段相同的 old_lines,改哪一段 文件里有两段完全相同的 old_lines ,模型只想改第二段,却没有给足够的 @@ 锚点。 seek_sequence 会改哪一段,为什么? 进阶一问:若希望单独命中第二段,锚点应该写在哪一行前面?同一份补丁里若先成功新增一个文件,再更新一个不存在的路径,磁盘上会留下什么?
Takeaway: 给模型的 diff 不要行号,定位交给运行时搜上下文。空白和标点可以逐级放宽,位置不猜。单文件对不上就不写盘;跨多个文件时,已经写下的文件会留在磁盘上。