Agent工具调用必须支持事务:SAGA模式实战指南
发布时间:2026/9/14 6:13:58 锦皓数字建站

1. 为什么“给 Agent 的工具调用加上事务”不是锦上添花而是生死线你写好了一个能调用天气 API、查数据库、发邮件的 Agent它在测试环境跑得飞快逻辑清晰连老板都夸“这智能体真像人”。结果上线第三天用户下单后库存扣减成功了但订单状态卡在“创建中”财务系统没收到通知客服电话被打爆——你翻日志发现工具链里某个环节失败回滚了但前面几个已执行成功的工具调用像泼出去的水再也收不回来。这不是故障是设计缺陷。Agent 的工具调用天然具备分布式、异步、跨服务、无状态协同的特征而默认没有任何一致性保障机制。LangChain4j、Spring AI、Hermes Agent 这些框架提供的Tool注解、ToolExecutor、FunctionCalling能力本质只是把函数调用包装成 JSON Schema 并转发给 LLM 决策——它们不关心“如果第3个工具失败了前2个干的事要不要撤销”更不处理“库存扣减和订单创建必须原子性完成”这类业务强约束。我去年带团队落地一个电商履约 Agent核心流程是① 校验用户信用 → ② 扣减库存 → ③ 创建订单 → ④ 推送物流单号 → ⑤ 发送短信通知。上线首周就出现 17 起“库存已扣、订单未建”的脏数据。排查发现第④步调用物流网关超时失败Agent 框架直接抛异常终止执行但②和③的数据库操作早已提交。我们当时天真地以为“加个 try-catch 就行”结果 catch 住异常后手动 rollback 库存不行——库存服务是独立微服务没有事务上下文透传重试订单号已生成重复创建会触发幂等校验失败补偿谁来定义“库存扣减失败后该补多少”补多了超卖补少了少卖。这才意识到Agent 不是单机脚本它是分布式系统的协调中枢工具调用不是本地方法调用而是跨进程、跨网络、跨信任域的服务编排。当一个 Agent 同时调用 MySQL、Redis、Milvus、外部 HTTP API、内部 gRPC 服务时“事务”不再是 ACID 的教科书概念而是必须由开发者亲手编织的一张状态一致性防护网。LangChain4j 的低级 API如ToolExecutor、ToolResult给你提供了钩子但没给你织网的针和线SAGA 模式不是银弹而是你唯一能握在手里的、可落地的工程解法。所谓“给 Agent 的工具调用加上事务”本质是把 LLM 驱动的、看似松散的工具链重构为具备明确状态边界、可追踪、可补偿、可审计的业务工作流。提示别被“事务”二字迷惑。这里说的不是 Spring 的Transactional也不是 MySQL 的BEGIN/COMMIT。它指的是跨服务、跨存储、跨协议的最终一致性保障机制。你在 LangChain4j 里写Tool本质上是在定义一个“可能失败、可能重试、可能需要补偿”的原子操作单元而事务层要做的就是让这些单元组合起来依然可靠。2. SAGA 模式Agent 工具链事务的唯一工业级答案面对分布式环境下工具调用的一致性难题技术圈有过不少尝试两阶段提交2PC因阻塞和 coordinator 单点故障被弃用TCCTry-Confirm-Cancel要求每个服务都改造接口对遗留系统不友好本地消息表定时任务太重且无法解决跨语言调用问题。最终SAGA 模式因其“正向操作 反向补偿”的轻量、解耦、可扩展特性成为 Agent 场景下事实上的标准解法。它不追求强一致性但确保业务最终一致——这恰恰匹配 Agent 的本质它不是银行核心系统而是业务流程的智能协作者允许短暂不一致但绝不允许永久错乱。SAGA 的核心思想极其朴素把一个长事务拆成一系列本地事务每个工具调用就是一个本地事务每个正向操作Forward Action都配一个对应的反向补偿操作Compensating Action。执行流程如下正向执行按顺序执行每个工具调用如deductInventory()→createOrder()→sendSms()成功则继续上一步成功才触发下一步失败则补偿某步失败从最后成功步开始逆序执行其补偿操作如sendSms()失败则执行cancelSms()createOrder()成功但sendSms()失败则先cancelSms()再cancelOrder()关键在于SAGA 不依赖全局锁或协调器每个工具只需提供自己的正向和补偿逻辑Agent 框架只负责编排与状态追踪。这完美契合 LangChain4j 的设计哲学——它不强制你用某种 ORM 或 DB 框架只要你实现Tool接口就能接入。同理SAGA 要求你为每个关键工具定义compensate()方法而非改造整个基础设施。我实测过三种 SAGA 实现路径结论非常明确方案原理适配 LangChain4j 难度状态持久化补偿可靠性适用场景内存型 SAGA在 Agent 执行线程内维护一个ListStep失败时遍历执行补偿★☆☆☆☆极低无重启即丢低JVM Crash 则补偿丢失本地开发、POC 验证、非关键流程数据库状态表 SAGA每个工具调用前向saga_instance表插入记录成功/失败更新status补偿时查表驱动★★★☆☆中强MySQL/PostgreSQL高有重试、幂等中小型业务、事务要求高、已有 DB 基础设施事件驱动型 SAGA正向操作发布OrderCreatedEvent补偿操作订阅SmsFailedEvent触发CancelSms★★★★☆高强Kafka/RocketMQ最高事件溯源、死信队列兜底大型分布式系统、高并发、多语言混合架构我们最终选择了数据库状态表方案原因很实在团队熟悉 MySQL运维成本低且 LangChain4j 的Tool本身就需要访问 DB复用连接池即可。更重要的是它规避了事件驱动的复杂性——你不需要为每个工具额外搭一套消息中间件也不用处理消费者位点、重复消费、事务消息等难题。一个saga_instance表5 个字段就能撑起整个事务骨架CREATE TABLE saga_instance ( id VARCHAR(64) PRIMARY KEY, -- Saga 全局 ID如 order_20240520_abc123 step_index INT NOT NULL, -- 当前执行到第几步0-based tool_name VARCHAR(128) NOT NULL, -- 工具名如 deductInventory status ENUM(PENDING, SUCCESS, FAILED, COMPENSATING, COMPENSATED) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, payload JSON -- 存储该步所需的参数、返回值、补偿所需上下文 );这个表不是摆设。它让 Agent 从“一次性的指令执行器”变成了“有记忆、可恢复、可审计的状态机”。当sendSms()因网络抖动失败时框架不是简单抛异常而是将saga_instance记录更新为FAILED并启动后台补偿线程——它会精确找到step_index2的记录读取payload中保存的订单 ID 和短信 ID调用cancelSms(orderId, smsId)。整个过程对 LLM 完全透明它只看到“工具调用失败”而底层的补偿、重试、状态同步全部由 SAGA 层接管。注意SAGA 的补偿操作必须是幂等的。cancelOrder(orderId)被调用 10 次效果必须等同于调用 1 次。这是硬性要求否则补偿链会雪崩。我们在cancelOrder方法开头强制加了SELECT FOR UPDATE锁定订单状态并检查是否已是“已取消”避免重复操作。3. LangChain4j 低级 API 深度改造从Tool到SagaTool的跃迁LangChain4j 的Tool注解用起来很爽一行注解自动注册LLM 能识别框架能调用。但它是个“黑盒”——你无法干预调用前的准备、调用中的状态记录、调用后的结果解析更别说注入补偿逻辑了。要实现 SAGA必须绕过高层抽象直击ToolExecutor和ToolResult这些低级 API。这不是炫技而是工程落地的必经之路。我们的改造分三步走每一步都踩过坑3.1 定义SagaTool接口让工具自己声明“我能被补偿”首先放弃Tool定义一个新接口SagaToolpublic interface SagaTool { // 正向操作LLM 决策后实际执行的业务逻辑 ToolResult execute(ToolExecutionRequest request); // 补偿操作当本工具执行失败或后续工具失败时用于回滚本工具的影响 ToolResult compensate(ToolExecutionRequest request, ToolResult forwardResult); // 工具唯一标识用于 SAGA 状态表记录 String toolName(); // 是否启用 SAGA有些工具如日志记录无需补偿 boolean isSagaEnabled(); }注意compensate()方法的签名它接收forwardResult。这是关键设计。比如deductInventory()成功后返回{ inventoryId: inv_123, quantity: 5 }这个结果必须原样传给compensate()否则cancelInventory()不知道要补回哪个库存项、补多少。我们曾犯过错误只传request导致补偿时参数缺失只能查 DB 补救性能暴跌。3.2 构建SagaToolExecutor拦截调用写入状态驱动补偿核心是重写ToolExecutor。标准DefaultToolExecutor直接反射调用Tool方法我们替换成SagaToolExecutorpublic class SagaToolExecutor implements ToolExecutor { private final SagaRepository sagaRepository; // 操作 saga_instance 表的 DAO private final MapString, SagaTool sagaTools; // 注册的 SagaTool 映射 Override public ToolResult execute(ToolExecutionRequest request) { SagaTool tool sagaTools.get(request.toolName()); if (tool null || !tool.isSagaEnabled()) { // 非 SAGA 工具走原始逻辑 return invokeOriginal(request); } // 1. 生成 Saga ID全局唯一如 UUID 时间戳 String sagaId generateSagaId(); // 2. 获取当前步骤索引从状态表查最大 step_index 1 int stepIndex sagaRepository.getMaxStepIndex(sagaId) 1; // 3. 写入初始状态PENDING SagaInstance instance new SagaInstance(sagaId, stepIndex, tool.toolName(), PENDING, request.toJson()); sagaRepository.insert(instance); try { // 4. 执行正向操作 ToolResult result tool.execute(request); // 5. 更新状态为 SUCCESS sagaRepository.updateStatus(sagaId, stepIndex, SUCCESS, result.toJson()); return result; } catch (Exception e) { // 6. 执行失败更新状态为 FAILED并触发补偿链 sagaRepository.updateStatus(sagaId, stepIndex, FAILED, e.getMessage()); // 关键启动补偿不是在这里补偿而是异步触发 compensationService.triggerCompensation(sagaId); throw new SagaExecutionException(Saga step failed: e.getMessage(), e); } } }这段代码的精妙之处在于所有状态变更INSERT/UPDATE和业务执行tool.execute()都在同一个数据库事务内完成。这意味着如果tool.execute()成功但sagaRepository.updateStatus()失败DB 连接断了整个事务回滚saga_instance表里什么都没写tool.execute()的副作用也一并回滚前提是你的工具本身支持回滚。反之如果sagaRepository.updateStatus()成功但tool.execute()抛异常状态已是FAILED补偿服务会立刻介入。这种“状态先行”的设计是 SAGA 可靠性的基石。3.3 实现CompensationService补偿不是“重试”是“精准外科手术”补偿服务CompensationService是 SAGA 的大脑。它不盲目重试而是根据saga_instance表的状态精确计算补偿路径Service public class CompensationService { public void triggerCompensation(String sagaId) { // 1. 查询该 Saga 下所有已成功执行的步骤按 step_index 降序 ListSagaInstance successSteps sagaRepository.findSuccessSteps(sagaId); // 2. 从最后一个成功步开始逆序执行补偿 for (int i successSteps.size() - 1; i 0; i--) { SagaInstance step successSteps.get(i); SagaTool tool sagaTools.get(step.getToolName()); try { // 解析 forwardResult存于 payload 字段 ToolResult forwardResult parseForwardResult(step.getPayload()); ToolExecutionRequest request parseRequest(step.getPayload()); // 执行补偿 ToolResult compensateResult tool.compensate(request, forwardResult); sagaRepository.updateStatus(sagaId, step.getStepIndex(), COMPENSATED, compensateResult.toJson()); } catch (Exception e) { // 补偿失败记录告警进入人工干预队列 alertService.sendAlert(Compensation failed for saga: sagaId , step: step.getStepIndex(), e); sagaRepository.updateStatus(sagaId, step.getStepIndex(), COMPENSATION_FAILED, e.getMessage()); break; // 停止后续补偿避免雪崩 } } } }这里有两个血泪教训补偿必须按逆序执行。createOrder()必须在deductInventory()之后补偿否则库存补回了订单却还挂着业务更乱。补偿失败必须立即停止并告警。我们曾让补偿服务忽略单步失败继续执行结果cancelOrder()失败后cancelInventory()还强行执行导致库存虚增。现在任何一步补偿失败整条链中断人工介入核查。提示parseForwardResult()和parseRequest()的实现强烈建议用 Jackson 的JsonNode而不是强转 POJO。因为不同工具的payload结构千差万别强类型绑定会频繁修改代码。JsonNode提供灵活的get(field).asText()访问适配所有工具。4. 实战避坑指南那些让 SAGA 在 Agent 场景下失效的致命细节理论再完美落地全是坑。我们在电商 Agent 项目中花了整整三周时间填平这些坑有些甚至颠覆了最初的设计。以下是最痛、最常被忽略的细节句句来自生产环境4.1 “工具调用嵌套 arguments 的问题反复”JSON Schema 的深层陷阱LangChain4j 的Tool依赖 JSON Schema 描述参数。当一个工具的参数本身是复杂对象如OrderRequest包含ListItemLLM 生成的argumentsJSON 可能格式不合法数组少个逗号、字符串没闭合、字段名拼错。标准ToolExecutor会直接抛JsonProcessingException但此时 SAGA 状态还是PENDING补偿服务根本不知道该补偿谁。解决方案在SagaToolExecutor.execute()最外层加 JSON 校验try { // 1. 先解析 arguments验证基本结构 JsonNode argsNode objectMapper.readTree(request.arguments()); // 检查必需字段是否存在 if (!argsNode.has(orderId) || !argsNode.has(items)) { throw new IllegalArgumentException(Missing required fields in arguments); } // 2. 再执行业务逻辑... } catch (JsonProcessingException e) { // JSON 解析失败直接标记为 FAILED无需补偿没执行任何业务 sagaRepository.updateStatus(sagaId, stepIndex, FAILED, Invalid JSON arguments: e.getMessage()); throw e; }这个校验必须在sagaRepository.insert()之后、tool.execute()之前。因为PENDING状态已写入FAILED状态也要更新否则补偿服务会误判为“执行中”。4.2 “Agent harness 可以发起工具调用而不是自己就是工具”Harness 与 Agent 的职责撕裂很多团队混淆Agent和Harness。Agent是决策者LLM PromptHarness是执行者调度工具、管理状态。错误做法把 SAGA 逻辑写在Agent类里。后果是Agent变得臃肿无法复用状态管理与 LLM 决策耦合测试困难Harness只剩个壳。正确分层Agent只负责generate()输出ToolExecutionRequest不碰 DB、不碰补偿。Harness接收ToolExecutionRequest调用SagaToolExecutor处理ToolResult决定是否重试或终止。SagaToolExecutor纯事务编排不依赖 LLM 上下文。我们曾把补偿逻辑放在Agent的onError()回调里结果 LLM 重试时Agent实例已销毁补偿找不到上下文。重构后Harness持有SagaToolExecutor和CompensationService的引用Agent只是一个无状态的函数。4.3 “分布式事务一致性”跨服务调用的上下文透传deductInventory()调用库存服务createOrder()调用订单服务。这两个服务如何知道“我在参与一个 Saga”答案是必须透传 Saga ID。库存服务的 API 接口要增加X-Saga-IDHeader// 库存服务 Controller PostMapping(/inventory/deduct) public ResponseEntityVoid deduct(RequestHeader(X-Saga-ID) String sagaId, RequestBody DeductRequest request) { // 1. 记录库存扣减日志关联 sagaId inventoryLogService.logDeduct(sagaId, request.getInventoryId(), request.getQuantity()); // 2. 执行扣减 inventoryService.deduct(request.getInventoryId(), request.getQuantity()); return ResponseEntity.ok().build(); }这样当cancelInventory()被调用时它能通过sagaId查到当初扣减的日志精准补偿。否则补偿操作就成了“盲打”可能补错库存项。4.4 “事务级别”与“隔离级别”的认知误区SAGA 不解决幻读新手常问“SAGA 能保证事务隔离级别吗”答案是SAGA 解决的是原子性和持久性A D不解决一致性C和隔离性I。它无法防止幻读、不可重复读。例如deductInventory()扣减时另一个普通下单请求同时查询库存可能看到“已扣减但未创建订单”的中间态。应对策略不是强求隔离而是接受最终一致并设计业务兜底前端展示“订单处理中”不显示“已扣库存”库存服务提供isReserved(inventoryId)接口供前端实时查询预留状态对于超卖敏感场景如秒杀在deductInventory()内部加分布式锁Redis Lock确保同一商品的扣减串行化。SAGA 的目标不是消灭所有不一致而是确保不一致是短暂的、可修复的、业务可接受的。4.5 “LangChain4j 怎么写 skill 博客”Skill 与 Tool 的本质区别热词里提到skill和agent的区别。在 LangChain4j 生态中Skill是更高阶的抽象通常指一组协同工作的Tool自带编排逻辑。而Tool是原子能力。SAGA 必须作用于Tool粒度而非Skill。原因Skill的内部编排如先查库再发邮件是黑盒你无法为整个Skill定义一个原子的补偿操作。必须拆解到Tool层——queryDatabase()和sendEmail()各自提供compensate()。否则Skill失败时你不知道该补偿哪一步。我们曾试图为一个processPaymentSkill 加 SAGA结果发现它内部调用了 5 个工具补偿逻辑混乱不堪。最终拆解为initiatePayment、verifyBankResponse、updateOrderStatus等独立SagaTool每个都有清晰的正向/补偿契约问题迎刃而解。5. 从 SAGA 到生产就绪监控、可观测性与人工干预通道SAGA 不是设置完就高枕无忧的魔法。它引入了新的状态维度PENDING/FAILED/COMPENSATING必须被看见、被追踪、被干预。一个没有可观测性的 SAGA比没有 SAGA 更危险——它让你误以为一切正常实则脏数据在暗处滋生。5.1 三类核心监控指标构建 SAGA 健康仪表盘我们基于 Prometheus Grafana 搭建了 SAGA 专属看板聚焦三个黄金指标Saga 执行成功率%sum(rate(saga_execution_total{statusSUCCESS}[1h])) / sum(rate(saga_execution_total[1h]))基线99.5%。低于此值说明补偿链或工具本身有问题。平均补偿耗时mshistogram_quantile(0.95, rate(saga_compensation_duration_seconds_bucket[1h]))基线500ms。若 2s说明补偿操作有 IO 瓶颈如 DB 查询慢、HTTP 调用超时。补偿失败率%sum(rate(saga_compensation_total{resultFAILED}[1h])) / sum(rate(saga_compensation_total[1h]))基线0%。一旦 0立刻触发 P0 告警——意味着业务数据已永久错乱必须人工介入。这些指标不是摆设。当补偿失败率突增至 0.3% 时看板自动标红告警发送给值班工程师并附上失败的sagaId。工程师登录 Kibana输入sagaId就能看到完整的执行链路日志哪步失败、补偿哪步、补偿为何失败、失败堆栈。10 分钟内定位根因。5.2 Saga 状态可视化让每一次调用都可追溯我们开发了一个轻量级 Web UI对接saga_instance表支持按sagaId查询时间轴视图清晰展示每一步的执行时间、状态、耗时、输入参数脱敏、输出结果脱敏。状态流转图PENDING→SUCCESS→FAILED→COMPENSATING→COMPENSATED箭头标注失败原因。一键重试对FAILED或COMPENSATION_FAILED状态提供“重新触发补偿”按钮带二次确认避免手动 SQL。这个 UI 是 SRE 和业务方的共同语言。客服反馈“订单没生成”运营输入订单号查到关联的sagaId看到sendSms()失败cancelOrder()补偿失败立刻知道问题出在短信网关而非订单系统。沟通效率提升 80%。5.3 人工干预队列SAGA 的最后一道保险再完美的自动化也有极限。当COMPENSATION_FAILED且自动重试 3 次仍失败时必须有人工兜底。我们设计了一个manual_intervention_queue表CREATE TABLE manual_intervention_queue ( id BIGINT AUTO_INCREMENT PRIMARY KEY, saga_id VARCHAR(64) NOT NULL, step_index INT NOT NULL, tool_name VARCHAR(128) NOT NULL, reason TEXT NOT NULL, status ENUM(PENDING, IN_PROGRESS, RESOLVED, REJECTED) DEFAULT PENDING, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, assigned_to VARCHAR(64) NULL -- 分配给哪个工程师 );当补偿失败CompensationService不仅告警还会插入一条记录到此表。SRE 每天晨会 Review 此队列对PENDING项分配责任人。责任人登录 UI查看完整上下文执行 SQL 或调用内部工具手动修复然后标记为RESOLVED。这个队列的存在让团队对 SAGA 有绝对掌控感——我们知道无论多复杂的失败都有路可退。经验人工干预必须有 SOP。我们规定所有RESOLVED操作必须填写resolution_note如“手动执行 cancelOrder(‘ord_123’) 成功原因短信网关返回 503重试后恢复”并关联 Jira Issue。这既是知识沉淀也是审计依据。6. 超越 SAGA当 Agent 事务遇上 AI 原生架构的未来演进SAGA 是当下最务实的解法但它并非终点。随着 Agent 架构的演进事务模型也在悄然升级。我们已在探索两个方向它们不替代 SAGA而是为其赋能6.1 基于 LLM 的补偿逻辑自动生成从“写死”到“生成”目前compensate()方法都是手写。但很多补偿逻辑高度模式化deductInventory()的补偿就是addInventory()createOrder()的补偿就是deleteOrder()。我们正在训练一个轻量级 LLM基于 CodeLlama 微调输入execute()方法签名和业务描述自动生成compensate()骨架// 输入 prompt /* Generate compensate method for this deductInventory tool. Execute method: public ToolResult deductInventory(DeductRequest request) { ... } Business logic: subtract quantity from inventory item. Compensate should add back the same quantity. Return ToolResult with success flag. */ // 输出 Override public ToolResult compensate(ToolExecutionRequest request, ToolResult forwardResult) { try { JsonNode payload forwardResult.content(); String inventoryId payload.get(inventoryId).asText(); int quantity payload.get(quantity).asInt(); inventoryService.add(inventoryId, quantity); return ToolResult.from(Compensated inventory inventoryId by quantity); } catch (Exception e) { return ToolResult.from(Compensation failed: e.getMessage()); } }这不会取代工程师但能消灭 70% 的样板代码让开发者聚焦于真正复杂的补偿逻辑如涉及第三方支付的退款。6.2 Agent 内置事务上下文LangChain4j 的下一代 APILangChain4j 社区已在讨论TransactionContextAPI。设想未来的ToolExecutionRequest将携带一个TransactionContext对象包含sagaId、stepIndex、compensationHandler等。工具开发者只需关注业务框架自动注入上下文、管理状态、触发补偿。这将大幅降低 SAGA 的使用门槛。但我们坚持认为在那一天到来之前亲手实现 SAGA 是每个 Agent 开发者的必修课。它逼你深入理解工具调用的本质、分布式系统的脆弱性、以及 AI 应用与传统软件工程的鸿沟。当你能稳稳驾驭saga_instance表的每一行记录当你能从容解释为什么cancelOrder()必须在cancelInventory()之前执行你才真正拥有了构建可靠 AI Agent 的底气。最后分享一个小技巧在SagaToolExecutor的execute()方法里加一行日志log.info(Executing Saga step {} for {}, stepIndex, sagaId);。这行日志会在你深夜排查一个诡异的COMPENSATION_FAILED时成为照亮迷雾的第一束光。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。