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_string | 48% | 删除时构造精确 old_string 太难 |
| diff hunk | 56% | 统一范式,删除天然优势 |
但这是浅层数字。深层发现更关键:
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 层渐进匹配:
- 精确:逐字节比对
- rstrip:忽略行尾空白
- trim:忽略首尾空白
- 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 e8b2e60、a2fd3e8)。
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 工具设计有两个核心原则:
- 用 LLM 训练数据中已有的格式。 unified diff 是 LLM 在预训练中见过无数次的东西。不要发明自定义 DSL——不管它多精确。
- 引擎要宽容,诊断要精确。 模型会犯错。4 层匹配容错,三层诊断指引修复。比"拒绝并报错"好一个数量级。
我们和 OhMyPi 走了不同的路:它保留多模式给模型选择权,我们砍掉多模式替模型做选择。两种哲学没有对错——但 50+ 次 eval 的数据让我们确信:在 LLM 的世界里,熟悉度比精确度更重要。
代码和 eval 数据集都在 github.com/Menfre01/waveloom 和 github.com/can1357/oh-my-pi,Apache 2.0 开源。