ARTICLE / 7 MIN READ
招聘 Agent 跑了三天崩在半路:Checkpoint 该存什么、恢复怎样不重复扣费
长周期工作流的断点续传设计——检查点粒度、状态快照结构、持久化与不持久化的分界、幂等键与 outbox,让恢复重放不重复发邮件。
一个招聘 Agent 的流程——筛选简历、安排面试、收集反馈、生成报告——天然要跨三天,因为它得等真人面完、等反馈交上来。这种长周期流程里,进程重启、OOM、机器被回收都不是异常,是必然会发生的事。设计的核心问题不是“怎么不崩”,而是崩了之后从哪继续、以及继续时怎么不把已经发出去的面试邀请再发一遍。断点续传的难点从来不在“存状态”,在“重放副作用时的幂等”。
检查点打在节点边界,不打在节点中间
第一个决策是粒度。我把工作流建模成有向图,每个节点是一个语义完整的步骤(筛简历 / 发面试邀约 / 收反馈 / 生成报告),检查点只在节点边界写:一个节点执行成功、其输出确定后,把整个工作流状态快照落库,再进入下一节点。不在节点内部打点,是因为节点内部往往是一次不可分割的模型调用或工具调用,从中间恢复既难又没意义。节点边界天然是“事务的提交点”——恢复时永远落在一个干净的、已知的状态上,而不是某次 LLM 调用生成到一半的现场。
状态快照存什么结构
快照要能让新进程“从零重建执行上下文”。我落库的结构大致是:
Checkpoint {
workflow_id: "hire-req-8821", // 业务主键,恢复的锚点
thread_id: "req-8821", // 同一招聘需求的会话线
current_node: "collect_feedback", // 当前停在哪个节点
status: "waiting_external", // running / waiting_external / done
version: 17, // 单调递增,乐观锁 + 顺序校验
state: { // 业务变量(可序列化的纯数据)
candidates: [{id, stage, scores}],
interviews: [{cand_id, slot, interviewer_id, status}],
pending: ["c-102", "c-118"]
},
history: [ {node, ts, summary} ], // 消息/步骤摘要,非全量对话
updated_at: "2026-07-22T09:14:03Z"
}
history 我只存摘要不存全量对话——完整的模型往返是可以从摘要 + 当前 state 重建的,全量存进检查点只会让快照越滚越大、恢复越来越慢。version 单调递增,恢复时校验它防止两个进程同时抢救同一个工作流(一个写 version=18 成功,另一个基于 17 的写就该失败)。
分界原则:可重建的不存,凭据不存,大对象存引用
存什么、不存什么,我用三条原则一刀切:
| 类别 | 处置 | 为什么 |
|---|---|---|
| 当前 Node ID、业务变量、pending 队列 | 必存 | 恢复的唯一依据,丢了无法定位 |
| 步骤/消息摘要 | 必存(摘要) | 重建上下文用,但不存全量 |
| 临时 API Token、DB 连接、HTTP client | 绝不存 | 有效期短,存下来恢复时早已过期;凭据落库是安全事故 |
| 简历原文 PDF、大附件、向量 | 存引用不存值 | 大对象进快照拖垮序列化;存 s3://bucket/key 或文档 ID,用时再拉 |
| 可由 state 派生的中间量(如排序后的候选列表) | 不存 | 能算出来的就别存,减少不一致面 |
一句话概括:凭据因为安全和时效不存,大对象因为体积存引用,能重建的因为冗余不存。检查点应该是“重建所需的最小事实集”,不是内存的整盘 dump。
幂等:每个副作用都要一把业务唯一键
恢复的致命风险是重放。假设进程在“发完面试邀请邮件、但还没写检查点”的瞬间 OOM——恢复时它会认为 send_invite 没做过,于是再发一遍。解决靠幂等键 + 去重表,而不是靠“把检查点写得更勤”(写检查点和发邮件之间永远存在一个无法消除的时间窗)。
每个副作用动作构造一个确定性的业务唯一键,比如 invite:{workflow_id}:{candidate_id}:{interview_round}。执行前先查去重表,命中就跳过:
key = "invite:hire-req-8821:c-102:round-1"
INSERT INTO idempotency_keys(key, status) VALUES(key, 'in_flight')
ON CONFLICT(key) DO NOTHING;
if 影响行数 == 0: // 键已存在
return 查到的既有结果 // 幂等命中,直接返回,不再发邮件
// 抢到键,真正执行副作用,完成后 UPDATE status='done' 并存 result
键必须来自业务语义(哪个需求、哪个候选人、第几轮),不能用随机 UUID 或时间戳——那样每次重放都是新键,去重表形同虚设。
扣费和发邮件:outbox 把副作用和状态绑进一个事务
更强的保证用 transactional outbox:把“写检查点”和“登记待发副作用”放进同一个数据库事务。节点执行时不直接调邮件/支付网关,而是往 outbox 表插一条待办记录,和检查点一起提交。一个独立的投递器轮询 outbox,带着幂等令牌真正去调外部服务,成功后标记 sent。
[节点执行] --同一事务--> { 写 Checkpoint(v18) ; INSERT outbox(invite, key=..., status=pending) }
│ commit(要么都成,要么都不成)
▼
[投递器] 轮询 outbox → 带幂等键调邮件/扣费网关 → 成功则 status=sent
这样“状态推进了但副作用没记账”或“扣了费但状态没更新”这类不一致,被压缩成了 outbox 里一条 pending 记录——投递器重试它即可,配合下游幂等键,重投也不会重复扣费。资金类操作我还会要求下游网关本身支持幂等令牌(大多数支付网关都有 Idempotency-Key 头),双保险。
恢复流程:定位节点,重放但跳过已完成副作用
进程重启后的恢复是确定的三步:
1. 按 workflow_id 读最新 Checkpoint,拿到 current_node 和 state
2. 定位到 current_node,重建执行上下文(凭据/client 现场重新获取,不从快照来)
3. 从该节点重放 —— 但每个副作用先查幂等键/去重表:
已 done 的直接取既有结果跳过,未做的才真正执行
“重放但跳过已完成副作用”是整个设计的收口:恢复逻辑不需要精确知道“上次到底做到哪一步”,它只要老老实实从节点头重跑,让幂等键去挡住那些已经发生的副作用。这比“记录极细粒度的进度、恢复时精确 seek”要健壮得多——细粒度进度本身也可能在崩溃时丢失,而幂等键是持久化在去重表里的客观事实。
边界
这套机制的成本是每个副作用都要显式设计幂等键,团队得养成“先想键、再写调用”的习惯,漏一个就埋一颗重复扣费的雷。它也假设外部服务要么幂等、要么可去重;碰上既不支持幂等令牌、又无法查询“这封邮件发过没”的老接口,只能退而用 outbox + 本地去重表尽量兜,无法做到理论上的精确一次。另外,检查点写入本身有开销,节点极多、单步极快的高频工作流,逐节点落库可能成为瓶颈,那种场景要么合并节点、要么放宽到“关键节点才打点”,用一点重放代价换写入吞吐。
GITHUB DISCUSSION
评论与回复
评论保存在 GitHub Discussions,登录 GitHub 后即可参与,发布和回复都在本页完成。
评论区进入视口后自动加载。