← Garden

Agent编辑工具为什么这么多

不同厂商设计了不同的 edit tool。Claude Code 需要模型提交精确的旧文本和一段新文本。OpenCode 则在精确匹配之外,还会尝试忽略缩进、空白和部分文本差异。Codex 的 apply_patch 不要求模型单独提交旧文本,而是生成一份类似 diff 的 patch,用上下文、删除行和新增行描述修改。OMP 的 Hashline 又换了一种方式:读取文件时先生成 snapshot tag 和行位置,编辑时直接引用这些位置。

Aider 和 OMP 是终端式 coding agent 工具,两者都会按模型选择 edit format。两者都不会固定使用一种 edit format,而是根据模型选择默认方案:Aider 会在 whole、diff、diff-fenced 和 udiff 等格式中为常见模型预设默认值;OMP 则会在 hashline、apply_patch、patch 和 replace 之间优先采用模型专属配置。

这些工具针对的是 edit 过程中不同的失败来源。模型可能无法准确复述旧代码,也可能生成不了合法的 patch;文件在读取后还可能被 formatter、用户或另一个 agent 改动;如果一次修改涉及多个文件,写入中途失败还可能留下部分结果。

这篇文章讨论的重点是局部编辑中的三步:模型描述修改,运行时按规则定位并校验目标,最后执行修改。文件写入是否具备跨文件原子性,属于更下层的执行问题,不必混进这次格式比较。

Claude Code 和 OpenCode:新旧文本精确匹配和其变体

Claude Code 的 str_replace 调用大致是这样:

{
  "file_path": "src/example.py",
  "old_string": "return a + b",
  "new_string": "return a - b",
  "replace_all": true
}

Anthropic 的文档要求 old_str 精确匹配,包括空格和缩进。实现方还应检查匹配结果是否唯一。找不到时返回错误;找到多处时,也要求模型补充更多上下文。

这个设计里,旧文本同时承担了两项工作。它告诉工具“要改哪里”,也声明“这里现在应该是什么样”。只要当前文件仍然有且只有一段这样的文本,修改就能执行。

它的好处是失败比较干净。模型少写一个空格,工具会拒绝;文件中出现两段相同代码,工具也会拒绝。

代价是模型需要重新输出已经读过的代码。修改一行时影响很小,删除几十行函数时,old_str 本身就会很长。代码中存在重复结构,模型还得继续加入函数签名、注释或前后语句,直到目标只剩一个。

空格和换行也会进入协议。模型理解了修改意图,仍可能因为少复制一个缩进而失败。失败本身通常安全,代价是多一次 read 和 edit。

Exact replace 还有一个较少讨论的限制。它校验的是内容,没有校验代码对象的身份。某段文字被完整移动到另一个函数中,只要仍然唯一,替换依旧可能成功。工具知道“这段文本存在”,不知道“它还是模型刚才理解的那个程序位置”。

所以 exact replace 适合短小而唯一的修改。协议简单,失败边界也清楚。大段删除、重复模板和频繁变化的工作区会放大它的成本。

OpenCode 对模型暴露的形式没有大改,仍然是:

{
  "filePath": "src/example.ts",
  "oldString": "...",
  "newString": "..."
}

区别在定位阶段。OpenCode 的宽松定位由一组内部 matcher 依次完成。模型仍然只提交 oldString 和 newString;精确匹配和逐行去除首尾空白后的匹配都失败时,运行时才会尝试 BlockAnchorReplacer。它不是一种新的 edit format,而是 OpenCode 在执行 old/new 替换时使用的一层 fallback。

BlockAnchorReplacer 主要处理至少三行的多行块。它把 oldString 的首行和末行去掉首尾空白后作为锚点,在当前文件中寻找首尾锚点一致、且总行数与原块相差不超过约四分之一的候选区间。随后,它对候选区间与 oldString 的中间各行计算归一化 Levenshtein 相似度。当前阈值是 0.65;出现多个候选时,选择得分最高并达到阈值的一项。候选返回后,外层替换逻辑还会检查实际文本是否唯一。

例如模型提交的是:

def load():
    value = old_api()
    return value

当前文件可能已经变成:

def load():
    value = new_wrapper(old_api())
    return value

严格替换会认为整段文本不存在。BlockAnchor 仍能用第一行和最后一行圈定区间,再检查中间部分是否足够相似。

宽松匹配相比于严格匹配能省掉一些重新读取和重试,也给错误候选留下了空间,OpenCode 用唯一性检查、区间长度限制和相似度阈值缩小风险。

Codex:用上下文和增删行描述修改

Codex 的 apply_patch 让模型输出这样的内容:

*** Begin Patch
*** Update File: src/example.py
@@
- return a + b
+ return a - b
*** End Patch

Patch 只描述发生变化的部分,不返回修改后的完整文件。- 表示删除,+ 表示新增,周围没有前缀的行用来提供定位上下文。一份 patch 可以包含多个连续修改片段;diff 里的正式术语是 hunk。Codex 的实现内部也直接使用 Hunk 表示这些片段。

这种格式适合一次修改多个位置。模型需要在同一个文件中改三处代码时,可以把三个修改片段放在一次调用里,不必为每一处分别提交一组 oldString/newString。实际变化也容易阅读,删了什么、加了什么会直接并排显示。

模型要承担的工作随之变化。Exact replace 要求它准确复述旧文本;patch 要求它正确生成文件标记、上下文和增删行。模型理解了代码,并不保证它一定能写出可解析的 patch。

Patch 也没有绕开定位问题。运行时仍然要拿上下文行去当前文件中寻找位置。Codex 当前的匹配会先尝试逐行精确匹配,失败后忽略行尾空白,再忽略首尾空白,最后还会归一化部分 Unicode 引号、破折号和特殊空格。

所以 patch 与 OpenCode 的宽松 replace 有一处相似:两者都会在精确定位失败后吸收一部分格式差异。主要区别在模型怎样描述修改。

OpenCode 和 Codex 两种方式都要输出被删除和新增的内容,patch 并不天然更省 token。它省下的主要是定位所需的未修改上下文:这些内容在 old/new 形式中往往要随旧块和新块各写一次,在 patch 中通常只出现一次。对于短小且唯一的单点替换,old/new 可能更简洁;目标块较长或一次包含多个修改片段时,patch 才更可能占优。

OMP Hashline:减少旧文本复述,同时定位目标并检查文件变化

OMP,也就是 Oh My Pi,是一个从 Pi 分支发展的 coding agent。它当前的 Hashline 模式把地址放到了读取阶段。

模型读取文件后,会看到文件路径、snapshot tag 和行号。编辑时可以写:

[src/example.ts#1A2B]
PUT 4.=4:
+const value = 2;

这里的 1A2B 标识模型读取时的文件快照,4.=4 指向那个快照中的第四行。模型不需要把旧的第四行重新输出一遍。OMP 还规定,同一次调用中的行号始终指向原始 snapshot,不会因为前面的修改插入或删除了行而发生漂移。

Hashline 同时处理了三个问题。

模型可以直接引用目标位置,省掉旧文本复述。Snapshot tag 记录了这次编辑基于哪个文件状态。目标文件已经改变时,运行时也能识别出旧引用。

OMP 会记录模型通过 read 或 grep 实际看过哪些范围。编辑指向未展示过的行时,工具会拒绝。Snapshot 已经过期时,运行时可以利用保存的快照链尝试恢复;只有恢复结果唯一且被判断为安全时才继续,否则要求模型重新读取。

这套协议减少了模型端的复述成本,也提高了旧状态检测能力。运行时需要为此保存更多状态。

Snapshot 必须建立、更新和回收。Compact 之后,旧 tag 是否继续有效要有明确规则。并发修改出现时,运行时要判断 anchor 能否安全迁移。Read 的每一行还会带上额外标记,这部分输入 token 也应计入成本。

Hashline 使用自定义语法,模型未必都适应。OMP 当前支持 hashline 和 apply_patch,也提供 patch、replace 两种模式。模式选择会优先读取模型专属 variant,然后才使用默认配置。这一实现本身已经把模型差异纳入设计。

Hashline 的定位还会使用语法块信息。OMP 支持基于语法块的操作,例如从函数定义的起始行解析到 tree-sitter 节点末尾。不过解析失败时,它对替换和删除会拒绝继续,而不会猜测块的边界。

和 exact replace 相比,Hashline 少让模型复述旧内容,多让运行时维护会话状态。它适合长会话和大段编辑;短小且唯一的单点替换,维护 snapshot 未必能带来同样明显的收益。

Aider 和 OMP 都会按模型选择格式

Aider 和 OMP 都把 edit format 作为模型配置的一部分。比如Aider 支持 whole、diff、diff-fenced 和 udiff 等格式,也有较新的 patch 与 editor-* 变体。它会为常见模型预设默认格式,用户也可以通过参数或模型配置覆盖。Aider 的 diff-fenced 主要解决部分 Gemini 模型难以遵循普通代码围栏的问题;udiff 则曾用于减少 GPT-4 Turbo 省略代码、留下占位注释的情况。格式选择针对的是具体模型已经表现出来的输出习惯。

这种做法增加了维护成本。模型版本更新后,原来的默认格式可能需要重新评测。不过,统一格式也不一定更简单:某个模型如果反复生成无效 patch,harness 省下的配置工作,最后会变成更多的失败重试。

Aider 和 OMP 的选择说明,edit format 很难脱离模型单独评价。格式也是模型需要生成的内容,训练与 post-training 中形成的工具使用习惯会影响它能否稳定遵循协议。

为什么没有统一的 Edit Tool

Anthropic 在 2024 年的一篇文章里讲过 edit tool 的设计,核心是找到模型表现最好的一种接口设计。他们为 SWE-bench 搭的 agent 只有一个 Bash 工具和一个 Edit 工具,思路是把控制权尽量交给模型,脚手架尽量少。指定文件修改的方式他们试过几种,字符串替换的可靠性最高:模型给出 old_str 和 new_str,只有 old_str 恰好匹配一处时才执行,否则返回错误让模型重试。这种严格和 Anthropic 的模型在 coding 场景里给人的印象是相称的。

OpenAI 则是先定了增量 diff 作为 edit tool 的主干,然后使用这个方案训练模型。这个方案的设计有两个出发点,一个是降低成本和延迟,一个是来自开发者对准确 diff 的需求。

Claude Code 和 Codex 都能围绕自己的模型长期固定一套 edit protocol,格式、提示词和运行时可以一起调整。OpenCode、Aider 和 OMP 没有这个条件,它们要接入训练方式和工具习惯各不相同的模型,只能把兼容工作放在 harness 一侧。OpenCode 用 matcher 吸收模型输出中的偏差,Aider 按模型选择格式,OMP 用 Hashline 减少对旧文本复述的依赖,同时保留其他 edit mode。它们的区别不在于谁找到了一种普遍更好的格式,而在于兼容成本落在匹配器、模型配置,还是 edit protocol 本身。

模型能力的增长,让这些差别变得没有那么明显,但也有新的问题出现。Armin Ronacher 今年观察到,Opus 4.8 和 Sonnet 5 在 Pi 的嵌套 edit schema 上比旧模型更容易添加不存在的字段,尽管 oldText 和 newText 本身通常是正确的;他推测,新模型在 post-training 中对 Claude Code 的扁平 edit schema 形成了更强先验。这也意味着对这类需要支持多模型的 harness 框架,按模型选择 edit format 以后可能不再只是优化,而是更为基本的兼容机制。

——浙ICP备2023052563号——
↑ Back to top