跳到主要内容

03 · 容错编辑:让近似的 oldString 也能精确落地

本章是 opencode 最值得带走的精华:edit 工具怎么把模型给的、几乎总有点偏差的"要替换的旧文本",可靠地落到真实文件上。

1. 它要解决的小问题

edit 工具的契约是:给 oldStringnewString,把文件里的 oldString 替换成 newString。问题是——模型记忆里的 oldString 几乎从不和磁盘上的字节一字不差:缩进差几个空格、换行符是 \r\n 还是 \n、引号被转义了、它只记得首尾行而中间凭印象……如果只做 content.indexOf(oldString),99% 会找不到。

2. 思路 / 直觉:一串降级匹配器

opencode 的解法:准备一排"匹配器"(Replacer),从最严格到最宽松依次尝试,谁先在文件里找到一个能定位的子串,就用它。每个 Replacer 是个 generator,吐出"它认为文件里实际存在的那段文本"。

oldString(模型给的近似文本)

▼ 依次尝试,命中即停
① 原样匹配 ──失败──► ② 逐行去空白 ──失败──► ③ 首尾行锚点 ──失败──►
④ 空白归一 ──失败──► ⑤ 缩进无关 ──失败──► ⑥ 反转义 ──失败──►
⑦ 去首尾边界 ──失败──► ⑧ 上下文锚点 ──失败──► ⑨ 多次出现

▼ 任一命中 → 拿到 search 子串 → 在文件里定位 → 替换

怎么读:从左到右是"越来越能容忍偏差"的顺序。越靠后越宽松、也越危险,所以靠后的匹配器自带相似度阈值与跨度闸门。

3. 真实实现:replace 函数

核心调度在 tool/edit.ts:682replace:

// tool/edit.ts:694 起,示意精简
for (const replacer of [
SimpleReplacer, // ① 原样
LineTrimmedReplacer, // ② 逐行去首尾空白后比对
BlockAnchorReplacer, // ③ 只锚定首行+末行,中间靠相似度
WhitespaceNormalizedReplacer,
IndentationFlexibleReplacer,
EscapeNormalizedReplacer,
TrimmedBoundaryReplacer,
ContextAwareReplacer,
MultiOccurrenceReplacer,
]) {
for (const search of replacer(content, oldString)) {
const index = content.indexOf(search)
if (index === -1) continue
notFound = false
if (isDisproportionateMatch(search, oldString)) throw new Error("...跨度过大,拒绝...")
if (replaceAll) return content.replaceAll(search, newString)
const lastIndex = content.lastIndexOf(search)
if (index !== lastIndex) continue // 非 replaceAll 时,有多处匹配则跳过(留给更精确的匹配器/报错)
return content.substring(0, index) + newString + content.substring(index + search.length)
}
}

注意两个安全设计:

  • 唯一性要求:非 replaceAll 模式下,如果 search 在文件里出现多次(index !== lastIndex),就不替换,继续尝试;全部跑完仍多处匹配则报"Found multiple matches"(edit.ts:728),逼模型给更多上下文。
  • 跨度闸门 isDisproportionateMatch(edit.ts:731):如果匹配到的 search 行数 ≥ 原 oldString 的两倍(或多 3 行),或字符数大太多,就拒绝替换并报错——防止某个宽松匹配器误吞掉一大段无关代码。

4. 挑两个匹配器细看

4.1 LineTrimmedReplacer(②)——容忍缩进差异

最常见的偏差是缩进对不上。这个匹配器逐行 trim() 后比对,命中后返回文件里的原始(带缩进)子串,这样替换是基于真实字节:

// edit.ts:248 起,示意精简
const originalLines = content.split("\n"), searchLines = find.split("\n")
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
const matches = searchLines.every((s, j) => originalLines[i + j].trim() === s.trim())
if (matches) yield content.substring(matchStartIndex, matchEndIndex) // 还原成文件里的真实片段
}

4.2 BlockAnchorReplacer(③)——只信首尾,中间凭相似度

当模型只记得一个代码块的第一行和最后一行、中间记串了,这个匹配器靠"首行锚点 + 末行锚点"找候选块,再用 Levenshtein 距离算中间行的相似度,过阈值(0.65,edit.ts:220)才接受:

// edit.ts:288 起,示意精简
if (searchLines.length < 3) return // 至少要有首/中/尾
const firstLineSearch = searchLines[0].trim()
const lastLineSearch = searchLines[searchLines.length - 1].trim()
// 收集所有"首行匹配 + 末行匹配 + 块大小相近"的候选,再用相似度挑最佳

块大小允许 ±25% 浮动(maxLineDelta,edit.ts:303),相似度用 levenshtein(edit.ts:226)逐行算。这是整条链里最"聪明"也最需要阈值兜底的一个。

5. 收尾:行尾与 BOM

替换前 edit 还做了两件杂活,保证写回的字节干净:

  • 行尾归一/还原:读入时把 \r\n 归一成 \n 比对,写回时按原文件的行尾风格还原(edit.ts:23131)。
  • BOM 处理:用 Bom.split 把字节序标记摘出来再拼回(edit.ts:133)。

6. 巧妙之处(可借鉴)

  • "命中即停 + 跨度闸门" 的组合:既最大化容错率(9 级降级),又用 isDisproportionateMatch 把宽松带来的风险按比例卡住。容错和安全在同一个循环里被同时照顾。
  • 匹配器返回"文件里的真实子串"而非"模型给的近似串":这样后续 indexOf + 替换永远基于磁盘真相,避免把模型的偏差写进文件。
  • 多处匹配时拒绝:宁可报错让模型补上下文,也不赌"大概是第一个"。这是"小而真胜过大而错"的工程体现。

7. 边界与局限

  • 全部 9 个匹配器都没命中 → 报"Could not find oldString"(edit.ts:723-727),要求模型重读文件。
  • 对 GPT 系模型 opencode 不用 edit,而用 apply_patch(见 02 章 §4),因为这些模型被训练成偏好补丁格式。
  • 极端宽松匹配仍可能误判;跨度闸门是最后防线,但它基于行数/字符数启发式,不是语义级保证。

8. 代码地图

主题文件路径符号名
替换调度(降级链)packages/opencode/src/tool/edit.tsreplace
跨度闸门packages/opencode/src/tool/edit.tsisDisproportionateMatch
原样匹配packages/opencode/src/tool/edit.tsSimpleReplacer
逐行去空白packages/opencode/src/tool/edit.tsLineTrimmedReplacer
首尾锚点 + 相似度packages/opencode/src/tool/edit.tsBlockAnchorReplacer, levenshtein
空白/缩进/转义归一packages/opencode/src/tool/edit.tsWhitespaceNormalizedReplacer, IndentationFlexibleReplacer, EscapeNormalizedReplacer
上下文锚点packages/opencode/src/tool/edit.tsContextAwareReplacer
行尾/BOM 处理packages/opencode/src/tool/edit.tsnormalizeLineEndings, convertToLineEnding