你的 Agent 会重复扣钱吗?我给 LangGraph 加了一层"步骤日志"
一次真实的事故复盘:40 秒的客服 Agent,崩在第 4 步,重跑之后退款发了两次。 这篇文章讲清楚 LangGraph 的 checkpoint 为什么救不了你,以及一个 1800 行的库是怎么解决的。
一、事故现场
一个客服 Agent,五个步骤:
读工单 → 查知识库 → 起草回复 → 退款 500 元 → 发邮件
整个过程大约 40 秒。跑到第 30 秒的时候,进程重启了------可能是发版、可能是 OOM、也可能只是上游模型超时。
接下来发生的事:
- 重跑从头开始
- 模型被重新调用了一遍(花钱)
- 退款接口被调用了第二次(事故)
这不是我编的场景。任何把 Agent 接到支付、发券、发邮件、写数据库的系统,都会遇到。
二、LangGraph 的 checkpointer 救不了你
很多人第一反应是:用 MemorySaver 或者 PostgresSaver 不就行了?
问题在于,checkpoint 记的是「位置」,不是「结果」。
python
# 你以为的恢复
graph.invoke({"topic": "durable execution"}, config)
# 实际上:从断点那个节点重新执行
框架告诉你"你走到了第 4 个节点",然后从第 4 个节点重新跑 。至于这个节点上次是不是已经把退款发出去了------它不知道,也不关心。
所以你会得到一个很荒诞的结果:
- 步骤确实"恢复了"
- 副作用也"恢复"了,第二次
三、三种常见的错误解法
解法 1:自己写 try/except 重试
python
try:
refund()
except Exception:
retry()
进程直接死了,except 根本不会执行。kill -9 面前,任何 Python 层的兜底都是废纸。
解法 2:上 Temporal
Temporal 是对的,但你要为它跑一套 Server、一个数据库、一堆 Worker。写一个 40 秒、五个节点的 Agent,用工业级工作流引擎------高射炮打蚊子。
解法 3:在数据库里打个标记
python
if not db.get("refunded"):
refund()
db.set("refunded", True)
这个方向是对的,但它只覆盖了"一个操作"。你有五个步骤、要重试、要人工审批、要限制预算------最后你会手写一个半成品的工作流引擎。
四、核心思路:存「结果」,不存「快照」
关键的一句话:
不要记录"我执行到哪了",要记录"这一步算出了什么"。
因为快照和日志会打架:
- 快照说:你在第 4 步
- 日志说:第 4 步已经成功了
到底退款了没有?没人知道。只有一份真相,才能判断。
所以每一步跑完,把返回值 存进数据库,键是 (run_id, 步骤名)。下次进入这一步,先查日志:
- 有结果 → 直接返回,函数根本不会被执行
- 没结果 → 正常执行,然后把结果写进去
五、实现:四句 SQL
「步骤日志」听上去玄乎,核心就三句 SQL(下面为简化示意,实际实现在 steps.py)。
① 抢占:谁先插进去,谁拥有这一步
sql
INSERT INTO step_record (run_id, step_key, status, started_at)
VALUES (?, ?, 'RUNNING', ?)
(run_id, step_key) 是联合主键。两个 worker 同时抢同一步,第二个会收到唯一约束冲突,直接返回 StepInProgress,不会重复执行。
没有 SELECT FOR UPDATE,没有 Redis 分布式锁,也没有 SKIP LOCKED。
② 执行(不是 SQL,但它是整个设计的关键)
就是调用你自己的函数。这一步可能花 30 秒,期间不持有任何数据库连接。
③ 落库:带条件的 UPDATE
sql
UPDATE step_record SET status = 'SUCCEEDED', output = ?
WHERE run_id = ? AND step_key = ? AND status = 'RUNNING'
看影响行数:是 1,说明这一步还归你管,写入成功;是 0,说明你已经被接管了,这次结果作废。
④ 回放:下次进来先查
sql
SELECT status, output FROM step_record WHERE run_id = ? AND step_key = ?
有 SUCCEEDED 的行,反序列化 output 直接返回。
用起来只有一行:
python
journal = Journal(conn, run_id)
graph.add_node("refund", journal.durable("refund", refund_fn))
六、跑一遍看看
仓库里有个可以真跑的 demo(子进程会模拟崩溃):
lua
$ python examples/crash_demo.py
-- attempt 1 --
[plan] calling the model ...
crashed at 'write': 529 overloaded (simulated upstream failure)
-- attempt 2: a new process, same database --
[write] calling the model ...
[publish] writing the file ...
-- journal after two attempts --
step status attempt executions
plan SUCCEEDED 1 1
write SUCCEEDED 2 2
publish SUCCEEDED 1 1
plan 那一行是关键:两次尝试,只执行了一次。
第二次进图的时候,它不是"跳过",而是回放了上一次的结果------对上层代码来说完全透明,像什么都没发生过。
七、顺手解决的三个问题
日志这套东西搭起来之后,很多问题会自然有解。
1. 人工审批:进程可以退出
大额退款不该自动执行。做法是把 run 挂起:
python
journal.durable("refund", refund_fn, needs_approval=True, expires_in=3600)
第一次调用不会执行,而是把 run 标成 SUSPENDED,抛一个 ApprovalRequired。
关键点:不占线程。 进程可以退出,服务器可以重启,三天后有人批了再继续。批了之后,前面所有步骤回放,只有退款这一步真正执行。
这里踩过一个坑:一开始审批没过我把步骤记成了
FAILED。后来发现FAILED在语义上意味着"可重试",下次恢复就会在没批准的情况下把退款真的发出去。现在改成把抢占的那行删掉,当成没发生过。
2. 预算熔断:死循环也花不爆
python
set_budget(conn, run_id, max_cost=0.50, max_tokens=200_000)
journal.charge(cost=0.02, tokens=1200)
判断和扣费写在同一条 SQL 里:
sql
UPDATE budget SET used = used + ?
WHERE run_id = ? AND used + ? <= max_cost
影响行数是 0 就是超了,这次不记账。两个 worker 同时扣,也只有一个能成。
钱用整数微单位 存。别问为什么,0.1 + 0.2 != 0.3 这个坑每个做账的系统都会踩一次。
3. 多 worker:死了有人接
worker 领任务时写一个租约(lease),后台线程定期续约。如果这个 worker 挂了,租约过期,别的 worker 就能接管。
接管之后,前面成功的步骤回放,失败的步骤重试。
八、一个意外收获:可以「分叉」和「对比」
因为存的是结果而不是不透明的快照,你可以做一件快照式引擎做不到的事:
bash
duragent fork <run_id> --at write
duragent diff <run_a> <run_b>
从任意一个已完成的步骤分叉,改输入,跑另一条分支,然后对比两条分支每一步的输出差异。
调试 Agent 时最常见的困惑是:"我改了 prompt,为什么结果没变?"------现在可以直接看到哪一步的开始分叉了。
九、几个诚实的限制
写开源项目最怕吹,所以这部分必须讲清楚。
① 不是 exactly-once
函数至少执行一次 ,结果至多保存一次。
"副作用已经发生"到"结果写进数据库"之间如果崩溃,函数会再跑一遍。这是端到端无法消除的窗口。
解法是给下游传幂等键:
python
payments.charge(amount, idempotency_key=journal.key("refund"))
任何声称 exactly-once 的方案,要么在偷换概念,要么只覆盖了一半。
② 重放是 O(n)
每次恢复都要从第 1 步开始回放。但回放是内存和数据库读,不调模型------agent 图几十个节点,是微秒级的事。瓶颈永远在模型调用上。
③ 还没到生产可用
0.1.0,一个人在写。没有真正的用户,也没有做过大规模压测。已知的坑都写在 README 里,没有藏。
十、怎么用
bash
pip install "git+https://github.com/a643036114-sys/duragent.git#subdirectory=python"
python
from duragent import Journal, connect, new_run_id
conn = connect("agent.db")
journal = Journal(conn, new_run_id())
graph.add_node("plan", journal.durable("plan", plan_fn))
graph.add_node("refund", journal.durable("refund", refund_fn, needs_approval=True))
默认 SQLite,给个 URL 就是 PostgreSQL,不用起任何新服务。
- 仓库:github.com/a643036114-...
- 核心代码:
python/src/duragent/steps.py(190 行) - 测试:101 个(含
kill -9恢复、双进程共享日志、线程竞争)
最后
这个库解决的是一个很小的问题:让你的 Agent 不会重复扣钱。
如果你正在用 LangGraph 做的东西接了真实业务------支付、发券、写库、发通知------大概率会撞上这个问题。欢迎来提 issue,或者直接在评论区说说你踩过的坑。
顺带说一句:如果你觉得这个问题该由框架解决而不是由库解决,我也同意。但在那天到来之前,这层日志还是得自己兜。