跳到主要内容

Codex apply_patch — 模糊补丁格式与四级容错匹配

这章讲什么: Codex 不让模型重写整个文件,也不用标准 unified diff,而是用一套自家的 apply_patch 格式。本章讲它长什么样、为什么这么设计,以及最精彩的部分——定位改动位置时的四级容错匹配,让「模型大致写对」也能精确落地。这是整个项目最值得单独拿走借鉴的工程亮点。

1. 它要解决的小问题

让模型改代码,有三种常见路子,各有痛点:

路子痛点
整文件重写模型得逐字复现整个文件,长文件又慢又容易把没动的地方改坏
标准 unified diff(@@ -12,7 +12,9 @@)行号必须精确,模型经常算错行号导致补丁打不上
行号编辑(「把第 42 行换成…」)模型对行号同样不可靠,且文件一变全乱

Codex 要的是:模型只需大致描述「改哪段、改成啥」,不必精确到行号、甚至不必逐字复现上下文,系统也能可靠地把改动落到正确位置。

2. 思路/直觉:用「上下文锚点」代替「行号」

核心点子:别让模型记行号,让它给几行紧挨改动的原文当「锚点」。系统拿锚点去文件里,搜到了就在那儿动手。

这跟 git apply 容忍上下文漂移是一个思路,但 Codex 把容忍度做得更高——锚点甚至不必和原文一字不差(见 §5)。

格式用一组醒目的 *** ... 标记包起来,模型很难写错边界:

*** Begin Patch
*** Update File: src/seek_sequence.rs
@@ fn normalise(s: &str) -> String
s.trim()
.chars()
- .map(|c| match c {
+ .map(|c| match c { // 归一化常见 Unicode 标点
*** End Patch

读法:@@ 后面那行是上下文锚点(通常是函数/类定义那一行),用来缩小搜索范围;下面以 (空格,保留)、-(删)、+(增)前缀逐行描述改动——和 diff 像,但没有行号

3. 支持的几种 hunk

一个补丁由若干 hunk 组成,Codex 只支持三种动作,简单到模型很难用错:

Hunk 类型标记作用
新增文件*** Add File: <路径> 后跟若干 +整文件新建
删除文件*** Delete File: <路径>删整个文件
更新文件*** Update File: <路径>(可带 *** Move to: 重命名)在文件内做若干上下文锚定的改动

这三种就是 Hunk 枚举(apply-patch/src/parser.rs:65 起):AddFileDeleteFileUpdateFile { path, move_path, chunks }。每个更新文件的改动是一个 UpdateFileChunk,带 change_context(那行 @@ 锚点)+ 一段要替换的连续行。

格式的权威语法是一段 Lark 文法,就写在 parser 顶部注释里:

// codex-rs/apply-patch/src/parser.rs:6 起(节选)
// start: begin_patch environment_id? hunk+ end_patch
// add_hunk: "*** Add File: " filename LF add_line+
// update_hunk: "*** Update File: " filename LF change_move? change?
// change_line: ("+" | "-" | " ") /(.+)/ LF

解析入口是 parse_patch(parser.rs,经 lib.rs re-export)。注意 parser 只解析与校验,不碰文件系统;它有意比文法更宽松,容忍标记前后的空白(注释 parser.rs:23-24)。

4. 谁来执行:模型「假装」调了个 shell 命令

一个反直觉但很妙的设计:apply_patch 不是一个独立的 function tool,而是被当作 shell 命令拦截下来的

模型发起的是一次普通的 shell 工具调用,argv 形如 ["apply_patch", "<整个补丁文本>"]。Codex 在执行 shell 前先探一眼:这是不是个 apply_patch?是就走补丁路径,不是才真当 shell 跑。

// codex-rs/core/src/tools/handlers/apply_patch.rs:546 —— 拦截
pub(crate) async fn intercept_apply_patch(command: &[String], cwd: &PathUri, /* … */) -> Result<Option<FunctionToolOutput>, FunctionCallError> {
let sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None, cwd);
match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd, fs, Some(&sandbox)).await {
codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
// 解析成功 → 走审批 + 落地补丁
}
// 否则:当作普通 shell 命令继续
}
}

好处:模型只需掌握「一个工具(shell)」,apply_patch 是它的一个特例;同时打补丁这件危险事仍然走和 shell 一样的审批/沙箱通道(见第 3 章)。maybe_parse_apply_patch_verified 还会顺手校验路径是否在可写范围内。

安全细节:lib.rs 里有个 ImplicitInvocation 错误——如果检测到一段「裸补丁」却没有显式 apply_patch 调用,会拒绝并要求模型「以 [\"apply_patch\", \"<patch>\"] 重新调用」(apply-patch/src/lib.rs:52-58)。这防止补丁被「偷渡」执行。

5. 精华:四级递减严格度的模糊匹配

这是全章最值得学的地方。补丁里的上下文行不一定和文件里一字不差——模型可能把制表符写成空格、把弯引号写成直引号、丢了行尾空白。seek_sequence逐级放宽的策略去定位:先严格,失败就放宽一档,直到匹配或彻底失败。

要在文件里定位补丁给的若干「上下文行」

① 精确匹配 ───────────── 命中?→ 用这个位置
│ 否
② 忽略行尾空白(rstrip)── 命中?→ 用这个位置
│ 否
③ 忽略首尾空白(trim)──── 命中?→ 用这个位置
│ 否
④ Unicode 标点归一后再 trim ─ 命中?→ 用这个位置
│ 否
放弃(补丁打不上)

怎么读这张图: 从上到下严格度递减,命中即停。越靠下越「宽容」,但也越靠后才尝试,避免过度宽松误匹配。

源码就是 seek_sequence 里依次排开的几个 pass:

// codex-rs/apply-patch/src/seek_sequence.rs —— 三级文本匹配 + 一级归一化(节选,凝练)
// ① 精确
for i in search_start..={ if lines[i..i+len] == *pattern { return Some(i); } }
// ② rstrip:逐行比 trim_end()
for i in{ if lines[i+p].trim_end() == pat.trim_end() { /* 全行都匹配则命中 */ } }
// ③ trim:逐行比 trim()
for i in{ if lines[i+p].trim() == pat.trim() { /* … */ } }
// ④ normalise:弯引号→直引号、各种破折号→'-'、各种空格→普通空格,再比
fn normalise(s: &str) -> String { /* \u{2018}|\u{2019} → '\'' 等 */ }

真实锚点:

  • 函数与「严格度递减」的总注释:seek_sequence.rs:1-11
  • 第④级的 Unicode 归一(破折号 / 弯引号 / 异形空格 → ASCII):seek_sequence.rs:76 起的 normalise,其设计意图注释明说「镜像 git apply 忽略字节级微小差异」(:67-73)。

还有两处防御性细节值得一看(都在 seek_sequence.rs:12-31):

  • pattern → 直接返回 Some(start)(无操作匹配);
  • pattern.len() > lines.len() → 返回 None,避免越界 panic(注释提到这曾在 2025-04-12 前导致过 panic)。

另外,匹配末尾(EOF)的上下文时,seek_sequence先从文件末尾试(eof 参数),好让「文件结尾处的改动」优先落在结尾——这是个贴合直觉的小优化(seek_sequence.rs:3-6:28-31)。

6. 一段教学示意:模糊匹配的精神

下面用简化代码演示「逐级放宽」的精神(不是源码,真实实现见 §5):

# 示意,非源码:演示「严格度递减、命中即停」的定位
def locate(file_lines, anchor_lines):
passes = [
lambda a, b: a == b, # ① 精确
lambda a, b: a.rstrip() == b.rstrip(), # ② 忽略行尾空白
lambda a, b: a.strip() == b.strip(), # ③ 忽略首尾空白
lambda a, b: norm(a) == norm(b), # ④ Unicode 归一后
]
for matches in passes: # 越往后越宽容
for i in range(len(file_lines) - len(anchor_lines) + 1):
window = file_lines[i:i+len(anchor_lines)]
if all(matches(x, y) for x, y in zip(window, anchor_lines)):
return i # 命中即停
return None # 四级都没中 → 放弃

重点看:同一组锚点,被四个越来越宽松的比较函数轮番尝试,这就是 Codex 让「大致写对」也能落地的全部魔法。

7. 边界与局限

  • 过宽的风险。 第③④级放宽到「忽略所有首尾空白 / 归一标点」后,理论上可能匹配到非预期位置;Codex 用「严格优先、命中即停 + 上下文锚点缩小范围」来压低这个风险,但并非零风险。
  • 宽松解析是为旧模型兜底。 parser 有个 ParseMode::Lenient,注释点名只有 gpt-4.1 已知需要宽松解析,但为省去到处传参,索性对所有模型放宽(parser.rs:45-52、常量 PARSE_IN_STRICT_MODE = false)。
  • 只认这三种 hunk。 复杂改动(如同一文件多处不相邻改动)靠多个 chunk 表达,且 chunk 要按文件内出现顺序排列(UpdateFile.chunks 的文档注释,parser.rs:78-80)。

8. 代码地图

主题文件符号
补丁格式语法(注释)codex-rs/apply-patch/src/parser.rs顶部 Lark 文法、BEGIN_PATCH_MARKER 等常量
解析与校验codex-rs/apply-patch/src/parser.rsparse_patchHunkUpdateFileChunk
四级模糊匹配codex-rs/apply-patch/src/seek_sequence.rsseek_sequencenormalise
流式解析codex-rs/apply-patch/src/streaming_parser.rsStreamingPatchParser
检测/校验入口codex-rs/apply-patch/src/lib.rsmaybe_parse_apply_patch_verifiedApplyPatchError::ImplicitInvocation
shell 中拦截codex-rs/core/src/tools/handlers/apply_patch.rsintercept_apply_patch
安全评估codex-rs/core/src/safety.rsassess_patch_safety