☰
Agent 改到第三步失败:已经写下去的数据怎么退回来
2026/10/15 10:50:00 网站建设 项目流程

版权与内容来源声明
本文为原创整理。文中涉及官方文档、开源仓库、论文与公开报道的内容,均在附表 A 中标注来源;引用官方原文保持原样,不作改写。文中命令、版本号与界面截图以本文成文时的实测/核验结果为准,标注「待验证」的部分请以你本地环境实际输出为判断依据。本文不推荐任何不合规的软件获取方式,也不对任何收益结果作承诺。转载请注明出处。

周二凌晨 1 点 40 分,一条「退订会员」的 Agent 流程卡在第三步。前两步已经落到线上库:会员状态改成「退订处理中」,券包里 3 张券被置为「冻结」。第三步调通知服务发短信,网关超时,重试三次没回来。值班同学先重启了 Worker——重启只能让流程不再往下走,退不掉那两条已经写下去的记录。第二天客服接到投诉:券冻结着,退订没办完,短信一条没发。

这篇只回答一个问题:第 N 步失败时,前面几步的副作用按什么顺序退回。

一、毁掉这次上线的不是失败那一步,是前面几步的「成功」

第 3 步失败只是触发器,真正的债务是前两步已生效的副作用。

1.1 只要一步成功,就留下了副作用

多步写操作失败那一刻,系统里已经躺着若干已生效的中间态:库存扣了、订单建了、状态改了。之后只有两条路——推到终点(正向恢复),或退回起点(逆操作补偿)。这篇只讲后者:第 3 步自己就是坏的那一步。

最容易犯的错,是把失败当点事件:失败点之前的写操作都成功了,不会因为你没收到返回值而改变——请求发出、下游执行完、回包丢了,Agent 看到失败,下游看到成功。

1.2 「先查状态再写」挡的是重复,不是半途

幂等键管的是另一个问题。Stripe 官方在 Idempotent requests 一节写的是:同一个幂等键重试,不会创建第二个对象、也不会把同一次更新做两遍;API v1 的键在至少 24 小时后可由系统自动清除。它承诺的只有一件事——同一次请求不要被执行两遍;一次请求内部有 5 个写操作、第 3 个生效第 5 个失败、前两个怎么退,不在范围内。

它管的是「请求」这一层,副作用在「下游服务」那一层,层级对不上就用错了工具。判据:只要某一步的副作用已经产生、而整体操作注定走不到终点,幂等和「先查状态再写」都只会让这个副作用原样留着。

1.3 补偿是语义撤销,不是把数据搬回原位

1987 年 SIGMOD 的 Sagas 论文里,原始表述是:每个 saga 事务都应配一个补偿事务,它「从语义上撤销该事务执行过的动作,但不必然把数据库恢复到该事务开始执行时的状态」。论文举的例子是订机票:补偿是取消订座,不是把剩余座位数写回原值。

Azure 架构中心的 Compensating Transaction 模式页讲得更口语:它补偿的是失败前已完成的那部分工作,不必然把数据恢复原状。

合成一条判据:别拿「回滚成原样」当目标,拿「业务上等价于没发生」当目标。

二、第一道闸门:动手写之前,逆操作必须已经在栈上

这道闸门只做一件事:写操作被调用之前,它的逆操作必须已经登记进补偿栈;登记不进去,就不许调用。

2.1 登记在后,会漏掉「做了一半」的那一步

直觉做法是「这一步成功了,再记下它的逆操作」。Temporal 官方文档解释了为什么它在带副作用的步骤上会出事:活动可能已经产生了效果,却在返回成功之前失败——文档举的例子是信用卡已经扣款、活动却返回失败。按「登记在后」,这笔扣款永久留在账上。

官方 Best practices 的默认建议是「Register compensations before Activity execution」,并要求补偿幂等、能处理正向动作从未执行的情况。登记条目因此要回答三件事:动了哪个服务、逆操作叫什么、要什么参数。

# 补偿登记记录:在正向调用之前写进去step:reserve_couponforward:service:couponaction:freeze_couponsargs:{user_id:U-88,count:3}compensation:service:couponaction:release_frozen_couponsargs:{user_id:U-88}# 不传“冻结了几张”:按当前状态解冻才是幂等的grade:A# 可补偿等级,第三章的闸门要用timeout_s:10retry_max:3

2.2 实测:两种登记时机,差一条脏数据

用一个 dict 当「已经写出去的数据」,前向 3 步依次预留库存、创建订单、发通知,其中第 3 步半途失败(消息投递成功后才抛超时)。同一段逻辑跑两遍,只改登记时机。

🧪 实测环境:Python 3.13.12 / macOS / 仅标准库,脚本 work/exp1015/exp1_compensation_stack.py

# 最小补偿栈:前向写出去的东西,失败后逆序退回defrun(register_before):ext={"stock":100,"order":None,"notify":[]}# 已写出去的数据base={"stock":100,"order":None,"notify":[]}# 出事前的基线stack=[]defundo_stock():ext["stock"]=100# 幂等defundo_order():ext.__setitem__("order",None)defundo_notify():ext["notify"].clear()deffwd_stock():ext["stock"]-=3;returnf"预留 3 件 -> stock={ext['stock']}"deffwd_order():ext["order"]="O-1001";returnf"创建订单 -> order={ext['order']}"deffwd_notify():ext["notify"].append("通知:订单O-1001已受理")raiseTimeoutError("下游超时")# 半途失败:副作用已落库steps=[("预留库存",fwd_stock,undo_stock),("创建订单",fwd_order,undo_order),("发通知",fwd_notify,undo_notify)]forname,forward,undoinsteps:ifregister_before:stack.append((name,undo))# 登记在前try:print(f"[前向]{name}:{forward()}")exceptExceptionase:print(f"[前向]{name}: 失败 ->{type(e).__name__}:{e}")breakifnotregister_before:stack.append((name,undo))# 登记在后(对照组)print(f"补偿栈:{[nforn,_instack]}")whilestack:_,undo=stack.pop()# 逆序弹出undo()print(f"回滚后状态:{ext}")print(f"与基线一致:{ext==base}\n")print("=== 对照 A:登记在后(执行成功才登记)===")run(register_before=False)print("=== 对照 B:登记在前(动手之前先登记)===")run(register_before=True)

原样输出:

=== 对照 A:登记在后(执行成功才登记)=== [前向] 预留库存: 预留 3 件 -> stock=97 [前向] 创建订单: 创建订单 -> order=O-1001 [前向] 发通知: 失败 -> TimeoutError: 下游超时 补偿栈: ['预留库存', '创建订单'] 回滚后状态: {'stock': 100, 'order': None, 'notify': ['通知:订单O-1001已受理']} 与基线一致: False === 对照 B:登记在前(动手之前先登记)=== [前向] 预留库存: 预留 3 件 -> stock=97 [前向] 创建订单: 创建订单 -> order=O-1001 [前向] 发通知: 失败 -> TimeoutError: 下游超时 补偿栈: ['预留库存', '创建订单', '发通知'] 回滚后状态: {'stock': 100, 'order': None, 'notify': []} 与基线一致: True

对照 A 的与基线一致: False不是补偿写错了,而是这一步的补偿压根没进栈:消息投递之后才抛异常,代码没走到stack.append。把登记挪到forward()之前,失败后退回干净。

2.3 没有它会怎样

没有补偿登记,出事时手里只有一个异常堆栈:它告诉你哪一步抛了,不告诉你哪几步已经生效,定位只能靠人翻下游日志。补偿登记的价值,是把「查状态」变成「读栈」。

三、第二道闸门:先给每个写操作标等级,再决定它能不能进自动链路

第二个闸门拦的是另一种事故:把一个只能人工介入的动作,当成能自动补偿的动作,写进了自动链路。

3.1 三级不够,要留一级「不可逆」

把写操作笼统标成「可补偿」,等于把补偿的失败概率写进链路。写操作至少分四档,按「逆操作存在吗、可靠吗」分,不按业务重要性:

  • A 可自动补偿:逆操作本地确定且幂等,删草稿、写回旧字段值、释放预留。
  • B 补偿需幂等重试:有逆操作,但它是跨服务调用,会超时会失败,如取消待支付订单。
  • C 只能人工介入:逆操作不可靠不可控,短信撤不回来、退款要人工审核。
  • D 不可逆:没有语义相反的动作,如已提交的监管申报。

Temporal 官方在 Common pitfalls 里点明:补偿活动若以不可重试的错误永久失败,Saga 就无法完整回滚。Azure 也直说,人工介入有时是唯一的恢复方式。

3.2 实测:等级表与判定器

🧪 数据实测:口径为「对外可见副作用 × 逆操作存在与可靠性 × 本文 2.2 节同一套模拟环境」,取数日期 2026-10-15。

操作类型对外可见副作用逆操作可补偿等级处置
写本地草稿表 / 内部状态字段否删除 / 写回旧值A 可自动补偿旧值随登记存
预留库存(带自动释放 TTL)是释放A 可自动补偿TTL 兜底
创建待支付订单 / 发站内通知是置为已取消 / 发更正B 补偿需幂等重试重试上限 3 次
改线上配置(灰度权重)是写回旧配置B 补偿需幂等重试先快照旧配置
发短信 / 推送是已送达即无C 只能人工介入排到链路后段
调支付网关扣款 / 下单是退款,非即时非全额C 只能人工介入排到最后
提交监管 / 法务申报是无D 不可逆禁止进自动链路

把判据写成判定器跑一遍:

🧪 实测环境:Python 3.13.12 / macOS / 仅标准库,脚本 work/exp1015/exp2_compensability_gate.py

# 可补偿等级判定器:先打等级,再决定它能不能进自动补偿流程OPS=[# 名称 对外可见 有逆操作 逆操作可靠性("写本地草稿表",False,True,"high"),("预留库存(TTL自动释放)",True,True,"high"),("创建待支付订单",True,True,"mid"),("发短信/推送",True,True,"low"),("调支付网关扣款",True,True,"low"),("提交监管/法务申报",True,False,None),]defgrade(has_undo,reliability):ifnothas_undo:return"D 不可逆","禁止进自动链;人工单独发起"return{"high":("A 可自动补偿","随前向执行,失败时逆序自动补偿"),"mid":("B 补偿需幂等重试","补偿重试上限 3 次,超限告警"),"low":("C 只能人工介入","执行前写人工回滚预案,值班人确认")}[reliability]blocked=[]forname,visible,has_undo,relinOPS:label,action=grade(has_undo,rel)verdict="过闸"if(notvisible)orhas_undoelse"拦下"ifverdict=="拦下":blocked.append(name)print(f"{name:<16}对外可见={'是'ifvisibleelse'否':<2}{label:<12}{verdict}|{action}")print(f"\n对外可见且有逆操作的:{sum(1for_,v,u,_inOPSifvandu)}个 —— 必须排在有逆操作的那些之后")print(f"对外可见且无逆操作的:{len(blocked)}个 ->{blocked}闸门线判定: 拦下")

原样输出:

写本地草稿表 对外可见=否 A 可自动补偿 过闸 | 随前向执行,失败时逆序自动补偿 预留库存(TTL自动释放) 对外可见=是 A 可自动补偿 过闸 | 随前向执行,失败时逆序自动补偿 创建待支付订单 对外可见=是 B 补偿需幂等重试 过闸 | 补偿重试上限 3 次,超限告警 发短信/推送 对外可见=是 C 只能人工介入 过闸 | 执行前写人工回滚预案,值班人确认 调支付网关扣款 对外可见=是 C 只能人工介入 过闸 | 执行前写人工回滚预案,值班人确认 提交监管/法务申报 对外可见=是 D 不可逆 拦下 | 禁止进自动链;人工单独发起 对外可见且有逆操作的: 4 个 —— 必须排在有逆操作的那些之后 对外可见且无逆操作的: 1 个 -> ['提交监管/法务申报'] 闸门线判定: 拦下

六条里被拦下的只有一条,恰恰是最不该自动化的那条。等级标注的作用,是让「过闸」可自动执行。

3.3 没有它会怎样

不分等级,团队会按最乐观的假设写补偿:全按 A 级自动退回。于是没有逆操作的动作也进了自动链路,补偿退化成except: pass,数据留在原地;D 级动作还被重试,申报提交两次,这是合规成本。

四、第三道闸门:对外可见的副作用一律排到最后

第三道闸门管的是顺序。顺序不是设计偏好,它直接决定这条链路出事后还能不能退。

4.1 Azure 把这条线写成了「不可逆点」

Azure 的 Compensating Transaction 模式页里有一段几乎就是给这道闸门写的规则:要定义清楚的「points of no return」与不可逆步骤;有些操作无法安全或有意义地撤销,例如对外部系统的副作用或具有法律约束力的动作;要把工作流设计成不可逆步骤只在所有关键校验通过之后才发生。

翻译成工程判据:凡是会产生对外可见副作用(发消息、下单、付款、改线上配置)的操作,必须排在该操作的补偿登记之后;没有逆操作,就不许出现在自动链路里。后半句不是「要谨慎」,是「不许」——对外动作一旦执行,补偿栈再深也没用。

4.2 排序判据:按「谁能退、退得多快」排

链路位置放什么动作判据
前段读状态、算参数、写补偿登记;草稿、缓存、内部状态字段无对外副作用,或逆操作在本服务内可控
中段预留库存、待支付订单、冻结券逆操作幂等,延迟在秒级
后段与链路外发消息、退款、改线上配置;短信、支付扣款、监管申报前三个逆操作慢或非全额,必须带重试与告警;后三个无逆操作,只走人工确认入口

4.3 没有它会怎样

顺序错了,链路就是一道单向门。第 4 段失败时前面几段已对外可见,补偿只能「减轻」不能「撤销」:发更正通知、开人工工单、等 TTL 过期。这些不是补偿,是止损——止损要人,补偿不要人。

AI 大模型知识库(在线可查):里面「Agent 工程化」专题把上面这套排序判据和补偿登记的字段结构整理成了可勾选的清单,比正文更适合照着改代码。放在资料包里,扫码即可获取:

五、第四道闸门:补偿自己失败时,闸门必须把人叫进来

补偿会失败,这不是异常情况,是常态——把补偿写成「一定会成功」是这套机制最常见的误用。

5.1 补偿失败要记进度,不是重跑整条补偿

Azure 文档在 Solution 里说得清楚:补偿事务本身是最终一致的操作,也会失败,系统应当记录进度,以便从失败点继续执行补偿;有时人工介入是唯一恢复方式,这时应抛出含失败原因的告警。Temporal 官方给的做法一致:记录补偿失败但继续执行其余补偿,持续失败的补偿要告警转人工。

这里有一个反直觉的落点:补偿链中某个动作失败,不该中断整条补偿链。逆序弹出时遇到失败就直接 raise,栈里剩下的补偿永远不会执行,前面成功的也白做。正确做法是记下失败、继续弹栈,最后把失败清单一起告警。

5.2 四道闸门合起来长什么样

闸门拦截目标落地动作
第一道:补偿登记没有逆操作的写操作调用下游写接口前先把逆操作压栈;压不进栈就不调用
第二道:等级标注C / D 级动作混进自动补偿流程每条登记必须带grade;A 自动、B 幂等重试、C 转人工、D 即拒绝
第三道:顺序先发消息、先扣款,回头再补前面按「谁能退、退得多快」分段;无逆操作的动作排到链路外
第四道:补偿执行补偿失败没人知道逆序执行、记录进度、超限告警派值班人

责任也要定下来,否则它只是漂亮图:第一、二道在写流程的人身上,他要为每条登记填出逆操作与等级;第三道在设计链路的人身上,要回答「这一步失败时前面几步对外可见了吗」;第四道在值班的人身上,要能回答「昨晚有几次补偿超限」。

5.3 什么时候该停手

闸门不是越多越好,判据只有三条。补偿是「人工开单」的动作,每失败一次占掉一个人工工位,每天能承受几次要事先算出来,超了就别再放进自动链路。补偿栈深度超过 8 到 10 层,就该把流程拆成两个 saga——栈越深,中途对外可见的中间态越多。对外动作没有逆操作又不能挪到末端时,正确结论是拆成「自动段 + 人工确认段」,而不是硬写一个补偿。

回到开头那次事故:券冻结能自动退、退订状态能写回,真正退不回来的只有那条短信。

大模型学习路线图:从零基础到能自己动手做 Agent,里面「Agent 工程化」那一阶段讲了补偿、幂等、可观测分别该在什么阶段学。放在资料包里,扫码即可获取:

附表 A:本文引用事实与出处对照表

#事实出处本文位置
1补偿事务「从语义上撤销该事务执行过的动作,但不必然把数据库恢复到该事务开始执行时的状态」(Azure 对同一机制的表述是:补偿的是原操作失败前已完成的那部分工作,不必然把数据恢复原状)Garcia-Molina & Salem,《Sagas》,ACM SIGMOD 1987,DOI 10.1145/38713.38742;Microsoft,Azure Architecture Center,Compensating Transaction pattern§1.3
2要定义清楚的不可逆点与不可逆步骤,让不可逆步骤只在关键校验全部通过后发生;补偿本身也会失败,需记录进度,人工介入有时是唯一恢复方式同上§4.1 / §5.1
3建议在活动执行之前登记补偿,补偿必须幂等并能处理正向动作从未执行的情况;佐证例子:信用卡已扣款但活动返回失败Temporal Documentation,Saga Pattern§2.1
4补偿活动若以不可重试错误永久失败,Saga 无法完整回滚;持续失败的补偿应告警转人工同上§3.1 / §5.1
5幂等键的作用是安全重试、避免同一操作被执行两遍;API v1 的键在至少 24 小时后可由系统自动清除Stripe Documentation,Idempotent requests§1.2
6本文两处实测:最小补偿栈两种登记时机的对照输出、可补偿等级判定器的判决输出本文实测,Python 3.13.12 / macOS / 仅标准库,脚本见 work/exp1015/§2.2 / §3.2

写在最后:这篇用到的资料

写这篇文章时,把相关的官方文档和源码又翻了一遍,顺手也整理了几份配套的东西:

  • 大模型学习路线图:从零基础到能自己动手做 Agent,按阶段说明每一步该学什么、哪些可以先跳过
  • 《LangChain + LangGraph + MCP 智能体开发实战》视频课:7 个模块,从私有化部署、Embedding+RAG 到 MCP+Agent 全流程
  • AI 大模型知识库(在线可查):Agent Skills 从入门到落地、Claude Skills 完全指南等专题,按目录浏览即可
  • 640 套 AI 大模型行业报告 + 经典 PDF 书籍:看行业落地案例和别人怎么做的时候用得上
  • 大模型零基础到精通教学视频:跟着敲一遍,比只读文档快得多

资料是我自己整理的,放在下面这个码上,扫码即可获取:






添加时备注「AI」,优先通过。

资料按「先路线、再动手、最后查漏」的顺序整理好了,建议先看学习路线那一份,照着它挑一条适合自己当前基础的路径再往下看。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询