第 40 章:持久化 Agent Loop
"崩了就从断点接着跑"——但要是接着跑的是一个三个月前的破坏性操作、而且没人在看,你会宁愿它当初直接失败。
⏱️ 快速通道(5 分钟掌握核心)
- 在阶段边界打 Checkpoint 并做防抖——不是每轮迭代,也不是只在最后
- 用持久化标记索引让崩溃的 Turn 可被发现,而不必扫描全部 Session
- 恢复需要三道门:开关、过期窗口、尝试次数上限
- 恢复的 Turn 永远算无人值守,不管它原来是从哪来的
- 尝试计数必须在模型调用之前落盘,否则崩溃循环会把它永远清零
10 分钟路径:40.1-40.3 → 40.5 → Kocoro OSS Lab
公开源码快照:实现细节取自 Kocoro 公开仓库
origin/main的4ec6772提交,复核日期为 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_abandonedevent instead of resumed.
注意这个论证的形状。四小时不是一个图省事的整数——它是「一个工作时段」,是用户意图还大概率成立的跨度。超过它,Checkpoint 代表的就不是用户还想要的东西,而是他们曾经在一个已经不存在的上下文里想要过的东西。
也注意:放弃是一个事件,不是一次静默丢弃。过期的 Checkpoint 应该可见。静默放弃和静默恢复,都是「做了件出人意料的事却不告诉任何人」。
尝试次数上限。 默认 3。而让它真正生效的细节是:尝试计数在模型调用之前落盘,不是之后。 之后才加,那么一个在调用期间把 Daemon 搞崩的 Turn 永远记不上它这次尝试——于是它永远重试,每次启动都崩,你的恢复系统现在是一个启动死循环。
40.5 恢复的 Turn 永远算无人值守
这是最微妙的一点,而且它是安全属性,不是正确性属性。
一个最初从交互式桌面会话发起的 Turn 是有人值守的。当时有人在,审批能往返到一个真人,权限系统正确地放行了那些需要同意的事。
当同一个 Turn 在 Daemon 启动时恢复,没有人在——但 Session 记录的来源仍然写着 desktop。按来源分类,恢复的 Turn 就继承了有人值守的特权,却没有人可以行使它。公开 Runtime 把这一点钉死了:
IsUnattendedRunis 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 |
| 尝试次数默认 3 | config.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 分钟上手)
- 读
InterruptedResumeMaxAgeHours的注释。注意它用一个从句写明了自己防的是什么失效——而且写在配置结构体里,正是改这个值的人会看到的地方。 - 找到
IsUnattendedRun,写下它返回true具体挡住了哪些工具进入无人值守运行。 - 在你自己的系统里,列出所有有外部副作用的工具。对每一个问:调用它的那一轮如果恢复了会怎样?答不上来,你有的就不是持久恢复,是持久重复。
划重点
- 在阶段边界打 Checkpoint 并防抖。 不是每轮,也不是只在最后。
- 标记索引让发现变廉价。 而「盘上标志 ⟺ 标记存在」这个双条件,必须在每一条持久化路径上都成立。
- 三道门:开关、过期、尝试上限。 过期窗口是大多数系统缺的那一道。
- 「有没有人」属于执行的那一刻。 恢复的 Turn 一律算无人值守,不管 Session 从哪来。
- 恢复不是幂等。 外部副作用要在工具层加幂等键;Checkpoint 告诉不了你邮件到底发出去没有。
下一章:第 41 章讲另一个方向——不是恢复一个停下来的 Turn,而是改变一个还在跑的。