我那个跑日报的 agent,一个 workflow 平均要调四十来次工具,里面大概两次会走重试路径。上周它给同一个客户发了三封内容一模一样的日报。日志拉出来,三次调用全是 200。
改法一句话:给工具加一个由调用方传进来的 effect_id,让它自己记账。
坑是这么来的。第一次调用超时,agent 没等到回包,按重试逻辑又发一遍;第二遍回包正常,agent 以为搞定;第三遍是 workflow 收尾的兜底重发。三次都“成功”,因为发件工具只知道“这次调用没报错”,它不知道外面那个世界上,同一个客户已经收过一份了。
9 月 14 号挂 arXiv 的《When Tool Calls Succeed but Workflows Fail》把这类事列得挺全:该发生的效果少一份或多一份、已经中止的效果还挂着没清、提交成功的效果其实依赖一个后来被撤掉的临时状态。八类。我半年里撞上三种,重复发送是最贵的一种。
论文里有句话我认同,大意是高级事务模型本来能管这些,前提是底层操作愿意说清楚几件事——这个效果到底发没发生、能不能补偿、能不能先暂存、能不能安全重排。老的那套 agent-tool 接口基本不暴露这些。他们顺手扫了 MCP 上已注册的九万八千多个工具,标准注解字段大家都在填,但也只到“调用层面的粗提示”这个程度,上面那几种能力一个都表达不了。
论文结论是“需要可复用的事务性契约”。这是给协议层提的方向,我这种写业务 workflow 的等不起。也别等!
自己包一层,十分钟的事:
def send_report(effect_id, payload):
if done(effect_id): # 已经干过了
return read_effect(effect_id)
r = do_send(payload)
commit_effect(effect_id, r) # 先记,再回
return r
⚡ 这一下起作用的是 effect_id 由调用方给,不由工具自己生成。重试过来的时候,撞的是自己上次留下的那行记录,不是客户的收件箱!
我一开始写成工具内部自己生成 id,用了两天发现等于没防——重试就是一次全新的调用,新 id,照样发两遍。返工改成调用方传,这才对上。
这思路论文里叫 effect-history 模型,说白了就是把“外部世界里真发生了什么事”和“运行时对这件事的观察”分成两份记录。工具记前一份,agent 的上下文记后一份。多数事故就出在这两份对不上,而大家只盯着后一份看。
第二个改动跟上面配套。光防重复不够。我出过一回更阴的:agent 先建了一份临时报表,拿它发了邮件,正文里带的是那个临时文件的下载链接,发完把临时文件删了。链接全死,日志全绿,客户回邮件问“这个附件怎么打不开”我才知道。
治它的办法不是让 agent 变聪明,是给它多一个只读工具:
list_effects(prefix="report:2026-09-")
写之前先读一遍。成本几十毫秒,挂在重试路径上无条件跑。
⚡ 关键不是这个工具本身,是别让 agent 拿自己的上下文去猜“我刚刚是不是已经干过了”。它的上下文里可能压根没出现过那次超时的回包,那它猜出来的答案一定是错的,而且错得很自信。
我把它接进去花了不到十分钟。这周用下来,重复发送从每周至少一次变成零。这是体感,我没上秒表。链接失效那种,最近没再出现过,样本还不够,先记着。
得说清楚边界。这套只在你能改那层 wrapper 的时候管用。调别人家已经上线的 MCP server,你进不去改它的注解,只能在最外面套个壳,壳里自己维护一份 effect 账本。本来挺烦的,但比起排查“客户为什么收到三封邮件”,这个烦我愿意受。还有一种情况我目前没解:跨多个服务的一次 workflow,每个服务的 effect 账本各记各的,中间那步被别人撤销了,我这边的账还是对的。论文说这种要靠“能不能安全重排”的语义,我暂时人工兜底,谁有招教我。
壳加起来不到五十行。上个月一次重复发送的事故,就够回本好几轮了。老实说,如果你手上的 agent 一天就调三五次工具、也没配重试,这套先别上,加壳的钱比省下的贵。去找你 workflow 里那个被调得最凶、又带重试的步骤,从那儿下手。
有更省事的写法,教教我。
