背诵原文才能编辑合理吗?
一次对抗训练数据的经历
如果有一个编辑器,要求你在修改代码时,确认旧范围里的每个字符,才能编辑,你觉得合理吗?
我觉得不合理。当我想重写一段代码时,我会按行按块删除旧内容,然后写新的代码。即使偶尔旧内容有参考价值,我也不会去记住它的每个字。
前段时间 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,失败大致三类:
- 缩进不对。例如少了一层缩进。
- 格式 (style) 不对。例如 prettier 格式化把函数调用参数列表换成多行了,还多了末尾逗号
,,LLM 可能没记住。 - 混淆了旧代码(状态)和新代码(状态)。例如之前提到的 Kimi 3 例子,同时其他模型包括 GLM 5.3 flash 也犯了类似错误。
对比 Kimi 3 和 DeepSeek v4 flash:
| 模型 | 编辑意图数 | pass@1 | pass@2 |
|---|---|---|---|
| Kimi 3 | 310 | 93.2% | 98.4% |
| DeepSeek v4 flash | 163 | 93.9% | 96.3% |
两个模型总/激活参数差一个数量级,pass@1 几乎一样,可能是工具本身有缺陷。
如果觉得 90%的成功率足够高,那我们换个角度,看错误是否难以避免。所有模型失败的长 oldText(超过 8 行)里,约一半(79/160)的开头签名行是对的,也就是说模型知道它要改哪里,只是它正文背错了。
业界方案
如果 str_replace 不够好,那业界有哪些其他方案呢?
- 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())
- OMP Line Hashes。按行编辑,不写原文,用行号和几个字母 hash 引用原文,hash 是 read/grep 工具提供的。
1:a3|function hello() {
2:f1| return "world";
3:0e|}
- 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。
验证分两档:
- 简单的编辑单个文件的任务,快速验证可行性。
- 更贴近现实的 Coding Benchmark Task,验证改进效果,包括 token 效率、任务完成率和使用结构化编辑的比例。
LLM 会不会用通配符?
先确定一下 LLM 输入的匹配内容是什么。重点是匹配部分可以省略,重写内容仍要求 LLM 输出完整代码,所以我认为不需要像 ast-grep 给通配符名字,统一用省略号就行。但 ASCII ... 省略号在一些语言里有用,例如 Python 里是 ellipsis expression。另一个选择是用 Unicode 省略号 …,它不容易与代码混淆,但可能会增加输入的复杂性。
那做实验比较三种情况:
- 对照组 Pi 原生工具
- 用 ASCII 省略号
...作为通配符 - 用 Unicode 省略号
…作为通配符
做简单编辑实验,3 个任务重复 3 次:
| 配置 | 通过 | wildcard 使用 |
|---|---|---|
| Pi native | 9/9 | 0 |
Unicode … | 9/9 | 0 |
ASCII ... | 9/9 | 0 |
Agent 完全没用通配符!
为了确认 Agent 有没有能力(而不是意愿)使用通配符,先把 task prompt 从“编辑文件”改成“使用通配符模式编辑文件”,3 个任务重复 5 次:
| 配置 | 通过 | wildcard 使用 | 平均 tokens |
|---|---|---|---|
Unicode … | 15/15 | 10 | 5,915 |
ASCII ... | 15/15 | 8 | 5,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 次运行) | 27 | 0 |
| SWE-bench 真实仓库任务 | 25 | 0 |
直接定更强的 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 |
| reward | 40/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 policy | MUST policy |
|---|---|---|
| rewrite 调用 | 20 | 21 |
| 含通配符 | 1 | 10 |
| 无通配符 | 19 | 11 |
MUST policy 成功引导模型使用了通配符:模型第一次提交完整 pattern 被拒,读报错后立刻学会写 …。
再跑偏现实的仓库任务,SCBench xjq,它会在同个仓库里分步骤给任务,容易有修改文件操作,我们拿前三步来测试:
| SCBench xjq | soft policy | MUST policy |
|---|---|---|
rewrite 成功比例 | 4/4 | 0/4 |
| 通配符成功比例 | 0/0 | 0/0 |
| 测试通过率 | 23/23 -> 47/51 -> 91/96 | 23/23 -> 46/51 -> 89/96 |
| 上下文消耗 | 55.0k / 66.4k / 47.4k | 85.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 flash | soft policy | MUST policy |
|---|---|---|
rewrite 成功比例 | 7/10 | 1/8 |
| 通配符成功比例 | 0/1 | 1/2 |
| Bash 调用 | 33 | 38 |
| 测试通过率 | 22/23 -> 47/51 -> 86/96 | 23/23 -> 48/51 -> 92/96 |
| 上下文消耗 | 8.4k / 16.4k / 14.5k | 12.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 flash | Pi 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 能背,原因可能是
-
Transformer 注意力架构比人更擅长从上下文里取回精确信息。例如 MRCR 这类从长聊天里翻出指定一段来复述的任务,而 LLM 小模型也能做好。
-
后训练专门强化 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 方案。
我得出个结论,不要在模型训练外单独改基础工具,很容易变成对抗训练数据,模型能力是训练模型时赋予的,要改基础工具新用法应该结合模型训练。