OpenAI Codex · 代码模式

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

模型填一份没有行号的补丁,人看的是事后算出来的 unified diff。同一处修改,两套格式各自会在哪一步翻车。

课程目标读完能说清两件事:给模型的 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 不要行号,定位交给运行时搜上下文。空白和标点可以逐级放宽,位置不猜。单文件对不上就不写盘;跨多个文件时,已经写下的文件会留在磁盘上。