第 40 章:持久化 Agent Loop

"崩了就从断点接着跑"——但要是接着跑的是一个三个月前的破坏性操作、而且没人在看,你会宁愿它当初直接失败。


⏱️ 快速通道(5 分钟掌握核心)

  1. 在阶段边界打 Checkpoint 并做防抖——不是每轮迭代,也不是只在最后
  2. 用持久化标记索引让崩溃的 Turn 可被发现,而不必扫描全部 Session
  3. 恢复需要三道门:开关、过期窗口、尝试次数上限
  4. 恢复的 Turn 永远算无人值守,不管它原来是从哪来的
  5. 尝试计数必须在模型调用之前落盘,否则崩溃循环会把它永远清零

10 分钟路径:40.1-40.3 → 40.5 → Kocoro OSS Lab


公开源码快照:实现细节取自 Kocoro 公开仓库 origin/main4ec6772 提交,复核日期为 2026-07-27。章节讲的是不变量;具体常量应以当前源码为准。

40.1 从一个迟到三个月才醒来的 Turn 说起

一个 Agent 正做到一半,执行的是破坏性操作。用户提的需求,用户批的准,人就坐在旁边。然后 Daemon 被杀了——升级、OOM,或者笔记本合盖。

这一轮被 Checkpoint 了。很好,这正是持久化的意义。

三个月后,用户升级。Daemon 启动,发现一个持久 Checkpoint,然后做了它被设计来做的事:恢复执行。破坏性操作跑了。没有人在看。那份意图早在一个季度前就过期了,连同让它当时正确的全部上下文——它引用的文件、它针对的分支、它当初是个好主意的理由。

这是真实存在的失效模式,而修法不是「少打点 Checkpoint」。修法是:没有过期时间的可恢复性,是一枚带持久化引信的定时炸弹。

40.2 在哪里打 Checkpoint

两个直觉选项都是错的。每轮迭代都打,你在热路径上为一份马上又要变的状态付序列化成本。只在最后打,一次崩溃全丢——而这正是你想解决的问题。

有用的答案是阶段边界:那些「刚产出了值得留下的东西、马上要做一件可能回不来的事」的点。模型调用完成。工具批次结束。压缩摘要写完——尤其是这一个,因为那份摘要很贵,丢了就得再买一次。

再加个防抖,别让一串密集的边界变成一串密集的写盘。两秒左右就够;Checkpoint 的意义是扛住崩溃,不是精确到毫秒。

而且 Session 要写在那件危险的事之前,不是之后。第 34 章从另一个方向讲过这条——Session 在 loop.Run 之前落盘,正是为了让执行中途的崩溃留下一条记录而不是一个洞。持久性是你提前买的,不然就没有。

40.3 崩掉的 Turn 怎么被重新找到

没人发现的 Checkpoint 只是占硬盘。

直觉方案——启动时扫描全部 Session、找出没跑完的——用得越久越慢,这正好反了。稳态启动应该是廉价的。

所以维护一份持久化的标记索引:每个中断的 Turn 一条小记录,跟 Session 放在一起。启动读索引,不读全集。要守的不变量是一个双条件:

盘上 InProgress = true 标记存在

每一条持久化路径都得维护它——完整保存,以及所有读-改-写的补丁路径。漏掉一条,你就会得到指向已完成 Turn 的孤儿标记,或者看起来完成、其实被中断的 Turn。这两种都比「没有恢复机制」更糟,因为它们让恢复系统本身变得不可信。

40.4 三道门,以及最要紧的那道

发现只是简单的一半。要不要执行,才是设计所在。

开关。 一个默认打开的布尔值,能完全关掉自动续跑,同时保留标记。运维需要一种说法叫「找出来但别跑」,尤其是在故障处理期间。

过期窗口。 就是 40.1 那道门。公开默认是四小时,理由写在配置里:

Default 4 covers crash/upgrade restarts within a work session; anything older executes a stale user intent with nobody present (a months-old interrupted destructive turn must never fire on upgrade). When it binds, the checkpoint is abandoned with an interrupted_turn_abandoned event instead of resumed.

注意这个论证的形状。四小时不是一个图省事的整数——它是「一个工作时段」,是用户意图还大概率成立的跨度。超过它,Checkpoint 代表的就不是用户还想要的东西,而是他们曾经在一个已经不存在的上下文里想要过的东西。

也注意:放弃是一个事件,不是一次静默丢弃。过期的 Checkpoint 应该可见。静默放弃和静默恢复,都是「做了件出人意料的事却不告诉任何人」。

尝试次数上限。 默认 3。而让它真正生效的细节是:尝试计数在模型调用之前落盘,不是之后。 之后才加,那么一个在调用期间把 Daemon 搞崩的 Turn 永远记不上它这次尝试——于是它永远重试,每次启动都崩,你的恢复系统现在是一个启动死循环。

40.5 恢复的 Turn 永远算无人值守

这是最微妙的一点,而且它是安全属性,不是正确性属性。

一个最初从交互式桌面会话发起的 Turn 是有人值守的。当时有人在,审批能往返到一个真人,权限系统正确地放行了那些需要同意的事。

当同一个 Turn 在 Daemon 启动时恢复,没有人在——但 Session 记录的来源仍然写着 desktop。按来源分类,恢复的 Turn 就继承了有人值守的特权,却没有人可以行使它。公开 Runtime 把这一点钉死了:

IsUnattendedRun is always true: a recovered turn executes at daemon start with no human present, regardless of the session's original source. Without this marker a desktop/kocoro-source checkpoint would classify as attended and bypass the unattended auto-approval deny-list.

一般规则:「有没有人」是执行那一刻的属性,不是 Session 出身的属性。 任何从存储下来的出身、而不是从当下现实推导权限的系统,早晚会把特权授予一个空房间。

同一套逻辑还要求:恢复请求必须走原来的 RouteKey,这样它才会和并发的入站流量抢同一把锁。绕过正常路由的恢复,可能和同一个 Session 上的活跃 Turn 并发执行——两个写者,一个 Session,中间没有锁。

40.6 快照证据

观察4ec6772 源码位置
持久标记索引驱动发现interrupted_recovery.go L34
恢复的 Turn 永远无人值守interrupted_recovery.go L116
四小时过期窗口及其理由config.go L186
可关闭恢复但保留标记config.go L194
尝试次数默认 3config.go L446
Session 在执行前落盘runner.go L2576

以上描述的是一个有日期的实现快照,不是普遍契约。

40.7 持久化不会给你的东西

恢复一个 Turn 不等于让它幂等,把这两件事混为一谈,正是恢复机制制造出它本该阻止的那场事故的方式。

如果被中断的那一轮已经发过邮件、扣过卡、推过分支,Checkpoint 记录的是这个工具被调用过——它记不了副作用有没有落地。盲目恢复,你可能发两遍。防线在工具层,不在恢复层:任何有外部效果的东西都要有幂等键,让重复变成 no-op 而不是重复执行。

持久化也扛不住一次坏部署。一个版本写的 Checkpoint,被另一个版本恢复,只有在消息形状兼容时才安全。给持久状态加版本号,并且把「读不出来的 Checkpoint」当作「带事件地放弃」,而不是「启动时崩溃」。

它也不能替代取消。用户点了取消,要的是这一轮消失,不是被 Checkpoint 下来、然后在下次 Daemon 启动时热心地复活。取消必须清掉标记。

40.8 常见的坑

尝试计数在调用之后才加。 产出一个看起来像崩溃 bug 的启动死循环。

没有过期窗口。 40.1 那个事故。任何有持久恢复而没有过期的系统,此刻就潜伏着这个 bug。

按 Session 来源判断有没有人值守。 把一套权限授予了没有人。

只在 Save 里维护标记,补丁路径里没维护。 不变量一直成立,直到那条没维护它的代码路径跑了一次——然后它就悄悄地不成立了。

恢复时绕过正常的路由锁。 一个 Session 上两个写者,之后表现为莫名其妙交错的历史。

把「工具被调用了」当成「效果发生了」。 Checkpoint 记录的是意图,不是结果。

Kocoro OSS Lab(10 分钟上手)

  1. InterruptedResumeMaxAgeHours 的注释。注意它用一个从句写明了自己防的是什么失效——而且写在配置结构体里,正是改这个值的人会看到的地方。
  2. 找到 IsUnattendedRun,写下它返回 true 具体挡住了哪些工具进入无人值守运行。
  3. 在你自己的系统里,列出所有有外部副作用的工具。对每一个问:调用它的那一轮如果恢复了会怎样?答不上来,你有的就不是持久恢复,是持久重复。

划重点

  1. 在阶段边界打 Checkpoint 并防抖。 不是每轮,也不是只在最后。
  2. 标记索引让发现变廉价。 而「盘上标志 ⟺ 标记存在」这个双条件,必须在每一条持久化路径上都成立。
  3. 三道门:开关、过期、尝试上限。 过期窗口是大多数系统缺的那一道。
  4. 「有没有人」属于执行的那一刻。 恢复的 Turn 一律算无人值守,不管 Session 从哪来。
  5. 恢复不是幂等。 外部副作用要在工具层加幂等键;Checkpoint 告诉不了你邮件到底发出去没有。

下一章:第 41 章讲另一个方向——不是恢复一个停下来的 Turn,而是改变一个还在跑的。

引用本文 / Cite
Zhang, Wayland (2026). 第 40 章:持久化 Agent Loop. In AI Agent 架构:从单体到企业级多智能体. https://waylandz.com/ai-agent-book/%E7%AC%AC40%E7%AB%A0-%E6%8C%81%E4%B9%85%E5%8C%96Agent-Loop/
@incollection{zhang2026aiagent_40_-_Agent-Loop,
  author = {Zhang, Wayland},
  title = {第 40 章:持久化 Agent Loop},
  booktitle = {AI Agent 架构:从单体到企业级多智能体},
  year = {2026},
  url = {https://waylandz.com/ai-agent-book/%E7%AC%AC40%E7%AB%A0-%E6%8C%81%E4%B9%85%E5%8C%96Agent-Loop/}
}