Fiber 双缓冲教 Agent state 三招:workInProgress、commit、rollback

1. 坑

上周三下午我让主 agent 干一件挺具体的事:把博客 src/utils/ 底下三个跟”目录锚点”相关的工具函数抽出来、合并成一个新模块 src/utils/toc/,同时把 TableOfContents.astro 里的调用点全改过来。

它开工干得挺利索。二十分钟后我回头一看,界面是这样的:三个原文件已经删掉了、新目录建了一半(toc/index.ts 有了但 toc/anchor.ts 只写了函数签名没有函数体)、TableOfContents.astro 已经改成 import { getAnchor } from '../utils/toc'——这个 import 现在拉不到东西。tsc 一跑,7 处 red squiggle。更糟的是它已经把这半成品状态 git add . && git commit -m "refactor: extract toc utils" 掉了。

现在问它”撤销”,回答是什么呢?“我可以帮你 git reset --hard HEAD~1。“——这句话技术上没错,但注意它已经在这个错误的方向上动过三次外部 state:删过文件、改过 import、写过 commit。每一步都是不可逆的原子操作,reset 能恢复 git 历史,恢复不了我这二十分钟里同时打开的 tmux window、编辑器 buffer、正在 hot reload 的 dev server——都还挂在那个错误的中间态上

这个坑不是 agent 独有的。Fiber 之前的 React 也是这个坑:setState 触发 reconciliation 是同步的、一次性递归下去改真实 DOM,改到一半发现新数据来了没法优雅打断,改错了没法整体回滚——用户看到过”表头已经换成新数据、表格 body 还是旧数据”这种半成品页面。当年 React 团队花了好几年从 stack reconciler 走到 Fiber,就是为了解决这一件事。

Agent 现在正在把这个坑再踩一遍。tool call 直接改磁盘、prompt cache 一污染就是一整轮、subagent spawn 出去改的是同一份工作目录——没有 draft/canonical 边界的 agent,任何”改到一半”都是灾难。上面那个 refactor 事故的根因不是模型笨,是架构上没有给”我在试”和”我要真改”之间划一条线

2. 桥

React 团队给 Fiber 的解法非常干净,一句话:同时维护两棵树,一棵给用户看、一棵在后台画,画完再原子换指针

  • current 树:屏幕上正在显示的这一版对应的 Fiber 节点,用户唯一看得见的东西
  • workInProgress 树(简称 wip):React 正在后台”试着变成”下一版的 Fiber,用户看不见,可以随时被更高优先级的更新打断、可以扔掉从头重建、可以里面 throw 一个 error 不影响 current 一根汗毛

关键在两棵树共存。以前 stack reconciler 就一棵树,改到哪里算哪里,改到一半 throw 出来页面就烂在中间态;Fiber 拆成两棵之后,wip 是试算区,试算失败你只需要把 wip 这个指针指向的内存空间标记成可回收,current 完全不动。

伪代码大概长这样,几行就说完了:

// React Fiber 双缓冲的核心(伪代码,非源码)
let current = initialFiberTree;    // 屏幕上正在显示的
let workInProgress = null;         // 后台正在画的

function scheduleUpdate(update) {
  workInProgress = createWipFrom(current);  // 从 current 派生一份可变副本
  try {
    beginWork(workInProgress);              // 递归 render,可能被打断多次
    completeWork(workInProgress);           // 递归收尾,构建 effect list
    commitRoot(workInProgress);             // 原子提交——就下面一行
    current = workInProgress;               // 指针切换,用户此刻才"看见"新版
  } catch (e) {
    workInProgress = null;                  // 试算失败,丢弃 wip,current 完全不动
    scheduleRetry(update);                  // 真实实现走 error boundary / 高优先级打断重启,此处简化
  }
}

上面这段代码有三个动作值得记住:派生 wip指针切换(current = workInProgress 那一行)丢弃 wip。这就是本文标题里的三招——workInProgress、commit、rollback。Agent 需要的模型跟这个结构完全同构

blog205 已经把”双缓冲”作为三条原则里的第一条讲过一遍,那篇的落点是”提议态 vs 应用态”的分离(maker 和 verifier 各持一份 context)。这篇要往下挖一层:分离本身不是终点,分离之后的三个动作——试算、提交、回滚——才是 agent 状态管理真正要抄的骨头

3. 真

招 1:workInProgress——所有”探索中”的状态放进一个物理隔离的 draft 命名空间

Fiber 的 wip 树在内存里跟 current 是两片独立的对象,只是通过 alternate 指针互相引用。这个”物理隔离”是双缓冲能成立的最底层保证——wip 上任何一次错误的写入都不会漏到 current 上。

Agent 抄这一条的第一件事:给”我在试”划一个物理隔离的命名空间。不是”我在心里想想”,是磁盘上真的另开一个目录。具体到 refactor 那种场景,正确姿势是:

  • 主 agent 不直接改工作树,先 git worktree add ../wip-refactor-toc 派生出一个隔离副本
  • 所有 tool call 都在这个副本里跑:读文件、改文件、跑 tsc、跑测试
  • 副本里 crash 了、方向错了——git worktree remove --force ../wip-refactor-toc 干净收场,主工作树一根汗毛没动过

Claude Code 官方那个 Agent 工具的 isolation: "worktree" 参数就是干这个的,跑完自动清理、有改动就把路径 + 分支返回给你。这个参数不是”提高效率的可选优化”,是agent 时代做任何多步 refactor 都应该默认开启的架构选择

划这条线的时候有一个反直觉的纪律:draft 期间不能有任何对外可见的副作用。不能发 API、不能改共享数据库、不能给用户发通知、不能触发 webhook。因为 rollback 的前提是”丢弃 wip 无成本”——只要 draft 里干过一件对外可见的事,rollback 就变成了 saga 补偿(要写反向操作)而不是干净丢弃。Fiber 的 wip 树只写内存不写 DOM,正是同一个纪律的极端版本。

招 2:commit——只有通过 gate 检查才能原子切换,切换是”改一个指针”

Fiber 的 commit phase 有一条容易被忽略的属性:它是同步的、不可打断的、原子的。前面 render phase 可以断成 5ms 一片让位给用户事件,但 commit 一旦开始就一路跑完——current = workInProgress 这一行必须要么完全生效、要么完全不生效,中间态是被架构禁止的。

原因很朴素:如果 commit 中途被打断,就会出现”一半 DOM 是新的、一半是旧的”这种前端最怕的状态。React 宁可让 commit 阻塞主线程几毫秒,也不肯让这种脏状态被用户看见。

Agent 抄这一条的关键是给 commit 设置 gate、并且让 gate 通过之后的切换是原子的。gate 里要放什么?主线 blog210 讲 memory 时提过一句”记结论,不记过程”——这个纪律在 gate 环节要升级成一套具体检查。以我博客发布流程为例,一篇文章从 draft 到 published 的 gate 长这样:

  1. 子 agent 6 维度审校(结构、逻辑、术语一致、事实核查、语气、金句密度)出报告
  2. 我人肉过 A/B/C 三档评分——A 直接过、B 让子 agent 按报告改一轮再来、C 打回重写
  3. gate 通过之后,改三行 frontmatter
# commit 前
draft: true
reviewed: false
approved: false

# commit 后
draft: false
reviewed: true
approved: true

注意这里的原子性——这三个 flag 要么一起翻、要么一个都不翻。不允许出现”reviewed: true 但 approved: false”这种中间态,因为下游 astro build 只看 draft: false,一旦 draft 先翻了别的没跟上,一个没审完的稿子就被 build 进静态站了。这就是 Fiber commit phase “同步不可打断”那条纪律的 agent 版:从子 agent 报告出来到三行 frontmatter 都翻完,中间不允许被别的 tool call 插队

再往下挖一层:为什么 Fiber 选”指针切换”而不是”逐字段 diff 合并”?因为指针切换是 O(1) 且天然原子,而 diff 合并是 O(n) 且合到一半可能出错。Agent commit 也一样——如果你的 commit 逻辑是”把 draft 里的 20 条 memory 一条一条 merge 进主 memory 文件”,那你就在把一个原子问题拆成 20 个非原子操作。正确姿势是改一个指针(比如 rename 一个目录、切换一个 config 里的 active version 字段),让下游读取时自然看到新版本。

招 3:rollback——draft 丢弃是一句 rm -rfgit branch -D,前提是招 1 做对了

第三招其实是招 1 的自然推论——如果 draft 命名空间物理隔离、期间没有对外副作用,那 rollback 天然是一句话的事

  • worktree draft?git worktree remove --force ../wip-refactor-toc
  • 一次 subagent 探索的中间产物?rm -rf ~/.claude/projects/<hash>/wip-*
  • 一篇没通过 gate 的博客?frontmatter 三个 flag 保持 draft: true / reviewed: false / approved: false,下次重跑就是了

反过来说,如果 rollback 变得复杂——需要写”逆向脚本”、需要”补偿事务”、需要”手动清理三四个地方”——那不是 rollback 逻辑本身有问题,是招 1 的物理隔离没做到位,把该放 draft 的东西泄漏到 canonical state 里去了。这是我自己踩过的最疼的一种坑:一开始觉得”draft 只是个 flag 嘛,改一下不就行了”,后来发现 draft 期间跑过的 subagent 已经往 shared memory 写了三条不该写的记忆,rollback 时忘了清、下一轮又被检索回填出来——一个假 rollback 污染了后面一周的判断。

Fiber 的 rollback 之所以那么干脆,是因为 wip 树里的所有 fiber node 都从 current 派生但独立存在——丢弃 wip 就是把那片内存标记可回收,GC 会来处理。Agent 想要同样干脆的 rollback,就得在架构上保证 draft 期间产生的所有工件(文件、记忆、config、subagent 状态)都有一个共同祖先的隔离容器,rollback 时对着这个容器整个删掉。

4. 干:博客发布流程就是我自己的双缓冲

上面三招在我自己博客发布流程里全部跑通过——不是设计出来的,是踩坑踩出来的收敛形态

这个流程五步,缺一不可(这条已经写进我的 auto-memory 里,每次 session 启动就在 context 里):写完先子 agent 审校 → 构建推送 → SSH 部署服务器 → 推文 + 掘金 markdown → 更新 last-deploy-commit。对着上面三招拆一下:

workInProgress 层:每篇稿子起草时 draft: true,物理上放在 src/data/blog/zh/blogNNN_*.md——文件是真的在磁盘上、路径是真的能被 build 系统扫到,但因为 draft: true,会被我传给 astro getCollection 的 postFilter 挡掉——不进 sitemap、不进 RSS、不上首页。这就是”物理隔离命名空间”:磁盘上存在、对外不可见。draft 期间我允许自己反复改、允许起草子 agent 迭代、允许推翻从头写——因为知道这些改动没有任何对外副作用。

commit gate:子 agent 六维度审校报告 + 我 A/B/C 评分。评分完之后三行 frontmatter 一起翻(上面招 2 那段代码)。这一步是我流程里最不能拆的原子操作——现在写了个 pre-commit hook 检查”三个 flag 是否一致”,检测到不一致直接拒绝 commit。相当于给 gate 加了架构级的护栏。

rollback:这个招我真用过。blog211 §4.4 里那条”要么走完,要么什么都没改”的纪律,就是从一次真实翻车里立起来的——我的发布 cron 早期版本负责扫出到期该发的稿子、推给 deploy 脚本、成功后更新 last-deploy-commit。那次 deploy 到一半 SSH 连接掉了、scp 传一半、服务器上文件损坏、但 last-deploy-commit 已经更新了(因为脚本没考虑 scp 失败)——下次 cron 起来一看”已经 deploy 过了”,就跳过了那篇稿子。canonical state(last-deploy-commit)被 draft 期间的失败污染了

修法就是补上 rollback 逻辑:cron 检测到 deploy 失败时,把 frontmatter 三个 flag 打回 draft: true / reviewed: false / approved: false,同时不更新 last-deploy-commit。相当于把这次尝试从 canonical state 里完全拿掉、退回 workInProgress 状态、下次重来。这一次真正干净——因为 draft 期间除了那次失败的 scp,没有别的对外副作用需要补偿。

dogfooding 到最深的一层是:为什么我坚持”draft/reviewed/approved 三个 flag 分开”而不是”published: true/false 一个 flag 搞定”?就是为了让 gate 前后的状态转移是多阶段可观测的——出问题时能看出来卡在哪一阶段、rollback 时能精确回退到哪一阶段。这是从 Fiber 抄来的另一个隐含设计:wip 树上每个节点都有 flags 字段标记 Placement / Update / Deletion——不是一个 boolean 决定”要不要改”,是多维标记记录”这次要做哪几种改动”。

5. 界

类比讲到这里必须给反向边界,否则就是套模板。Fiber 双缓冲和 Agent 双缓冲有三处结构性不同,硬抄会翻车。

第一处:时间尺度差三到八个数量级。Fiber 的 wip 树生命周期是毫秒级——一次 render 从 begin 到 commit 通常几毫秒到几十毫秒,commit 完 wip 就地转正、旧 current 降级成 alternate 等下一轮复用。Agent 的 workInProgress 可能跨 subagent、跨 session、甚至跨天——我上个月批量并行起草 fe2agent 系列 7 篇 draft 那次,7 个 draft 在 draft: true 状态下同时存在了一个多星期,中间还改了两三轮。这意味着Agent 的 wip 需要真正持久化到磁盘(Fiber 的 wip 是纯内存对象),并且要考虑 wip 之间的冲突(7 篇 draft 里如果有两篇引用同一段 memory,一篇 commit 之后另一篇的引用可能过时)。

第二处:commit 之后旧 canonical 的命运不同。Fiber commit 之后,旧 current 降级成 alternate 等着被下一轮复用——React 不保留任何可回溯的历史语义,因为 UI state 不需要审计,用户只关心”现在长什么样”。Agent commit 之后旧 canonical 通常要保留完整历史:博客发布之后旧版本要留在 git 里、memory 更新之后旧记忆最好归档到 archive 目录、config 切换之后旧 config 要能回查是什么时候变的。这不是”想不想留”的选择,是审计和 debug 的刚需——agent 时代所有对外可见的 commit 都相当于生产系统的 deploy,没有历史就没有事故复盘。

第三处:Fiber 只有一路 DOM buffer,Agent 有多路异构 buffer。Fiber 双缓冲对着的是唯一一个”要保持一致的外部世界”——DOM。Agent 面对的外部世界至少四路:memory(磁盘 markdown 文件)、context(内存 messages 数组)、外部 API(第三方服务的 state)、subagent state(并行 agent 各自的进度)。四路 buffer 需要各自的 workInProgress,也需要一个整体的 commit 协调机制——一路 commit 了别的没跟上,就是招 2 里说的”半原子”事故。这是 Fiber 教不了 agent 的东西,需要 agent 自己在架构里另设一个”分布式 commit 协调层”(有点像数据库的两阶段提交,但更粗糙也更实用)。

我踩过第三处的最新一次坑是几周前:让主 agent 更新一条 memory 之后同步推送一条博客更新,memory 那一路 commit 成功了、博客那一路推送时网络失败,结果memory 里已经指向了一个还没上线的博客链接。修法是给这种跨 buffer 的 commit 加一个”暂存区”——两路都在暂存区 ready 之后一起翻,任何一路失败就一起 rollback。这个模式在数据库里接近两阶段提交的 prepare/commit、在前端叫 optimistic update rollback,本质都是双缓冲思想在多路 buffer 场景下的推广。

6. 钩

一句预警:如果你正在设计一个 agent 系统还没画 draft/canonical 的边界,下一次翻车只是时间问题。不是”可能会翻”,是”翻车形态和严重程度取决于你什么时候翻”——早翻是 refactor 事故、晚翻是生产事故。

一个动作项:明天上手 agent 之前,先在纸上(真的拿张纸)画一下你的系统里 draft 和 canonical 的边界在哪。三个问题:draft 的物理隔离容器是什么(目录?分支?namespace?)?commit gate 检查什么、由谁触发?rollback 触发条件是什么、动作是几行代码?三个问题如果任何一个答不上来,先别写下一个 tool call。

系列收官致意——这是番外 C,也是《从 useEffect 到 Agent Loop》系列 10 主线 + 3 番外全 13 篇的最后一篇。写这个系列最大的意外收获,不是”前端经验能帮上 agent”这个结论(这个结论 blog01 就写了),是发现前端十几年攒下的老经验,agent 时代还没用完。React 从 stack reconciler 到 Fiber 那四年、从 Fiber 到 Concurrent Mode 那三年、从 Suspense 到 Server Components 那两年——每一次架构演进对应的问题都在 agent 时代重演,而且形态更严重(因为副作用不再局限于 DOM,而是所有对外可见的世界)。所以下一步不是关掉 React 的老书,是把老书翻得更认真——每一条曾经被验证过的架构约束,在 agent 时代都值得抄一遍。

fe2agent 系列到这里收官,但前端到 agent 的类比不会。下一个坑我踩到了会再写。


延伸阅读