代理故障重试为何会重复退款
文章以退款成功后、检查点保存前发生崩溃为例,说明工作流重试可能重复触发外部操作。核心问题是如何在至少一次执行的前提下保证副作用幂等,适用于支付、邮件和基础设施操作。
AI 深度解读
AI Agent 最危险的重试,是上一次操作已经成功,系统却没来得及记下来。退款到账后进程崩溃,恢复逻辑再执行一次,客户就可能收到两笔钱。工作流最终显示成功,业务却已经出错。
作者用一个 TypeScript 发布流水线,把这个问题做成了可复现的故障实验:暂停一个 worker,等另一个 worker 接管,再让原进程恢复,观察旧结果能否覆盖新结果。结论很明确:可靠恢复需要幂等、租约隔离和检查点保护一起工作,不能只依赖重试。
执行可以重复,业务结果为什么不能重复
原文区分了四个容易混在一起的概念:
- 至少执行一次(At-least-once execution):失败恢复时,同一步可能运行多次。
- 恰好一次效果(Exactly-once effect):同一个逻辑操作只对外部状态产生一次改变。
- 幂等(Idempotency):重复提交同一个操作,不会增加额外效果。
- 隔离旧持有者(Fencing):worker 失去任务所有权后,不能继续提交旧结果。
假设 Agent 批准了 $100 退款,支付成功后,worker 在保存检查点前崩溃。恢复系统看到步骤未完成,再调用支付工具,就可能多退 $100。
问题发生在两个提交之间:外部业务已经提交,工作流进度还没提交。
稳定的幂等键可以标识「这是同一笔操作」,但去重必须由业务边界原子地执行。两个 worker 如果都先查询 payment-123,都发现不存在,再分别支付,键的存在也救不了它们。
另一个问题是旧 worker 复活。Worker A 的租约过期后,Worker B 接管并完成任务,A 却仍握着内存里的旧结果。此时除了识别重复操作,还要判断 A 有没有写入资格。
幂等解决重复操作,fencing 解决过期身份,两者不能互相替代。
作者开发的 AGBoss 建在 LangGraph 之上,提供租约、检查点恢复、确定性的步骤幂等键,以及过期检查点写入保护。外部数据库、文件和 API 的一致性,还需要应用自己配合。
两次故障,分别验证补偿和接管
实验是一个包含三个模块的 AI 发布流水线,由协调器和两个独立 worker 进程执行。协调器通过 ChildPlacement.AnyInstance 分配子工作流,声明 maxConcurrency: 4、maxItems: 8,不指定某个模块必须交给哪个进程。
第一次故障发生在三个产物都已暂存之后。
初始配置是 --opt-level=3 --deterministic,但 payment-engine 缺少 --abi-compat=v2,集成 smoke gate 因此失败。这些参数是演示场景里的构建配置,并没有传给真实编译器;失败也是刻意注入的。
此时需要 Saga 补偿:删除暂存文件,把 SQLite 记录标记为 COMPENSATED,释放候选版本锁。AGBoss 用 compensateWith 和显式错误分支编排补偿,但真正删除文件、撤销业务操作、保证补偿可重试,都是应用的责任。
补偿后,ReleaseRecoveryPlannerAgent 通过 InspectFailedAttemptTool 读取实际诊断和补偿状态,再生成 RecoveryPlanDto。录制的在线实验使用 OpenRouter 上的 openai/gpt-4o-mini,进行了两次模型调用,消耗 1,705 个输入 token、178 个输出 token,补上了缺失参数。
第二次故障才是僵尸 worker 测试。
payment-engine 完成 Step 1 并保存检查点后,在 Step 2 被操作系统的 SIGSTOP 暂停。进程内存保留,但无法执行代码,也无法续租。真实的 30 秒租约到期后,另一个 worker 接管。
接管沿用同一个 executionId:
f9907abf-19fd-498b-8397-49152e25345f
leaseEpoch 从 1 升到 2,替代 worker 从已提交的检查点继续,Step 1 没有重跑,运行时发出 execution.recovered。
随后,SIGCONT 让原进程恢复。它仍以为自己持有 leaseEpoch = 1,继续尝试提交旧结果。
为什么检查点拦住了,外部写入还要再拦一次
AGBoss 的 Action 可以在 execute() 中执行外部 I/O,之后运行时才提交检查点。因此,即使框架拒绝旧检查点,外部文件或数据库也可能已经被改了。
实验为暂存步骤保留了稳定的幂等键:
f9907abf-19fd-498b-8397-49152e25345f:stage-module-artifact-action:1
它由执行、步骤和访问次数标识组成,恢复后不变。同时,Action 开始时捕获租约持有者和 epoch,把它们交给应用账本验证。
下面这段简化代码解决的是「旧 worker 还能不能写」:
if (
params.capturedLeaseEpoch < activeLeaseEpoch ||
(
activeHolder !== params.capturedInstanceId &&
params.capturedLeaseEpoch <= activeLeaseEpoch
)
) {
// 记录 ZOMBIE_WRITE_REJECTED
return new StaleLeaseFenceError(/* ... */);
}
关键是第一行的 epoch 比较,以及原文强调的事务边界:真实实现把检查放进应用的 SQLite 事务中。脱离提交过程单独检查,仍可能留下竞争窗口。
僵尸 worker 的 epoch 为 1,当前为 2,账本拒绝外部写入并记录 ZOMBIE_WRITE_REJECTED。之后,LeasedCheckpointSaver 再拒绝它的检查点写入,抛出 ExecutionLeaseLostError,错误码为 execution.lease-lost。
业务状态和工作流状态,都需要在各自的提交边界检查所有权。
作者还用独立验证器读取文件系统、应用 SQLite 账本,以及 execution_leases、checkpoints、child_execution_queue。录制结果中,恢复任务的六个检查点形成单一的 parent_checkpoint_id 链,没有僵尸分支,也没有遗留子任务。
运行需要 Node.js 22.13+ 和 npm。基础复现命令如下:
git clone https://github.com/agbossjs/distributed-agent-recovery.git
cd distributed-agent-recovery
npm ci
npm run check
npm test
npm run demo:cluster
npm run verify
这里要看最后一行 npm run verify:工作流结束之后,还需要检查实际状态是否满足约束。
默认测试使用确定性的离线模型传输,不需要 LLM API key。真实时间实验先执行 npm run build,再运行 node dist/src/demo.js --cluster --real-clock;在线模型模式使用 OPENROUTER_API_KEY="..." node dist/src/demo.js --cluster --live --real-clock。容器版本入口是 npm run demo:compose。
我的判断
这篇最有价值的地方,是把模型推理和一致性机制分开了。LLM 负责读诊断、提修正计划;租约、去重、检查点和事务写入交给确定性的程序约束。靠提示词要求 Agent「不要重复操作」,无法处理支付成功后进程崩溃这样的故障。
实验也给出了比成功日志更强的证据。作者报告,独立 Node.js 进程、Docker Compose、单节点 Kubernetes kind 三项 CI 验证全部通过,并另外测试了 Docker worker 突然退出。SIGSTOP/SIGCONT 则专门覆盖旧进程恢复的情况,二者验证的故障不同。
但这些结果有明确边界。实验使用 SQLite WAL 和应用自己实现的事务账本;单节点 kind 也不能证明跨节点、跨地域或网络分区下的行为。把账本换成第三方支付、邮件或发布 API 后,仍要确认对方是否支持原子去重,以及如何阻止旧请求生效。
修复方案也不能据此推广为「AI 擅长自动修复发布」。缺失参数是预设故障,原文没有提供多种未知故障的成功率,也没有规则修复的对照数据。这个实验展示的是流程可以接入真实 LLM。
AGBoss 的 agboss check 能静态检查工作流组合、DTO 转换和依赖注入约束,但不能代替故障注入。读取内部表同样只是与被测框架版本绑定的诊断方法,不是推荐的公开集成 API。
此外,提供的原文在最后一节中途截断,不能替作者补全后续保证。
能拿来做什么
- 给 Node.js Agent 的外部工具调用补上操作身份:优先使用稳定的业务键或执行步骤键,并确认去重与实际写入处于同一原子边界。
- 如果任务允许多个 worker 接管,就同时检查业务写入和检查点写入的所有权,避免只保护工作流进度,却让旧进程修改业务数据。
- 为发布流水线分别设计补偿测试和僵尸测试:前者检查失败后的暂存产物是否清理,后者检查旧 worker 恢复后能否覆盖新结果。
- 验证恢复时检查落盘事实:执行 ID 是否保留、检查点有没有分叉、队列是否残留任务、产物是否重复,不能只看任务最终返回成功。