【AI Agent】长任务中断后怎么恢复?——检查点、幂等与人工审批实战

一个 Agent 正在整理几十份文档,已经完成检索、生成计划和风险检查,准备写入知识库。此时进程被重启,界面只留下“任务失败”。如果重新执行,前面的模型调用会重复计费;如果直接从写入步骤继续,又无法确定上一次写入到底成功没有。长任务真正困难的地方不是“让模型多思考几轮”,而是让一次执行跨越进程重启、网络超时和人工等待之后,仍能知道自己走到哪一步、哪些副作用已经发生、下一步能否安全重试。

本文用 Python 标准库和 SQLite 实现一个最小可运行的持久化工作流。它不依赖具体模型,重点验证五件事:步骤完成后保存检查点;重启后跳过已完成步骤;外部写入使用稳定幂等键;高风险动作暂停等待审批;崩溃发生在副作用前后时都有明确处理策略。

普通 Agent 循环与可恢复执行的边界

教学图:左侧是“内存状态 + 进程内重试”,右侧把运行状态、步骤结果、审批和副作用回执持久化。恢复能力来自可重建的事实,而不是让模型回忆上一次发生了什么。

一、长任务为什么不能只靠 while 循环和 try/except

常见的 Agent 循环大致如下:模型生成工具调用,程序执行工具,把结果追加到消息列表,再让模型决定下一步。只要进程一直存活,这种结构非常直接;一旦任务跨越几分钟、几个小时,甚至需要人第二天审批,内存中的 messages 就不再是可靠状态源。

while not finished:
    decision = model.invoke(messages)
    result = call_tool(decision)
    messages.append(result)

try/except 只能处理当前进程还活着时抛出的异常。容器被驱逐、机器断电、进程被 SIGKILL、发布过程中实例替换,都不会给业务逻辑一个完整收尾机会。即使加入重试,也会立刻遇到另一个问题:工具调用可能已经成功,只是响应在返回途中丢失。此时再次执行“发送邮件”“创建工单”“扣减额度”,副作用就会发生两次。

因此要把故障拆成三个层次:

层次典型问题需要的机制
计算失败模型超时、解析失败、临时 503有上限的重试、退避、超时
进程失败重启、崩溃、节点迁移持久化检查点、恢复游标
副作用不确定请求已送达但回包丢失幂等键、回执、唯一约束、对账

Temporal 将这类能力称为 Durable Execution:应用在故障之后可以从已记录的位置继续。LangGraph 的持久化与 interrupt 机制采用相似思路:检查点保存图状态,thread_id 用来重新定位同一条执行记录。框架可以帮我们保存和重放,但“哪些步骤允许重放、外部写入如何去重”仍需要业务设计。

二、先把 Agent 建模成状态机,而不是一段长函数

本文示例把任务拆成五个步骤:

  1. collect_context:读取任务上下文;
  2. make_plan:生成执行计划;
  3. approval:高风险写入前等待人工审批;
  4. execute_action:执行一次带副作用的外部动作;
  5. summarize:记录最终摘要。

每个步骤都有 PENDINGRUNNINGCOMPLETEDFAILED 等状态。运行实例还有 RUNNINGWAITING_APPROVALREJECTEDCOMPLETED。恢复程序不需要猜测模型“可能进行到哪里”,只读取数据库中的当前步骤和步骤完成记录。

运行状态、步骤状态与检查点之间的关系

教学图:运行状态负责全局生命周期,步骤记录保存尝试次数和输出;只有步骤输出与状态在同一事务中提交,才能形成可信检查点。

到达高风险步骤

审批通过

审批拒绝

步骤超过重试上限

人工修复后恢复

所有步骤完成

RUNNING

WAITING_APPROVAL

REJECTED

FAILED

COMPLETED

这里有一个容易忽略的边界:不要先把步骤标记为完成,再单独保存输出。两个写操作之间如果崩溃,会出现“状态显示完成,但结果为空”。示例中的 _checkpoint() 使用同一个 SQLite 事务更新 step_runs.output_jsonruns.state_json。生产环境使用 PostgreSQL 时也应保持这个原子边界。

三、用 SQLite 写一个可运行的检查点存储

完整代码位于 code/durable_agent.py,只使用 Python 标准库。数据库包含三张表:

  • runs:运行实例、当前步骤、输入和聚合状态;
  • step_runs:每一步的状态、尝试次数、输出、错误与幂等键;
  • effects:模拟外部系统保存的副作用回执,idempotency_key 是主键。

关键建表语句如下:

CREATE TABLE step_runs (
    run_id TEXT NOT NULL,
    step_name TEXT NOT NULL,
    status TEXT NOT NULL,
    attempts INTEGER NOT NULL DEFAULT 0,
    output_json TEXT,
    error TEXT,
    idempotency_key TEXT NOT NULL,
    updated_at TEXT NOT NULL,
    PRIMARY KEY (run_id, step_name)
);

CREATE TABLE effects (
    idempotency_key TEXT PRIMARY KEY,
    effect_type TEXT NOT NULL,
    payload_json TEXT NOT NULL,
    created_at TEXT NOT NULL
);

示例启用了 SQLite WAL:

self.conn = sqlite3.connect(db_path)
self.conn.row_factory = sqlite3.Row
self.conn.execute("PRAGMA journal_mode=WAL")
self.conn.execute("PRAGMA foreign_keys=ON")

WAL 有利于本地演示时减少读写互相阻塞,但它不会把 SQLite 变成分布式工作流数据库。单机、小规模工具可以使用 SQLite;多个 Worker 并发抢占任务时,应改用支持行锁、租约或消息队列的存储,并为 Worker 领取任务设计 lease_ownerlease_until

LangGraph interrupt 与持久化检查点的官方说明

官方证据:LangGraph 文档说明,interrupt 会通过持久化层保存图状态,并使用相同 thread_id 恢复同一检查点。原始页面:https://langchain-ai.github.io/langgraph/concepts/breakpoints/

四、实测:进程崩溃后从检查点继续

先做语法检查:

python -m py_compile code/durable_agent.py

然后启动任务,并要求程序在 make_plan 完成检查点之后模拟崩溃:

python code/durable_agent.py --db agent_state.db \
  start demo-001 "生成周报并写入知识库" \
  --crash-after make_plan

预期输出:

checkpoint: collect_context -> COMPLETED
checkpoint: make_plan -> COMPLETED
simulated crash after durable checkpoint: make_plan

进程退出码为 75。再次运行恢复命令:

python code/durable_agent.py --db agent_state.db resume demo-001

程序不会重新执行 collect_contextmake_plan,而是进入审批步骤:

checkpoint: approval -> COMPLETED
WAITING_APPROVAL

批准后继续,并在外部动作完成检查点后再次模拟崩溃:

python code/durable_agent.py --db agent_state.db \
  resume demo-001 --approve yes \
  --crash-after execute_action

最后再恢复一次:

python code/durable_agent.py --db agent_state.db resume demo-001
python code/durable_agent.py --db agent_state.db status demo-001

实测最终状态为 COMPLETED,五个步骤的 attempts 都是 1,effect_count 为 1。也就是说,两次进程崩溃没有让已经完成的模型步骤重跑,也没有制造第二条外部副作用。

Worker B外部系统审批人Checkpoint DBWorker AWorker B外部系统审批人Checkpoint DBWorker A保存 collect_context / make_plan进程崩溃按 run_id 读取检查点保存 WAITING_APPROVAL写入批准结果携带稳定幂等键执行动作保存唯一回执回执后进程崩溃恢复并跳过已完成动作保存 COMPLETED

副作用前后发生崩溃时的四个故障窗口

教学图:最危险的窗口是“外部系统已成功,本地还没保存完成”。仅靠本地步骤状态无法判断结果,需要外部系统按稳定幂等键去重或提供可查询回执。

五、检查点不等于 exactly-once:幂等才是重试的安全带

很多系统宣称“失败后自动恢复”,但恢复通常意味着某个步骤可能再次执行。AWS Durable Execution 文档明确区分 at-least-once 与 at-most-once:前者在中断后可能重跑,适合读取、upsert、带幂等键的 API;后者避免自动重跑,但也不能单独保证整个工作流端到端 exactly-once。

AWS 对 at-least-once、at-most-once 与幂等操作的说明

官方证据:AWS 文档指出,重放与重试都可能多次运行同一个操作;外部副作用必须通过幂等键、条件写入、事务或唯一约束去重。原始页面:https://docs.aws.amazon.com/durable-execution/patterns/best-practices/idempotency/

示例为每个运行步骤生成稳定键:

def stable_key(run_id: str, step_name: str) -> str:
    raw = f"{run_id}:{step_name}:v1".encode("utf-8")
    return hashlib.sha256(raw).hexdigest()[:24]

执行外部动作时,通过数据库唯一约束模拟服务端去重:

INSERT INTO effects(idempotency_key, effect_type, payload_json, created_at)
VALUES (?, 'external_action', ?, ?)
ON CONFLICT(idempotency_key) DO NOTHING;

关键点不是键长,而是稳定性和作用域:同一次业务动作的所有重试必须使用同一个键;不同用户、不同资源或不同版本的动作不能错误共享同一个键。推荐将 tenant_id + business_id + action + semantic_version 纳入键的来源,并在日志、检查点和外部请求中贯穿同一个标识。

对于不支持幂等键的旧接口,可以按风险依次选择:先查询状态再写入、在自己控制的数据库中建立 outbox、使用带唯一事件 ID 的消息、把外部动作改为人工确认,或将其标记为不可自动重试。不要用“请求超时就再发一次”处理付款、发信和权限变更。

重试策略也应成为步骤定义的一部分,而不是散落在工具代码里的固定循环。一次模型生成和一次数据库读取的成本完全不同:读取可以短间隔重试,昂贵模型调用应先检查上次结果是否已经持久化,外部写入则必须确认幂等能力。常用退避可写成 min(max_delay, base * 2 ** attempt) + jitter,同时设置最大尝试次数和总时间预算。只有“失败仍有较大概率恢复,且重复尝试不会扩大损失”的错误才值得自动重试。

六、人工审批为什么必须是持久化状态

高风险 Agent 常需要人在执行前确认:计划是否合理、目标资源是否正确、写入范围是否过大。错误做法是 Worker 阻塞等待一个 HTTP 请求或在内存里保存 Future。审批可能持续数小时,部署和扩缩容随时会让等待对象消失。

示例到达 approval 后把运行状态写为 WAITING_APPROVAL,随后进程可以安全退出。审批接口只做两件事:验证审批者权限;把审批结果与时间写入同一个运行记录。恢复程序再次读取相同 run_id,才继续后面的外部动作。

生产环境还要补齐四个字段:

  • approval_policy_version:记录当时使用的审批策略版本;
  • approverapproved_at:形成审计链;
  • plan_hash:确保批准的是当前这份计划,计划变化后必须重新审批;
  • expires_at:审批超过有效期后失效,避免旧授权在很久以后被使用。

审批不是一个漂亮的“暂停按钮”,而是授权边界。模型生成计划之后、真正提交副作用之前,应重新校验目标、参数、权限和策略版本。

七、至少要处理的八类失败案例

1. 步骤标记完成,但输出尚未落库

将状态和输出放在同一个事务里提交。恢复逻辑以数据库事实为准,不以日志中曾出现“success”为准。

2. 外部系统成功,本地回执丢失

使用服务端幂等键;如果外部系统支持按请求 ID 查询结果,恢复时先查回执,再决定是否重试。

3. 幂等键每次重试都重新生成

随机 UUID 如果在每次尝试时创建,就失去了去重作用。键应在步骤首次创建时持久化,后续所有尝试复用。

4. 非确定性步骤在恢复时重新执行

模型输出、当前时间、随机数和外部查询都可能变化。把它们的结果保存为步骤输出;恢复时读取事实,不重新向模型提问。

5. 所有异常都自动重试

网络抖动、限流和临时 5xx 可以退避重试;参数错误、权限拒绝、内容违规和资源不存在通常应直接失败或转人工。重试分类必须按错误类型,而不是按一条宽泛 except Exception

6. 多个 Worker 同时恢复同一个任务

加入租约、行锁或原子领取操作。领取时写入 lease_owner 和过期时间,只有持有租约的 Worker 能更新步骤。

7. 审批后计划发生变化

审批记录绑定 plan_hash。任何工具参数、目标资源或计划步骤改变,都使旧审批失效。

8. 检查点无限增长

保存必要的结构化状态,不把所有模型上下文和大文件直接塞进一行 JSON。大对象放对象存储,检查点只保存内容哈希、版本和地址;运行结束后执行归档与保留策略。

八、从演示代码升级到生产架构

最小示例证明了恢复语义,但生产系统还需要调度、并发控制和可观测性。一个实用架构可以分成五层:API 接收任务并分配稳定 run_id;调度器把可运行步骤投递给 Worker;状态库保存检查点与租约;策略层处理审批和权限;工具执行器携带幂等键调用外部系统并保存回执。

生产级可恢复 Agent 的分层架构

教学图:模型负责提出计划,工作流引擎负责确定性推进;外部工具被隔离在带权限校验、幂等键和审计记录的执行层中。

选择方案时可以按复杂度递进:

场景推荐起点需要升级的信号
单机个人工具SQLite + 显式步骤状态多进程竞争、远程 Worker
中小型后台任务PostgreSQL + 队列 + 租约长时间等待、复杂分支、补偿
Agent 图工作流LangGraph Checkpointer + interrupt需要跨语言或强运营能力
核心业务长流程Temporal 等持久执行平台大量长流程、重试、定时器、审计

无论使用哪种框架,都应保留这些观测指标:各步骤成功率与 P95 时长、重试次数、等待审批时长、恢复次数、重复请求命中数、租约超时数、失败类型分布。日志至少包含 run_idstep_nameattemptidempotency_keytrace_id,否则故障时仍然无法把一次执行串起来。

恢复还涉及版本兼容。一个任务可能在旧代码启动、在新代码部署后恢复;步骤名称、状态结构和提示词都可能已经变化。生产系统应把 workflow_versionmodel_idprompt_version、工具参数模式版本写入运行记录。对进行中的任务,可以让旧 Worker 完成旧版本,或提供显式状态迁移;不能让新代码悄悄按不同语义解释旧检查点。

数据安全同样不能被“可恢复”掩盖。检查点里可能包含用户输入、模型输出、工具参数和审批意见,应进行字段分级、加密、访问控制和保留期限管理。密钥与短期令牌不要写进状态 JSON,恢复时应从受控凭据服务重新获取。删除用户数据时,还要同步处理活动检查点、历史回执、对象存储与审计副本。

持久化频率需要在可靠性和性能之间取舍。每个微小 token 都同步落库会放大写压力,只在任务结束时保存又会丢掉太多进度。更实用的边界是“一个可重放步骤完成后立即保存”,高风险副作用前后各建立检查点;纯展示型流式输出可以批量刷新。上线前用任务规模和故障恢复目标测量写入吞吐、数据库容量与恢复时间,而不是统一套用固定间隔。

检查清单

  • 运行实例有稳定且可查询的 run_id
  • 每个步骤都有明确输入、输出和完成条件;
  • 步骤状态与输出在同一事务中形成检查点;
  • 模型输出、随机数、时间和外部查询结果会被持久化;
  • 外部副作用使用稳定幂等键或唯一约束;
  • 可重试错误与永久错误分开处理;
  • 高风险动作在副作用前执行持久化审批;
  • 审批绑定计划版本或哈希,并设置有效期;
  • 多 Worker 使用租约、行锁或原子领取防止重复执行;
  • 日志和指标可以按 run_id 还原完整时间线;
  • 检查点有大小限制、归档与数据保留策略;
  • 定期演练“副作用成功但本地未记账”这一最危险故障窗口。

长任务可靠性的本质,是把执行过程从一段不可恢复的内存状态,变成一组可以核验、重放和审计的持久化事实。模型可以更换,Worker 可以重启,审批可以隔夜,但已经发生的动作必须有回执,准备重试的动作必须能去重。

你现在的 Agent 如果在工具调用返回前进程崩溃,系统能判断“动作没有执行”,还是只能再次调用碰运气?这个故障窗口最值得优先做一次演练。

更多推荐