编辑格式之战:str_replace、apply_patch 与 hashline

自己搭过 Agent harness 的人,都在同一个地方摔过跤:模型的思路完全正确,落到改文件这一步却搞砸了——目标代码找不到、缩进错位、或者把文件里另一处长得像的代码块给改了。

我第一次遇到这个问题时的反应很典型:在提示词里加规则,「编辑时请确保精确匹配原文,注意缩进」。没用。第二反应是换个更强的模型。有点用,但贵。直到后来我意识到这根本不是模型问题,是接口设计问题——让模型用什么格式表达「我要改这段代码」,这个看起来微不足道的决定,直接决定编辑成功率和 token 账单。

正好最近 hashline 格式的测试数据出来了,把三种主流方案放一起讲讲,最后附一点我自己复测的结果。

str_replace:成也精确匹配,败也精确匹配

主流 coding agent 的默认格式。模型输出一段 old_string 和一段 new_string,harness 精确匹配前者、替换为后者,要求 old_string 在文件中唯一。

优点是概念简单、模型零学习成本。缺点全部长在「精确匹配」上:

模型必须逐字复现原文——每个空格、每个缩进、每个全角半角。人都做不好这件事,让一个概率模型做,长代码块出错是必然。我们的日志里,str_replace 的失败案例里七成以上是「复现原文时抄错了」,其中中文注释和混合缩进是重灾区。

唯一性约束又逼着模型带上大量上下文行来消歧——输出 token 跟着膨胀。而输出 token 是账单里最贵的部分。

还有个隐蔽的坑:文件在读取后被改过(另一个会话、另一个工具、甚至人手动改的),旧的 old_string 可能仍然能在新文件里匹配到——只是位置和语义已经不对了。这种「成功执行了错误编辑」的静默事故,比失败可怕得多。

apply_patch:理论最优,实践头疼

另一派用类 diff 的补丁信封:输出一个近似 unified diff 的结构,上下文行、删除行、新增行。理论上 diff 是最紧凑的变更表达,这条路线看起来很正统。

实践里的尴尬在于它是自创方言而不是标准 diff。模型训练语料里的海量标准 diff 会「串味」——你要方言,它时不时给你来两句普通话,harness 端就得写一堆容错解析去接「差一点就对」的输出。我实测的体感:同一个模型,apply_patch 的格式错误率明显高于 str_replace;换个模型,遵循率还会剧烈波动。兼容性是三者里最差的。

hashline:换个思路,别让模型抄书

hashline 是 can_boluk(游戏安全出身,oh-my-pi 的维护者)发明的格式,思路和前两者完全不同:既然模型抄原文容易抄错,那就别让它抄

模型读文件时,每行附一个 2-3 字符的内容哈希:

11:a3|function hello() {
22:f1|   return "world";
33:0e|}

编辑时只引用标签:「把 22:f1 那行换成 …」。harness 校验哈希——文件被改过,哈希对不上,直接拒绝执行。

一个设计吃掉三只鸟:不用复现原文(最大的错误源被结构性删除);输出大幅变短(有模型实测输出 token 降了 61%);并发安全白送(哈希校验就是乐观锁,str_replace 那种「匹配到过期内容还执行成功」的事故在这里不可能发生)。

公开测试的规模够说话:16 个模型 × 3 种格式 × 每种 540 个编辑任务,hashline 几乎在所有模型上追平或超过 str_replace。最值得玩味的是分布:弱模型获益最大。想想也对——强模型抄书也能抄对,最需要「免抄豁免」的就是弱模型。

我自己的复测

公开数据再漂亮也得用自己的负载验一遍。我从我们真实任务里抽了 80 个编辑场景(Go 为主,混一些 PHP 和 SQL,特点是中文注释多、有几个超长文件),在自建 harness 上跑了 str_replace 和 hashline 的对比(apply_patch 解析器没写利索,弃权):

  • 编辑成功率:str_replace 87%,hashline 96%。hashline 挂掉的三例都是同一个原因——模型引用了错误的行号标签,属于「想错了」而不是「表达错了」。
  • 输出 token:hashline 平均省 40% 出头,没到公开数据的 61%,估计和任务构成有关(我们的平均编辑块比较小)。
  • 中文注释重灾区:str_replace 在这批 case 上成功率掉到 70% 台,hashline 无感——它根本不需要复现那些注释。

结论:至少对我们的负载,切换是值得的。如果你也自建 harness,这个实验半天就能跑完,建议做。

想自己接 hashline 的几个提醒

如果复测结果说服了你,实现层面有几个我趟过的细节,写下来省你半天:

哈希和行号要一起校验,且编辑后全部重发。一次编辑成功后,后续行的行号全变了,旧标签全部作废。我的第一版实现偷懒没让模型重新读文件,它拿着旧标签继续编辑,全被拒绝,来回空转。正确做法是编辑工具的返回值里直接带上受影响区域的新标签,省一次重读。

2-3 字符的哈希会撞。短哈希只在「行号 + 哈希」联合校验下才可靠——同一行号上撞哈希的概率可以忽略,但别只用哈希做全文件定位。我见过一个实现拿哈希当全局唯一 ID 用,重复行(比如多个 })立刻出事。

给拒绝一个好的错误信息。哈希失配被拒时,返回「该行内容已变化,请重新读取 N 行附近区域」比返回「edit rejected」有天壤之别——前者模型一步就恢复,后者它会开始瞎猜。harness 的错误信息是写给模型看的文档,这个意识很多实现没有。

保留 str_replace 做降级通道。个别场景(新建文件、整段追加)hashline 没有优势,两个工具并存、在描述里写清各自适用场景,让模型自己选,实测选得挺准。

这件事背后的普遍规律

编辑格式之争我最后沉淀下来一条 harness 设计原则,适用范围远超这个具体问题:

模型的失败模式,用机制去消除,不要用提示词去劝阻。

str_replace 的思路是「请你抄仔细点」——对抗概率;hashline 的思路是「你根本不用抄」——改变结构。前者永远有失败率,后者把失败的可能性从物理上删掉了。同样的分野到处都是:与其在提示词里写「不要编造 API」,不如给它一个能查真实 API 的工具;与其叮嘱「别改错文件」,不如用权限把可写范围圈死。

每次你想往系统提示词里加一条「请注意…」的时候,都值得先停三秒问问:这个失败模式,能不能用机制直接干掉?能的话,那条提示词就不用写了——而且机制不占指令预算,不用戒断,不随模型换代贬值。