Skip to content

第 7 章 Agent 怎样修改文件 ​

本章要解决的问题

第 5 章解决了“工具调用怎样到达正确函数”,第 6 章确定了每轮上下文的组成。但文件编辑真正困难的不是函数分发,而是模型用文本表达修改意图,文件系统却需要精确位置;读取后文件还可能被别人改动,写入也可能中途失败。

本章比较八个方案或生产组合,并说明怎样处理并发检查、原子写入和恢复。

7.1 为什么局部修改通常不应整文件重写 ​

最朴素的想法是让模型输出整个文件的新内容,再覆盖保存。听起来简单,实际有三个问题:

text
任务:在一个 500 行的文件里改 3 行

① 浪费:  输出 500 行只为改 3 行;具体 token 数和价格取决于代码、模型与 provider
② 易错:  每一行都是一次"重新生成"的机会——模型可能顺手"优化"无关代码、
         漏掉后半段、幻觉出不存在的 import,而且这些改动混在 497 行原样输出里极难察觉
③ 慢:    输出是自回归逐 token 生成的,输出越多等越久(见第 1 章)

7.2 模型怎样准确表达修改位置 ​

模型输出的是文本流,而文件修改需要精确位置。两者提供的信息不同:

text
模型擅长的表达:  "把那个打印点击事件的地方换成 logger"        (表达意图,但没有准确位置)
文件系统需要的:  "第 22 行,从第 5 列开始,替换为 ..."        (精确、坐标级)

所有编辑方案都在把模糊描述转换为精确位置,区别在转换方式。

评价方案时看四个维度:

  1. 可靠性:模型照它的格式输出,系统能否准确找到位置?一字之差会不会失败?
  2. token 成本:模型要为"定位"付出多少额外输出?(比如删 100 行要不要把这 100 行原文抄一遍?)
  3. 模型负担:格式是否反直觉?小模型能不能稳定用好?
  4. 基础设施:需不需要额外的模型/索引/配套工具?

下面按这四项逐一比较。

7.3 五类基础定位方案 ​

先交代社区的大方向:主流实现已经收敛到"内容定位"一族——拿内容本身对位置,而不是拿坐标。下面五类方案里,old/new 字符串是事实标准(Claude Code、codex、今天的 Cursor 都在这条线上),行号主要出现在 IDE 集成场景,类 Vim 命令基本停在实验,Apply Model 路线的开创者 Cursor 也已转回内容定位。理解每一类方案,是为了看清这个收敛是怎么发生的。这一节先讲五类基础思路;7.4 再加入 grok 的 hashline 和 codex 的 apply_patch。对比表还单列 dsh 的“old/new + 版本校验”和 pi 的“old/new 批量替换 + 同文件写队列”;它们是定位语法与并发策略的组合,不是新的定位语法。代表产品与公开实现会随版本变化。

方案一:差异描述 + Apply Model(Cursor 的做法) ​

主模型不输出精确位置,只输出一份只包含改动和少量上下文的草稿,未变化部分用省略标记表示:

text
// 模型输出(只展示主要字段):
function handle click() {
  ...existing code...
- console.log("clicked")
+ logger.debug("clicked")
  ...existing code...
}

然后交给一个专门的 apply model,把草稿和原文件合并成完整新文件。Cursor 公开介绍的 fast-apply 路线还使用 speculative decoding(推测解码),以原文件作为候选输出来加速生成。具体吞吐和正确率取决于模型、硬件、文件与任务,不能只凭架构名称保证。

  • 可靠性:取决于 apply model 及任务;相似代码较多时仍可能合并错位置
  • token 成本:最低(省略号代替未变部分)
  • 模型负担:低
  • 基础设施:需要额外模型服务;是否自行训练取决于所选方案

方案二:旧字符串 → 新字符串(Claude Code / opencode 的做法) ​

模型提供 old_string(要被替换的原文)和 new_string(替换后内容),框架做字符串查找替换:

json
{ "path": "src/index.ts",
  "old_string": "console.log(\"clicked\")",
  "new_string": "logger.debug(\"clicked\")" }
  • 可靠性:old_string 在目标快照中存在且唯一时能确定定位;并发修改、换行和编码处理仍要单独检查(匹配失败的降级策略见 7.4.2)
  • token 成本:高——删除 100 行必须把这 100 行原文完整抄一遍,一个字符偏差(空格、全角/半角)就匹配失败
  • 模型负担:低,格式即直觉
  • 基础设施:无,十几行代码就能实现

pi 也选择 old/new,但加了三个实用变化:

  1. 一次调用可以提交同一文件中多处互不重叠的替换,所有 oldText 都针对同一份原文件匹配,不会因第一处修改让第二处坐标漂移;
  2. 先精确匹配,失败后再规范化行尾空白、Unicode 引号、破折号和特殊空格;一旦发现多个候选就拒绝,不会任选一个;
  3. 同一个真实文件路径的 edit/write 进入同一写队列串行执行,不同文件仍可并行。

这套方案减少模型调用次数,也避免同一 Agent 批次里的两个写操作互相覆盖。但它没有 dsh 的“先读凭证”:执行时会重新读取当前文件并匹配内容,如果外部程序恰好在读取之后、写回之前再次修改,仍存在竞态窗口。

方案三:行号定位 ​

模型指定"删除第 X 到 Y 行,插入新内容"。行号精确无歧义,大段删除只需两个数字。

  • 主要弱点:模型可能抄错行号;若前一处编辑增加或删除了行,后面的旧行号就会漂移。可以从后往前应用、每批重新读取或维护版本化的行号映射,但不能直接并行写同一快照

  • 实践缓解:读文件时附加行号前缀,批量编辑先基于同一版本校验;IDE 还可以在编辑后更新行号映射

方案四:类 Vim 编辑命令 ​

借鉴 Vim 的 dd/yy/p 等命令序列,移动代码块可以很简洁,但模型需要学会一套状态化命令语法。不同模型上的错误率需要用自己的任务集测试,不能只按“强模型/小模型”预判。

方案五:字符串首尾匹配 ​

old/new string 的改良:不输出完整的 old_string,只给要删内容的开头几行和结尾几行,中间省略;框架靠"这对首尾"定位替换区。

  • 可靠性:较高(仍基于内容匹配,首尾组合唯一即可定位)
  • token 成本:中——处理大段删除无需抄全文
  • 是"可靠性"与"经济性"的折中,适合自建 Agent 处理大块改动

五类基础方案速览 ​

方案定位机制可靠性token 成本基础设施代表
Apply Model保留部分内容 + 专用模型合并取决于合并模型与任务低额外模型服务Cursor
old/new string精确子串 + 唯一性唯一匹配时确定高低Claude Code / opencode
行号行坐标中(可能数错;前文修改后行号会变化)低需行号读取配套IDE 集成场景
类 Vim命令序列需实测低需稳定解释器实验/自建方案
首尾匹配首尾行内容首尾组合唯一时确定中低部分自建

7.4 锚点校验(grok-build 的 hashline) ​

前面五种方案都让模型自己"描述要改哪里"。grok-build 的 hashline 反过来:读文件时给每行发一个"地址牌",编辑时把地址牌原样交回,系统验证它仍然有效。

配套的读工具输出文件时,每行前面带一个锚点。

锚点按"校验多严格"分三档设计:
最轻的只校验行内容;
再进一步是行 + 块指纹(默认档,3 字符摘要、8 行一块,下面的样子);
最重的还会挂上检查点链——校验最新鲜,但编辑后锚点失效得也最快,

hashline 锚点的结构解剖

两段摘要的含义:
行摘要由空白规范化后的行内容算出(所以格式化工具改了缩进或行尾空格,摘要不变);
块摘要来自以 8 行为单位的邻近块,用来发现"附近上下文变了"。

案例:一次锚点校验的完整旅程 ​

第 1 步,读。 模型读 src/index.ts,每行带锚点返回:

text
20:a1f:rst→  function handleClick() {
21:b2e:rst→    console.log("clicked");
22:c3d:rst→    track("click");
23:e4f:rst→  }

第 2 步,编辑。 模型要改第 22 行,把锚点交回来:

json
{ "file_path": "src/index.ts",
  "edits": [ { "op": "replace", "anchor": "22:c3d:rst",
               "content": "  track(\"click\", { source: \"hero\" });" } ] }

第 3 步,校验(顺利情况)。 系统重新读盘上的文件,逐段核对:

  1. 第 22 行重算行摘要,还是 c3d ✓;
  2. 所在块重算块摘要,还是 rst ✓。
  3. 两级都过,替换执行。

注意:模型报的行号本身也要过内容检查,光"数字对"不算数——这正是不用裸行号的原因。

第 4 步,校验(文件被改过)。

假设用户在文件开头插入了一行注释:现在第 22 行是 console.log(...),行摘要对不上。

系统在默认 ±15 行范围内重找"行摘要 == c3d"的行,唯一命中:内容挪到了第 23 行。

本次编辑仍然被拒绝——内容确实过期,校验不过就是不过——但错误信息附带上一步的料:"该内容现在位于第 23 行,新锚点 23:c3d:rst,请用新锚点重试"。

模型换锚点重发,成功。找不到、或找到多个候选,分别返回对应错误。

锚点校验的本质

把"位置"从模型的单方声明,变成读工具签发、编辑工具验证的往返凭证——行号会漂、记忆会错,但"这行内容的摘要"对不上就拒绝。

它带来的能力和边界:

  • 比裸行号多一层内容检查,比抄整段原文短。 摘要降低了静默改错位置的概率,但短摘要仍可能碰撞,重复行也可能造成歧义;它不是密码学证明。
  • 小范围行号漂移可辅助找回,但找回不等于通过。 ±15 是当前默认值,不是通用最佳值;找回后必须带新锚点重试,不能静默通过。
  • 同一文件内的一批修改先全部校验后应用。 所有锚点和重叠关系都通过后才生成新内容,并从文件末尾往前改(避免前面的修改影响后面的行号)。它不提供跨多个文件的事务。

代价:锚点由读工具生成,读和编辑必须配套使用,不能像 old/new string 那样独立存在。

7.4.1 apply_patch:把修改写成一段"迷你 diff" ​

codex 的 apply_patch 不是又一个 JSON 参数方案,而是一种补丁语法:模型输出一段自包含的补丁文档,长得像 mini git diff:

text
*** Begin Patch
*** Update File: src/index.ts
@@
 function handleClick() {
-  track("click");
+  track("click", { source: "hero" });
 }
*** Update File: src/util.ts
@@
-export function deprecated() {}
*** End Patch

定位方式与前面的方案都不同:不给行号、不交锚点,也不抄完整的 old_string——靠上下文行(补丁里没有 +/- 前缀的行)在文件里找到位置,与 git diff 的 hunk 同一个思路。

解析器在文件里逐行查找这段上下文,按三级严格度依次放宽(apply-patch/src/seek_sequence.rs):

  1. 逐行精确匹配;
  2. 忽略行尾空白再匹配;
  3. 忽略行首和行尾空白再匹配。

第三级就是给"格式化工具改过缩进/行尾空格"留的容错——apply_patch 因此对格式化干扰天然更耐受。标记为文件结尾的补丁还会先尝试文件尾部,避免在文件中间误命中重复片段。

与前面方案的区别一目了然:

old/new、首尾匹配hashline 锚点apply_patch
一次表达多少修改一处一个文件内一批多文件、多处
定位靠什么精确子串读时签发的锚点上下文行,可逐级放宽
形态JSON 参数JSON 参数freeform 文本协议(需补丁解析器)
失败语义整条拒绝过期错误 + 新锚点可能部分成功,返回已应用的变化

它的优势是一次调用表达多文件、多块修改;代价是维护补丁语法与解析、以及部分失败的错误报告。模糊匹配不等于一定成功,重复片段中仍可能产生歧义;当前实现会返回已经成功应用的文件变化,不保证多文件全部成功或全部不变。

7.4.2 匹配失败时的降级阶梯 ​

编辑定位失败只有两种原因,处理思路完全不同:

text
A. 文件变了:格式化工具改了空白、用户改了内容、行号漂移   → 降级匹配能救
B. 模型记错了:抄错一个空格、漏抄了一行                  → 降级匹配可能"救错位置"

四家的容错哲学排成一个光谱:

  • codex(apply_patch):三级空白放宽(见 7.4.1)——精确 → 忽略行尾空白 → 忽略首尾空白,主打对格式化干扰的耐受。
  • grok(hashline):摘要天生免疫空白变化——行摘要按规范化后的内容计算,改缩进不改摘要;行号漂移靠 ±15 行重定位,且找回也不静默通过,必须带新锚点重试。
  • dsh:宁严不松——字面匹配,唯一的宽容是换行符(CRLF/LF 规范化后参与匹配,写回时按原风格恢复);匹配零次报 FS_EDIT_NOT_FOUND,匹配多次报 FS_AMBIGUOUS_EDIT,错误信息直接给出下一步动作:"provide a more specific old_string or set replace_all to true"。不做空白放宽——dsh 的取舍是:失败后让模型重抄一次,比放宽后静默改错位置便宜。
  • pi:受限的文本规范化——精确匹配失败后,允许行尾空白、Unicode 引号/横线/空格归一化;候选必须唯一,多个替换还必须互不重叠。它比 dsh 宽松,又没有 hashline 的读时凭证。

7.5 读取之后,文件变了怎么办 ​

7.2~7.4 解决的是如何在一份已知的文件快照里找到修改位置。但 Agent 在读取和写入之间还会思考、调用其他工具,同期的用户、格式化器或子 Agent 也可能改文件。因此位置表达正确仍不够,运行时还要确认它基于的快照没有过期。

为什么会变 ​

变更的来源至少有四个:

text
① 用户与 IDE:   linter 自动格式化、保存时格式化、用户顺手改了几行
② 模型自己:     同一轮里前一个编辑已改变文件,后面的编辑还基于旧快照
                (行号漂移是它的最常见形态,见 7.3 方案三)
③ 其他写入者:   并行的子 Agent、另一个会话、构建或脚手架生成
④ 时间:         读文件发生在很久之前,或上下文已压缩、只剩摘要(第 13 章)

不检测会怎样?最坏的情况叫丢失更新(lost update):用户在 t1 改的内容,被 Agent 在 t2 基于 v1 的修改静默覆盖——用户的劳动无声消失,而且双方都不知道:

text
t0  Agent 读 A.ts → 上下文里是 v1
t1  用户(或 linter)改了 A.ts → 磁盘上已是 v2
t2  Agent edit A.ts(带着 v1 的 old_string/锚点/版本)
     ├─ 无校验:   照写不误 → 用户在 t1 的修改被覆盖 ❌
     ├─ 内容匹配: old_string/锚点对不上 → 报错,但分不清"文件变了"还是"我记错了"
     └─ 版本凭证: 指纹对不上 → 明确报"文件已被修改",附下一步动作

如何检测变更 ​

真正回答"文件变没变"的检测策略有两种,按"对比的是什么"划分;第三种做法不检测变更,而是把住写入的准入门槛:

对比/依据是什么代表
策略一:内容匹配内容本身:精确子串 / 上下文行 / 行摘要old/new、apply_patch、hashline
策略二:版本凭证时间戳等文件元数据,不碰内容dsh
前置防线:强制先读不对比任何东西——没读过,就没有写的资格Claude Code、dsh

策略一:内容匹配即检测。 编辑本来就依赖内容定位,匹配失败就是“过期”最常用的信号。

  • 优点:零额外基础设施;
  • 缺点:失败原因模糊——模型抄错一个字符,和用户改了文件,报错长得一模一样;
  • 做到最细的是 grok:锚点三段校验 + ±15 行重定位,确认过期时返回新锚点让模型重试(7.4)。

策略二:版本凭证,即乐观并发(dsh)。 读文件时记下 stat 指纹,受守卫保护的 edit/write 在写入前重查指纹

  • 指纹不匹配 → FS_STALE_VERSION("file changed since it was read");
  • 本会话从没读过就写 → FS_NOT_OBSERVED;

除此之外,Claude Code 的公开行为是:Edit 之前必须 Read;文件在读取后被外部改动(用户或 linter)则拒绝写入、要求重读;

dsh 的 FS_NOT_OBSERVED 是同一思想。它挡住两类事故:凭想象编辑没读过的文件,以及覆盖自己从没见过的修改。

pi 的折中:执行时重读 + 同路径串行。 pi 不要求模型事先调用 read,也不保存 stat 指纹。每个 edit 真正执行时先读磁盘当前内容,在这份内容上匹配并生成新内容;因此较早的无关修改通常会被保留。同一进程内针对同一真实路径的 mutation 会排队,避免两个工具同时读到旧版再相互覆盖。它解决的是自身并发,不是完整的外部乐观锁:其他进程仍可能在“读完—写回”的窗口内改文件。

检测到变更怎么办 ​

四家的共同点是定位不确定时拒绝,不在多个候选中猜一个。dsh 还能把“文件已变”和“文本没找到”分成不同错误;pi/codex/grok 主要依赖各自的内容定位结果。

  • 错误码与修复指引分开:

dsh 给守卫错误追加修复指令——FS_STALE_VERSION → "re-read the file, then retry",FS_NOT_OBSERVED → "read the file, then retry";

错误码本身保留,供重试、权限、UI 各层继续路由。

  • 先分清“谁变了”:版本不匹配时先报 stale、再做 old_string 匹配——过期文件统一报“文件变了”,而不是拿新内容报“old_string 找不到”。把“文件变了”(该重读)和“我记错了”(该重抄)分开,模型才能选对下一步,这正是“错误信息是给模型的 API”(第 4 章)的落地。
  • 没有一家做自动三方合并(git merge 式):合并错了比显式失败更贵,而且用户的改动可能是有意的。真需要多方写同一文件,靠任务分工(不同文件归不同 Agent)或显式协调(第 16 章)。

7.6 改坏了怎么办:回滚要回答的三个问题 ​

回滚兜底的是"改坏了",但前提是"写"自己别制造烂摊子。先看一个具体场景:

text
直接写:fs.writeFile("src/index.ts", 新内容)
  → 进程写到一半崩溃(或电脑突然断电)
  → 磁盘上可能是:前半段新的、后半段旧的,甚至是空文件
  → Agent 下一轮读到半个文件,后面的判断全错

正确姿势是"先写副本,再换名字",三步、每步都有明确的理由:

text
① 把新内容写进同目录的临时文件(如 src/index.ts.tmp)
   —— 全程原文件纹丝未动;写坏了,坏的也只是副本
② fsync:让操作系统把数据真正写到磁盘
   —— 平时的写入会先停在内存缓存里"攒着",断电就蒸发;
      fsync 是唯一能确认"已经落盘"的手段
③ rename:把临时文件改名为 src/index.ts
   —— "改名"在同一个磁盘分区内是原子的:要么成功(新版整体上线),
      要么没发生(原文件完好),不存在中间态

dsh 的本地文件后端把这条链路做得最完整:在目标文件旁创建仅当前用户可访问的临时目录,用独占方式创建临时文件,写入并 fsync,最后按平台使用 rename/replace 发布;Windows 还要复制原文件的访问控制。发布已经成功、只剩临时目录清理失败时,它不会把一次已提交写入伪装成失败。

grok、codex 和 pi 的普通写入路径没有做到这个程度。pi 的同文件写队列防并发交错,但直接 writeFile 仍不等于崩溃安全的原子替换。

多文件修改。

一次修改涉及十个文件,写到第七个崩溃,前六个已经在盘上,怎么办。 grok 在单文件内"先全部校验、再一次性应用"; codex 的 apply_patch 返回已实际应用的变化,让上层知道部分成功到了哪里。更细的一点是:一段多文件 patch 可能先提交前几个文件,之后才被拒绝或失败;工具最终显示 error,已经提交的前缀仍会进入本轮净 diff。也就是说,工具失败不等于没有副作用。

这里都没有实现类似数据库事务的跨文件原子性。实现者必须分别保存“请求修改什么”“实际提交什么”和“最终调用状态”;只保存模型请求的 patch,会在部分成功时得到一份错误的审计记录。

7.6.1 diff 应该根据哪一份内容生成? ​

diff 至少有三种,用途不同:

text
预览 diff    参数刚生成时,基于当时读到的文件推算——用于让用户提前看
提交 diff    工具写入成功后,根据 before/after 生成——用于审计和回传模型
本轮净 diff  连续多次修改合并后,比较轮次起点与当前状态——用于总结整轮变化

pi 会在参数完整时异步计算预览,真正执行时重新读取文件;如果期间文件变化,最终结果携带的 diff 可能与预览不同,界面必须以结果 diff为准。codex 的本轮 diff 跟踪器则消费 apply_patch 已经确认提交的 before/after,将创建、删除、移动和多次修改合成净变化;后一次修改把前一次改动撤掉时,净 diff 可以重新变空。

不要从工具参数直接拼审计 diff。参数是意图,磁盘是事实;模糊匹配、格式恢复、外部写入和部分成功都可能让两者不同。

问题一:备份哪一个版本? ​

"改之前备份一份"听起来够用,但一个文件在一个会话里可能被改十几次——于是有一堆版本:备份哪一个?回到哪一个?这是备份节奏的决策,有四档:

节奏备份什么能回到代价
每次编辑每次写入前的 before任何一次编辑之前版本最多:全量存太贵,只能存增量链
每轮对话每个对话轮边界的工作区状态某一轮对话之前中等;一轮内的多次编辑合并看待
会话首尾会话开始/结束各一份整个会话之前最省;粒度最粗
不备份—用户自己的 git零成本;前提是 Agent 从不替用户提交

节奏之外还有三个必须一起定的子问题:

  • 全量还是增量:全量快照恢复最简单(直接写回),但存储随版本数线性增长;增量(diff 链、before/after)省空间,但恢复要沿链重放,坏一环影响后面所有版本。好在"每次编辑的 before/after"天然就是增量备份——事件日志记到位,备份就顺手有了。
  • 保留多久:不可能无限留。grok 的磁盘镜像默认关闭,开启后也只保留最近 64 个——保留窗口是存储成本与"能回多远"的权衡。
  • 存在哪:内存(会话结束即失)、磁盘(重启还在)、还是用户的 git 对象库——第三条路 codex 走过又退了回来(见下)。

四家的处理方式:

  • grok 选每轮对话节奏(状态跟踪器按 prompt 边界记录;持久化默认只存内存,磁盘镜像是可选档)
  • dsh 是每次编辑节奏的免费副产品(edit/write 事件带 before/after,材料齐全,但没有做恢复命令)
  • codex 不备份——早期用 git 游离 commit 对象(ghost snapshot)存工作区快照,2026 年 4 月整体移除,教训:游离对象会进用户的 git 对象库、和用户自己的工作流互相干扰、维护成本高——"用 git 对象做撤销"在生产里输给了"不做撤销"
  • pi 不把会话分支当文件备份——会话树保留模型与工具轨迹,切换分支不会撤销已经写入工作区的内容;恢复文件仍依赖用户自己的 Git 或自定义扩展

问题二:最小可回滚单元是什么?怎么分清"这行是谁改的"? ​

备份解决"有底片",归属解决"撤得准"。看一个最难的场景:AI 在文件里改了 10 行,随后用户在同一文件改了几行,并行的子 Agent 又改了几行——现在要只撤销第一个 AI 的那 10 行,怎么办?

绝对不能拿快照整文件覆盖:旧全文一盖,用户和子 Agent 的改动一起消失——等于亲手制造一次 7.5 要防的丢失更新。正确做法是把"那次编辑引入的 diff"从当前文件里反向剥掉:

text
当前文件 = V0 ─AI 应用 d1(改 10 行)→ 用户应用 d2 → 子Agent 应用 d3→ …
撤销 d1 = 对当前文件反向应用 d1:
  ├─ d1 的 10 行没被后人动过(用户/子Agent 改的是别处) → 精确剥离 ✅
  └─ d1 的行又被别人改过 → 冲突:自动三方合并,或放弃自动、交给用户裁决

所以最小可回滚单元不是文件、也不是快照,而是"一次可归属的操作"——单元上必须绑着"谁、哪次操作、改了哪段"。能撤到多细,取决于记录时归到多细:

  • grok:prompt 边界快照 + 可选 hunk 记录。rewind 会先比较磁盘当前内容与 Agent 最后一次写入后的快照,并报告“被外部删除、创建或修改”的冲突类型。一个反直觉边界是:当前实现报告冲突后仍然执行恢复,冲突只是告知,不是 CAS 拒绝。因此它适合明确的“回到这轮之前”,不能当作“绝不覆盖用户后续修改”的保证。
  • dsh:编辑级材料齐全。每次编辑的 before/after 足够支撑"按次反演";而反演出的写回同样是一次写,要过 7.5 的版本检查——目标文件又被改过时守卫先拦,不会吞掉别人的改动。只是目前没有内置消费者。
  • codex 的教训:工作区级快照没有行/块归属,"回滚"只能是整个工作区回到过去、或者不回——粗粒度,加上污染用户 git 库的问题,最终整个功能被移除。

问题三:在哪个粒度回滚——按用户意图选择 ​

前两问(有版本链、有归属)解决之后,粒度就不再是技术问题,而是把选择权交给用户:

用户想表达粒度动作
"这轮任务全搞砸了,重来"从某个任务节点/某轮对话回滚该边界之后所有文件回到边界前;若不想卷走用户自己的改动,结合问题二的归属做选择性撤销
"只有这个文件坏了"单文件回滚该文件恢复到指定版本(某次编辑前,或某轮对话前)
"就这几十行不要"块级回滚逐 hunk Accept/Reject

grok 覆盖第一档(prompt 边界 rewind,且对话与文件可分离)并保存可选 hunk 材料;codex 两档都不做;dsh 有材料、无命令。grok 的恢复也不是一个跨 Git 与文件系统的原子事务:Git 状态、文件内容和重新暂存分阶段完成,中途可能部分成功。只要有文件恢复失败,它会保留检查点供重试,而不是删掉唯一的恢复依据。

对 Git 纪律严格的团队,"Agent 不自带文件回滚、恢复靠用户自己的分支与提交"本身也是一种答案,前提是 Agent 的改动都留在工作区、从不替用户提交(第 22 章会把"内部检查点"与"git 提交"的边界再讲一遍)。

四家对照:

grokcodexdshpi
文件恢复prompt 边界 rewind + hunk 材料;冲突会报告但仍恢复无(ghost snapshot 已移除,恢复靠用户 git)无内置命令(before/after 材料在事件里)无内置命令;会话树分支不回滚文件
默认持久化内存;磁盘镜像默认关、上限 64不适用会话记录 ≠ 文件回滚会话 JSONL ≠ 文件备份
防并发覆盖hashline 局部指纹;rewind 冲突检测不阻止覆盖apply_patch 上下文匹配,不是版本锁写前版本校验 + 原子发布当前内容匹配 + 同路径写队列;无外部版本锁

【对比小结】 ​

把八个方案或组合放进一张定位图(横轴:定位 token 成本;纵轴:可靠性;颜色:基础设施投入):

八个编辑方案或组合的定位图

再放进同一张表。这里的可靠性与成本是定性比较,会随模型、提示、文件重复度和并发情况变化;选型前应在自己的任务集上测成功率、误改率、重试次数与 token:

方案可靠性定位 token 成本基础设施失败后怎样恢复
Apply Model取决于合并模型与任务低额外模型服务重新合并或回退
old/new string唯一精确匹配时明确高(需抄原文)低报错+模型重试
old/new + 版本校验(dsh)再检查读取后的文件版本高需记录观察版本返回 stale/not-observed 修复指引
old/new 批量替换(pi)唯一匹配;同文件内部写入串行高(需抄原文)低报错+模型重试;无外部版本凭证
行号易受行号漂移影响低需行号读取重新读取行号
类 Vim取决于模型对命令语法的掌握低需稳定解释器报错+重试
首尾匹配首尾组合唯一时明确中低报错+重试
锚点(hashline)局部摘要校验;短摘要/重复行仍有边界低读编辑必须配套±15 行给出重定位建议
apply_patch(codex)多级上下文匹配;重复片段仍可能歧义中需语法解析器返回部分应用情况或报错

选型建议:

  • 自建 Agent 起步 → old/new string:无需额外服务,报错原因清楚,多数场景够用
  • 处理大块删除/移动多 → 首尾匹配 或 apply_patch:省 token
  • 能承担额外模型服务并已实测合并效果 → 可评估 Apply Model
  • 多 Agent/多进程会同时改文件 → 增加版本校验或局部锚点校验;局部校验仍不能代替跨文件冲突处理
  • 核心取舍再强调一遍:可靠性 ↔ token 成本 ↔ 基础设施投入,没有全赢的方案,只有匹配你场景的方案。

本章小结 ​

  • 读取格式与编辑协议要配套;匹配失败时不能在多个候选中猜一个。
  • 用版本凭证区分“模型记错了”和“文件已经变化”,发现并发修改就拒绝写入并要求重读。
  • 单文件写入要原子化,跨文件部分成功必须如实报告;审计依据是实际 before/after,不是模型请求。
  • 回滚要明确备份版本、最小单元和目标粒度,并说明冲突是否会阻止覆盖。

现在 Agent 能较可靠地修改文件,也能在部分失败后说明发生了什么。但文件写成功不代表代码正确:还需要运行类型检查、lint、测试和构建。下一章因此进入 Shell 工具。

基于 MIT 协议发布,欢迎转载与贡献。