你的 Agent 会重复扣钱吗?我给 LangGraph 加了一层"步骤日志"

你的 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,或者直接在评论区说说你踩过的坑。

顺带说一句:如果你觉得这个问题该由框架解决而不是由库解决,我也同意。但在那天到来之前,这层日志还是得自己兜。

相关推荐
小田学Python1 小时前
DeepSeek Harness 完整上手指南:安装部署 + 必装插件 + 用一周就回不去的功能
大数据·大模型·agent·ai工具·deepseek
程序员阿晟1 小时前
10 分钟搭一套自己的智能客服系统:果唯云客服 + Hermes Agent 实战
agent
去伪存真2 小时前
AI Agent Harness 深度测评与技术选型实战
前端·agent
七夜zippoe2 小时前
Agent 超时与幂等性:重复请求、副作用控制与状态一致性
ai·agent·超时·控制·幂等性
用户460381291942 小时前
ChatMemory 要被弃用了?Spring AI 2.1 用事件溯源重做 Agent 记忆
llm·agent
泯泷4 小时前
别再死磕提示词了:Anthropic 说,AI Agent 真正的胜负手是“上下文工程”
llm·aigc·agent
后端小肥肠4 小时前
漫画工坊上线用户破百:公众号漫画从需求到变现全复盘
人工智能·aigc·agent
isboyjc4 小时前
Lomo Agent:面向创作型、持续协作 Agent 的架构设计与工程实践
人工智能·架构·agent
Together_CZ5 小时前
EvoSkill: Automated Skill Discovery for Coding Agents——面向多智能体系统的自动化技能发现
agent·skill·skill discovery·evoskill·—面向多智能体系统的·自动化技能发现·coding agents