Edit 工具的 bench:我们怎么把「-97% 的优化」杀掉
先说结论
Edit 工具是 harness 里最小的一格:差不多就是「拿 oldText 找位置、换上新内容」几百行代码。但它正好卡在模型和环境之间——模型负责知道要改什么,它负责让这件事真的改对。
我们给它搭了三层 bench,把四个 harness 放进同一批 case 里对撞,最后拿到的最有价值的产出不是某个优化点,而是对自己的三次证伪:
- 我们排在第一位、反事实重放里「修复 97% 失败」的改动(行首缩进归一化)——不可复现,降级。 那 97% 集中在单一 case,且 live A/B 里 base 三次全过。反事实重放的收益不等于生产收益。
- 长块拆分引导——零行为改变。 描述里写「长块建议拆小」,模型照旧整体替换,一次通过。模型根本不需要这句话。
- 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。
这层最贵的不是钱,是有效性。我们踩了两个坑,都写出来:
- AGENTS.md 污染。 第一版 workdir 放在 bench 目录里,被测 agent 读到仓库的
AGENTS.md(里面有「证据不手改」之类规则),直接在两个 case 上拒绝执行改动。这版数据作废。修正:workdir 挪到/tmp/projects/...、跑时临时隐藏 AGENTS.md、结果里加leakDetected标记。 - 反事实 ≠ 验证。 拿历史调用重放新旧匹配器,只能证明「匹配器能匹配」,不能证明「模型会因此做对事」。我们有 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 是外部对照:
| harness | tasks | task pass | edit 失败率 | 去掉长块 case 后 |
|---|---|---|---|---|
| cohub | 156 | 153/156 | 39.7% | 6.1% |
| pi | 52 | 52/52 | 11.9% | — |
| Claude Code | 53 | 49/53 | 36.0% | 7.7% |
| Codex CLI | 53 | 46/53 | 6.7% | — |
聚合失败率非常骗人:cohub 的 39.7% 里,94 次来自 claude 在一个 case(large-multiline,604 字符 oldText)上的重试循环。去掉它,claude 是 0/37。
那个 case 长这样:claude 在 r2 里试了 67 次全部 found 0 后放弃;r3 把编辑拆成 94/101/86 字符的小片段 + 7 次 bash 才通过,单轮烧了 739k tokens。
统一模型再看一次(四家都换 deepseek-v4-flash):
| harness | tasks | task pass | 工具层失败率 |
|---|---|---|---|
| cohub | 46 | 46/46 | 4.2% |
| pi | 40 | 38/40 | 4.8% |
| Claude Code | 40(33 有效) | 30/33 | 4.4% |
| Codex | 40 | 40/40 | 3.3% |
换成 deepseek 之后,工具层失败率全部收敛到 35%——之前的 3070% 主要是「模型在长块上复述不准」。差异转移到了 task 层,而那里唯一的真失败是零报错的静默空白损坏:
mixed-line-endings:pi、Claude、Codex 都会把混合 CRLF/LF 统一成单一 EOLwhitespace-crlf:Claude、Codex 会丢行尾空格;Codex 还会把tab+CRLF变成空格+LFstale-pasted-snippet:Codex 把 tab 缩进静默改成 4 空格
这四家坏的方式各不相同,但都指向同一条轴线:落盘保真(write-back fidelity)——不只「能不能匹配上」,还有「改动区外、行尾、EOL 是否原样保留」。
- cohub 是区域替换,天然保真,在上面这些 case 上是唯一全过的。
- pi / Claude Code 会按主 EOL 重写整个文件,混合行尾必坏。
- Codex 的 apply_patch 只校验上下文找不找得到,不校验新写入行的缩进/行尾风格——它不怕长块、不怕重复块,但用空白保真度换来的。
还有一类共同触发点:陈旧读。文件被外部改过、模型没重读就写 oldText,四家都会失败(Codex 报 apply_patch verification failed)。但四家都能重读自愈——所以这是「省重试成本」的优化,不是正确性问题。
三个被证伪的结论
这部分是我们自我审计的过程。每一个改动都走同一条流水线:
- 反事实重放(历史调用过新旧 matcher)→ 筛出候选
- 在 pi 扩展里把内置 edit 换成候选实现(
PI_BENCH_MATCHER=base|variant)→ 真模型行为 A/B - 逐字节 grader + 不允许静默写错 → 决定发不发
① 「行首缩进归一化」:反事实 -97%,live 不可复现
候选来自一个很具体的观察:把 claude 在 large-multiline 上的 74 条真实失败 oldText 全部回放分析——74/74 都只差行首缩进(嵌套块整体少缩进两格),字符内容完全正确。工具的 fuzzy 只归一化行尾空白,所以全部拒绝。
反事实重放看起来很硬:
| 指标 | 现状 | 候选 |
|---|---|---|
| 失败的 edit 调用 | 105/307 | 3/307(-97.1%) |
| 对成功调用的回归 | — | 0/147 |
| 合成语料 content-correct | 25.9% | 60.1% |
| claude 74 条失败 oldText 命中 | 0/74 | 74/74 |
然后审计来了。三处虚高:
| 指标 | 我们最初说的 | 审计后 |
|---|---|---|
| 修复的失败调用 | 102/105 | 93/96(9 个是重放伪修复) |
| 修复的构成 | 广义 | 93 个全部来自 claude + large-multiline 这一个 case;其他模型的真实失败修复率 ≈ 0 |
| 修复的内容质量 | 未说 | 0/90 逐字节一致;86/90 只是空白/缩进差异(内容对、模型自己的 newText 缩进有错) |
最后上了一记 live A/B(pi 扩展,claude × large-multiline × 3 repeats):
| 变体 | task pass |
|---|---|
| base | 3/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/80 | 83 | — |
| opencode 式渐进 fuzzy | 74/80 | 109 | 6 个落盘保真 + 10 次 schema 摩擦 |
| Codex apply_patch 移植版 | 71/80 | — | 31/39 是解析错误,主要是 grok |
几个具体发现:
- fuzzy 确实吃掉了那个确定性缩进陷阱(1 调用 0 失败,base 要 2 调用 1 失败)——但在完整矩阵里输了:它赢了匹配,输在落盘(混合行尾被统一、行尾空格被剥掉)。
- patch 的失败几乎全是模型格式先验:grok 把标记写成
*** Begin Patch ***(多了一个尾巴),严格解析器直接拒绝。格式不是「能不能解析」的问题,是「模型愿不愿意说这门语言」的问题。 - 还有一个隐形成本:schema 摩擦。模型按 pi/cohub 的风格传参被拒 10 次——换 schema 的代价不只在匹配层。
最后留下了什么
被证伪的很多,真正留下的只有五件:
- bench 本身 = 回归设施。 L1 的 158 条变形 + 真实流量重放 + 变体对撞,改 edit 前后各跑一次,全免费、秒级。这是这次投入里最保值的部分。
- 批量逐条诊断。 唯一有确定性机制支撑的候选:多 edit 调用失败批次里,44% 带着至少一条本来能用的 edit 一起被丢弃;浪费率 2.3%。做法是写盘保持原子,但返回「哪条坏、其余可用」,让模型只重发坏的那条。
- 落盘保真。 四家对撞出来的真问题。匹配错是看得见的失败,落盘错是看不见的失败——而看不见的那种更难被用户接受。
- read-first 软强制 / 陈旧预检。
fs.write有 size/mtime 预检,edit 没有。加上的收益是省重试成本(四家都自愈,不修正确性)。 - 口径。 别用聚合失败率(一个 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 工具只有几百行,但它接住的是模型和环境之间最容易漏的那部分。给它造一套能证伪的设施,比给它加一个聪明功能更值。
参考资料
- The Harness Problem — Stencil(hashline、16 模型 edit 格式对比)
- Aider:Editing formats benchmark(格式对模型表现的直接影响)
- OpenAI Codex apply_patch(
seek_sequence四级容错语义) - 我们自己的 bench 报告、case 与逐条证据(在内部 space
edit bench里)