Appearance
第 7 章 Agent 怎样修改文件
本章要解决的问题
第 5 章解决了“工具调用怎样到达正确函数”,第 6 章确定了每轮上下文的组成。但文件编辑真正困难的不是函数分发,而是模型用文本表达修改意图,文件系统却需要精确位置;读取后文件还可能被别人改动,写入也可能中途失败。
本章比较八个方案或生产组合,并说明怎样处理并发检查、原子写入和恢复。
7.1 为什么局部修改通常不应整文件重写
最朴素的想法是让模型输出整个文件的新内容,再覆盖保存。听起来简单,实际有三个问题:
text
任务:在一个 500 行的文件里改 3 行
① 浪费: 输出 500 行只为改 3 行;具体 token 数和价格取决于代码、模型与 provider
② 易错: 每一行都是一次"重新生成"的机会——模型可能顺手"优化"无关代码、
漏掉后半段、幻觉出不存在的 import,而且这些改动混在 497 行原样输出里极难察觉
③ 慢: 输出是自回归逐 token 生成的,输出越多等越久(见第 1 章)7.2 模型怎样准确表达修改位置
模型输出的是文本流,而文件修改需要精确位置。两者提供的信息不同:
text
模型擅长的表达: "把那个打印点击事件的地方换成 logger" (表达意图,但没有准确位置)
文件系统需要的: "第 22 行,从第 5 列开始,替换为 ..." (精确、坐标级)所有编辑方案都在把模糊描述转换为精确位置,区别在转换方式。
评价方案时看四个维度:
- 可靠性:模型照它的格式输出,系统能否准确找到位置?一字之差会不会失败?
- token 成本:模型要为"定位"付出多少额外输出?(比如删 100 行要不要把这 100 行原文抄一遍?)
- 模型负担:格式是否反直觉?小模型能不能稳定用好?
- 基础设施:需不需要额外的模型/索引/配套工具?
下面按这四项逐一比较。
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,但加了三个实用变化:
- 一次调用可以提交同一文件中多处互不重叠的替换,所有
oldText都针对同一份原文件匹配,不会因第一处修改让第二处坐标漂移; - 先精确匹配,失败后再规范化行尾空白、Unicode 引号、破折号和特殊空格;一旦发现多个候选就拒绝,不会任选一个;
- 同一个真实文件路径的 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 行一块,下面的样子);
最重的还会挂上检查点链——校验最新鲜,但编辑后锚点失效得也最快,
两段摘要的含义:
行摘要由空白规范化后的行内容算出(所以格式化工具改了缩进或行尾空格,摘要不变);
块摘要来自以 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 步,校验(顺利情况)。 系统重新读盘上的文件,逐段核对:
- 第 22 行重算行摘要,还是
c3d✓; - 所在块重算块摘要,还是
rst✓。 - 两级都过,替换执行。
注意:模型报的行号本身也要过内容检查,光"数字对"不算数——这正是不用裸行号的原因。
第 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):
- 逐行精确匹配;
- 忽略行尾空白再匹配;
- 忽略行首和行尾空白再匹配。
第三级就是给"格式化工具改过缩进/行尾空格"留的容错——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 提交"的边界再讲一遍)。
四家对照:
| grok | codex | dsh | pi | |
|---|---|---|---|---|
| 文件恢复 | 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 工具。