跳转至

第 7 章 代码即工具

上一章讲了工具系统的"通用规则"。这一章我们单拎出最特殊的一只手——改代码。对 coding agent 来说,"修改源文件"是最高频、最核心、也最容易闯祸的动作。它怎么被工程化成一个安全、可审查、可回滚的工具,值得专门一章。

末尾我们再看一个更前沿的范式:code mode——让模型不是"一个个调工具",而是"写一段代码来调工具"。

引子:最笨的改法,是让模型重写整个文件

想让 agent 改代码,最直觉的做法是:把整个文件发给模型,让它返回改好的完整新版本,然后覆盖写回。

这招在小文件上能用,但一到真实工程就处处是坑:

  • 烧钱烧上下文:一个 800 行的文件,改 3 行,却要让模型重新吐 800 行。输入输出都翻倍,钱和上下文预算(第 4 章)哗哗地流。
  • 没法审查:你拿到一个全新文件,得自己 diff 才知道它到底改了啥——而且模型很可能"顺手"改了你没让它改的地方。
  • 容易整段写崩:模型重写时漏掉一段、改错缩进、丢了个 import,整个文件就废了,且不易察觉。

所以几乎所有严肃的 coding agent,都不让模型"重写文件",而让它提交一个补丁(patch)——只说"改哪几行、怎么改"。Codex 的这只手,叫 apply_patch

一、为什么"补丁"是更好的编辑接口

补丁的好处,正好对着上面三个坑:

  • :只描述变化的那几行,token 最小。
  • 可审查:补丁天生就是 diff,谁改了什么一目了然,能进 code review、能被人或另一个 agent 一眼看懂。
  • 低破坏半径:补丁只动它声明要动的地方,且应用前先校验——上下文对不上就拒绝,而不是悄悄写错。
  • 可回滚:每个补丁配一次 git 提交(第 3 章见过 commit 在循环里的位置),改坏了 git revert 就回来了。

一句话:把"改代码"从"覆盖"变成"打补丁",就是把一个高风险动作,变成一个可审查、可校验、可回滚的工程操作。

整文件重写 vs 打补丁

图 1:把“覆盖整文件”换成“只提交补丁”,就是把高风险写操作改造成可审查、可回滚的工程动作。

二、apply_patch 的补丁长什么样

Codex 的补丁是一种自带格式的纯文本,不是标准 unified diff。一个典型补丁(示意):

*** Begin Patch
*** Update File: src/util/date.rs
@@ fn to_local(ts: i64) -> String
-    let tz = "UTC";
+    let tz = detect_timezone();
     format_with_tz(ts, tz)
*** End Patch

先抓三个最重要的点:

  • *** Begin Patch / *** End Patch 包住整体;文件操作有三种:*** Add File:*** Update File:*** Delete File:
  • @@ 后面跟的是上下文锚点(一个函数签名、一个标志性的行),而不是行号
  • 真正的改动用 (上下文)、-(删)、+(增)标记。

为什么用"上下文锚点"而不是"行号"?因为模型对行号几乎总是数不准,但对"在哪个函数、哪段代码附近"反而判断得不错。 用上下文锚点,patch 就能靠"找到这段上下文"来定位,对行号的轻微偏移天然有容错。也正因为如此,apply-patch crate 里才专门放了一个做模糊匹配的模块:seek_sequence。它负责在目标文件里把锚点和待改的旧行对上,是整个定位的核心;下一节我们就专门拆它。

这里顺手补一个旁证。OpenAI 官方的 apply_patch 指南,给它的定位也是structured diffs:不是让模型整段重写文件,而是让它产出"创建 / 更新 / 删除"这类结构化编辑操作,再由你的 harness 去应用并把结果回报给模型。也就是说,Codex 这个自带格式不是孤例,而是整类 coding agent 都会收敛到的一条工程路线。

顺带澄清一个常见误解:crate 里确实也用到了 similar::TextDiff,但它不参与定位——它只在补丁应用完、算出新内容之后,由 unified_diff_from_chunks* 拿"旧内容 + 新内容"生成一份可读的 unified diff 用于展示(给人看、进日志)。定位与模糊匹配从头到尾都由 seek_sequence 独立完成,和 TextDiff 无关。

补丁格式解剖

图 2:apply_patch 不是自由发挥的文本,而是一种结构化编辑格式;模型只有看懂格式,才能稳定产出补丁。

三、从补丁到落盘:apply-patch crate 干的事

模型吐出上面那段文本后,apply-patch 这个独立 crate 接手,干三件事:

  1. 解析parse_patch 把文本解析成结构化的 Hunk / UpdateFileChunk(哪个文件、哪几段、怎么改)。它还有一个流式解析器,能边接收模型输出边解析。
  2. 定位与计算:用模糊匹配在目标文件里找到那段上下文锚点,算出实际要替换的内容。找不到、对不上,就报 ParseError / ComputeReplacements 错误——宁可失败,也不乱写
  3. 落盘:通过受控的文件系统接口写回(这一步还要过沙箱与权限,第 10 章)。

这里有两个很能体现"工程纪律"的细节:

  • 必须显式调用:如果模型直接吐一段补丁文本、却没正经走 apply_patch 工具调用,Codex 会报一个专门的错——ImplicitInvocation("检测到补丁但没显式调用 apply_patch,请重跑为 ["apply_patch", "<patch>"]")。不接受"暗示式"改文件,必须走正门。
  • 自我调用为独立命令:Codex 二进制能用一个特殊参数 --codex-run-as-apply-patch 把自己当成一个独立的 apply_patch 命令来跑。这样"改文件"既能作为内置工具、也能作为沙箱里的一条命令统一处理。

补丁的一生

图 3:补丁从模型文本变成磁盘写入,中间会经过解析、定位、权限与落盘这几道工程闸门。

四、seek_sequence:又要容忍排版差异,又要拒绝错配

上一节说,apply_patch 会靠"模糊匹配"在目标文件里找上下文锚点。这里的"模糊"两个字,恰恰是它最微妙、也最值得细品的工程权衡。我们把它单拎出来看。

先想清楚它同时要满足的两个、彼此拉扯的目标:

  • 要容忍排版差异。模型笔下的补丁,和磁盘上真实的源文件,常常在"看不见的字符"上对不齐:行尾多一个空格、缩进用了 tab 还是空格、模型把源码里的弯引号 “” 写成了直引号 "、把 em dash 写成了 ASCII 连字符 -、甚至混进一个不间断空格(NBSP)。这些差异在人眼里"就是同一行",可一旦逐字节比较就全是 false。如果定位器对这些一律较真,那补丁会大面积地、莫名其妙地"找不到上下文"——而模型根本无从知道自己错在一个看不见的字符上。
  • 又要拒绝错配。但"模糊"不能滑向"凑合"。如果匹配太宽松,把一段长得像但其实不是的代码当成锚点改下去,那就是在错误的位置悄悄写错——这正是第二节反复强调的、比报错更可怕的事。所以容忍必须是有等级、可解释的:先严后宽,且每一级都仍要求整段逐行对上,绝不允许"差不多就行"。

seek_sequence 的解法,可以理解成一个分四档的降格匹配器:先用最严的比较试;不行,再放宽一点;再不行,再放宽一点。但只要某一级已经整段命中,就立刻返回,不会继续往更宽松的级别滑。 这样既给了排版差异活路,又守住了一条底线:能精确匹配时,绝不模糊。 四级从严到宽是:

  1. 精确:整段逐行字节级相等。
  2. 忽略尾部空白:每行按 trim_end() 比较——容忍行尾多余空格/制表符。
  3. 忽略首尾空白:每行按 trim() 比较——再容忍缩进层面的出入。
  4. Unicode 归一化:把各种排版字符折叠回 ASCII 再比——弯引号→直引号、各种 dash→-、NBSP 等异形空格→普通空格,对齐 git apply 的行为。
// seek_sequence 的四级降格(裁剪示意,省去边界与循环)
if lines[i..i+n] == *pattern { return Some(i); }                 // ① 精确
if 每行 lines[i+k].trim_end() == pat.trim_end() { return Some(i);} // ② 忽略尾部空白
if 每行 lines[i+k].trim()     == pat.trim()     { return Some(i);} // ③ 忽略首尾空白
if 每行 normalise(lines[i+k]) == normalise(pat) { return Some(i);} // ④ Unicode 归一化
return None;                                                       // 四级都不中 → 拒绝

注意最后那个 None:四级全不命中,它就老实返回"找不到",让上层抛 ComputeReplacements 错误。这就是"宁可失败也不乱写"落到代码里的样子:容忍度是有上限的,越过上限不是猜,而是拒。

除了这四级主线,seek_sequence 还有几个不起眼但很要紧的防御分支,都是被真实事故喂出来的:

  • EOF 优先:当这一段补丁意在改文件末尾时(eof 为真),它会先把搜索起点挪到"文件长度减去 pattern 长度"处,从尾部开始找。这样"在文件结尾追加/替换"这类补丁能稳定落在末尾,而不会误匹配到文件中段某处长得像的行。
  • 空 pattern → 直接命中:上下文为空时返回 Some(start),当成一次无操作匹配,不去做无意义的搜索。
  • pattern 比文件还长 → 直接 None:源码注释里明确写着,这一分支是为了避免越界 panic(2025-04-12 之前真的崩过)。模型给出的上下文比整个文件还长,是不可能匹配的,早返回即可。

这里还有一个容易被忽略、但很关键的细节:compute_replacements 在逐段定位时,会带着一个不断前移的 line_index 游标。也就是说,命中第一段以后,第二段不是再从文件开头乱找,而是从前一段后面继续往下找。这样一来,同一个补丁里的多段改动天然保持前后顺序,也降低了"同样的上下文在文件里出现多次、却匹配错地方"的概率。再加一处很小、但很实用的兼容:如果"待删旧行"的末尾多了一个空字符串(通常代表文件末尾的换行),它会在直接匹配失败时去掉这个末尾空行再试一次——又是一处被真实补丁喂出来的处理。

把这些拼起来,seek_sequence(再加上调用它的 compute_replacements)就成了一个"分级容错 + 顺序约束 + 明确兜底"的定位器:能对齐就严格对齐,对不齐就按可解释的等级放宽;放宽到头还不中,就干脆拒绝。它体现的是 harness 工程里一条很硬的纪律——容错是为了少打扰模型,不是为了掩盖错误;每一级放宽都要说得清"宽在哪、为什么宽",而不是丢一个谁也调不准的相似度阈值。

seek_sequence 的四级降格漏斗

图 5:seek_sequence 的核心不是“越模糊越好”,而是“分级容错、命中即停、兜底就拒绝”。

五、补丁说明,也是写给模型的 prompt

还记得第 6 章那句"工具规格就是写给模型的 prompt"吗?apply_patch 是最好的例子。

Codex 专门有一份"apply_patch 使用说明",连同补丁格式的语法,一起作为工具描述喂给模型——因为模型只有先"看懂"这个自带格式,才可能正确地产出补丁。这份说明写得相当克制和精确:格式长什么样、三种文件操作怎么写、锚点怎么给。它本身就是一篇高质量的"格式 prompt",值得你在写自己的编辑工具时借鉴。

换句话说:apply_patch 这只手好不好用,一半在 crate 的解析与模糊匹配(代码),另一半在那份格式说明写得够不够清楚(prompt)。代码与 prompt,各占一半——这是 harness 工程里反复出现的二元。

六、Code mode:让模型"写代码来调工具"

apply_patch 是"把一个动作做好"。还有一种更前沿的思路,是改变调用工具的方式本身——Codex 里叫 code mode

设想一个场景:模型要对 20 个文件做同一种小改动。用普通工具,它得发 20 次 apply_patch 调用,一来一回 20 轮,又慢又占上下文。code mode 的做法是:让模型写一小段代码,在代码里用循环、条件、变量去调用这些工具,然后把这段代码丢进一个运行时一次性执行。

Codex 的实现(core/src/tools/code_mode/ + code-mode crate + 一个 V8 运行时)大致是:

  • 给模型暴露一个 exec 工具(写代码执行)和一个 wait 工具(等待长任务)。
  • 模型写的代码在"单元(cell)"里执行,代码里可以嵌套调用别的工具(CodeModeNestedToolCall)。
  • 运行结果(含中间产物)整理后回灌模型。

那模型在这段代码里怎么"看见"可调用的工具?做法很直接:code mode 的运行时(一个 V8 实例)启动时,会往全局作用域里塞两样东西。

  • 一个 tools 全局对象:每个启用的工具都挂成它的一个方法,例如 await tools.exec_command(...)。MCP 工具名也会先被规范化成合法的 JS 标识符,比如 tools.mcp__ologs__get_profile(...)
  • 一个 ALL_TOOLS 数组:列出每个工具的 { name, description } 元数据,给模型当"可调用工具目录"看。

于是模型写 code mode 脚本时,本质上就像在调用一组本地异步函数:想知道"有哪些工具",就看 ALL_TOOLS;想真正调用,就走 tools.<name>(...)。另外,运行时还顺手删掉了 consoleAtomicsSharedArrayBufferWebAssembly 这些全局,把执行环境收得更紧——这又是第 10 章沙箱思路的一个缩影。

这里最好再强调一句,免得第一次读的人误会:“没有文件系统、没有网络”说的是这段 JavaScript 自己不能直接碰 Node / FS / socket;不是说它什么都做不了。 真正的外部动作,是通过 tools 这个全局对象里的嵌套工具去做的。也就是说,code mode 不是把安全边界拆掉,而是把"组合逻辑"搬进代码里,把"真实副作用"仍留在工具层。

这正是 Anthropic《Code execution with MCP》讲的思路:当工具很多、调用很密集时,"写代码调工具"比"一个个 function call"更省、更灵活——循环、组合、过滤都能在代码里完成,不必每一步都往返模型。它在文章里给出的总论点很直接:代码执行环境能让 agent 按需加载工具、在执行环境里先过滤数据、只把高信号结果送回模型。这和本章前半讲的 apply_patch,其实是同一条母题在两个层面的展开:前者优化"怎么改文件",后者优化"怎么调很多工具"。

function call 海 vs code mode

图 4:当调用很多、而且模式重复时,“写代码调工具”比“一次次 function call”更省上下文,也更适合批量任务。

不过要留一句平衡的话:code mode 不是银弹。它更适合"批量、可程序化"的工具使用;对"走一步看一步"的探索式任务,老老实实一个个 function call 反而更可控。第 6 章那句"先找最简单的方案,需要时才加复杂度",在这里同样适用。

七、三种编辑/调用范式,怎么选

把这一章收束成一张选择表:

范式 适合 代价
重写整文件 极小文件、全新文件 烧 token、难审查、易写崩
apply_patch(补丁) 绝大多数代码修改 需要模型学会补丁格式
code mode(写代码调工具) 批量、可程序化的工具使用 运行时复杂度、调试更难

默认用 apply_patch;批量重复时考虑 code mode;重写整文件只在万不得已时。

本章小结

  • 改代码 = 打补丁,不是覆盖整文件:补丁省 token、天生可审查、低破坏半径、配 git 可回滚——把高风险动作变成工程操作。
  • apply_patch 用"上下文锚点"而非行号定位:因为模型数不准行号、却判断得准"在哪段代码附近";定位由 seek_sequence多级降格匹配(精确→忽略尾部空白→忽略首尾空白→Unicode 归一化,外加 EOF 优先、空 pattern、pattern 超长等防御分支),能精确就精确、能容忍才容忍、越界就拒绝——容错是为了少打扰模型,不是掩盖错误。(similar::TextDiff 只在事后生成可读 diff 用于展示,不参与定位。)
  • 补丁格式说明本身就是 prompt:工具好不好用,一半在解析代码、一半在那份格式说明——代码与 prompt 各占一半。
  • code mode:让模型写一段代码去(嵌套)调用工具,适合批量、可程序化的场景,比"一个个 function call"更省更灵活;但不是银弹,探索式任务仍宜逐步调用。

下一章,我们离开"工具"本身,去看"能力"这一层的第一块——Skills:怎么把成套的专家流程封装起来,又怎么在不撑爆上下文的前提下让模型"知道有哪些技能、用时才加载"。


参考来源

解剖标本(codex-rs 源码)

  • apply-patch/src/lib.rsApplyPatchError、自调用入口
  • apply-patch/src/parser.rsparse_patch/Hunk
  • apply-patch/src/streaming_parser.rs — 流式解析
  • apply-patch/src/seek_sequence.rs — 多级降格模糊定位
  • apply-patch/tests/fixtures/scenarios/* — 真实补丁样例
  • core/src/tools/code_mode/CodeModeServiceexec/wait 工具
  • code-mode/src/runtime/globals.rs — V8 全局注入与裁剪
  • code-mode/src/description.rs — 工具暴露说明

方法论

注:补丁示例为格式示意,实际语法以 apply-patch crate 为准;code mode 仍在演进,类型/工具名以你 clone 到的版本为准。

留言