跳到主要内容

Continue — 编辑应用(把模型的话落到文件)

本章讲编码 agent 的看家本领:模型说「把 A 这段换成 B」,Continue 怎么可靠地在真实文件里找到 A 并换成 B。难点不是替换,是定位——模型记忆里的「旧代码」常和磁盘上的真实代码有细微出入(空格、缩进、大小写)。

1. 这一章解决的小问题

Edit 工具的参数是 old_string / new_string:把文件里的 old_string 换成 new_string。听起来就是一句 str.replace。但现实是:

  • 模型给的 old_string 可能多了或少了缩进(它凭记忆复述代码)。
  • 可能大小写和原文件差一点。
  • 文件可能在模型上次读之后已经变了

如果只做精确匹配,稍有出入就「找不到、编辑失败」,agent 体验会非常脆。所以 Continue 做了前置校验 + 多级降级匹配

2. 思路/直觉:先确认你读过,再一层层放宽匹配

两道保险:

  1. 先读才能改(read-before-edit): 不准编辑一个你这轮没用 Read 读过的文件。这逼模型先拿到最新真实内容,大幅降低「凭旧记忆乱改」。
  2. 降级匹配(graded fallback): 先试最严格的精确匹配;不中,就放宽一级(忽略首尾空白);再不中,再放宽(忽略大小写);最后忽略所有空白。命中即停,越早命中越可信。

3. 核心机制

3.1 前置校验:先读、文件在、不是敏感文件

真实实现: validateAndResolveFilePath()(extensions/cli/src/tools/edit.ts:20)按顺序做:

  1. file_path 必填,解析成绝对路径并 fs.realpathSync 解软链。
  2. throwIfFileIsSecurityConcern() 挡掉敏感文件(如 .env、ignore 命中的)。
  3. 文件必须存在。
  4. 必须读过:readFilesSet.has(resolvedPath) 不成立就报错,提示「编辑前请先用 Read 读 X」(edit.ts:50-55)。readFilesSetRead 工具维护的「本轮读过哪些文件」集合。

这条「读过才能改」是 Edit/MultiEdit 共用的硬约束,工具描述里也明写了警告(edit.ts:78-80)。

3.2 降级匹配:四级 fallback,命中即停

这是整章的精华。定位 old_string 用一串策略依次尝试。

怎么读下面这张图:从上到下是放宽顺序,任意一级命中就停,并记下是哪级命中的(strategyName)。

findSearchMatch(fileContent, searchContent)

├─ 1. exactMatch 原样 indexOf,最可信
│ └ 命中→返回
├─ 2. trimmedMatch 把 search 去首尾空白再找
│ └ 命中→返回
├─ 3. caseInsensitiveMatch 两边转小写再找
│ └ 命中→返回
├─ 4. whitespaceIgnoredMatch 去掉所有空白再找,再映射回原位置
│ └ 命中→返回
└─ 都不中 → 返回 null(编辑失败)

真实实现: 策略表 matchingStrategies(core/edit/searchAndReplace/findSearchMatch.ts:303)按上面顺序排列;findSearchMatch()(findSearchMatch.ts:324)空搜索特判后,for 遍历策略,第一个非 null 即返回并带上 strategyName(findSearchMatch.ts:336-341)。

四个策略各自实现:

级别符号名一句话
1 精确exactMatch (findSearchMatch.ts:30)直接 indexOf
2 去首尾空白trimmedMatch (findSearchMatch.ts:47)searchContent.trim() 后再找
3 大小写无关caseInsensitiveMatch (findSearchMatch.ts:65)两边 toLowerCase()
4 忽略所有空白whitespaceIgnoredMatch (findSearchMatch.ts:85)删掉全部空白找到后,再逐字符映射回原文件的真实位置

第 4 级最精巧:它在「无空白版本」里找到下标后,要回到带空白的原文算出真实 startIndex/endIndex——靠数非空白字符对齐(findSearchMatch.ts:103-141)。

关键细节/坑: 代码里还有一个 Jaro-Winkler 模糊匹配策略 findFuzzyMatch(findSearchMatch.ts:224),但它在策略表里是注释掉的(findSearchMatch.ts:308),旁边有 TODO 说当前实现有 bug(只返回一行)。也就是说:当前线上只跑前四级确定性匹配,字符串相似度模糊匹配是关着的。 这是「诚实」该点出的边界——不要以为它在用编辑距离。

3.3 替换与多处匹配

找到位置后,executeFindAndReplace()(core/edit/searchAndReplace/performReplace.ts)做替换;Edit 工具在 preprocess 里就先算出 newContent 并生成 diff 预览(edit.ts:117-145),run 里才真正 fs.writeFileSync(edit.ts:153)。replace_all=false 时,若 old_string 在文件里不唯一会失败——工具描述明确要求此时给更大的上下文或改用 replace_all(edit.ts:78-79)。

找全部匹配用 findSearchMatches()(findSearchMatch.ts:354):反复调 findSearchMatch 并推进 offset,还做了「新匹配起点不前进就 break」的防死循环(findSearchMatch.ts:382-389)。

3.4 另一条路线:lazy apply(模型只写改动)

它要解决的小问题: 让模型重写整个大文件既慢又费 token。更省的做法是让模型只写改动部分,中间不变的地方用 // ... existing code ... 这种省略号占位带过——这叫 lazy edit(惰性编辑)。但占位符不能直接写进文件,得还原成真实代码。

真实实现: streamLazyApply()(core/edit/lazy/streamLazyApply.ts:14)流程:让模型按 lazy 模板输出改动 → streamFillUnchangedCode 把省略号段落用第二次 LLM 补全(getReplacementWithLlm)填回真实代码 → 再 streamDiff 转成 diff 行流式回显。还有一条确定性路线 deterministic.ts:用 tree-sitter 解析 AST,把 lazy 块对应到原文件的 AST 节点直接替换,不必再调一次模型(core/edit/lazy/deterministic.ts,isLazyTextdeterministic.ts:20)。

两条路线的分工:

路线何时用代价
Edit 搜索替换模型能给出精确 old/new 片段(agent 主力)便宜、确定
lazy apply模型只想写「改动 + 省略号」可能要再调一次 LLM 补全省略号

4. 巧妙之处

  • 降级是有序且可解释的。 每次匹配都带 strategyName,你能知道是「精确命中」还是「靠忽略空白才命中」——后者可信度低,便于日志/调试定位「为什么改错了地方」。
  • 「读过才能改」把模型从凭记忆改代码里救出来。 一个极简单的 Set 约束(readFilesSet),却根治了一大类「old_string 对不上」的失败。
  • lazy apply 的省略号优先用 AST 确定性还原,实在不行才回退到再调一次 LLM,省钱又稳。

5. 边界与局限

  • Jaro-Winkler 模糊匹配当前被禁用(findSearchMatch.ts:308 注释),所以四级都不中就是硬失败,不会「找最相似的」。
  • 降级到第 3/4 级(忽略大小写/空白)有误匹配风险:理论上可能匹配到一个语义不同但去空白后字符相同的位置。Continue 靠「越早命中越优先」和「先读最新内容」来压低概率,但没有语义级校验。

6. 横向对比

「搜索替换 + 多级容错匹配」是当代编码 agent 的通用范式(很多 agent 都做缩进容忍 / 唯一性检查)。Continue 的取舍是确定性优先:四级都是确定算法,模糊相似度故意关掉——宁可失败也不猜。lazy apply 这条「模型写省略号、程序还原」的路线则是 IDE 类工具(如内联 Apply 按钮)更常见的玩法。

7. 代码地图

主题文件符号名
Edit 工具extensions/cli/src/tools/edit.tseditTool / validateAndResolveFilePath
降级匹配主入口core/edit/searchAndReplace/findSearchMatch.tsfindSearchMatch
策略表core/edit/searchAndReplace/findSearchMatch.tsmatchingStrategies
忽略空白匹配core/edit/searchAndReplace/findSearchMatch.tswhitespaceIgnoredMatch
(禁用)模糊匹配core/edit/searchAndReplace/findSearchMatch.tsfindFuzzyMatch / jaroWinklerSimilarity
找全部匹配core/edit/searchAndReplace/findSearchMatch.tsfindSearchMatches
执行替换core/edit/searchAndReplace/performReplace.tsexecuteFindAndReplace
lazy apply 流core/edit/lazy/streamLazyApply.tsstreamLazyApply
lazy 确定性还原core/edit/lazy/deterministic.tsisLazyText