← Home

背诵原文才能编辑合理吗?

一次对抗训练数据的经历

如果有一个编辑器,要求你在修改代码时,确认旧范围里的每个字符,才能编辑,你觉得合理吗?

我觉得不合理。当我想重写一段代码时,我会按行按块删除旧内容,然后写新的代码。即使偶尔旧内容有参考价值,我也不会去记住它的每个字。

前段时间 Pi 社区反馈 Opus 也常调用 edit 失败,Pi 一个作者出来解释 Pi Edit Tool 原理,它的输入是:

{
  "path": "...",
  "edits": [
    {
      "oldText": "...",
      "newText": "..."
    }
  ]
}

我是有一些疑惑的,为什么这些优秀的 Coding Agent,包括 Claude Code、OpenCode 等,它们的 Edit Tool,都设计成 str_replace 的方式,要求 LLM 提供精确匹配的 oldText 呢?实际上这些 Coding Agent 的 Edit Tool 也做了空白符容错等模糊匹配,但 LLM 还是得记住几乎所有内容。 应该有更好的、不用背诵原文的方案才对。

从结果看,现在的 Edit Tool 对 LLM 足够好用了。我做了一些尝试才认识到这点。

编辑失败

DeepSeek v4 pro preview 刚出来时,说话有点人味,我喜欢跟它交互着改 GPT 写的文档和调整代码文件组织,只是它改代码时经常出现编辑错误。我以为大多是空白符错误,参考流行 harness 做空白符和缩进容错就好了。可调查后发现不少错误是过拟合了常见代码写法,例如实际代码是 ///| 注释,它经常会写 /// 作为 oldText。过拟合信号!

这可能是模型能力差,我想。到用上 Kimi 3 时,我发现它也会有编辑错误,有时它会把自己新代码里的东西当成旧代码已有的结构,例如这段旧代码只处理 markdown .md 文件:

const parser = (data: ParsedStatus): ParsedStatus => {
  // ...
  // 直接解析 markdown
  const tree: Ast.Root = unified()
    .use(markdown)
    .use(gfm)
    .use(frontmatter)
    .parse(modifiedValue) as Ast.Root;

  // ... 后面约 50 行

  return data;
};
export default parser;

它想增加 mdx 支持,一种 markdown 扩展格式,所以想加个 parseWith helper:

const parseWith = (value: string, mode: "plain" | "mdx"): Ast.Root => {
  const base = unified().use(markdown).use(gfm).use(frontmatter);
  return (mode === "mdx" ? base.use(mdx) : base).parse(value) as Ast.Root;
};

const createParser = // ... 里面跟之前的 parser 类似

const parser = createParser("plain");
export const mdxParser = createParser("mdx");

结果它把想要增加的 parseWith 放进 oldText 里了,就像人话说快了导致口误:

const parser = (data: ParsedStatus): ParsedStatus => {
  // ...
  // 原文件根本没有 parseWith
  const parseWith = (plugins: "mdx" | "plain"): Ast.Root => {
    // ...
  };

  // ...
};

而且现代模型会在思维链 CoT 里演练新代码要怎么写,假定没有完美的注意力机制,那我猜这些新旧状态甚至思维草稿会相互污染,就像这个例子一样。

统计本地 Pi sessions,失败大致三类:

  1. 缩进不对。例如少了一层缩进。
  2. 格式 (style) 不对。例如 prettier 格式化把函数调用参数列表换成多行了,还多了末尾逗号 ,,LLM 可能没记住。
  3. 混淆了旧代码(状态)和新代码(状态)。例如之前提到的 Kimi 3 例子,同时其他模型包括 GLM 5.3 flash 也犯了类似错误。

对比 Kimi 3 和 DeepSeek v4 flash:

模型编辑意图数pass@1pass@2
Kimi 331093.2%98.4%
DeepSeek v4 flash16393.9%96.3%

两个模型总/激活参数差一个数量级,pass@1 几乎一样,可能是工具本身有缺陷。

如果觉得 90%的成功率足够高,那我们换个角度,看错误是否难以避免。所有模型失败的长 oldText(超过 8 行)里,约一半(79/160)的开头签名行是对的,也就是说模型知道它要改哪里,只是它正文背错了。

业界方案

如果 str_replace 不够好,那业界有哪些其他方案呢?

  1. OpenAI Apply Patch。直接给出 diff 形式输入,包括原文和新内容。
@@ class Foo:
- def baz(self):
+ def rand(self):
  return f"foo {randint()}"

@@ def main():
  foo = Foo()
- print(foo.baz())
+ print(foo.rand())
  1. OMP Line Hashes。按行编辑,不写原文,用行号和几个字母 hash 引用原文,hash 是 read/grep 工具提供的。
1:a3|function hello() {
2:f1|  return "world";
3:0e|}
  1. OpenCode BlockAnchorReplacer。精确匹配失败后,改用首尾行精确匹配,中间内容用最短编辑距离容错匹配。

Apply Patch 对 LLM 要求高,不少模型厂商得专门后训练使用这种格式。 Line Hashes 又是 hack read 工具,又是强制 LLM 记住没有语义的 hash。 BlockAnchorReplacer 更像是模糊匹配容错。 有没有一个跟现有输入类似,也符合人类直觉的方案呢?例如重写一个 hello 函数时,我希望用:

function hello(...) {
  ...
}

表示我要重写这个函数(范围),然后输出新的函数内容:

function hello(w) {
  return "hello " + w;
}

结构化编辑

这个方法听上去好像是在编辑语法树(AST),类似 ast-grep,目前流行的 AST 搜索和编辑工具。但有区别,如果你想重写 if 分支内容,例如把下面调用 a 重写为调用 c,d:

if (true) {
  a();
} else {
  b();
}

期望用这个输入:

if (true) {
  $CODE;
}

匹配,重写新内容:

if (true) {
  c();
  d();
}

你会发现结果是:

if (true) {
  c();
  d();
}

else 分支被删除了。因为在语法树里,else 是 if 的子节点,严格来讲我们的输入不能匹配这整个 if-else 语法树,然后 ast-grep 支持智能模式,自动扩展匹配剩下的节点,这让我们用 if 能匹配到 if-else,但重写又用新内容替换掉整个节点。

这问题可以解决,我们把重写范围改成我们精确匹配的部分(if 节点)就行。所以我们绕过 ast-grep,使用它下面的基础库 treesitter,一个现代 parser 库(包括 lexer),支持输出 syntax tree 语法树。

工具实现的可行性解决了,来做实验看看效果吧。

实验

我们用 Pi 作为 Coding Agent,这样测试不同版本的编辑工具只需要加载不同的 extension 即可。 实验所有代码放在 GitHub 仓库 structural-edit-experiments。 模型默认用 DeepSeek v4 flash。

初始的工具版本是保持原生编辑工具的输入格式,支持 oldText 里用通配符并在 tool description 中说明,在原生精准匹配失败时 fallback 到语法树和通配符匹配。 编辑后记录 telemetry 监控信息,实验后分析 agent trace。

验证分两档:

  1. 简单的编辑单个文件的任务,快速验证可行性。
  2. 更贴近现实的 Coding Benchmark Task,验证改进效果,包括 token 效率、任务完成率和使用结构化编辑的比例。

LLM 会不会用通配符?

先确定一下 LLM 输入的匹配内容是什么。重点是匹配部分可以省略,重写内容仍要求 LLM 输出完整代码,所以我认为不需要像 ast-grep 给通配符名字,统一用省略号就行。但 ASCII ... 省略号在一些语言里有用,例如 Python 里是 ellipsis expression。另一个选择是用 Unicode 省略号 …,它不容易与代码混淆,但可能会增加输入的复杂性。

那做实验比较三种情况:

  1. 对照组 Pi 原生工具
  2. 用 ASCII 省略号 ... 作为通配符
  3. 用 Unicode 省略号 … 作为通配符

做简单编辑实验,3 个任务重复 3 次:

配置通过wildcard 使用
Pi native9/90
Unicode …9/90
ASCII ...9/90

Agent 完全没用通配符!

为了确认 Agent 有没有能力(而不是意愿)使用通配符,先把 task prompt 从“编辑文件”改成“使用通配符模式编辑文件”,3 个任务重复 5 次:

配置通过wildcard 使用平均 tokens
Unicode …15/15105,915
ASCII ...15/1585,975

两种省略号效果类似,没有出错,编辑质量相同。 这结果至少说明 Unicode 省略号不会降低 LLM 使用通配符的能力,也比 ASCII 省略号更不容易与代码混淆。 为了减少实验选项,实验过的重要方案,就像这个 Unicode 省略号 …,直接采纳进主线,作为后续新实验的基础,我们把精力集中在引导 LLM 用通配符上。

怎么提示 LLM 用通配符?

通用工具原则上只应该用 Tool 和 System Prompt 影响 Agent。所以我们先不在 Task Prompt 强调结构化编辑。 先用上通用的提示词技巧,在 System Prompt 里加几条指南,以及一些示例:

  • tool 描述:Edit one file with atomic targeted replacements using small exact matches or structural patterns.
  • oldText 描述:Existing code to replace. Prefer the smallest unique text; Unicode … can omit structurally matched code.
  • System Prompt 加入 prompt guidelines:Use edit instead of Bash or scripts for targeted file changes. Keep each oldText as small as possible while remaining unique.

我们简称为 soft policy。多次实验,不断微调提示,结果 LLM 还是完全不用通配符:

任务类型编辑次数含通配符
定向小任务(15 次运行)270
SWE-bench 真实仓库任务250

直接定更强的 System Prompt,要求 LLM 必须使用通配符:

Always use the Unicode wildcard … in edits[].oldText to omit unchanged code instead of copying the full original text: put … alone on a line between stable source anchors. Never restate a whole function body when only part of it changes.

可以叫 MUST policy。实验结果是最坏情况,LLM 不用通配符:

MUST policy结果
含通配符的编辑0/79
reward40/40

质量没有问题,模型就是不用。事后让它复述 system prompt,它能逐字背出这条规则,但编辑时就是不用。

形状完全不同的 Tool

System Prompt 引导 LLM 用通配符的效果不理想,很可能是与 LLM 训练轨迹有冲突,要想个方法,避免 LLM 拟合到精确匹配的编辑工具输入上。 之前为了让 Edit Tool 能处理非代码文本和一些不完整的代码片段,我们复用了 Pi 原生编辑工具的输入格式,这可能与模型训练数据拟合,导致 LLM 继续使用精确匹配。那我们尝试一个完全不同的工具格式:

{
  "name": "rewrite",
  "parameters": {
    "path": {
      "type": "string"
    },
    "pattern": {
      "type": "string"
    },
    "replacement": {
      "type": "string"
    }
  }
}

新的 rewrite tool 只负责结构化编辑,参数也换成新名字 pattern 和 replacement。 精确文本替换的功能由 Bash 和 cli 工具实现,希望能把精确匹配的惯性引导到 Bash 那边,结构化编辑的倾向(如果有的话)分流到 rewrite。 实验也分两组,分别是 soft policy(工具接受没有通配符)和 MUST policy(工具要求至少一个通配符)。

先跑简单编辑任务,4 个任务重复 5 次,都通过了:

简单编辑任务soft policyMUST policy
rewrite 调用2021
含通配符110
无通配符1911

MUST policy 成功引导模型使用了通配符:模型第一次提交完整 pattern 被拒,读报错后立刻学会写 …。

再跑偏现实的仓库任务,SCBench xjq,它会在同个仓库里分步骤给任务,容易有修改文件操作,我们拿前三步来测试:

SCBench xjqsoft policyMUST policy
rewrite 成功比例4/40/4
通配符成功比例0/00/0
测试通过率23/23 -> 47/51 -> 91/9623/23 -> 46/51 -> 89/96
上下文消耗55.0k / 66.4k / 47.4k85.6k / 23.6k / 66.8k

没有带通配符的 rewrite。 在 soft policy 里,DeepSeek rewrite 参数里全是精确原文; 在 MUST policy 里,DeepSeek 还想用原文作为 rewrite pattern 参数,被拒绝后彻底退回精确匹配路线直到结束。 也就是说,在有难度压力的情况下,DeepSeek 宁愿用精确匹配。

换个模型如何?用 GLM 5.3 flash 试试:

SCBench xjq — GLM 5.3 flashsoft policyMUST policy
rewrite 成功比例7/101/8
通配符成功比例0/11/2
Bash 调用3338
测试通过率22/23 -> 47/51 -> 86/9623/23 -> 48/51 -> 92/96
上下文消耗8.4k / 16.4k / 14.5k12.6k / 23.5k / 11.6k

出现了,第一个 benchmark task 下成功使用通配符的 agent,这是它用的 pattern:

def main(argv):
  …
  try:
      results = tree.xpath(query)
  except etree.XPathError:
      sys.stderr.write("error: invalid xpath expression: %s\n" % query)
      return 1

  if not results:
      return 0

  if isinstance(results, (bool, float, str)):
      sys.stdout.write(normalize_text(results) + "\n")
      return 0
  …
  return 0

令我惊讶的是,它竟然还用了进阶功能,在 replacement 里用 … 表示 pattern 里省略的内容,所以这里 pattern 才会用两个通配符 … 捕获不需要改动的内容。 我复测了几次,GLM 都能成功使用一到两次通配符 rewrite,比 DeepSeek 好,但 rewrite 成功率都很低,都重复犯用原文作为 pattern 的错误。 对比一次 Pi 原生工具的执行:

SCBench xjq — GLM 5.3 flashPi native
edit 调用成功率6/10
Write 调用2
Bash 调用32
测试通过率22/23 -> 47/51 -> 91/96
上下文消耗13.0k / 27.5k / 16.2k

Pi native 上下文消耗稍微多点,其他没多少差别。 核心问题始终存在,模型(DS/GLM)就想用精确匹配。 严格要求通配符还会让 rewrite 错误率远高于最简单的 str_replace 方案。 模型想用精确匹配的惯性太大了。

LLM 能背

背诵原文才能编辑合理吗?对 LLM 合理。str_replace 优点是通用、极简,缺点是要精确复述原文,即理论失败率高、token 开销大。从实验结果看,LLM 能背,原因可能是

  1. Transformer 注意力架构比人更擅长从上下文里取回精确信息。例如 MRCR 这类从长聊天里翻出指定一段来复述的任务,而 LLM 小模型也能做好。

  2. 后训练专门强化 LLM 使用 str_replace 这种精确匹配编辑工具。早在2024年,sonnet 3.5 是 cursor 上唯一能用于改代码的模型,其他模型频繁出错,现在参数量更小的 Qwen 3.8 27B 都很擅长编辑项目代码做长任务,无疑是后训练带来的。

这降低了失败率,token 开销也因为长上下文架构效率优化不值一提,甚至重复一遍原文有加强注意力的好处。

回顾

一开始看到不少前沿模型在 debug、反编译用复杂工具游刃有余,却时不时编辑文件失败时,我认为是编辑工具的问题。这直觉可能混淆了 LLM 与人类擅长的领域。

跟 ChatGPT 聊结构化编辑和相关研究时,它说这个方向很对,没有其他人做,只有 pi-hashline-edit 比较接近不重新提供 old text,ast-grep 和 OpenCode BlockAnchorReplacer 都是我自己找的。 这是个早期信号,GPT (5.6 sol high) 普通 prompt 下倾向于“完成任务”:偷偷选个简单的问题面并给出答案,类似猴爪许愿。

做实验时,我想让 GPT 直接完成这整个项目:实现工具,然后用 git worktree 做不同实验,甚至观察实验对象 (LLM) 遇到的问题并自己提出新实验。 一开始进展很快,然后猴爪许愿又来了,例如我让它分析实验对象 DeepSeek v4 Flash 使用工具出错原因时,才发现 GPT 为了降低实现成本砍掉了很多功能,例如它不允许通配符放在末尾,也就是下面这个 pattern 是不允许的:

def hello(...):
  ...

也就是本文开头的 js 例子换成 python 就不支持了,完全偏离结构化编辑的初衷。最终用老方法,亲自理解原理,再设计实验。

这次尝试,经历了调整 System Prompt 和 Tool Schema 依旧扭转不了模型用精确原文作为匹配参数,以失败告终。 当时 deepseek-harness 刚好发布,看了源码后发现他们也用 str_place 方案。 我得出个结论,不要在模型训练外单独改基础工具,很容易变成对抗训练数据,模型能力是训练模型时赋予的,要改基础工具新用法应该结合模型训练。