Coding Agent 的 Edit 工具实现:三种范式与一个答案

Coding Agent 的核心能力是修改代码。但"修改代码"这个看似简单的操作,不同 Agent 的实现方式截然不同。我们在 50+ 次 eval 实验中对比了 Claude Code、OpenAI Codex 和 OhMyPi 的方案,最终找到了自己的答案。

四种方案:Claude Code、Codex、OhMyPi、以及我们的初版

先看市面上四个有代表性的方案:

Claude Code — old_string / new_string 内容匹配

模型传一个 old_string(文件里精确存在的文本片段),引擎找到它,替换为 new_string。删除就传空字符串。简单直观,但容错仅一层(弯引号↔直引号归一化)。

OpenAI Codex — unified diff hunk

模型传标准 unified diff 格式(@@ 头、空格上下文、- 删除、+ 新增)。引擎用 4 层渐进匹配(精确 → rstrip → trim → unicode 归一化)定位并应用。替换、删除、插入在同一个范式下。

OhMyPi — hashline + 多模式并存

OhMyPi(20k+ stars,目前最受关注的终端 Coding Agent 之一)的 edit 工具支持三种模式:hashline(默认)、patch(unified diff)、replace(内容匹配)。hashline 用内容哈希 TAG + 行号定位,有 boundary repair、SWAP.BLK block 操作、流式 diff 预览等高级特性。三种模式并存,模型可以自己选。

Waveloom(初版)— 自研 hashline

我们最初也自研了 hashline:TAG(内容哈希)+ 行号 + %OLD/%NEW sentinel。读文件拿 TAG,edit 带 TAG 证明"我读过且文件没被改"。语法是自定义 DSL:SWAP、DEL、INS.PRE、INS.POST、INS.HEAD、INS.TAIL。有 297 行 prompt 和 11 种常见错误枚举——和 OhMyPi 的 hashline 思路如出一辙。

实验:到底哪种更好?

我们建了一套 eval 体系——7 个真实编码任务,每个跑 5 轮,deepseek-v4-pro 驱动,byte-level 与 gold 文件比对。

范式通过率核心问题
hashline (TAG + 行号)38%297 行自定义语法,模型学不会
old_string/new_string48%删除时构造精确 old_string 太难
diff hunk56%统一范式,删除天然优势

但这是浅层数字。深层发现更关键:

hashline 的最佳场景不可替代。 rename-function 任务中,单行号 SWAP 做到 90%。内容匹配做不到——函数名可能在注释、字符串、调用处多次出现,行号定位天然无歧义。问题是这 90% 需要模型学会 297 行的自定义语法,而模型 90% 的时间不用它。OhMyPi 的 hashline 也同样面临这个矛盾:精确但不被模型偏爱。

内容匹配的致命伤是删除。 delete-function 任务中,模型要给出"要删的精确文本"。人类觉得"删函数和调用"是两步,但模型需要把 5 行精确文本放进 old_string——它总是多一行或少一行。内容匹配删除的通过率只有 20%。

diff hunk 删除天然正确。-func oldHelper... 比精确复制 5 行文本容易得多。hunk 格式下 delete-function 跳到 80%。而且 hunk 失败后模型会自我纠正——re-read 文件、调整上下文行、重试。这是 old_string 模式下从未发生过的。

第三个实验:给模型选,它会选什么?

我们把三种范式同时注册,让模型自己选。结果:模型 90% 选 old_string/new_string。

不是因为它最好——是因为 system prompt 里 297 行都在教 hashline 和内容匹配,diff hunk 只有 35 行。模型选的是最熟悉的,不是最好的。 这也解释了为什么 OhMyPi 三种模式并存时,模型几乎从不选 patch 模式——不是 patch 不好,是另外两种在 prompt 里占了更多篇幅。

于是我们做了一个极端实验:只给 diff hunk,砍掉其他两种。模型被迫用 @@/-/+/空格。结果:从 20%(初次接触)涨到 66%(适应后),prompt 优化后到 74%,readState 防冲突接入后到 86%。

这就是我们和 OhMyPi 的分岔路口。OhMyPi 选择了保留三种模式,让模型有选择权;我们选择了只保留一种,替模型做选择。两种策略没有对错——但我们的数据表明,对 LLM 来说,格式的熟悉度比精确度更重要。 给模型选择权,它选的是 prompt 里篇幅最大的那个,而不是效果最好的那个。

最终方案:diff hunk 统一范式

我们最终的 edit 工具只有一个接口:

edit(file_path, hunk)

hunk 格式就是标准 unified diff:

@@ func greet
 func greet() string {
-    return "hello"
+    return "hello, " + name
 }

三个操作统一:

  • 替换- 行 + +
  • 删除:只有 -
  • 插入:只有 +

多 hunk 协调(同文件多处修改):

@@ func greet
-    return "hello"
+    return "hello, " + name

@@ func main
-    msg := greet()
+    msg := greet("world")

跨文件(*** Update File: 头):

*** Update File: main.go
@@ ...

*** Update File: helper.go
@@ ...

引擎设计:4 层匹配 + 三层诊断

模型给出的上下文行可能有微小偏差(多一个空格、少一个 tab)。引擎不是直接拒绝,而是 4 层渐进匹配:

  1. 精确:逐字节比对
  2. rstrip:忽略行尾空白
  3. trim:忽略首尾空白
  4. unicode 归一化:弯引号→直引号、全角空格→半角

匹配失败时,不是丢一句 “not found”——三层诊断:

✗ helper.go: @@ func helper — not found
  pattern:        ← 模型给了什么
  file content:   ← 文件实际有什么
  closest match at line 4 (distance=2):  ← Levenshtein 最近的候选
  →  Line 4: func helper(x int) int {
           ^^^^^^                        ← 字符级 diff
  hint: check indentation whitespace     ← 明确的修复方向

模型看到"差在哪"就知道怎么改。

readState:比 TAG 更轻量的冲突检测

hashline 时代,TAG 承担双重职责:证明"我读过文件" + 检测"文件没被外部修改"。但代价是模型必须理解 TAG 概念,edit 时必须带上正确的 4 位 hex。

我们用更轻量的 readState 替代:

read 时:系统记录 {path, content, mtime}
edit 时:系统自动校验 — 读过没?mtime 变了?内容真变了?

模型全程无感知。对模型来说,edit 只需要 file_path + hunk。TAG 概念从 system prompt 中彻底消失。

代码量:从 1700 到 1150

之前:edit_file.go (118行) + hashline patcher (500行) + 内容匹配引擎 (1100行)
      模型需学 3 种范式 + 297 行 prompt + 11 种常见错误

之后:edit_file.go (274行) + apply_hunk.go (789行) + read_state.go (93行)
      模型只需学 1 种格式 + 201 行 prompt

删掉了 ~560 行业务代码,prompt 从 297 行砍到 201 行。不是删功能——是把三套等价的东西合并为一套更好的。

为什么不用 AST 或 Language Server

有人问:为什么不做语义编辑?比如"把 greet 的参数改成 name"直接改 AST?

两个原因。第一,Language Server 不等价于"知道怎么改"——它能告诉你函数签名在哪,但不能帮你构造正确的 diff hunk。第二,LLM 本身就是最好的语义理解引擎——它不需要 AST 来理解"添加参数"的含义。工具应该做 LLM 不擅长的事(精确匹配、冲突检测),让 LLM 做它擅长的事(理解意图、生成代码)。

经验总结:十条来自 100+ 次 commit 的教训

从 hashline 到 diff hunk,我们在 edit 工具上迭代了 100+ 次 commit。以下是刻在代码里的十条经验:

1. 不要发明自定义 DSL。 hashline 的 297 行自定义语法(SWAP、DEL、INS.PRE、INS.POST、INS.HEAD、INS.TAIL)精确到行号级别,但模型 90% 的时间不用它。unified diff 是 LLM 在预训练数据中见过无数次的格式——用已有的,别造新的。

2. 给模型选择权,它选的是 prompt 里篇幅最大的。 三种范式并存时模型 90% 选内容匹配——不是因为它最好,是因为 system prompt 里 297 行都在教 hashline 和内容匹配,diff hunk 只占了 35 行。模型的选择反映的是 prompt 占比,不是效果优劣。

3. 引擎要宽容,诊断要精确。 4 层渐进匹配(exact → rstrip → trim → unicode)给模型犯错空间,但失败时的诊断必须精确到字符级:Levenshtein 最近候选、逐行 diff、明确的 hint 方向。模型看到"差在哪"会自己纠正。

4. LLM 会绕过你的工具。 我们发现模型在 edit 失败后不重试,而是直接用 bash 重定向或 python 脚本修改文件。不得不在 system prompt 中新增 7b 规则(edit/write 是唯一文件修改工具)并在失败 footer 追加明确禁止回退提示(commit 2ff46f3)。

5. Unicode 归一化要覆盖全面。 最初只有基础归一化,模型输出的弯引号、全角符号、特殊空格仍然匹配失败。补齐 152 个 Unicode 标点映射后(commit aecc8dd),匹配率才真正稳定。

6. 文件历史备份是低成本安全网。 edit 执行前自动 TrackEdit 备份原始文件(commit c2f2b1f),配合 rewind 功能,用户可以随时回退到编辑前状态。代价几乎为零,收益是零风险编辑。

7. read / write / edit 三工具必须语义一致。 路径解析必须统一为绝对路径,状态管理(readState / FileHistory)必须覆盖全部三个工具,prompt 中的交叉引用必须对齐。不一致会导致 LLM 困惑——比如 read 用绝对路径但 edit 用相对路径,跨 turn 的 readState 校验就会失败(commit e8b2e60a2fd3e8)。

8. 信封格式是跨文件编辑的最简方案。 *** Update File: 头(commit bcf43be)让一次 edit 调用修改多个文件成为可能,无需引入额外的 multi-edit 抽象。模型理解这个格式——它在代码审查和 patch 文件中见过无数次。

9. 冲突检测要对模型无感。 hashline 的 TAG 要求模型理解哈希概念、edit 时带上正确的 4 位 hex。readState 把这一切藏在引擎层:read 时记录 {path, content, mtime},edit 时自动校验。模型只需要传 file_path + hunk(commit 2519ebf)。

10. 砍掉比保留更难,但更正确。 删掉 hashline patcher(500 行)和内容匹配引擎(1100 行)意味着承认初版设计走了弯路。但 1900 行代码的消失换来了 86% 的通过率——不是删功能,是把三套等价的东西合并为一套更好的。

结论

Coding Agent 的 edit 工具设计有两个核心原则:

  1. 用 LLM 训练数据中已有的格式。 unified diff 是 LLM 在预训练中见过无数次的东西。不要发明自定义 DSL——不管它多精确。
  2. 引擎要宽容,诊断要精确。 模型会犯错。4 层匹配容错,三层诊断指引修复。比"拒绝并报错"好一个数量级。

我们和 OhMyPi 走了不同的路:它保留多模式给模型选择权,我们砍掉多模式替模型做选择。两种哲学没有对错——但 50+ 次 eval 的数据让我们确信:在 LLM 的世界里,熟悉度比精确度更重要。

代码和 eval 数据集都在 github.com/Menfre01/waveloomgithub.com/can1357/oh-my-pi,Apache 2.0 开源。