拆.
工具——AI 怎么'做事' · 第 05

Edit 的 9 层 Fallback

错误即教学

3800读完约 20 分钟craft:B+发布于 2026-06-21

一、原理:为什么"字符串替换"对 AI 来说很难

让 LLM 修改一份代码文件,听起来是 AI Agent 最基础的能力之一。但实际上这是个被严重低估的难题。

LLM 的输出是模糊的。它会按"差不多"的方式生成文本。当它说"把这段 if (status === 'ok') 改成 if (status === 'success')"时,它生成的"原文"可能跟文件里实际的字节并不完全一致。可能是引号是单引号还是双引号、可能是 status 前面有没有空格、可能是分号的位置、可能是 if 后面有没有空格、可能是缩进用的是 tab 还是 4 个空格。

而文件编辑是精确的。文件系统不接受"差不多"。要替换一段文本,你要么准确给出那段文本的字节序列,要么替换失败。

这两件事的本质冲突,就是 AI Agent 工具设计的核心难题之一:如何让一个模糊的输出系统操作一个精确的目标系统

理论上有几种解法。

方案 A:让 LLM 自己负责精确。给 LLM 看到完整文件内容(带行号),让它生成精确的"修改前/修改后"对照。优点是简单。缺点是 LLM 经常"以为"自己抄对了,实际上抄错了空格、引号、缩进。一次失败你就要把整轮重新跑一遍——代价高昂。

方案 B:用 diff 格式。让 LLM 生成 unified diff(带 + - 前缀的那种)。Git / GitHub / 大多数代码工具都用这个。优点是表达紧凑。缺点是 LLM 经常在 diff 的"context line"(不带前缀的上下文行)上犯错——多 1 行少 1 行,整个 diff 就 apply 不上去。

方案 C:分层 fallback。LLM 给的字符串先尝试精确匹配;不行就尝试容差更大的匹配(忽略空格差异);再不行就尝试结构匹配(匹配关键标识符);一直 fallback 到最宽松的匹配。每一层都比上一层宽容一点,让 LLM 的"差不多"也能被吃下去。

opencode 走的是方案 C。它的 edit 工具内部有 9 层 fallback——9 个不同宽容度的"替换器"按顺序尝试,从最精确到最宽松。

这是一段被严重低估的代码——它不是单纯的工具实现,它体现了 opencode 对"LLM 是不可靠的"这件事的根本态度:不试图让 LLM 变可靠,而是让工具能吃下 LLM 的不可靠

二、案例:opencode 的 9 层 Replacer

opencode 的 edit 工具在 packages/opencode/src/tool/edit.ts。打开看,里面定义了 9 个 Replacer(替换器),按下面这个顺序尝试。

第 1 层:SimpleReplacer——精确匹配。LLM 给的原文必须跟文件里的字节完全一致,才算找到。

第 2 层:LineTrimmedReplacer——按行修剪。把 LLM 给的原文和文件内容都按行 split,每行去掉首尾空格再比较。这一层能容忍尾部多余空格、Windows 行尾 \r 残留这种差异。

第 3 层:BlockAnchorReplacer——块锚点。如果 LLM 给的原文有 3 行以上,opencode 会用"第一行 + 最后一行"作为锚点去文件里找,中间的行不做精确比较。这一层能容忍 LLM 把中间某行抄错的情况。

第 4 层:WhitespaceNormalizedReplacer——空白规范化。把所有连续空白(空格、tab、换行)都规范成单个空格再比较。这一层能容忍缩进风格的差异(tab vs 空格)。

第 5 层:IndentationFlexibleReplacer——缩进灵活。把每行的首部缩进都去掉再比较。这一层能容忍 LLM 把整段代码缩进抄错(比如多缩进了 2 个空格)的情况。

第 6 层:EscapeNormalizedReplacer——转义规范化。处理 \\n vs \n\\" vs " 这种转义差异。LLM 经常把字符串里的转义符号弄错。

第 7 层:TrimmedBoundaryReplacer——边界修剪。把整个原文的首尾空白去掉再匹配。容忍 LLM 在原文前后多加了空行。

第 8 层:ContextAwareReplacer——上下文感知。如果前面 7 层都失败了,尝试用"前几行 + 后几行"作为上下文,在文件里找到对应的"中间区域"。

第 9 层:MultiOccurrenceReplacer——多次出现处理。如果原文在文件里出现多次,但 LLM 只想替换其中一处,这一层处理"用哪一处"的判断。

只有这 9 层都失败,edit 工具才会返回错误。

但 opencode 在错误返回的环节做了一件更精妙的事——错误信息本身也是 prompt 工程的产物。它不只说"没找到",它说怎么改进。

如果 9 层都没找到匹配,错误信息是:

Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings.

不是说"匹配失败",是说"你下次该怎么写"。

如果 opencode 发现 LLM 给的原文在文件里出现多次(也就是不唯一),错误信息变成:

Found multiple matches for oldString. Provide more surrounding context to make the match unique.

不是说"找到了多个",是说"加更多上下文"。

如果 opencode 发现 LLM 给的原文跟文件里某段匹配,但匹配区域比 oldString 本身大很多(说明可能 LLM 给的是个简短关键字,把整个文件都当成"匹配"了),错误信息变成:

Refusing replacement because the matched span is much larger than oldString. Re-read the file and provide the full exact oldString for the intended replacement.

不是说"匹配过宽",是说"重新读文件,给完整原文"。

每一种错误,opencode 都把它从"做不到"翻译成了"这样做"。

三、设计启示:错误信息是 prompt 工程的隐藏战场

这一章只有一个核心论点:错误信息是 prompt 工程的隐藏战场

大多数工程师把错误信息当成"故障诊断"——给出错的原因,让用户或调用者去看。但在 AI Agent 场景里,错误信息有第二个读者:LLM 自己。LLM 在下一轮 turn 里会看到上次工具调用失败的错误信息,然后决定下一步怎么做。

如果你的错误信息是"操作失败"——LLM 看了不知道下一步怎么改,可能就重试一次一样的调用,又失败,又重试,陷入无意义的循环。

如果你的错误信息是"找到了 3 个匹配"——LLM 看了知道是匹配不唯一,但不知道该怎么解决。它可能选其中一个赌一把。

如果你的错误信息是"找到了 3 个匹配。请加更多上下文让匹配唯一"——LLM 看了直接知道下一步怎么做。它会把原文前后扩几行再调一次,往往一次就成功。

这就是 opencode 在 edit 的错误信息上的真正设计:每一种失败都附带修复路径。这种设计模式可以推广到任何 AI Agent 工具——

  • bash 工具超时时,告诉 LLM 可以传更大的 timeout 参数
  • read 工具找不到文件时,告诉 LLM "是不是想读这几个相似名字的文件之一"
  • web 请求失败时,告诉 LLM 这个域名的 robots.txt 不允许访问,建议换源

每一个错误都是一次"教学机会"。LLM 看到的不应该是"你做错了",而应该是"这样改就对了"。

回头看 9 层 fallback 这件事。从纯工程角度,它是个挺笨重的设计——9 层意味着 9 段独立代码、9 套测试、9 个边界条件。一个更"优雅"的设计可能是用某种通用算法(比如基于 AST 的差异比较)统一处理。

但 opencode 没选优雅,它选了笨重。原因是:LLM 出错的模式是多样的。每一层 Replacer 对应一种已经观察到的 LLM 错误模式——某些模型在 escape 上犯错、某些模型在缩进上犯错、某些模型在块锚点上犯错。统一算法处理不了这些不同的"病症"。

这跟第 1 篇我们讲过的"五张脸"是同一种产品哲学:LLM 不是一个对象,是一组各有偏好的对象。你的产品要么针对它们的偏好做专门处理,要么接受输出质量下降。opencode 选了前者,在 prompt 层(五张脸)和工具层(9 层 fallback)都坚持。

这种"笨重的针对性"是 AI 产品跟传统软件的本质差别之一。传统软件输入是确定的,错误处理可以分类穷举。AI 产品输入是 LLM 的"差不多",错误处理必须用宽容度递进的 fallback chain 才能吃得下来。

如果你在做 AI 工具,遇到 LLM 经常调用你的工具失败的情况,先别急着改 prompt 让 LLM 变"更小心"——看看你的工具的错误处理是不是"教学型"的。一个好的 AI 工具,错误信息应该让 LLM 不需要重新思考就能下一步成功

opencode 还有一个隐藏的细节值得点出来。9 层 Replacer 里的每一层,opencode 都没有对外暴露它叫什么名字、当前用的是哪一层。LLM 在调用 edit 工具时只看到"成功"或"失败",看不到"是被第 5 层的 IndentationFlexibleReplacer 救回来的"。

这是有意的。opencode 不想让 LLM 知道工具有"容错层"——一旦 LLM 知道工具能容错,它会变得更草率,原文给得越来越粗糙。给 LLM 看的是"严格的工具 + 教学型的错误",背后藏着的是"宽容的实现"

这是 AI 工具设计里一个反直觉的原则:工具对外要严格,对内要宽容。严格让 LLM 保持认真,宽容让真实场景能 work。两者结合,才能做出在生产环境真的能用的 AI Agent。