开源编程 Agent pi 的文件编辑:差异比对与写入排队各挡什么事故

2026-07-29

本文基于 pi v0.82.1(仓库 commit 027a584,2026-07-28)梳理,该项目仍在快速迭代,具体行为以官方文档 https://pi.dev/docs/latest 与仓库最新代码为准。

一个 Agent 把代码改坏,绝大多数时候不是模型写错了逻辑,而是它想改的那段文本被落到了错误的位置,或者它的改动被另一次写入静悄悄覆盖掉了。 这两类事故跟模型能力关系不大,属于工具实现层面能不能兜住的问题。pi 这个开源编程 Agent 把它们分开来治:一边是把「模型给的 oldText」翻译成「文件里的确切字节区间」的差异比对逻辑,一边是保证「同一个文件同一时刻只有一个改动窗口」的写入队列。两边各挡一类事故,缺一个另一个也救不回来。

pi 采用 MIT 许可证,主仓库在 https://github.com/earendil-works/pi,截至 2026 年 7 月 GitHub 上约 8 万 star。它的编辑工具实现集中在三个文件里:packages/agent/src/harness/tools/edit.ts 是工具主流程,packages/agent/src/harness/tools/edit-diff.ts 是匹配与差异计算,packages/agent/src/harness/tools/file-mutation-queue.ts 是写入排队。这三个文件加起来不到七百行,但里面每一处判断基本都对应过一个真实踩过的坑。

站内此前几篇讲的都是使用者视角的通用方法论:《AI 把能跑的代码改坏了:先止损再回滚》讲事后怎么补救,《几千行的单文件总是改不动》讲人该怎么切片下指令、为什么整文件重写风险最高,《一句需求换来 30 个文件的改动》讲事前怎么收住范围。本篇换成工具实现者视角,不重复那些操作建议,只看一个具体项目把「不许改错位置、不许悄悄覆盖」这两条落到代码里长什么样:哪些判断是硬编码在工具里的,哪些它压根不管、只能靠上面那几篇的人工流程兜。

一、从输入到落盘:edit 工具的主流程

pi 的 edit 工具参数很简单:一个 path,一个 edits 数组,每一项只有 oldTextnewText 两个字段。工具描述里明确写了约束:

Edit a single file using exact text replacement. Every edits[].oldText must
match a unique, non-overlapping region of the original file. If two changes
affect the same block or nearby lines, merge them into one edit instead of
emitting overlapping edits. Do not include large unchanged regions just to
connect distant changes.

这里有三个关键词:唯一(unique)、不重叠(non-overlapping)、对着原始文件(the original file)。第三点最容易被忽略——edits 数组里每一条都是对着改动前的文件内容匹配的,不是「第一条改完之后再拿第二条去找」。参数 schema 的描述里也把这句话重复了一遍:Each edit is matched against the original file, not incrementally

为什么强调这个?因为模型在生成多处改动时,脑子里的参照系天然就是原文件。如果工具按增量语义执行,模型给的第二条 oldText 就可能因为第一条改动而失配,或者更糟——碰巧匹配到了改动之后新出现的一段文本,改到不该改的地方。把语义定死在「全部对着原文匹配」,模型的直觉和工具的行为就对上了。

输入解析这一层还做了两处容错,写在 prepareEditArguments 里。一是 edits 被模型输出成 JSON 字符串时,尝试 JSON.parse 再判断是不是数组;二是兼容旧的顶层 oldText / newText 写法,把它们追加进 edits 数组。这两处都用了失败即忽略的写法——JSON.parse 外面套的是空 catch,解析不出来就保持原样往下走,让后面的校验去报错。这个取向值得注意:容错只在「意图明确、格式跑偏」的地方做,不在「意图本身就不清楚」的地方猜。

一次编辑要过的几道关

edit.ts 的执行体本身是一条直线:拿到绝对路径 → 进队列 → 查文件信息 → 读文本 → 剥 BOM → 探测换行风格 → 归一到 LF → 算改动 → 还原换行、补回 BOM → 写盘 → 生成 diff。真正的把关都在 applyEditsToNormalizedContent 里,它按顺序做了这几件事:

空 oldText 拦截。 逐条检查长度为 0 就抛错。空字符串在 indexOf 里永远返回 0,不拦下来就等于在文件开头插内容,跟模型的意图完全对不上。

匹配与唯一性检查。 先用 fuzzyFindText 找位置,找不到抛「Could not find」;找到了再用 countOccurrences 数一遍出现次数,大于 1 就抛错,错误信息里直接给出次数并要求补上下文:Each oldText must be unique. Please provide more context to make it unique. 这一步挡的是最经典的事故——模型给了一段 return null; 之类的短文本,文件里有七处,改中的是随机的那一处。

重叠检查。 所有匹配按起始位置排序后,相邻两条一比:

if (previous.matchIndex + previous.matchLength > current.matchIndex) {
  throw new Error(
    `edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${path}. Merge them into one edit or target disjoint regions.`,
  );
}

这道关挡的事故最隐蔽。因为语义是「全部对着原文匹配」,两条区间一旦相交,无论先应用哪条,另一条的坐标都失效了,落盘结果会是一段谁也没预期到的文本。直接拒绝、要求模型合并成一条,比试图猜一个合理的合并结果安全得多。

逆序应用。 通过检查之后,替换是从后往前应用的(for (let i = replacements.length - 1; i >= 0; i--))。前面的替换改变了后面文本的偏移量,倒着来则每一条应用时它自己的 matchIndex 都还有效。这是个老技巧,但在多条编辑同时落盘的场景里是必需的。

空改动拦截。 最后一道:如果算出来的新内容跟旧内容完全相同,抛 No changes made,而不是安安静静写一遍原样文件返回成功。这个判断的价值在于反馈——模型以为自己改了,实际什么也没发生,如果工具回报成功,它就会带着错误的世界模型继续往下走。工具返回值该怎么设计才不误导模型,站内《Agent 工具返回值设计》里有更系统的讨论。

二、模糊匹配:先精确,失败再降级,代价关在行内

fuzzyFindText 的逻辑是两段式的。第一段是 content.indexOf(oldText),纯精确匹配,命中就直接返回,usedFuzzyMatch 为 false,用于替换的内容就是原始内容。只有精确匹配失败,才进入第二段:把文件内容和 oldText 都过一遍 normalizeForFuzzyMatch,在归一化后的空间里再找。

归一化做的事写得很直白:先 NFKC,然后逐行剥掉行尾空白,再把各种智能单引号、智能双引号统一成 ASCII 引号,把 U+2010 到 U+2015 加上 U+2212 这一串连字符、破折号、减号统一成 ASCII 连字符,最后把 NBSP、窄空格、表意空格等一堆特殊空格统一成普通空格。这几类字符的共同点是:肉眼几乎分不出来,模型复述时又极容易替换成 ASCII 版本。不做这层归一,中文文档、带全角字符的代码注释、从网页复制来的片段,全都会以「oldText 找不到」告终。

但模糊匹配有代价,而且是很实际的代价:一旦在归一化空间里做替换,直接把结果写回去,等于把全文的智能引号、行尾空白、特殊空格全部改成了 ASCII 版本。模型只想改三行,git diff 里跳出来两百行——这种「顺手把整个文件洗了一遍」的改动,是 review 时最让人头大的那种。

pi 的处理办法是 applyReplacementsPreservingUnchangedLines。它的做法是:把每条替换的字符区间扩展成它实际触及的行范围,相邻或重叠的行范围合并成组,只有落在这些组里的行才从归一化内容里取新版本,组以外的所有行按原始内容的字节原样拷回。函数注释里还点出了一处关键考量——用实际的替换区间来驱动行保留,而不是靠内容比对,这样归一化之后出现的重复行就不会被对齐到错误的那一次出现上。函数入口还有一道断言:如果原始内容和归一化内容的行数对不上,直接抛错拒绝,而不是硬着头皮往下拼。

这是个挺好的设计取向样本:降级路径本身是必要的,但降级带来的副作用要被限制在最小范围内,而不是让它扩散到整个文件。

三、各部分职责一览

组成部分它负责什么对应仓库位置你什么时候会碰到它
edit 工具主流程解析参数、解析路径、进队列、读文件、算改动、写回、产出 diffpackages/agent/src/harness/tools/edit.ts每一次模型调用 edit
applyEditsToNormalizedContent多条替换统一对着原文匹配,校验空值、唯一性、重叠,逆序应用packages/agent/src/harness/tools/edit-diff.ts看到「找不到 / 不唯一 / overlap」这类报错时
fuzzyFindTextnormalizeForFuzzyMatch精确匹配失败后按 NFKC、行尾空白、引号破折号空格归一再找一次packages/agent/src/harness/tools/edit-diff.ts文件里有智能引号、全角字符、行尾空格时
applyReplacementsPreservingUnchangedLines模糊命中时只重写被触及的行块,其余行按原字节拷回packages/agent/src/harness/tools/edit-diff.ts一次模糊命中的编辑落盘后看 git diff 时
generateDiffString / generateUnifiedPatch生成带行号的展示用 diff,以及标准 unified patch,默认四行上下文packages/agent/src/harness/tools/edit-diff.ts终端里那段带 +/- 和行号的输出
withFileMutationQueue按执行环境加规范化路径,把改同一个文件的操作串成队列packages/agent/src/harness/tools/file-mutation-queue.ts同一轮里多个工具动同一个文件
write 工具整文件覆盖写,同样进上面那条队列packages/agent/src/harness/tools/write.ts模型判断整体重写比逐条替换划算时

四、写入排队这一侧:并行工具调用下丢改动的那类事故

前面所有的校验都是单次编辑内部的事。还有一类事故它们完全管不了:两次编辑各自都完美无缺,但它们同时发生。

pi 官方文档 packages/coding-agent/docs/extensions.md 里把这个场景讲得很清楚——工具调用默认是并行的,不入队的话,两个工具可能读到同一份旧内容,各自算出不同的更新,最后谁写盘晚谁赢,另一份改动就没了。文档给的例子是:自定义工具在改 foo.ts,同一轮里内置 edit 也在改 foo.ts,两边都从原始版本出发,其中一份改动直接消失。

withFileMutationQueue 就是解法。它的实现有几个点值得看:

队列的键是规范化路径,不是用户给的路径。 getMutationQueueKey 先把路径转成绝对路径,再走 env.canonicalPath 拿真实路径;只有当错误码是 not_foundnot_supported 时才退回绝对路径。落到实际效果上:同一个文件的符号链接别名会共享同一条队列,而新建文件因为还没东西可以解析真实路径,就用绝对路径兜底。仓库测试里专门有一条 uses the same queue for symlink aliases 验证这个行为。

注册动作本身也是串行的。 状态对象里除了 queues 这个 Map,还有一个 registration promise。因为拿队列键要走异步的路径解析,如果两个调用同时进入、同时读到「当前队列为空」,就会各自建一条互不相干的链——等于没排队。把注册阶段串起来,保证「读当前队列、挂上新队列、写回 Map」这一串是原子的。

入队的是整个改动窗口,不只是最后那次写。edit.ts 就明白:查文件信息、读文本、算改动、写盘,全都在 withFileMutationQueue 的回调里面。文档里也把这条单独拎出来强调过:要把读—改—写整段逻辑都放进队列,而不是只圈住写入那一行。只锁写入是不够的,因为丢改动的根因发生在「读」的那一刻——两边读到了同一份旧内容。

中断处理刻意绕开了一个坑。 pi 在编辑流程里检查中断信号的方式是每个 await 之后查一次 signal?.aborted,而不是挂一个 abort 事件监听器去 reject。仓库里对这个选择留了注释,大意是:从 abort 监听器里 reject 会在文件操作可能还没结束的时候就把队列释放掉。也就是说,用户按了取消,但那次写盘可能已经发到操作系统了;如果这时队列放行,下一次编辑读到的就是一个处于中间状态的文件。测试里有两条用例专门盯这个场景,验证「被中断的写入还在飞的时候,队列必须保持锁住」。这是那种不出事则已、一出事极难复现的问题,Agent 并发编排里的类似坑站内《Agent 并发编排》有更多展开。

write 工具走的是同一条队列。所以「一个 edit 和一个 write 同时改一个文件」也是有序的——测试里验证了这种混合场景,后进队的那次整文件覆盖会完整生效,而不是跟前一次编辑的结果交织成半成品。

五、边界与代价:它明确不管的事

这套设计并不是万能的,它放弃的东西也很明确。

队列只在一个进程、一个执行环境内有效。 状态存在一个以执行环境为键的 WeakMap 里。换句话说,队列管的是「这个 Agent 自己的多个工具调用之间」的顺序。你在另一个终端里同时编辑同一个文件、编辑器的自动格式化在后台改盘、另一个 Agent 进程在跑——这些它一概不知道,也没有文件锁去防。多个会话同时动一份代码的冲突问题,是另一个层面的事,站内《多会话并发冲突》讲的就是那一层。

它保证顺序,不保证正确。 队列把并发改成串行,让第二次编辑读到第一次的结果。但两次改动在语义上互相矛盾时,队列只会让它们依次生效,不会告诉你这里有问题。顺序正确不等于结果正确。

它不做版本控制、不做回滚、不做备份,也没有事务性的多文件改动。 写盘就是写盘,edit 返回的 diff 和 unified patch 是给人看、给 UI 渲染的产物,不是能用来撤销的事务日志。队列是按文件粒度的,一次涉及五个文件的重构,如果第三个文件写失败,前两个已经落盘的改动不会自动撤回。

模糊匹配是有损的,只是把损失关小了。 命中模糊路径时,被触及的那几行确实会按归一化后的样子写回去——行尾空白没了、智能引号变成了 ASCII 引号。对绝大多数代码文件这无所谓,但如果你的文件里那些字符是有意义的(某些测试夹具、某些数据文件),就要留意。

唯一性要求会把成本转嫁给上下文。 要求 oldText 在全文唯一,意味着模型必须带上足够上下文;而工具描述又要求「不要为了连接两处远距离改动而塞进大段未改内容」。平衡点得靠模型自己拿捏,工具只能在越界时报错。

六、上手与避坑清单

别把「文件没改动」当成功。 会踩是因为不少工具实现里,替换没命中就默默写一遍原文返回 OK,模型收到成功信号就往下走了,错要到很后面才暴露。pi 的做法是新旧内容相同直接抛错。你自己实现编辑工具时照抄这一条:无变化必须是显式失败。

别在编辑工具里做「第 N 处出现」的语义。 会踩是因为「把第 2 个 foo 换成 bar」看起来很自然,但模型对序号的计数极不可靠,数错一位就改到别处,而且改完看起来还挺像那么回事。pi 的选择是要求唯一、不唯一就报错并把出现次数告诉模型,让它自己补上下文。

别只把最后那次写盘放进锁里。 会踩是因为直觉上「冲突发生在写」,所以只锁 write。但丢改动的根因在读——两个调用读到同一份旧内容时,事故就已经注定了,后面锁不锁都救不回来。整个读—改—写窗口必须在同一把锁内。

别用用户传进来的原始路径当队列键。 会踩是因为同一个文件有太多种写法:相对路径、绝对路径、符号链接别名,键不一样就等于没排队。pi 先解析成绝对路径、再走真实路径规范化,只在文件还不存在或平台不支持时才回退。

别在中断时立刻释放锁。 会踩是因为「用户取消了,赶紧放行」看起来是对的,但被取消的那次写盘可能已经在路上了,提前放行会让下一次操作读到中间状态。pi 的写法是在每个异步操作之后检查中断标志,而不是靠事件监听抢跑。

扩展自己的文件类工具时,记得接进同一条队列。 会踩是因为自定义工具默认跟内置工具是两套代码路径,各写各的,谁也不知道对方存在。pi 把 withFileMutationQueue@earendil-works/pi-coding-agent 导出来就是为了这个,文档里给了直接可抄的写法,并特别提醒要传解析后的真实目标路径,而不是用户参数的原样。

用 CRLF 的仓库要留意换行处理的位置。 pi 的顺序是读进来先剥 BOM、探测原换行风格、全量归一到 LF 再做匹配,写回前再按原风格还原、把 BOM 补回去。匹配环节看到的永远是 LF,因为模型给的 oldText 里几乎不会带 \r,更不会带那个看不见的 BOM 字符。如果你自己实现,这两处漏掉任何一个,Windows 用户那边就是清一色的「找不到文本」。

收尾

压成一句话:编辑工具的可靠性不在于匹配算法多聪明,而在于它在多少种「说不清楚」的情况下选择了拒绝而不是猜。 空 oldText 拒绝、不唯一拒绝、区间重叠拒绝、改完没变化拒绝、行数对不上拒绝——五处拒绝,加上一条覆盖整个读改写窗口的队列,就是 pi 这块的全部安全边界。回滚、事务、进程外并发它都不管,也从不假装自己管。

想接着往下看,建议按这个顺序读:先 packages/agent/src/harness/tools/edit.ts 走一遍主流程,再回头看 edit-diff.tsapplyEditsToNormalizedContent 那几十行校验,最后看 file-mutation-queue.ts——它只有五十多行,但把 registration 那段异步注册串行化的写法看明白,比看十篇讲并发的文章管用。要验证理解对不对,packages/coding-agent/test/file-mutation-queue.test.ts 里那几条用例是现成的答案。

自查自己那套工具时,对着这几个问题过一遍:替换没命中时你返回的是成功还是失败?目标文本出现多次时你改的是哪一处?两个工具同时改一个文件时,读操作有没有被同一把锁圈住?用户中途取消时,在飞的写入结束之前锁放开了吗?换行风格和 BOM 是在匹配前处理的还是写盘前才想起来?五个问题里有一个答不上来,那就是下一次改坏文件的入口。

本篇属于一个把开源编程 Agent 项目 pi 逐层拆开讲的系列,整体地图见开源编程 Agent pi 是什么;沿着这条线往下,还可以看 开源编程 Agent pi 的基础工具层开源编程 Agent pi 的会话存储

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。