专题篇章 · 拆开 OpenAI Codex

apply-patch,给模型设计一种 diff

一份没有行号的补丁语言:定位靠上下文锚点,应用靠四级匹配,单文件对不上就不写盘

本页解决的问题

先给结论

「apply-patch,给模型设计一种 diff」要解决的关键问题是什么?

一份没有行号的补丁语言:定位靠上下文锚点,应用靠四级匹配,单文件对不上就不写盘

判断标准

跟着交接处走,不要只看 Demo。 系统是否可靠,往往取决于模型、工具、状态、权限和人的交接处。把每次交接都当成可以观察、测试和恢复的地方。

下一步

为一个自动化步骤写清输入、负责人、审批和恢复动作。

常见误区

一次运行成功了,却说不清发生了什么,也无法安全重放。

课程目标读完能说清两件事:给模型的 diff 为什么只写一行上下文锚点,不写 @@ -l,s +l,s 那四个数字;以及上下文对不上时,单文件为什么整份不落盘,跨多个文件时这个保证为什么不成立。
先玩一遍 · 同一处修改,两种写法
greet 里的 pass 换成 return 123
磁盘上的文件
切到插了两行,左边四个数字会偏。切到锚点丢了,右边会停笔。
unified diff四个数字要填对
等待开始。
apply-patch一行锚点现搜
@@ def greet():
等待开始。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 文法里的 @@ 只有锚点,没有起始行和跨度parser.rs L20
  2. 更新文件的 chunks 必须按在文件中出现的先后排列parser.rs L74
  3. change_context 时,从 line_index 往下搜这一行file_update.rs L99
  4. 先整行精确比,再抹掉行尾空白,再两边 trimseek_sequence.rs L40
  5. 锚点找不到,立刻报 Failed to find context,不按附近行猜file_update.rs L109
  6. 单文件全部 chunk 在内存里算完,才调用 write_filelib.rs L695
  7. 跨文件失败时带着已经提交的 delta 返回,没有回滚lib.rs L453
  8. 给人看的 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 不要行号,定位交给运行时搜上下文。空白和标点可以逐级放宽,位置不猜。单文件对不上就不写盘;跨多个文件时,已经写下的文件会留在磁盘上。

「先玩一遍 · 同一处修改,两种写法」的能力藏在每次交接里

「你让模型改一个函数:把 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」。回看这条线索时,最值得保留的是:当输入、规模或风险改变,哪些判断需要重新做一遍。

标记为已学完 阅读进度会自动记录
← 上一篇下一篇 →

继续阅读

同一条线上的下一篇。

文章讨论

读到这里,留下一个判断。

把刚想明白的地方、还没想通的问题,留给下一位一起学习的人。

正在讨论 apply-patch,给模型设计一种 diff 拆开 OpenAI Codex
3条讨论文章讨论 · 与共学社区同步
在共学社区查看
AM
Asha Morgan内容编辑
观点实践记录

我把这篇文章里的一个判断改写成了今天可以验证的小实验。比记住结论更有用的是,知道下一步要观察什么。

文章讨论7 有帮助
LH
Lin Harper独立开发者
观点观点

读完以后我先回头找它成立的条件,而不是直接把方法搬进项目。这个顺序让后面的取舍清楚很多。

文章讨论5 有帮助
KM
Kiki Moore产品运营
问题问题

如果把这个判断放到真实工作里,最先需要补的约束是什么?我想知道从阅读到第一次实践之间,哪一步最值得先做。

文章讨论4 有帮助