Dark Dwarf Blog background

Agent 持久化设计:从 durable execution 到 checkpoint

本文整理自 Checkpoints and RollbackLong-Running Background Agents: Durable ExecutionHuman-in-the-Loop: Propose-Then-Commit

Agent 持久化设计:从 durable execution 到 checkpoint

Durable execution 解决的不是 “Agent 不会执行错误”,而是 “agent 出错或基础设施出错后,可以从最近 checkpoint 恢复,而不是全部重来”。

1. Durable execution 概述

a.a. 关键模块

durable execution 的四个关键模块为:Workflow、Activity、Event Log、Replay:

  • Workflow(工作流):确定性的编排代码。必须可 deterministic replay——从同一个 event log 重新执行时不会走岔。
  • Activity(活动):非确定性的工作单元。LLM 调用、工具调用、HTTP 请求、文件写操作都属于 activity。
  • Event log(事件日志):持久化的后盾。每次 activity 开始、完成、失败、重试都写入 log。
  • Replay(回放):恢复时 workflow 代码从头重跑,已完成的 activity 直接从 log 取结果,只有未完成的才真正执行。

这个模式和工作流引擎(Temporal 等)已经做了十年的事情一致。新的地方在于:LLM 调用现在也是一种 activity——非确定、昂贵、可能失败、可能有副作用。把 LLM 调用包成 activity 后,我们可以获得下面的东西:Retry with backoff、Checkpoint across restarts(崩溃后不用重新计费)、Replayable trace(调试时可以完整回放一次运行)。

b.b. 存储相关的设计

Durable execution 首先需要决定的问题是:存什么、什么时候存、怎么恢复。

首先是存什么。在 nonoka agent 的设计中我们提到了 event-driven 的 agent 架构的问题:event 本身是非确定性的,同一个 prompt 或者 Session State 可能会让 Agent 生成一个完全不同的 event。因此可以初步得到下面的结论:恢复应该建立在”保存下来的状态”上。例如在 nonoka agent 中:

  • 会话级:checkpoints 表存整个 SessionState 快照;
  • 步骤级:step_updates 表存 plan step 的状态增量(避免每个 step 变化都重写整个 session JSON 的 N+1 问题);
  • 记忆级:memory_entries 表存长期记忆,模型上下文与执行状态分开管理。

记忆必须跟着 checkpoint 走:nonoka 早期只有 Runner.resume 会恢复 memory,带 session_idrun_react 会新建 WorkingMemory——第二轮对话 agent 直接失忆(「我最喜欢的数字是 42」不记得了),评测里更严重:模型 read_file 后进程重启,恢复后不记得读过哪个文件、可能重复读。恢复 = 重建模型上下文,不只是重放步骤。

快照 + 增量并存时,加载的合并语义就是正确性的一部分:nonoka 曾踩过「completed_steps 已恢复但 step_statuses 仍是 RUNNING」的坑(P1.2),根因是双路径写入 + SQLite 秒级时间戳的同秒 race。修复是加载时按序合并,并保护终态不被非终态覆盖——排序只是降低概率,merge 不变量才保终态。

另外有一个恢复后会遇到的问题:恢复可能会导致之前执行过的副作用再次被执行(比如文件修改等),持久化语义只能保证 “at least once”,要消除重复执行的后果需要依靠幂等键、precondition 和 verify 这些专门用来处理副作用安全的模块来做。

然后是什么时候存。每次 checkpoint 都有 I/O 和存储成本,存储策略应该按动作副作用大小、用户等待成本和恢复代价来设计。例如 nonoka agent 中的设计:

阶段策略理由
初版设计每完成一个 Step 就 checkpoint最安全,崩溃损失最小
v3 设计默认每层完成后批量 checkpoint性能更好;层内失败恢复时重跑整层
最终设计checkpoint_interval: "per_step" | "per_layer",默认 per_step做成配置项,安全优先,性能可换

这里面的 layer 是指 Plan DAG 中的一层(同一层内的 step 互不依赖、可以并行执行)。

checkpoint 写入点是代码内的固定边界:每轮循环结束、执行工具之前、工具结果写回之后、外部工具/审批暂停时、终态:

# Persist the assistant tool_calls message before execution so a
# crash mid-turn leaves a checkpoint that resume() can repair.
await runner.checkpoint_store.save_session(session.session_id, session.to_state())

工具调用是副作用的分界线。工具执行前的 checkpoint 保证崩溃后能知道“哪些调用发出去但没拿回结果”,这正是解决前面说的重复执行副作用的重要入口。

最后是如何恢复状态。还是以 nonoka agent 为例,它有下面这个轻量状态机:

mark-in-flight → execute → verify → mark-committed
  • 执行前持久化 “in-flight” 状态;
  • 执行后 verify 成功才写 “committed”;
  • 如果状态是 in-flight 但 verify 发现已完成,直接 mark-committed;如果 verify 发现没完成就安全重试。

状态机是整个恢复流程的骨架,它保证的是整个流程的确定性:从同一个持久化状态出发,恢复逻辑永远得出同样的结果。除了状态机外还需要下面的机制来解决 double execute 问题:

机制状态机的盲区缺了它会怎样
幂等键状态机只记录“执行到哪一步”,不区分“这是第几次提交”——网络超时重试会把同一次批准当成两次提交double-execute:同一副作用被执行两次
Precondition check状态机不感知外部世界变化——批准时条件成立(如“余额>1000”),执行前可能已不成立条件失效仍执行:导致透支、越权等错误
Verify状态机只能记录“我认为做完了”,但是接口返回 200 不等于目标系统真的写入成功假成功:agent 带着错误前提继续推进,污染后续决策
Rollback状态机不处理“动作本身错了”的情况——这不是重复问题,而是回退问题动作出错后只能人工介入,无法自动恢复

这些在 nonoka agent 框架中对应如下:

  • 幂等键:工具结果按 pending tool_call_id 去重,resume 只注入缺失的结果;checkpoint 是唯一事实源。
  • Precondition check:外部工具 resume 必须带 WorkspaceAttestation(before/after digest)校验工作区未变;
  • VerifyCompletionContract 把“完成”从代码层面进行校验。
  • Rollback:git checkpoint / rollback。

2. 副作用相关概念&模块

下面展开讨论一下用于减轻 Agent 副作用的相关模块:

a.a. 幂等键

幂等键被用于解决 “同一提案被提交两次” 的问题。

通用的做法是把 (thread_id, action_signature) 的哈希作为唯一 key,durable store 里记录 ”key k 已经执行过“;第二次重试若发现 key 已存在,直接返回上次结果,不再真正调用目标系统。Stripe、AWS 的 API 都使用这个模式。

这个“只注入缺失结果”的设计非常重要,nonoka cli 的一个 check resume 的 bug 就是因为这个发生的:opencode 在 resume 后会完整重放所有记录,如果不这样的话会造成“旧结果覆盖新结果”的问题,而且还没有错误日志,很难发现。

但是幂等键只能管理重复提交,不能管理 Agent 是否在系统中已经做了一些事情,这正是 precondition check 的职责。

b.b. Precondition check

Precondition check 是对现有系统进行检查、看 Agent 是否已经做了一些事情。

它的做法是执行前重新读取条件:条件仍满足才执行,不满足就停止并告警。每个 consequential action 都需要这两个组件进行审查:幂等键防重复,precondition 防环境变化。

在 nonoka 中,声明会改工作区的外部工具,resume 时必须出示执行方的工作区凭据 WorkspaceAttestation**,整个流程如下:

[框架] 发出 tool call → session 置 PAUSED + checkpoint → 进程退出
[host] 执行前:遍历工作区,算 before_digest
[host] 执行工具(比如写文件)
[host] 执行后:算 after_digest,diff 出 created/modified/deleted
[host] 把凭据随结果回传
[框架] resume_external_tools 校验凭据 → 通过则继续循环

工具定义侧只需要声明 requires_workspace_attestation 为 True:

from nonoka import ExternalCapability
from nonoka.core.execution import ToolExecution

write_file = ExternalCapability(
  name="write_file", description="Write a file to the workspace",
  parameters={"type": "object", "properties": {
    "path": {"type": "string"}, "content": {"type": "string"},
  }},
  execution=ToolExecution(mutates_workspace=True),  # ← 声明:会改工作区
)

这个 ToolExection 就是专门用来声明工具的执行策略的,包括是否有副作用:

@dataclass(frozen=True)
class ToolExecution:
  """Declare the side-effect semantics of a capability."""

  read_only: bool = False          # 只读:无副作用
  mutates_workspace: bool = False  # 会改工作区文件(→ 外部工具必须 attestation)
  exclusive: bool = False          # 独占:不与任何调用并发
  stateful_action: bool = False    # 有状态动作:返回值之外还会改变外部状态
  pagination: bool = False         # 支持分页

然后执行方会在工具执行前后对工作区内容各算一次的内容哈希:跳过 .git / .nonoka / node_modules 等目录,把每个文件按”相对路径 + 内容哈希“记录,排序后对整个清单再做一次 sha256,类似下面的结果:

{
  "tool_call_id": "call_01_vUX0KUSMQFBKTXhFsTID0885",
  "result": "wrote answer.txt",
  "host": "opencode",
  "workspace": {
    "root": "/workspace/repo",
    "before_digest": "9d75d560c682073ac4340182f26d8df5...",
    "after_digest": "1f3ab2c9d8e7f6a5b4c3d2e1f0a9b8c7...",
    "created": ["answer.txt"],
    "modified": [],
    "deleted": [],
    "collector": "host"
  }
}

然后框架内置的 resume_external_tools 就会执行 check 逻辑:

requires_attestation = bool(getattr(capability, "requires_workspace_attestation", False))
if requires_attestation and (receipt is None or receipt.workspace is None):
  raise ValueError(
    f"External tool '{tc_name}' declared workspace mutation but returned no workspace attestation."
  )

c.c. Verify

Precondition Check 主要是看工具调用有没有做改动,而 verify 会真正消费这些变化,它会重新读取目标资源并进行 verify,verify 失败时工作流进入 known-bad 状态,触发 rollback 或人工告警,而不是假装成功继续走。

nonoka 中通过 CompletionContract 对最终的结果进行约束,这个也是在 Agent 声明中可配置的:

agent = Agent(
  name="coder",
  completion_contract=CompletionContract(
    require_workspace_mutation=True,     # 便捷字段,不用自己组装 rules
    require_focused_verification=True,
    enforcement="advisory",
  ),
)
字段防的事故
require_workspace_mutation「什么都没做就说完成」——模型输出一段总结就声称 done,实际没改任何文件
require_observed_effect「改错地方」——系统级任务的真实效果在 cwd 之外,只看工作区变更要么永远过不了、要么被「改了个无关文件」糊弄
require_focused_verification「用过期/不可信的验证糊弄」——验证的是改之前的代码
verification_enforcement「严格终止毁掉可评分状态」——外部 scorer 权威时,strict 终止会丢弃工作区状态,scorer 没东西可检查

这个的设计是在运行多个 benchmark 后发现 Agent 经常觉得自己工具调用完事后完成任务了、就直接宣称完事了,所以添加的。

d.d. Rollback

Rollback 处理的 “是动作本身错了”。有下面几种类型:

类型做法适用场景
In-band rollback在原动作所在的同一系统里反向操作(DELETEINSERT 的行、git reset目标系统支持反向操作
Compensating transaction发一个新动作“中和”原动作(邮件发错了 → 再发一封更正邮件)不能直接撤销,但可以补偿
Out-of-band rollback暂停工作流、告警、等人介入无法自动恢复,或自动恢复风险更高

in-band / out-of-band 来自通信领域的「带内/带外」:控制信息和业务数据是否走同一条通道。借用到回滚上,问题变成:回滚动作和原副作用是否发生在同一个系统里

判断方法只有一个问题:副作用发生在哪里?能不能反向操作

  • 自己控制的系统(数据库、文件系统):in-band 直接反向;
  • 外部系统:如果对方提供反向接口(cancel / refund)则为 in-band,否则只能补偿或人工;
  • 完全不可逆(消息已送达):no-op rollback,必须在提案阶段就声明,并在 HITL 时提高审查强度。

nonoka 使用 git checkpoint 实现了初级的 In-band rollback:

  1. 写工具(write_file / edit_file / search_and_replace / delete_file)执行前先打 checkpoint,已有的脏改动先以 “nonoka: preserve pre-existing changes” 提交;
  2. 创建 nonoka checkpoint: <iso> 提交(作者标记 <user> (nonoka));
  3. 工具失败时 rollback_last 回退,按 --author=(nonoka) 过滤只回退自己的检查点,不碰用户的手动提交。

Hermes 采用了影子仓库的设计,在影子仓库中存放 git,nonoka 为了简单就没这样了。

3. 将 HITL 变成 durable 状态机

a.a. propose-then-commit 状态机

2023 年常见的 HITL 是模态框:“我要给 X 发邮件,批准吗?”用户点 Approve。这个界面给人的感觉是“系统在人类控制下“,但是实操中几乎必然变成 rubber-stamp:用户点击速度比阅读还快,审计日志里只有一长串自己都想不起来的“已批准”。

这里面的问题是:系统丢了三样东西:上下文(批准对应哪一次副作用)、审计轨迹(用户看到了什么、为什么批准)和重复执行保护(网络抖动一次,一次批准可能变成两次执行)。

对此,Propose-then-commit 把审批改造成一个 durable 的状态机:

Propose → Surface → Commit → Verify
  • Propose:agent 生成拟执行动作,写入 durable store,提案不止是动作本身,而是一组元数据;
  • Surface:审查者(人)在独立界面看到完整提案;
  • Commit:正面确认后系统才真正执行;
  • Verify:执行完毕后重新读取目标状态确认副作用真实发生。

这和数据库事务很像:propose 是 BEGIN,commit 是 COMMIT,verify 是“SELECT 回来再核对一次”。

一个合格的提案至少包含:

  • intent(为什么做)
  • data_lineage(数据来源)
  • permissions_touched(触碰哪些权限)
  • blast_radius(最坏影响)
  • rollback_plan(怎么回退)
  • idempotency_key(唯一标识这次批准)

这些字段的作用不是让审批变得复杂,而是把“批准”从一次点击变成一次有记录的决策。

b.b. 跨进程审批:PAUSED 与 resume

审批状态不能放在 agent 内存里,因为进程会重启。nonoka 的做法是:

  1. 命中审批规则的调用不阻塞,而是把 pending 事实和 PAUSED 状态写进 checkpoint,进程退出;
  2. 审批 UI 拿到带唯一 IDapproval_request 事件;
  3. 用户决定后,resume_approval 按 ID 精确映射批准/拒绝/改参,拒绝则写入错误 observation,modified_args 则替换参数后执行。

这个设计是基于早期的 bad case:早期 HITL 写在工具 hook 里 await approver(),当前协程等用户点击批准,然后继续调用工具。单进程 demo 没有问题,但 CLI 或 server 一重启、网络一断开、用户十分钟后才回来,协程早就不存在了,审批对应哪个 tool call、原始参数是什么、已执行哪些同批工具全都无法回答。

PAUSED 的深层含义是:暂停不是进程内的“阻塞等待”,而是“冻结 + checkpoint + 进程退出”:进程可以随时关闭,但是 Session 一直未被丢失。像 nonoka 对 HTIL 就是非常暴力地直接关掉当前进程、在 approve 或者拒绝后新 spawn 进程、然后 resume 之前持久化好的 session:

================================================================================
                        PROCESS A (Terminated)
================================================================================
  run()

   ├── Hit approval rule
   ├── PAUSED + yield approval_request (Unique ID)


 [ save_session ] ────► [ SQLite Storage ]
                         │ - assistant (tool_calls)
                         │ - pending tool_call_id
                         │ - status = PAUSED

 (Process A dies)        │

~~~~~~~~~~~~~~~~~~~~~~~~─┼────────────────~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  [ User approves ]      │

 [ load_session ] ◄─────┴

================================================================================
                        PROCESS B (Newly Spawned)
================================================================================

   ├── Validate status == PAUSED
   ├── Match pending tool_call_id
   ├── Inject approval decision / execute tool


 [ Re-enter ReAct Loop ] ───► Normal LLM & Tool cycle...

4. 不同范式的恢复语义

Durable execution 的 deterministic replay 机制天然更契合 workflow 型 agent:路径由开发者预先定义(prompt chaining、routing、LangGraph 显式状态图),LLM 只是在每个节点做填空;因为路径固定,系统才能可靠地记录每个 activity 的输入输出,并在崩溃后跳过已完成步骤。

Autonomous agent(ReAct loop)则不同:模型自己决定下一步调用什么工具、循环多少轮、何时停止,没有固定 DAG,因此更常见的持久化方式是状态快照:保存对话历史、计划、观察结果、记忆引用,resume 时读回来让模型继续决策。

维度Durable execution(workflow 型)状态快照(autonomous 型)
持久化对象Activity 的输入输出事件当前状态、记忆、计划
恢复方式Replay event log,跳过已完成 activity读取最新状态,让模型继续决策
确定性要求Workflow 代码必须 deterministicLoop 代码简单,但模型输出本身不 deterministic
核心收益不重复副作用、不重复 LLM 计费不丢失上下文、跨会话继续

但两者不是非此即彼:autonomous agent 可以把一次完整的决策轮次(observation → LLM 思考 → action)当 activity 记录,恢复时已完成的轮次直接复用结果;工具调用有副作用时,还需要幂等键兜底,否则只靠状态快照恢复可能重复执行。

下面简单介绍 nonoka agent 对这两种不同范式的持久化实现(前面的篇幅其实就是 nonoka 的 ReAct Loop 的持久化实现了,然后 nonoka 也支持 DAG Plan)。

a.a. workflow 型的持久化

workflow 型的持久化分两层——Plan 定义执行进度分开存:

  • Plan(DAG)本身随 SessionState.current_plancheckpoints 快照;
  • 执行进度走 step_updates 增量表:每个 step 的 status / result / error 单独 upsert(复合主键 (session_id, step_id, update_type)),避免每步都重写整个 session JSON 的 N+1;
  • 恢复 = 快照 + 增量按序合并PlanExecutor.resume() 跳过 completed_steps,从断点继续按层执行。

b.b. autonomous 型的持久化

见前,前两章节基本都围绕 ReAct Loop 的持久化来讲的。