状态、持久化与中断恢复:让长任务 Agent 活过重启
前两篇讲的是记忆——短期记忆(会话内的工作记忆)和长期记忆(跨会话的事实与流程)。这一篇讲的是另一种”活下来”的能力:当进程崩溃、节点故障、或者部署打断了一个正在跑的 Agent,它能不能恢复过来——不重做已经花了大价钱做完的工作,也不把有副作用的操作(发邮件、扣款、建 PR)做第二遍。
这就是持久化执行(durable execution)。它在开发环境里几乎从不出问题,第一次真正咬到你,往往是在生产环境一次带负载的部署时。
本篇原理部分主要整合自 agentic-ai-system-course 第 08 章,真实项目对比来自「16 个开源 Agent 项目差异对比」的会话持久化与中断恢复两个维度。
一个真实的故障
一个编程 Agent 已经跑了 40 分钟。它读了 50 个文件、做了 12 次编辑、生成了 3 份 PR 描述。这时部署上线了,进程重启。Agent 丢掉了内存里的中止令牌,但 checkpoint 显示它当时在第 23 步。重放时你发现,Agent 把其中一份 PR 描述又发了一遍——因为发 PR 描述的那个工具不是幂等的,而 harness 重试了它。你团队的 GitHub 上现在有一个重复的 PR。
模型没问题,Agent 的代码也没问题。是持久化层漏了。
什么才算”运行时状态”
在写任何持久化代码之前,先盘清楚:一个运行中的 Agent 到底有哪些状态,每一样该怎么处理?
| 状态 | 说明 | 恢复策略 |
|---|---|---|
| 消息数组 | 每轮模型输出、每次工具调用与结果,只增不改 | 持久化,重放的真相来源 |
| 工具执行状态 | 每个调用/running/completed/failed | 提交前落盘 |
| 在途副作用 | 已开始但还没返回的写入/发送/扣款 | 最难恢复,最容易误判 |
| 工作记忆 | 上一篇讲的可变草稿纸 | 必须持久化(从transcript重建未必精确) |
| 中止令牌 | 进程本地信号 | 不跨重启——崩溃后失控的run停不下来 |
| 凭证 | auth profile | 启动时重载,或从凭证池重建 |
| 提示词指纹 | 第04篇的 SHA | 往返落盘,保证重建的系统提示字节一致、缓存存活 |
| 成本/token 账本 | 预算上限用 | 从消息日志重算,或单独持久化 |
一个持久化的运行时,意味着上面每一项都有明确的策略
、提交后持久化、恢复时重建、或者接受丢失。没有”默认答案”,逐项决定并写下来。这里最凶险的是中止令牌不跨重启这条。如果你只靠中止令牌来停止失控的 run,那么一次崩溃就会留下一个谁也停不掉的僵尸 run 在后台烧钱。
提交点
回到第02篇那个概念——一个”步骤”是一次完整的循环迭代(模型调用 → 工具分发 → 反思)。提交点就在 Reflect 和 Stop 之间,正是第02篇标记的”一切都挂靠在这里”的边界。
一个步骤完成后,循环把控制权交出去之前,三样东西必须已经在磁盘上:
- 新消息追加到审计日志
- 工具执行状态转到终态
- 工作记忆和成本/用量计数器更新
OpenCode 在每次 LLM.stream() 循环后 flush;Hermes 做同样的 _flush_messages_to_session_db;Paperclip 每个 heartbeat_run_events 行提交一次。模式是通用的
// 一次步骤边界提交携带的内容
type Checkpoint = {
sessionId: string;
stepIndex: number;
status: "running" | "waiting_for_approval"
| "completed" | "failed";
messageRange: [number, number]; // 本步追加的消息
workingMemory: WorkingMemory;
tokensSpent: number;
costSpent: number;
promptFingerprint: string; // 第04篇
lastError?: string;
committedAt: string;
};
两条纪律:不要把密钥写进 checkpoint——存密钥的引用,运行时再解析;不要把重试计数写进消息日志——它属于 checkpoint,在那里更新。
run 状态机
一个 run 是”从一条用户消息(或定时触发)到它的终态回答”之间的工作单元。每个生产系统都用一个显式状态机来建模。隐式转换是 Agent 系统里一半”副作用重复”bug 的来源。
stateDiagram-v2
[*] --> Queued : 用户消息 / 定时任务
Queued --> Running : 原子认领 (CAS)
Running --> Running : 步骤边界提交
Running --> WaitingApproval : 审批门触发
WaitingApproval --> Running : 收到审批
Running --> ScheduledRetry : 瞬时错误
ScheduledRetry --> Queued : 退避时间到
Running --> Completed : 最终答案
Running --> Failed : 永久错误 / 步数上限
Running --> Cancelled : 用户中止
Completed --> [*]
Failed --> [*]
Cancelled --> [*]
Paperclip 几乎原样实现了这个——heartbeat_runs.status IN (queued, running, completed, failed, cancelled, scheduled_retry)。规则:
- 每个依赖当前状态的转换都需要条件更新——
UPDATE ... WHERE status = <预期>是底线。典型竞态是queued → running(两个 worker 抢同一行)。同样的WHERE也守护审批、中止、重试转换和终态写入,防止并发覆盖。基于一个已经在你脚下变了的状态做转换,就是丢失更新。 running → 终态一旦赢得就是幂等的,是个 no-op——这正是重放/重试时想要的行为。- 终态永不回头。一个需要重试的
failedrun 产生一个新的 run,用parent_run_id链回去——绝不原地复活。
崩溃恢复 vs Resume vs “Resume 按钮”
这三个听起来像,行为差别很大:
- 崩溃恢复(Crash recovery):同一意图,新的进程躯体。部署重启了,用户期待工作继续。系统提示没变,缓存可能还热(前提是前缀字节一致地往返了磁盘,而且没超过 provider 的 TTL,而且路由到同一模型同一区域)。在途工具调用需要仔细分诊。
- Resume:同一会话,更晚的时间。用户关了标签页几小时后回来。缓存可能已过期,系统提示可能在两次访问间被编辑过,审计日志能干净重放但世界可能已经变了。
- “Resume 按钮”:用户的显式动作,继续一个暂停的会话。用户知道中间有间隔,系统有更多自由去确认、展示发生了什么、必要时重置工作记忆。
混淆这三者会产生微妙的 bug。崩溃恢复对可安全重放的工作应该静默而激进(只读操作、标了 idempotent: true 的工具、有 outbox 兜底的副作用);其余的走在途分诊,把不可重放的工具调用暴露给用户而不是静默重试。
最难的一关
一个工具调用开始了、结果没回来、进程死了。重启后有四个选项,按优先级排:
- 工具标了
idempotent: true(第03篇)。第二次调用返回同样结果。 - 工具有外部幂等键,下游系统去重。
- 工具在执行前写了持久化 outbox outbox,如果意图已标记完成就跳过,否则用同一个键重试。
- 工具不安全重放 run 标记为 failed,暴露给用户。一句尴尬的”这个操作发生了吗?”好过一封重复的邮件。
这就是第03篇那个 idempotent / destructive 元数据标志真正兑现价值的地方——它不是给模型看的,是给恢复逻辑看的。
真实项目”恢复”其实是四件事
「16 个开源项目对比」在中断恢复维度上,澄清了四个最容易混淆的概念——它们不是同一件事的不同实现,而是四件不同的事:
| 关注点 | 代表项目 | 解决的问题 |
|---|---|---|
| 执行状态精确恢复 | Eino | 从第五步继续,不是从第一步重来 |
| 用户取消与优雅退出 | Goose | Cancellation Token |
| 运行时健康检测 | Hermes | Agent 还活着吗?卡死了吗? |
| 跨会话任务进度 | Oh My Open Agent | 上次做到哪了? |
Eino 在这个维度做得最深,有几个独有设计值得记:
InterruptWithStateCheckpointCompositeInterruptSkipPreHandler(防止重复发邮件/重复扣费)——这正是上面”在途工具调用”那一关的框架级解法- 恢复时机保证 Checkpoint,再通知用户(防止竞态)
而会话持久化又是另一个维度。这里 Goose 的精确恢复(SQLite V11 schema、逐条消息恢复、7 种会话类型、LLM 自动命名会话)和 Oh My Open Agent 的摘要注入(压缩后注入”继续第 N 个任务”)形成对比——不是好坏之分,是场景之分:
Eino Checkpoint 解决的是”从哪里恢复执行”,Goose Session 解决的是”从哪里恢复对话”——两者不可互换。
存储选型 / Postgres / 工作流引擎
- SQLite、单进程、零依赖。Goose、Hermes、Nano Cloud 都用它。适合桌面工具和单用户 Agent。Goose 的 Schema V1→V11 自动迁移是产品演进的忠实记录。
- Postgres、多 worker 并发、需要 CAS 认领和行级锁时。Paperclip 用它做 run 状态机 + 加密凭证 + 成本审计。
- 持久化工作流引擎(Temporal 一类)“存状态”,而是”把整个执行编排成可重放的确定性工作流”时。重,但对复杂的多步长任务是最干净的答案。
选型问题回到「16 项目对比」那句忠告:没有最好的架构,只有最适合你约束的架构。单用户助手用文件 + SQLite 就够;企业多租户平台才需要 Postgres + CAS + 工作流引擎。
小结
- 持久化执行的目标:崩溃后不重做已完成的工作,不重复有副作用的操作。
- 先盘清运行时状态,逐项决定持久化策略——中止令牌不跨重启是最容易被忽略的陷阱。
- 提交点在步骤边界,消息、工具状态、工作记忆都要落盘。
- 用显式的 run 状态机 + 条件更新(CAS)防止并发重复;终态永不回头。
- 区分崩溃恢复 / Resume / Resume 按钮——三者对缓存、对用户的态度都不同。
- 在途工具调用按 幂等→幂等键→outbox→暴露给用户 四级分诊。
- 中断恢复的四个概念(执行状态/用户取消/健康检测/任务进度)是四件事,别用一个方案硬套。
下一篇进入协调部分
Agent 不够用,怎么规划任务、怎么把工作拆给多个 Agent。主要参考:
- agentic-ai-system-course ch08(State and persistence)
.adb/agent开发/项目差异对比总览(维度7 会话持久化、维度9 中断恢复).adb/agent开发/8会话持久化、11中断恢复