开源终端 Agent opencode 怎么改你的代码:覆盖、替换、打补丁三条路径的失手点
本文基于 opencode 仓库 commit 7fe9938(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/anomalyco/opencode 最新代码与文档为准。
在 opencode 这个开源终端编码 Agent 里,模型用哪种方式改你的代码,不是你在配置里选的,是模型 ID 字符串决定的。(下文说的 opencode 一律指这个仓库里的项目,不是泛指”开源代码”,也和名字相近的其它工具无关。)packages/opencode/src/tool/registry.ts 在向模型下发工具清单时会算一个布尔值:模型 ID 含 gpt-、且不含 oss、且不含 gpt-4,就只给它 apply_patch;否则只给 edit 和 write。两组工具互斥下发,不共存。这意味着你换一个模型,改代码的整套失败模式就换了一套——超时、报错、静默改错地方的形态都不一样。搞不清这一层,你排查 Agent 改坏代码的时候会一直在错误的方向上找原因。
站内已经有几篇相邻的文章:Pi 编辑工具的安全边界讲的是另一个项目怎么给编辑加约束,AI 改坏代码怎么回滚讲的是事后止损,Agent 工具设计通则讲的是抽象层面的取舍;这一篇只做一件事——把 opencode 这三个工具的匹配逻辑和护栏逐行摊开,看它们各自在什么输入下会失手。
一、三种改法各自解决什么问题
模型手里只有文本。让它修改一个已存在的文件,本质是要它描述”改哪里、改成什么”。描述方式有三档精度:
最粗的一档是不描述位置,直接把整个文件的新内容吐出来,由运行时覆盖写盘。这就是 write。它的好处是没有匹配环节,因此不存在匹配失败;代价是模型必须完整重写文件,长文件的 token 开销和抄错风险都由它自己扛。
中间一档是给出一段唯一的原文和替换后的文本,由运行时定位后替换。这是 edit,参数只有四个:filePath、oldString、newString、可选的 replaceAll。它的核心难题是模型给的 oldString 常常和文件里的字节不完全一致——缩进差一格、引号是全角、行尾是 CRLF。
最细的一档是让模型写一份结构化补丁,一次描述多个文件的增、删、改、移。这是 apply_patch,参数只有一个 patchText。它的补丁语法在 packages/opencode/src/tool/apply_patch.txt 里写死给模型看:
*** Begin Patch
*** Add File: hello.txt
+Hello world
*** Update File: src/app.py
*** Move to: src/main.py
@@ def greet():
-print("Hi")
+print("Hello, world!")
*** Delete File: obsolete.txt
*** End Patch
注意这里出现了 edit 和 write 都没有的两种操作:删文件和移动文件。这一条差别的安全含义比它看上去大得多,后面会说。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
WriteTool | 整文件覆盖写入,写完额外汇报最多 5 个其它文件的 LSP 诊断 | packages/opencode/src/tool/write.ts | 新建文件,或模型判断改动面太大直接重写 |
EditTool | 单文件按串替换,带九层匹配回退与单文件锁 | packages/opencode/src/tool/edit.ts | 绝大多数常规改动 |
九个 Replacer 与 replace() | 从精确匹配一路降级到模糊匹配,失败则抛错 | packages/opencode/src/tool/edit.ts 同文件下半部分 | 模型给的 oldString 和文件对不上时 |
ApplyPatchTool | 解析补丁、全量预演、一次授权、逐文件落盘 | packages/opencode/src/tool/apply_patch.ts | 模型 ID 命中 gpt- 规则时 |
Patch.parsePatch 与 seekSequence | 补丁语法解析 + 四遍宽容度递增的行序列定位 | packages/opencode/src/patch/index.ts | 补丁里的上下文行和文件对不上时 |
| 工具下发过滤 | 按模型 ID 决定给 apply_patch 还是给 edit+write | packages/opencode/src/tool/registry.ts | 换模型之后行为突然变了 |
二、edit 的九层回退:越往后越像在猜
edit.ts 里的 replace() 函数是整篇最值得逐行看的地方。它按固定顺序试九个替换器:
for (const replacer of [
SimpleReplacer,
LineTrimmedReplacer,
BlockAnchorReplacer,
WhitespaceNormalizedReplacer,
IndentationFlexibleReplacer,
EscapeNormalizedReplacer,
TrimmedBoundaryReplacer,
ContextAwareReplacer,
MultiOccurrenceReplacer,
]) {
第一个 SimpleReplacer 就是原样返回 oldString,即精确匹配。只要模型抄对了,后面八个永远不会执行。后面八个是给”抄得不太对”准备的降级通道,而降级到第三个开始,判据就不再是”相等”,而是”够像”。
BlockAnchorReplacer 要求 oldString 至少三行,取首行和末行 trim 后作为锚点,在文件里找同样首尾的块,块长度差在 25% 以内即成为候选,中间各行用 Levenshtein 距离算平均相似度,阈值 0.65。它的失手方式很具体:当文件里存在多个首尾相同的块(重构留下的重复代码、结构一致的多个 case 分支、一串写法雷同的测试用例),它不会报”歧义”,而是遍历所有候选取相似度最高的那个直接替换。相似度 0.66 和 0.98 在它眼里都是”通过”。
WhitespaceNormalizedReplacer 更激进:它把 oldString 按空白切成词,用 \s+ 拼成正则去匹配行内子串。这意味着你以为在替换一整行,实际被替换的可能只是行里的一小段,也可能是另一行里恰好词序相同的一段。EscapeNormalizedReplacer 会把 \n、\t、\\ 这类转义序列反转义再比对——处理普通代码没问题,处理本身就在操作转义字符的代码(正则、模板生成器、序列化逻辑)时,它可能把字面量 \n 和真换行当成同一个东西。ContextAwareReplacer 的门槛最低,首尾行对上、中间非空行匹配率过半就算数。
护栏只有一道,叫 isDisproportionateMatch:
function isDisproportionateMatch(search: string, oldString: string) {
const oldLines = oldString.split("\n").length
const searchLines = search.split("\n").length
if (searchLines >= Math.max(oldLines + 3, oldLines * 2)) return true
if (oldLines === 1) return false
return search.trim().length > Math.max(oldString.trim().length + 500, oldString.trim().length * 4)
}
匹配到的跨度比 oldString 大太多就拒绝执行,并要求模型重读文件给出完整原文。这道护栏挡得住”想改三行结果匹配上三十行”。但看第三行和第四行的顺序:单行 oldString 的行数门槛是 max(4, 2) 即 4 行,而一旦 oldLines === 1 就直接返回 false,字符长度那条判据对单行输入永远不生效。所以模型给一个单行 oldString、模糊匹配到一段两三行的内容,是能通过的。
还有一条容易被忽略的规则:非 replaceAll 模式下,命中之后还要检查 content.indexOf(search) === content.lastIndexOf(search),不唯一就 continue 到下一个替换器继续试。也就是说”多处匹配”不会立刻中止,而是让流程滑向更宽松的替换器。最终若九个都没能给出唯一命中,才抛出”Found multiple matches for oldString”。
三、apply_patch:先全量预演,再一次性落盘
apply_patch.ts 的执行顺序值得单独说,因为它和 edit 的风险曲线完全不同。它先 parsePatch 拿到所有 hunk,然后对每个 hunk 在内存里算出 newContent、算好 diff 和增删行数,全部存进 fileChanges 数组。这一整段里任何一步失败——补丁语法不对、要更新的文件不存在、上下文行找不到——都会直接 Effect.fail 返回,此时磁盘上一个字节都没动。
预演通过之后,它才发一次权限询问,patterns 是这批文件的相对路径数组。批准之后进入写入循环,按 add / update / move / delete 分支逐个落盘。这里没有回滚:写入循环里若中途出错,前面已经写完的文件保持新状态,没有任何补偿逻辑。
定位靠 patch/index.ts 里的 seekSequence,它按四遍逐级放宽:精确相等 → trimEnd() 后相等 → trim() 后相等 → Unicode 标点归一化后相等。最后这一遍是 normalizeUnicode,它把弯引号统一成 ASCII 直引号、各种破折号统一成短横、省略号展开成三个点、不换行空格换成普通空格。
对中文项目,这一遍是个真实的暗雷。假设你的源码注释或字符串里用的是全角引号,模型在补丁的上下文行里写成了 ASCII 直引号,前三遍都对不上,第四遍归一化之后对上了——然后这一段会被补丁里的新行整体替换。旧行的原始标点不会被保留,因为替换是整段换掉,不是逐字符 patch。改动看起来成功,diff 里却混进了你没打算做的标点变更。
补丁文本本身的行尾也有讲究。parsePatch 拿到 patchText 之后是 trim() → 剥 heredoc → split("\n")。如果补丁文本用的是 CRLF,切出来的每一行末尾都挂着一个 \r。上下文行靠 seekSequence 的第二遍 trimEnd() 能救回来;但 *** Add File: 段落里的 + 行是原样拼进新文件内容的,走的是 parseAddFileContent——它只做一件事:碰到以 + 开头的行就砍掉首字符、补一个 \n 接到新内容后面,中间不经过 seekSequence 那四遍宽容比对,也没有任何行尾归一化。所以补丁文本的 \r 会跟着进新建的文件,你要到后面某次 edit 匹配不上、或者 lint 报行尾不一致的时候才发现。
另外 deriveNewContentsFromChunks 会返回一个 unified_diff 字段,由文件里那个自带注释、只做逐行对齐的 generateUnifiedDiff 生成;而 apply_patch.ts 实际展示给你看的 diff 并不用它,用的是 diff 库的 createTwoFilesPatch。你在终端里看到的改动预览和补丁内部那份简化 diff 不是同一个东西。
四、write:没有匹配环节,也就没有兜底
write.ts 的逻辑短得多:拼绝对路径、检查是否越出工作目录、读旧内容拿 BOM 状态、生成 diff、询问权限、写盘。全程没有任何一处检查”你确定要覆盖的是这个文件吗”。旧内容读出来只用于两件事:算 diff 给你看,以及决定新内容要不要保留 BOM。
它唯一比 edit 多做的一件事,是写完之后汇报诊断时不止看当前文件。MAX_PROJECT_DIAGNOSTICS_FILES 常量是 5,它会把其它文件里最多 5 个的 LSP 报错也拼进返回给模型的文本里。这个设计取向能读出来:整文件覆盖最容易连累别处(删掉一个别人在用的导出、改了签名),所以多给模型一点跨文件的反馈。
需要说清楚的是,这些诊断只是拼在输出文本里给模型看的提示,不构成任何阻断。文件已经写了,LSP 报错也照样报在已落盘的内容上。指望它当”编译不过就不写入”的闸门,是想多了。
write.txt 里写着”如果是已存在文件,你必须先用 Read 工具读过”。这句话是写给模型看的提示词约束,不是运行时约束——write.ts 里没有任何读取记录的追踪。edit.txt 里那句”没读过就编辑会报错”也一样。顺带一提,edit.txt 声称匹配失败的报错是”oldString not found in content”,而 edit.ts 实际抛的是”Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings.”。描述文本和实现的措辞已经对不上了,你按描述去抓日志关键词会抓空。
五、边界与代价:这套设计明确不管的事
**它不管你有没有版本控制。**三个工具都是直接写工作区文件,没有一个会在改动前替你留后路。edit 会构造一个 Snapshot.FileDiff 结构放进元数据,但那是给界面渲染和记录用的,不是可回滚的存档。真正的兜底得靠你自己在外面用 git,这方面的做法见AI 改坏代码怎么回滚。
并发保护只覆盖一半。edit.ts 顶部维护了一个以解析后路径为 key 的 Semaphore 映射,同一个文件的多次 edit 会串行,测试里也专门验证了”并发改同一文件的不同段落,两处改动都保留”。但这把锁是进程内的 Map,只锁 edit 一个工具:write 和 apply_patch 都不走这把锁,你在编辑器里手动改同一个文件更是完全不在管辖范围内。
**格式化器会在你背后改内容。**三个工具写盘后都会调 format.file(),返回真则重新读一遍文件同步 BOM 状态。落到磁盘上的最终内容可能和模型给的文本不一致。这直接影响下一轮:模型脑子里的”文件现在长这样”和实际内容差了一次格式化,下一次 edit 的 oldString 就可能对不上,然后滑进第二节讲的模糊匹配通道。
**权限询问的粒度是可以被你自己放宽到最松的。**三个工具的 ctx.ask 都带 always: ["*"]。你在提示里选”总是允许”,等于把整个工作区的写权限一次性交出去。permission/index.ts 里把 edit、write、apply_patch 三个工具归到同一个 edit 权限位——批准一个等于批准三个,而其中的 apply_patch 是唯一能删文件和移文件的。更麻烦的是它一次询问覆盖整批文件:一份补丁里同时有新增、修改和删除,你看到的是一次询问,点下去是一整批落盘。权限该怎么切见Agent 最小权限设计。
跨出工作目录不是禁止,是再问一次。external-directory.ts 里的逻辑是:目标路径不在实例目录内,就发一个 external_directory 权限询问,patterns 和 always 都是那个父目录下的通配。批准之后,那个目录就长期在允许名单里了。它拦的是”你没注意到”,不是”绝对不能”。
**内容会离开你的机器。**这类工具的工作前提是把文件内容送进模型上下文再拿回改动建议。哪些片段被读进去、发给谁、对方留存多久,各家服务商规则不同且会调整,以官方最新说明为准。有 .env、密钥、客户数据、未公开代码的仓库,这件事要在接入前就想清楚,别指望编辑工具本身来拦。相关的范围控制思路见AI 改动范围失控怎么办。
六、上手与避坑清单
**换模型之后先确认自己在哪条路径上。**为什么会踩:registry.ts 那条 usePatch 判据是纯字符串匹配,gpt- 命中、oss 和 gpt-4 排除,你换个模型名可能就从 edit 切到了 apply_patch,而两者的失败信息、权限弹窗形态、能否删文件全都不同。怎么避:看一次工具调用记录里出现的是 edit 还是 apply_patch,以此为准,不要凭印象。
**别把”改动成功”当成”改对了”。**为什么会踩:edit 的九层回退里,第三层往后都是相似度判定,命中之后返回的是同一句 “Edit applied successfully.”,没有任何”我是靠模糊匹配才找到的”提示。怎么避:把 diff 当成必看项而不是滚屏噪音,尤其是改动落在你没预期的行号上时立刻停下来。
**在有重复结构的文件里,让模型给更长的 oldString。**为什么会踩:BlockAnchorReplacer 在多候选时取相似度最高者且不报歧义,重复代码块正是它最容易选错的场景。怎么避:改动涉及一串写法雷同的分支或用例时,主动要求把上下文扩到能唯一定位的范围,而不是只给要改的那几行。
**中文和排版符号密集的文件,优先走精确匹配。**为什么会踩:seekSequence 第四遍的 normalizeUnicode 会把全角引号、破折号、省略号归一化,一旦靠这一遍才匹配上,被替换的整段标点就按补丁里的写法覆盖了。怎么避:这类文件改完专门看一眼标点有没有莫名其妙变化,别只看代码逻辑对不对。
**开了格式化器就假定”你看到的不是最终内容”。**为什么会踩:写盘之后 format.file() 可能重排缩进和引号,模型下一轮基于旧印象构造 oldString 就会对不上。怎么避:连续多次改同一个文件时,中间插一次重读,别让模型连改三轮都不看实际内容。
**别给整个工作区一次性长期授权。**为什么会踩:always: ["*"] 加上三个工具共用 edit 权限位,一次”总是允许”就把删文件的能力也放出去了。怎么避:授权粒度尽量停在具体路径,尤其在有构建产物、部署脚本、密钥文件的仓库里。
**在干净的 git 工作区里跑。**为什么会踩:apply_patch 的写入循环没有回滚,一批文件写到一半出错就是半成品状态;edit 的锁也管不到你手动改的内容。怎么避:开跑前 git status 清干净,让 diff 成为唯一的真相来源。
收尾
opencode 把三种改法都实现了一遍,这件事本身说明它接受了一个前提:模型描述改动位置的能力靠不住,得给不同精度的描述各留一条通道。代价是这三条通道的失败模式完全不同,而使用者往往感知不到自己正走在哪一条上。
想接着往下读的话,顺序建议是:packages/opencode/src/tool/edit.ts 的下半部分(九个替换器和 replace())看它降级到什么程度还算”匹配成功”,packages/opencode/src/patch/index.ts 的 seekSequence 与 normalizeUnicode 看补丁路径的宽容度边界,packages/opencode/src/tool/registry.ts 里那条 usePatch 判据确认你自己在哪条路上。三个文件加起来不到两千行,比任何二手总结都可靠——包括这一篇。
本篇属于一个把开源AI 编程 Agent 项目 opencode逐层拆开讲的系列,整体地图见 opencode 是什么:终端里的开源 AI 编程 Agent 全景地图;沿着这条线往下,还可以看 opencode 工具层拆解:实现与描述分家,注册表决定模型看见什么 和 开源编码 Agent opencode 找代码三件套:读取、按名找、按内容找。