我先把话说在前头:如果一个AI智能体只做一次模型调用,那确实不需要操心什么一致性。但一旦你把智能体接入真实业务,让它调用商品服务、库存服务、订单服务、营销服务,串成一条“识别需求->推荐->锁库存->下单->发券->扣积分”的任务流水线,重点就从“模型聪明不聪明”转移到了“链路一半成功一半失败时怎么收场”。我最近做AI商品推荐智能体时,就被这个问题磨了两天,最后是靠TCC结合Saga才把这条流水线的分布式事务一致性彻底兜住。这篇实战教程就把我的拆解思路、代码设计和踩坑记录摊开讲清楚,给同样在搭AI智能体工作流的朋友当个参考。
1. 为什么AI智能体的任务跑着跑着就“残”了
1.1 一条任务流水线的真实链路
先说一个我手头的例子。用户点了“一键配齐”按钮,AI智能体要自动完成一次商品推荐与下单。它表面上是“智能”的,实际上是在按编排好的步骤依次调用多个后端服务:先读取用户画像和历史行为,再根据画像匹配候选商品,然后锁定库存、创建订单、核销优惠券、扣减积分,最后把结果推给用户。
这里每一步都可能失败。比如库存明明显示有货,锁定瞬间被另一个用户抢走;比如用户积分刚够,但订单创建超时;比如券已经核销了,但数据库回滚脚本没跑对。麻烦的是这些服务各自有独立的数据库和独立的事务边界,你不可能靠一个本地数据库事务把它们全部包住。跑起来你就会发现,链路越长,出问题的概率越高,而普通代码根本处理不了“前面都成功、后面突然失败”的残留状态。
1.2 本地事务的“势力范围”只有一个库
很多开发者的第一反应是:用数据库事务不就行了吗?确实,单机单库下,ACID事务处理“一致”和“回滚”非常优雅。start transaction,执行SQL,commit或rollback,整件事就像排队进闸机一样整齐。
但问题在于,AI智能体任务流水中跨越的服务一多,本地事务就失效了。你不可能在库存服务里开启一个事务去回滚订单服务里的记录,也不应该因为营销服务超时,就强制把别的服务拖进同一个本地事务。跨库、跨服务、跨网络的事务边界一旦被突破,就需要一套分布式事务方案来管理“每个参与方各自成功或失败”之后如何对齐最终结果。这就是TCC和Saga登场的背景。
1.3 一致性不是只有“原子性”一种答案
很多人一听到“一致性”,就默认要求强一致,也就是所有节点要么全部成功、要么全部失败,像单库事务那样。但在分布式场景里,强一致成本极高,而且对长链路并不现实。工程上更常用的其实是“最终一致”和“准强一致”。
- 强一致:所有节点在同一时刻看到相同结果,实现代价高,不适用于跨服务的长流程。
- 最终一致:允许中间状态不一致,但通过补偿、重试等手段,在短时间内把数据对齐。
- 准强一致:介于两者之间,比如TCC的Try阶段把资源“锁定”,后续Confirm/Cancel能保证关键数据在关键节点是确定性的。
AI智能体任务流水线天生适合“以最终一致为主、关键资源准强一致”的组合方案。这也是我在实战里选择TCC结合Saga的根本原因。单一模式解决不了所有问题,哪个环节需要多严格的控制,就给它上对应强度的手段。
2. Saga模式:用“补偿”给长流程兜底
2.1 核心思想:把一个长事务拆成一串可补偿的子事务
Saga的核心思想特别朴素:别硬撑一个大事务,把一个长流程拆成N个有顺序的子事务,每个子事务都有对应的正向操作和补偿操作。如果第K步失败,就把第K-1步、第K-2步……一直往前做逆序补偿,把已经造成的状态“撤销”回原点。
我用一个生活化类比来理解它:你请朋友吃饭,正餐一道道上,如果最后一道甜品上错了,不是把前五道菜全部回炉重造,而是把甜品撤掉、把上错的那份退给厨房,前面吃掉的饭已经消化了,但不影响这顿饭整体“合理收尾”。Saga不追求“全部没发生”,而是追求“结果可接受,数据能对齐”。
2.2 编排式与事件式:两条不同的实现路线
Saga的落地方式主要有两种,我在实际选择时需要先分清楚:
| 维度 | 编排式(Orchestration) | 事件式(Choreography) |
|---|---|---|
| 控制方式 | 一个中央协调器按顺序调用各个子服务 | 服务之间通过事件发布/订阅驱动 |
| 业务耦合 | 协调器集中管理,子服务不知道全局流程 | 服务间通过事件解耦,但全局流程隐式 |
| 失败处理 | 协调器统一决策补偿顺序 | 各服务自己监听事件并触发补偿 |
| 适合场景 | 有清晰状态的流程,比如AI智能体任务 | 事件流天然分层的系统 |
| 调试复杂度 | 相对低,状态集中在协调器 | 较高,需要反复追踪事件链 |
对AI智能体任务流水线来说,我强烈推荐编排式。原因很简单:智能体本身就是“有状态的调度大脑”,它天然知道当前任务跑到哪一步、下一步该调谁。你再用一套隐式事件链去驱动反而会打架。编排式把状态机放在协调器里,出了问题也能一眼看出卡在哪个环节。
2.3 Saga的边界条件:不是所有步骤都能补偿
Saga看起来很美好,但有一个硬性前提:每个子步骤必须能提供“可靠补偿”。补偿不是把SQL删掉重跑,而是要能在业务上抵消前一步的影响。
举几个例子:
- 优惠券核销后,补偿是回补优惠券可用次数,可行。
- 积分扣减后,补偿是返还积分,可行。
- 但向第三方渠道发送了短信通知,这条短信无法“撤销”,只能做“替代性补偿”,比如再发一条更正通知,这对严格一致性而言并不完美。
- 部分外部支付一旦资金已划转,补偿就是退款流程,但退款有延迟,还会有手续费损失。
所以,用Saga前必须逐个步骤评估“补偿是否可实现”。如果一个步骤不可补偿,它就不能被简单放入Saga链路,要么改成TCC模式做更强的控制,要么通过人工介入兜底。
3. TCC模式:关键资源要“先占后得”
3.1 Try、Confirm、Cancel三段式流程
TCC是另一种分布式事务思想,全称是Try-Confirm-Cancel。它把一次业务操作拆成三个阶段:
- Try阶段:尝试执行业务,检查并预留资源。比如锁库存,这个阶段不会真正扣减库存,而是先把目标数量的库存“冻结”起来。
- Confirm阶段:确认执行业务,真正把预留的资源落库。比如把冻结的库存改成已售,或者真正生成订单。
- Cancel阶段:取消执行业务,释放Try阶段预留的资源。比如把冻结库存解除,恢复可售状态。
这里的关键是:Try是“先占”,Confirm是“落子”,Cancel是“止损”。相比Saga的“事后补偿”,TCC在事前就锁定了资源边界,所以它能提供更强的保护,避免超卖、重复扣减这类恶性问题。
3.2 空回滚与悬挂:最容易踩的两个坑
TCC用起来比理解要难,主要难在三个字:空回滚、悬挂。这两个问题不处理,线上一定会出事故。
空回滚是指:Try请求因为网络超时没到达服务端,但Cancel请求到了。服务端自己没有Try记录,却收到了Cancel,这时候如果直接执行Cancel逻辑,就会影响别的正常流程。正确做法是:收到Cancel时发现没有Try记录,直接返回成功,不做多余动作,这就是空回滚。
悬挂是指:Cancel先于Try到达,随后迟到的Try又来了。如果Try发现之前已经执行过Cancel,还继续执行业务预留资源,资源就会被一直悬挂着锁死,最终导致库存冻结无法释放。正确做法是:在Try入口检查是否存在Cancel记录,如果存在就拒绝执行Try。
这两个问题解决起来都不复杂,关键是必须在分支事务状态表里记录足够的状态流转信息,并且整个状态切换要保证原子性。
3.3 幂等与状态机:TCC的地基
TCC对幂等的要求非常高。Confirm和Cancel都可能因为网络重试被调用多次,如果实现不幂等,就会出现重复入账、重复释放。我一般会给每个参与方维护一张“分支事务记录表”,字段大致包括:
| 字段 | 说明 |
|---|---|
| tx_id | 全局事务号,协调器生成 |
| branch_id | 分支事务号,唯一标识一次子事务 |
| status | TRYING / CONFIRMED / CANCELED / FAILED |
| try_time / confirm_time / cancel_time | 各阶段执行时间 |
状态流转必须是一个闭环:TRYING -> CONFIRMED,或 TRYING -> CANCELED。每次执行操作前都先检查状态,如果已经是CONFIRMED就直接幂等返回成功。这张表不只是用来防重复,更是排查线上问题时最重要的凭证。
4. 为什么最终要“TCC+Saga”组合,而不是二选一
4.1 两个模式各有胜场,组合才是务实选择
TCC和Saga不是竞争关系,它们是针对不同资源强度的两种控制手段。用TCC处理所有步骤,会导致每个服务都要实现Try/Confirm/Cancel和状态表,代码量大且性能损耗高;用Saga处理所有步骤,又会在库存、资金这类敏感资源上缺乏“事前锁”的强度,容易出现超卖和超扣。
我在实战里采用的策略是:全局用Saga编排器做长流程调度,关键敏感资源内部用TCC协议保护。Saga负责“知道什么时候该补偿、补偿谁”,TCC负责“具体资源在手里的那一刻不能被任何人抢走”。
4.2 流水线里的资源分层:什么步骤配什么方案
| 步骤类型 | 示例 | 一致性方案 | 原因 |
|---|---|---|---|
| 只读查询 | 偏好分析、商品匹配 | 普通调用,失败直接终止 | 不产生写状态,无需事务 |
| 强敏感资源 | 锁库存、扣余额 | TCC | 需要事前预留,防止并发争抢 |
| 可补偿写操作 | 核销优惠券、扣减积分 | Saga补偿 | 事后能可靠回补,不强求事前预留 |
| 外部不可逆操作 | 短信通知、对接第三方推送 | Saga补偿+人工兜底 | 无法真正撤销,只能冲正 |
这张分层表是我做流水线设计时最先画的图。别一上来就纠结用什么框架,先把每个步骤在资源维度归好类,方案自然就出来了。
4.3 编排器不只是“顺序调用”,它要管状态
真正落地的编排器不是simple for循环调服务,它至少要做四件事:
- 定义任务模板,明确每一步的操作类型和补偿关系。
- 持久化任务状态,让整个流水线在宕机后还能恢复。
- 处理重试、超时与死信,避免一个请求卡死整条链路。
- 输出补偿审计日志,方便定位哪个环节导致不一致。
我选择将智能体本身作为编排器,但比“让模型自己随意调用工具”多了一层严格的流水线状态管理。也就是说,模型可以决定“调用什么工具”,但工具调用成功后怎么提交、失败后怎么补救,不能交给模型自由发挥,而是由预定义的状态机来控制。
5. 实战:AI商品推荐智能体任务流水线
5.1 场景与任务拆分
场景设定如下:用户在小程序点“一键配齐”,AI智能体根据用户画像推荐3件商品并自动组合下单,同时完成“新客立减券核销”和“积分抵扣”。我把它拆成7个步骤:
- 获取用户偏好画像(只读服务)
- 基于画像匹配候选商品(只读服务)
- 锁定推荐商品的库存(TCC敏感操作)
- 创建组合订单(可设置为主流程核心操作)
- 核销新客立减券(Saga可补偿)
- 扣减用户积分(Saga可补偿)
- 推送“配齐成功”消息(外部不可逆操作)
第1、2步失败就直接终止,不产生写操作;第3步用TCC护住库存;第4、5、6步放进Saga调度,出问题逆序补偿;第7步是通知,失败不影响交易正确性,只记日志。
5.2 库存锁定:用TCC守住关键资源
先看库存服务怎么实现TCC三段逻辑。我用一个简化的Java接口展示核心方法:
public interface StockReserveService { // TCC Try阶段:尝试锁定库存,不真正扣减 boolean tryReserve(String txId, Long skuId, Integer quantity); // TCC Confirm阶段:确认锁定,真正扣减可售库存 boolean confirmReserve(String txId, Long skuId, Integer quantity); // TCC Cancel阶段:取消锁定,释放冻结库存 boolean cancelReserve(String txId, Long skuId, Integer quantity); }对应数据库可以有两张表:库存表(stock)和库存冻结表(stock_frozen)。Try阶段在stock_frozen写入一条冻结记录,并把可售库存转为冻结库存;Confirm阶段把冻结记录标记为已确认,真正扣减可售库存;Cancel阶段把冻结记录标记为已取消,可售库存恢复。最关键的是,整个分支状态在分支事务记录表里必须串行流转,防止空回滚和悬挂。
5.3 优惠券与积分:用Saga补偿做最终一致
优惠券核销和积分扣减,我放进Saga只管“正向执行+反向补偿”。以优惠券为例,定义一个Step:
public class CouponRedeemStep implements SagaStep<CouponContext> { @Override public void execute(CouponContext context) { // 正向操作:核销优惠券,将状态改为USED couponService.redeem(context.getCouponId(), context.getUserId()); } @Override public void compensate(CouponContext context) { // 补偿操作:回补优惠券,将状态改回AVAILABLE couponService.refund(context.getCouponId(), context.getUserId()); } }积分扣减也是同样的写法:正向执行扣积分,补偿执行加回积分。这里要特别小心幂等:补偿操作可能被重复执行,所以每次操作都要记录补偿事务号,状态已回补就直接返回成功。
5.4 编排器与流水线状态管理
全局编排器的核心是状态机。我会维护一张任务流水线表:
| 字段 | 说明 |
|---|---|
| task_id | 全局任务号 |
| agent_request_id | 智能体请求ID |
| current_step | 当前执行到的步骤 |
| status | RUNNING / SUCCESS / FAILED / COMPENSATING |
| compensate_log | 补偿日志JSON |
编排器伪代码如下,展示了Saga全局调度与TCC的衔接思路:
public void executeRecommendTask(TaskRequest req) { TaskState state = taskRepo.create(req); try { // 只读步骤:失败直接结束 UserProfile profile = userService.getProfile(req.getUserId()); List<ProductItem> items = matchService.match(profile); // 关键资源TCC:锁定库存 StockReserveService stockService = rpcClient.create(StockReserveService.class); branch tx = branchRegistry.createBranch("stock", txId); if (!stockService.tryReserve(txId, items)) { throw new TaskException("库存锁定失败"); } state.setCurrentStep("STOCK_TRY"); // 核心事务步骤开始,进入Saga管控范围 try { Order order = orderService.createOrder(txId, items); couponStep.execute(couponCtx); // 核销券 pointsStep.execute(pointsCtx); // 扣积分 notifyStep.execute(notifyCtx); // 推消息 stockService.confirmReserve(txId, items); // TCC Confirm state.setStatus("SUCCESS"); } catch (Throwable ex) { // 全局逆序补偿 state.setStatus("COMPENSATING"); sagaCoordinator.compensate(txId, ex.getStepIndex()); stockService.cancelReserve(txId, items); // TCC Cancel state.setStatus("FAILED"); throw ex; } } catch (TaskException e) { state.recordError(e); throw e; } }这段代码真实反映了我项目里的核心逻辑,但实际生产比这更繁琐:每一步的返回结果要落表,每个分支的状态要落表,每个补偿动作要记日志。别嫌麻烦,这些记录在排查线上问题时比任何日志都管用。
5.5 与Agent工具链对接:让智能体“编排”而非“乱调”
真实搭建AI智能体时,我不会让模型直接拿着HTTP URL满天乱飞。更稳的做法是把上述编排逻辑封装成一个个“Agent工具”,然后让大模型只能选择调用这些工具,而工具的完整事务语义由后端定义。
比如在LangChain或Coze这类框架里,我可以把“一键配齐”定义成一个工具,模型的输出中只要出现对这个工具的调用,后端就自动触达编排器的executeRecommendTask方法。异步结果通过Webhook回传,再按模板整理成给用户看的自然语言结果。
这样做的核心收益是:模型负责意图理解和工具选择,后端负责可靠执行和状态兜底。两边各管各的,互不拖累。
6. 常见问题与排查技巧实录
6.1 幂等表为什么覆盖不了“先Cancel后Try”
初期我做过一个错误示范:只在Confirm/Cancel里做幂等判断,只要“执行过了”就直接返回。结果线上出现一个诡异问题:一个分支先收到了Cancel,后到的Try反而执行成功,把库存永久冻结了。
后来我把分支事务表改成显式状态机,增加悬挂检测:Try入口先查Cancel状态,如果Cancel已经执行,就直接拒绝Try;Cancel入口查不到Try记录时,按空回滚处理。这张状态表是TCC的命根子,不能省。
6.2 补偿顺序错了,数据乱了怎么修
Saga的补偿必须严格按照正向操作的逆序执行。比如正向是“创建订单->核销券->扣积分”,补偿就必须是“回补积分->回补券->作废订单”。有一次我为了图省事,把补偿步骤并行发出,结果积分回补成功、券回补成功,订单却因为还在处理中导致作废失败,数据就出现了短暂错位。
我的对策是:所有补偿动作统一交给Saga协调器串行执行,严格遵循栈式顺序,并且每个补偿动作都有独立状态记录,哪个失败就停止后续补偿,进入人工处理队列。宁可慢,不可乱。
6.3 超时、重试与“重试风暴”
分布式事务里,重试本身会引发雪崩。一个服务超时,如果触发即时重试,多个服务一起重试,就可能把下游打挂。我后来规定:每个参与方的重试次数上限为3次,重试间隔采用指数退避策略(1s / 2s / 4s),且只对“状态未终结”的事务做重试,绝不盲目重放Confirm/Cancel。
同时,编排器里必须配置全局超时时间。超过阈值后,把任务标记为死信,由定时任务扫描死信表,人工介入处理后重新触发补偿。这套机制救了我好多次。
6.4 怎么观察一条流水线的健康状况
实用监控三板斧:
- 每次进入/离开一个步骤,都要打一条结构化日志,包括task_id、step、成本、状态。
- 为任务状态表建立看板,按RUNNING / COMPENSATING / FAILED分组计数。
- 对单个分支事务表做延迟告警,比如TRYING状态超过30秒未流转就报警。
我用这套监控在线上发现过多次“库存冻结超过10分钟未处理”的隐患。注意:有监控不算完,还要能顺着task_id把整条调用链拉出来,这才是排查问题的关键。
写在最后的一个小技巧
如果在落地时只能记住一点,我会选择:先把每个步骤的资源和可补偿性写清楚,再写代码。TCC和Saga都是成熟的原子方法论,真正让它们出问题的,往往不是模式本身,而是对每一步的边界认知不清楚。设计阶段多花一个小时画资源分层表,线上就少熬一个通宵。这个习惯,我自己已经受用很久了。