Edit 工具的 bench:我们怎么把「-97% 的优化」杀掉 edit tool bench Agent EditTool Harness Benchmark AgentEngineering AI工程
4259 字
21 分钟

Edit 工具的 bench:我们怎么把「-97% 的优化」杀掉

先说结论#

Edit 工具是 harness 里最小的一格:差不多就是「拿 oldText 找位置、换上新内容」几百行代码。但它正好卡在模型和环境之间——模型负责知道要改什么,它负责让这件事真的改对。

我们给它搭了三层 bench,把四个 harness 放进同一批 case 里对撞,最后拿到的最有价值的产出不是某个优化点,而是对自己的三次证伪

  1. 我们排在第一位、反事实重放里「修复 97% 失败」的改动(行首缩进归一化)——不可复现,降级。 那 97% 集中在单一 case,且 live A/B 里 base 三次全过。反事实重放的收益不等于生产收益。
  2. 长块拆分引导——零行为改变。 描述里写「长块建议拆小」,模型照旧整体替换,一次通过。模型根本不需要这句话。
  3. found-0 精确原文 hint、found-N 命中点上下文——无差异。 模型本来就能在一次重试内自愈。

留下的反而是几件不起眼的东西:bench 本身作为回归设施、批量逐条诊断、以及四家 harness 对撞出来的一个真问题——落盘保真

外部有篇很值得读的文章 The Harness Problem(Stencil,2026-02)讲的是同一件事的另一面:他们只换了 edit 格式(hashline),16 个模型平均 +15 分,最强的单点提升是 Grok Code Fast 1 从 6.7% 到 68.3%,输出 token 最多降 61%,训练成本为零。里面那句话我很认同:

The model is the moat. The harness is the bridge.

这篇不重复他们的结论,讲我们自己怎么量、量出了什么、以及为什么「看起来很有道理」的优化基本都不成立。

为什么 edit 工具值得单独做 bench#

先说外部现状,省得觉得我们小题大做。

  • Codex 用 apply_patch:让模型吐一个 OpenAI 风格的 diff 字符串。给 GPT 系用没问题,给别的模型就是灾难——Stencil 的实测里 Grok 4 的 patch 失败率 50.7%,GLM-4.7 是 46.2%。不是模型不会改代码,是它们不说这门语言。
  • Claude Code(和大多数 agent)用 str_replace:找精确旧文本、换新文本。模型必须逐字节复述旧内容,包括空格和缩进。「String to replace not found」多到有自己的 issue 合集。
  • Cursor 直接训了一个 70B 的合并模型:专门负责把草稿编辑正确地落进文件。harness 问题难到一家融资最多的公司选择再上一个模型。
  • Aider 自己的 benchmark:同一模型只换格式,GPT-4 Turbo 从 26% 到 59%;GPT-3.5 同样的格式只有 19%,因为它写不出合法 diff。格式和模型一样重要。
  • JetBrains 的 Diff-XYZ 系统性确认:没有一种 edit 格式在所有模型和场景下占优。

所以这里没有共识,只有取舍。我们的取舍是:不靠猜,把「编辑失败」变成可测量、可回归、能证伪的对象。

bench 怎么搭#

三层,共用同一套 case 和证据格式。从便宜到贵:

L0 离线变体 纯 JS matcher / 匹配器对撞 零 LLM 成本,秒级
L1 工具层矩阵 直连 sandbox 的 fs.edit RPC 零 LLM 成本,看真实工具行为
L2 agent bench 真 harness、真模型、新会话 有成本,测端到端

L0 / L1:零成本的先筛#

L1 的做法是直接连 sandbox 的 fs.edit RPC(agent 用的同一条链路),把 golden 片段的 oldText 做 158 条变形:行尾空白、CRLF/LF、行首缩进、空格↔tab、反斜杠、折叠空行、截断、删首行……逐条看真实工具是报错,还是静默改错

这层不用一个 token,改匹配器前后都能跑,所以我们把它当成了硬回归。

L2:真跑,但要控制有效性#

每个 case 起一个全新 session,被测 agent 只能看到 runs/<runId>/<case>/<model>/r<n>/work/,prompt 里只给任务描述。证据不依赖模型自述,直接从 session 的 jsonl 里抓每次 edit 的 oldText 原文和 toolResult。

这层最贵的不是钱,是有效性。我们踩了两个坑,都写出来:

  1. AGENTS.md 污染。 第一版 workdir 放在 bench 目录里,被测 agent 读到仓库的 AGENTS.md(里面有「证据不手改」之类规则),直接在两个 case 上拒绝执行改动。这版数据作废。修正:workdir 挪到 /tmp/projects/...、跑时临时隐藏 AGENTS.md、结果里加 leakDetected 标记。
  2. 反事实 ≠ 验证。 拿历史调用重放新旧匹配器,只能证明「匹配器能匹配」,不能证明「模型会因此做对事」。我们有 105 次失败调用、307 次总量的反事实重放,后来被 live A/B 打脸——后面细说。

case 的设计原则#

第一批 13 个,后来加到 20 个。比起「极限长度」,我们更想要确定性失败

  • 长度:单行 2745 字符、60 行 3230 字符函数、250 行文件 + 10 轮 churn
  • 缩进/行尾:CRLF/LF 混合、tab 多行、行尾空格
  • 重复:20 个重复函数体、repeat-all
  • 上下文:凭记忆改(禁止重读)、外部改名后的陈旧读、贴错片段
  • 批量:一次 10 个 edit

grader 是逐字节比对(格式化后),并且有一条铁律:不允许「静默写错」——工具可以报错、模型可以重试,但不能在没有信号的情况下把内容改坏。

首轮数据:失败不长你以为的样子#

L1:最危险的不是报错#

102 次编辑、13 个 case,把 golden oldText 做变形,看真实工具的反应:

oldText 变形RPC 报错率内容正确率
exact / CRLF↔LF / 行尾空白0%100%
行首缩进 +1、空格↔tab、反斜杠、折叠空行100%0%
截断尾部 / 删最后一行0%0%
删首行 / 只留中间行20%0%

第三行是重点:oldText 是目标区域子串时(模型漏行、截断),工具照常成功返回,只改了一部分——不报错、不进失败统计。

行首缩进的硬失败也值得注意:工具的 fuzzy 只归一化行尾空白和 CRLF,行首缩进完全不容错。而我们自己的生产数据里,空白/缩进类问题占了约 27%。

L2:97% 的失败是「复述不准」#

全部 cohub runs 汇总(199 份结果、307 次 edit 调用):

  • 105 次失败里,102 次是 not_found(ambiguous 2、noop 1)。
  • 这些 not_found 里,86% 的 oldText 超过 200 字符,91% 是多行

也就是说:失败的大头不是模型不会改代码,是长多行块逐字符复述不准

长度本身还不是关键。我们后来加了 7 个更狠的 case(单行 2745 字符、60 行 3230 字符函数、20 个重复函数体、CRLF 混合、一次 10 个 edit)——4 个模型全部 0 失败

打穿模型的不是「长度」,是「多行块 + 缩进结构」。

还有一个反差:多 edit 调用反而比单 edit 更不容易失败(20.9% vs 37.8%,当然这里有混杂——模型通常只在改动简单时才批量)。

四种 harness 对撞:空白漂移的四种死法#

同一批 case,跑四个 harness。先看模型维度(各模型用自己家的 harness)——cohub 和 pi 是我们自己维护的,Claude Code、Codex 是外部对照:

harnesstaskstask passedit 失败率去掉长块 case 后
cohub156153/15639.7%6.1%
pi5252/5211.9%
Claude Code5349/5336.0%7.7%
Codex CLI5346/536.7%

聚合失败率非常骗人:cohub 的 39.7% 里,94 次来自 claude 在一个 caselarge-multiline,604 字符 oldText)上的重试循环。去掉它,claude 是 0/37。

那个 case 长这样:claude 在 r2 里试了 67 次全部 found 0 后放弃;r3 把编辑拆成 94/101/86 字符的小片段 + 7 次 bash 才通过,单轮烧了 739k tokens。

统一模型再看一次(四家都换 deepseek-v4-flash):

harnesstaskstask pass工具层失败率
cohub4646/464.2%
pi4038/404.8%
Claude Code40(33 有效)30/334.4%
Codex4040/403.3%

换成 deepseek 之后,工具层失败率全部收敛到 35%——之前的 3070% 主要是「模型在长块上复述不准」。差异转移到了 task 层,而那里唯一的真失败是零报错的静默空白损坏

  • mixed-line-endings:pi、Claude、Codex 都会把混合 CRLF/LF 统一成单一 EOL
  • whitespace-crlf:Claude、Codex 会丢行尾空格;Codex 还会把 tab+CRLF 变成空格+LF
  • stale-pasted-snippet:Codex 把 tab 缩进静默改成 4 空格

这四家坏的方式各不相同,但都指向同一条轴线:落盘保真(write-back fidelity)——不只「能不能匹配上」,还有「改动区外、行尾、EOL 是否原样保留」。

  • cohub 是区域替换,天然保真,在上面这些 case 上是唯一全过的。
  • pi / Claude Code 会按主 EOL 重写整个文件,混合行尾必坏。
  • Codex 的 apply_patch 只校验上下文找不找得到,不校验新写入行的缩进/行尾风格——它不怕长块、不怕重复块,但用空白保真度换来的。

还有一类共同触发点:陈旧读。文件被外部改过、模型没重读就写 oldText,四家都会失败(Codex 报 apply_patch verification failed)。但四家都能重读自愈——所以这是「省重试成本」的优化,不是正确性问题。

三个被证伪的结论#

这部分是我们自我审计的过程。每一个改动都走同一条流水线:

  1. 反事实重放(历史调用过新旧 matcher)→ 筛出候选
  2. 在 pi 扩展里把内置 edit 换成候选实现(PI_BENCH_MATCHER=base|variant)→ 真模型行为 A/B
  3. 逐字节 grader + 不允许静默写错 → 决定发不发

① 「行首缩进归一化」:反事实 -97%,live 不可复现#

候选来自一个很具体的观察:把 claude 在 large-multiline 上的 74 条真实失败 oldText 全部回放分析——74/74 都只差行首缩进(嵌套块整体少缩进两格),字符内容完全正确。工具的 fuzzy 只归一化行尾空白,所以全部拒绝。

反事实重放看起来很硬:

指标现状候选
失败的 edit 调用105/3073/307(-97.1%)
对成功调用的回归0/147
合成语料 content-correct25.9%60.1%
claude 74 条失败 oldText 命中0/7474/74

然后审计来了。三处虚高:

指标我们最初说的审计后
修复的失败调用102/10593/96(9 个是重放伪修复)
修复的构成广义93 个全部来自 claude + large-multiline 这一个 case;其他模型的真实失败修复率 ≈ 0
修复的内容质量未说0/90 逐字节一致;86/90 只是空白/缩进差异(内容对、模型自己的 newText 缩进有错)

最后上了一记 live A/B(pi 扩展,claude × large-multiline × 3 repeats):

变体task pass
base3/3
候选3/3

base 在 pi 上根本复现不出失败。 同一个模型、同一个 case,昨天在 cohub 里 94/97 失败,今天两种 harness 都一次过。

结论:这类失败是模型/环境相关的、当前不可复现的。它对应的改动从「看起来 -97%」降级成「针对一个明确子类、0 回归、收益待 canary 验证」——我们没让它进主线。

② 长块拆分引导:零行为改变#

动机很顺:claude 94 次失败都在长块,那就引导模型把大替换拆成小 edit。

行为 A/B(base vs split,claude ×3):两组都是 1 次调用、1 个 edit、oldText 21 行,全部通过。 描述里写「长块请拆小」完全没用——任务说「整体替换」,模型照做。

要改变行为只能硬拒绝长 oldText,但那会拒绝本来成功的编辑。收益未证明,不做。

③ found-0 hint / found-N 上下文:无差异#

  • 精确原文 hint:claude 的 base 和 hint 数字完全一样(都是 2 调用、1 失败后自愈);deepseek 唯一的可测收益是偶尔省一次 read
  • found-N 命中点上下文:base 6/6 vs foundn 6/6,无收益,反而多花调用。

模型本来就能在一次重试内自愈(自己把缩进补对,或重读文件)。工具塞给它的信息,它并不需要。

顺带还否掉了一个方向:静默接受缩进差异#

有人提过「干脆宽容一点,别拒绝缩进差异」。我们量了 claude 的 74 条失败 oldText:

检查结果
缩进偏差是统一偏移(可整体平移还原)0/74
newText 的缩进偏差是统一的0/74
能靠「反向平移 newText」修到正确0/74

模型的缩进漂移是逐行随机的,工具无法从旧文本反推出正确缩进。宽容接受 = 把逐行错缩进的代码静默写进去(86/90 是「内容对、缩进错、没有任何信号」)。

所以这条改动最后的形态是:匹配做宽容是为了识别出这是缩进问题,然后把该区域的精确原文交回给模型——不是替模型写。

对撞外部格式:fuzzy 和 patch 的两次败仗#

既然外部有 hashline / patch / fuzzy 这些路线,我们干脆把它们做成同类扩展,在自己环境里正面对撞。20 case × 4 模型:

变体task pass调用数失败构成
cohub 匹配器80/8083
opencode 式渐进 fuzzy74/801096 个落盘保真 + 10 次 schema 摩擦
Codex apply_patch 移植版71/8031/39 是解析错误,主要是 grok

几个具体发现:

  • fuzzy 确实吃掉了那个确定性缩进陷阱(1 调用 0 失败,base 要 2 调用 1 失败)——但在完整矩阵里输了:它赢了匹配,输在落盘(混合行尾被统一、行尾空格被剥掉)。
  • patch 的失败几乎全是模型格式先验:grok 把标记写成 *** Begin Patch ***(多了一个尾巴),严格解析器直接拒绝。格式不是「能不能解析」的问题,是「模型愿不愿意说这门语言」的问题。
  • 还有一个隐形成本:schema 摩擦。模型按 pi/cohub 的风格传参被拒 10 次——换 schema 的代价不只在匹配层。

最后留下了什么#

被证伪的很多,真正留下的只有五件:

  1. bench 本身 = 回归设施。 L1 的 158 条变形 + 真实流量重放 + 变体对撞,改 edit 前后各跑一次,全免费、秒级。这是这次投入里最保值的部分。
  2. 批量逐条诊断。 唯一有确定性机制支撑的候选:多 edit 调用失败批次里,44% 带着至少一条本来能用的 edit 一起被丢弃;浪费率 2.3%。做法是写盘保持原子,但返回「哪条坏、其余可用」,让模型只重发坏的那条。
  3. 落盘保真。 四家对撞出来的真问题。匹配错是看得见的失败,落盘错是看不见的失败——而看不见的那种更难被用户接受。
  4. read-first 软强制 / 陈旧预检。 fs.write 有 size/mtime 预检,edit 没有。加上的收益是省重试成本(四家都自愈,不修正确性)。
  5. 口径。 别用聚合失败率(一个 case 能主导全部数字);别用「占失败的比例」(要算发生率);指标应该是:task success、首次成功率、成功所需调用数、重发字符数,以及 found-0 的细分占比

方法沉淀#

如果你也在做 agent 产品,这是我觉得最可复用的几条:

先量,再改。 我们第一步是把生产里的失败样本变成 case,而不是先读别人的优化方案。你流量里哪种错占主导,决定了你该补哪一格。

反事实重放只能筛,不能定。 真正决定成本/收益的判断必须来自 live 行为 A/B(我们用的是「替换内置 edit 的 pi 扩展 + 逐字节 grader」)。

每个改动过三关才算「有意义」:

标准含义
契约明确模型该做什么是清楚的(读到最新 → 抄准确 → 小步编辑 → 失败照原文重试)
信息给到工具把模型缺的信息(精确原文、哪条 edit 坏了、文件变过)放进结果里
行为验证真模型 A/B 确认它真的照做,且不允许出现静默写错

「实现方式暴露的是它假设模型会怎么错」——这句话我在对着四个 harness 的代码时反复想起:

  • Codex 用 patch,是赌模型能力(格式先验已经训进模型里)
  • Claude Code 死守精确匹配 + 强制先读,是赌防幻觉/防误删,用重试换安全
  • pi / cohub 做 fuzzy + hint,是赌「模型记得内容、但格式会漂」
  • 上 hash 预检 / 回滚的,是赌「文件状态会在并发里漂」

四个假设没有对错,取决于你的流量里哪种错占主导。这正是 bench 的意义。

最后#

这次最大的收获,是三个我们自己提出、自认为很有道理的优化,被我们自己的设施杀掉了。如果只看反事实重放,第一个改动会带着「-97%」的标题进主线,然后在生产里既救不了什么、又背着无人验证的风险。

Edit 工具只有几百行,但它接住的是模型和环境之间最容易漏的那部分。给它造一套能证伪的设施,比给它加一个聪明功能更值。

参考资料#

Edit 工具的 bench:我们怎么把「-97% 的优化」杀掉
https://bangwu.me/posts/edit-tool-bench/
作者
棒无
发布于
2026-09-14
许可协议
CC BY-NC-SA 4.0