Agent 崩溃重试如何避免重复扣款?用 Forked 管理状态分支与副作用
Agent 执行支付、发邮件或调用外部 API 时,最棘手的故障往往发生在“外部操作已成功,但进程尚未来得及保存结果”这一刻。此时从头重跑可能重复产生副作用。Forked 是一个 Apache-2.0 开源项目,尝试把 Agent 的结构化状态版本化,并将副作用结果纳入提交与重放语义。
它管理什么,不管理什么
Forked 将自己称为“Git for agent state”。它管理消息、记忆、计划和工具结果等键值 JSON 状态,提供提交、分支、历史、回退和差异比较;它不是文件系统快照,也不会替你撤销已经发出的邮件或完成的付款。回退的是 Agent 状态,不是外部世界。
状态采用持久化、结构共享的数据结构。创建分支只需写入引用,不复制整份状态;README 的基准示例显示,分支成本取决于后端,内存约 1.4 毫秒、持久化 SQLite 约 276 毫秒(示例为创建 1000 个分支)。这些是项目提供的基准数据,不应直接当作任意生产环境的性能承诺。
副作用记录如何帮助重试
核心机制是 effect record:调用方为操作提供幂等键,并记录操作结果。相同键再次提交时,系统可返回已存结果,而不是再次执行对应副作用。项目文档说明,效果记录存于提交中,提交与记录通过后端一次 compare-and-swap 保持原子性;其“effectively-once”语义有条件,尤其要求一个会话同时只有一个写入者。
这不是第三方支付服务的幂等性保证。业务程序仍须决定何时调用支付 API、如何生成稳定且不冲突的键,以及成功结果如何记录。若外部调用成功后、记录写入前进程崩溃,单靠本地状态工具仍无法消除所有不确定性;优先结合外部服务支持的幂等键,并为恢复流程设计对账机制。
本地启动与 TypeScript 示例
仓库 README 给出的自托管服务启动方式:
cargo run -p forked-server -- --db forked.db
TypeScript SDK 示例展示如何创建会话、分支并记录操作结果:
import { ForkedClient } from "@forked-run/client";
const forked = new ForkedClient({ baseUrl: "http://localhost:8080" });
const session = await forked.createSession();
const main = session.branch();
await main.put({ plan: ["draft", "review", "send"], step: 1 });
const alternative = await main.fork("alternative");
await alternative.put({ step: 2 });
await session.recordEffect({
key: "send-email:42",
kind: "email.send",
result: { messageId: "abc" },
});
该代码只向 Forked 记录效果及其结果,不会真的发信。实际集成时,应由业务逻辑执行外部请求,并在定义好的恢复协议中写入结果。key 应在同一业务操作的重试之间保持稳定,但不能意外复用于不同操作。
除自托管 Rust 服务外,项目提供 Rust 核心与存储适配器、TypeScript 客户端、LangGraph checkpointer,以及 MCP 服务端组件。部署前应按仓库文档确认具体组件、API 版本和存储后端;不要把托管云端的账户或控制台能力误认为都属于开源仓库。
适用边界与落地检查
当 Agent 需要保留执行历史、探索多个方案,或在重试时复用已经记录的结果,Forked 提供了明确的状态模型。它不是任务队列、分布式事务协调器或外部 API 代理;多写者竞争、跨系统一致性、密钥权限及幂等键生命周期仍由集成方负责。
可以从一个可控的非生产任务开始,验证三种情况:首次操作成功;进程在操作后退出并恢复;相同幂等键再次提交。检查应用是否返回预期记录,并由外部服务日志确认没有重复请求。之后再测试不同键、并发写入和外部服务超时等边界。还可以注入“外部请求超时但服务端可能已完成”的故障,确认恢复代码先查幂等键或对账,而不是直接重复提交。若系统允许同一会话并发写入,应验证冲突如何返回、调用方如何重试,以及是否会覆盖另一分支的新状态。最后明确保留期限、敏感结果的脱敏策略和审计日志访问权限,避免把可重放能力变成新的数据泄露面。这样才能判断“可重放状态”是否真正覆盖自己的故障路径,而不是仅凭一次成功演示推断安全性。