场景引入:你做了一个订餐 Agent,它先调用「下单」工具成功扣了钱,接着调用「通知商家」工具时接口突然 500,整个任务失败。此时订单已创建、钱已扣,用户却不知道发生了什么——这就是 Agent 工具调用最常见的「半途而废」问题。本文用 LangChain4j 1.19.0 刚发布的 @CompensateFor + compensateOnToolErrors 特性,解决「工具调用一半失败,前面成功的动作怎么办」的问题,全程附完整可运行代码。

一、这个问题到底是什么

先看一个真实场景。你给电商系统做个客服 Agent,它有三个工具:

  • createOrder

    :创建订单

  • deductBalance

    :扣用户余额

  • notifyWarehouse

    :通知仓库发货

正常流程是三个工具按顺序调用,全部成功。但大模型(LLM)调工具是「走一步看一步」的:它先调 createOrder,拿到结果后再决定要不要调 deductBalance。如果 deductBalance 调用到一半,下游数据库连接断了,抛了异常,这时候会发生什么?

  • createOrder

     已经成功了,订单躺在数据库里

  • deductBalance

     失败了,钱没扣

  • Agent 整个任务报错退出

结果就是:数据库里多了一个没付钱的订单,没有任何人处理它。这在真实业务里叫「部分成功」(partial success),是所有 Agent 工程化绕不开的坑。

传统 Java 事务解决的是「同一个数据库连接里,要么全成功要么全回滚」。但 Agent 的工具调用横跨多个系统:订单系统、支付系统、仓储系统,根本不在一个事务里,分布式事务(比如 Saga)太重了,小团队玩不转。

LangChain4j 1.19.0(2026-08-14 发布)给出的方案很轻量:给工具方法配一个「补偿方法」,标记 @CompensateFor("原动作名")。当 Agent 后续任何工具失败或抛出异常时,框架自动逆序执行之前所有成功的补偿方法——下单成功了就自动取消订单,扣款成功了就自动退款。这就是「工具动作补偿」(tool actions compensation),本质是给 Agent 内置了一个简易版 Saga。

注意区分两个概念:

  • 重试(retry)

    :失败后把同一个动作再执行一遍,适合网络抖动

  • 补偿(compensation)

    :失败后执行「撤销之前动作」的新动作,适合已经产生副作用(扣钱、下单、发消息)的操作

本文全部代码基于 LangChain4j 1.19.0 GA 版本,Spring Boot 3.5.x(langchain4j-spring-boot-starter 1.19.0 的基线版本),JDK 21。

二、底层原理到底怎么回事

要理解补偿机制,先要理解 LangChain4j 1.x 的 Agent 执行循环(agentic loop)。用大白话说,Agent 执行一轮任务是这样的:

  1. 把用户问题 + 工具清单发给大模型

  2. 大模型决定:要么直接回答,要么说「我要调某个工具,参数是 XXX」

  3. 框架执行这个工具,把结果回传给大模型

  4. 大模型根据结果决定下一步,重复 2~3 步

  5. 直到大模型觉得任务完成,给出最终答案

这个循环在 LangChain4j 里由 AiServices 编排。AiServices 是 1.x 的核心入口,它把你定义的接口(比如 Assistant)动态实现成 Agent,把带 @Tool 注解的方法注册成工具。

1.19.0 在 AiServices 上加了一个开关方法:

<span leaf="">AiServices.builder(Assistant.<span>class</span>)</span>

compensateOnToolErrors(true) 表示:开启后,只要这次任务里任何一个工具调用失败,或者 Agent 抛出异常,框架就进入补偿流程。

补偿流程的机制,官方 release notes 的原话是:

When compensateOnError(true) is set on an agentic system, all previously successful tool invocations with @CompensateFor actions are compensated in reverse order if any tool in any sub-agent fails or any agent throws.

翻译成人话:开启后,之前所有成功的、带 @CompensateFor 的工具调用,会按逆序(后执行的先补偿)逐个执行对应的补偿动作。触发条件是「任何一个子 Agent 的工具失败,或任何一个 Agent 抛异常」。

为什么逆序?这是补偿设计的核心思想,跟栈一样先进后出(LIFO)。比如你先 createOrder 再 deductBalance,补偿时应该先退钱(撤销扣款),再取消订单(撤销下单)。因为 deductBalance 依赖 createOrder 产生的订单号,必须先撤销依赖它的动作,再撤销被依赖的动作,否则退款时找不到订单。

@CompensateFor 注解长这样(定义在 dev.langchain4j.agent.tool 包):

<span leaf=""><span>@Retention(RetentionPolicy.RUNTIME)</span></span>

用法:在普通工具方法(带 @Tool)旁边,再写一个补偿方法,方法上标 @CompensateFor("原工具名")注意 value() 里填的是原工具的 name,不是方法名。@Tool 默认用方法名当工具名,如果 @Tool("cancelOrder") 显式指定了,@CompensateFor 就要填 cancelOrder

补偿方法本身可以带参数(比如订单号),框架会把原工具调用时的参数自动注入到补偿方法里(通过参数名匹配)。这非常关键:补偿方法不需要自己再去查一遍「刚才下单用的什么参数」,框架记得。

还有一个细节:@CompensateFor 标记为 @Experimental,说明官方还在打磨这个 API,生产环境用的话要留意后续版本变更,但 1.19.0 已经 GA 可用。

整体时序图可以这样理解:

<span leaf="">用户提问</span>

三、实战:手把手写代码

下面用一个「订餐 Agent」完整演示。场景:用户说「帮我订一份 58 元的牛肉面,送到公司」,Agent 依次执行:创建订单 → 扣款 → 通知商家。其中「通知商家」这个工具我故意让它抛异常(模拟第三方接口故障),验证补偿机制会不会自动把订单取消、把钱退掉。

先看完整项目结构:

<span leaf="">agent<span>-</span>compensation<span>-</span>demo<span>/</span></span>

3.1 创建 Maven 项目(pom.xml)

<span leaf=""><span>&lt;?xml version=</span><span><span>"1.0"</span></span><span>&nbsp;encoding=</span><span><span>"UTF-8"</span></span><span>?&gt;</span></span>

说明几个点:

  • spring-boot-starter-parent

     3.5.16 是当前 Spring Boot 3.x 最新 GA(2026-08-15 实查 Maven Central),和 langchain4j 1.19.0 的 starter 基线兼容

  • 这里没有用 langchain4j-spring-boot-starter,因为要演示最核心的 AiServices 纯 Java 用法(更直观,不依赖 Spring 注入)。如果你要接 Spring Boot,把 langchain4j 换成 langchain4j-spring-boot-starter(1.19.0),配置 langchain4j.open-ai.chat-model.api-key 即可,核心代码不变

  • <parameters>true</parameters> 必须加

    @CompensateFor 补偿方法靠参数名匹配注入原工具参数,编译时不开 -parameters 参数名就丢了,补偿方法会拿不到参数

  • logback-classic

     必须显式声明:Spring Boot parent 只管理版本不引入依赖,没有日志实现跑不起来会报警告

3.2 业务服务 OrderService

<span leaf="">package com.<span>example</span>.<span>agent</span>;</span>

这段代码没有魔法:createOrder 返回订单号,deductBalance 扣钱,notifyRestaurant 故意抛异常模拟故障,cancelOrder 和 refund 就是补偿动作。真实项目中这些方法对接的是支付网关、订单库,逻辑一样,只是把日志换成真实调用。

3.3 Agent 工具类 OrderAgentTools(核心)

<span leaf="">package com.<span>example</span>.<span>agent</span>;</span>

关键点讲解:

  1. @Tool 和 @CompensateFor 是一对

    createOrder 配 cancelOrderdeductBalance 配 refund@CompensateFor("createOrder") 里的字符串必须和工具名完全一致——工具名默认是方法名,如果 @Tool 里显式改了名字,要填改后的名字

  2. 补偿方法的参数靠名字注入

    cancelOrder(String orderId) 里的 orderId 是怎么来的?框架记录了 createOrder 那次调用的返回值和入参,把返回值 ORDER-123456 注入到同名参数 orderId。同理 refund 的 orderIdprice 来自 deductBalance 的入参。参数名对不上,框架就注入不了(这也是为什么必须开 -parameters

  3. 补偿方法不需要返回值给用户看

    ,框架只是执行它。返回 String 是为了日志里能看到结果

  4. notifyRestaurant

     没有补偿方法——因为它在整个链路最后,失败时没有更靠后的动作需要撤销(补偿只撤销「在它之前成功」的动作)

3.4 Assistant 接口 + 组装入口

<span leaf=""><span>package</span>&nbsp;com.example.agent;</span>

Assistant 接口只有一个 chat 方法,@SystemMessage 定义 Agent 的「人设和流程要求」。AiServices.builder() 会生成它的实现类,方法返回值 String 表示这是同步问答。

<span leaf=""><span>package</span>&nbsp;com.example.agent;</span>

运行前设置环境变量 OPENAI_API_KEY=sk-xxx,然后 mvn -q compile exec:java 或直接运行 Main。预期日志顺序:

<span leaf="">【工具】createOrder 被调用: itemName<span>=</span>牛肉面, price<span>=</span><span>58.0</span></span>

看到没:notifyRestaurant 失败后,框架没有直接崩掉,而是把之前成功的 deductBalance 和 createOrder逆序补偿掉了——先退款再取消订单,业务回到了「什么都没发生」的状态。

如果把 compensateOnToolErrors(true) 改成 false 或删掉这行,同样跑这个场景:notifyRestaurant 抛异常后任务直接失败,订单和扣款留在系统里,这就是「脏数据」。

3.5 验证「部分工具没补偿方法」的行为

再验证一个边界:如果 deductBalance 不配 refund 补偿方法,只给 createOrder 配 cancelOrder,失败时框架只补偿有 @CompensateFor 的方法——createOrder 会被取消,deductBalance 的扣款没人管。所以每个有副作用的工具都要配补偿方法,这是使用铁律。

四、踩坑经验和最佳实践

这一节全是真实会踩的坑,逐个说。

坑1:参数名注入失败(最常见)。补偿方法参数名必须和原工具入参/返回值的名字一致,且编译要开 -parameters。不开的话,Spring Boot 里用 langchain4j-spring-boot-starter 时会看到补偿方法参数为 null 或直接报错。Maven 配 <parameters>true</parameters>,或 Spring Boot parent 默认就带(Spring Boot 3.x parent 默认开启 -parameters),纯 Java 项目必须手动加。验证方法:给补偿方法加个空值判断,跑一次失败场景看日志有没有「参数为 null」

坑2:@CompensateFor 的值写错。填的是工具名不是方法名。@Tool("下单") 改了名,@CompensateFor("下单") 才对,写 createOrder 匹配不上,补偿静默不执行——框架不会报错,只会不补偿。检查方法:把工具名打印出来看,或者统一不显式改名,用默认方法名,最省心。

坑3:补偿动作本身失败怎么办。补偿方法里调退款,退款接口也挂了,框架会怎样?当前版本补偿执行失败会向上抛,Agent 任务最终报错。实践建议:补偿方法内部做「幂等 + 重试」(同一订单重复退款要返回成功),并且给退款、取消订单这类操作做本地持久化(比如记一张 compensation 表),失败后由定时任务补跑。补偿是「尽力而为」的,不是强一致事务。

坑4:不要拿它当分布式事务用。补偿机制解决的是「工具调用序列中途失败」的清理问题,它不保证「两个服务同时提交」的原子性——那是分布式事务的范畴。判断标准:你的场景是「一个 Agent 调一串工具」→ 补偿合适;「跨服务强一致扣款」→ 别用,上 Seata 之类。

坑5:无副作用的工具不需要补偿。比如「查天气」「算价格」这类只读工具,失败就失败,没有副作用可撤销,配 @CompensateFor 是浪费。

最佳实践清单

  • 每个有副作用的 @Tool(写库、扣款、发消息、下单)都配一个幂等的补偿方法

  • 补偿方法参数尽量精简:能传订单号就传订单号,别依赖原工具的全部入参

  • 工具按依赖顺序设计:被依赖的(下单)放前面,依赖别人的(扣款)放后面,逆序补偿正好先撤销后者

  • 日志里把「工具调用 + 补偿调用」都打出来,线上排查全靠它

  • 观察 compensateOnToolErrors 对延迟的影响:补偿是同步执行的,工具链长、补偿多的场景会拖慢失败路径的响应,超时阈值要留够

五、性能对比和技术选型

和「手动补偿」比:以前没有这个特性,你只能自己在每个工具调用外面 try-catch,维护一个「已执行动作栈」,失败时手动逆序调撤销方法。代码量差不多,但分散在业务逻辑里,容易漏——新加一个工具忘写补偿,出事故才知道。@CompensateFor 把补偿声明和工具放在一起,声明式、可检查,心智负担小很多。性能上两者没有本质区别,都是同步顺序调用,多不了几个毫秒。

和「Saga 分布式事务」比:Saga 靠消息队列异步推进、有事务协调器,能处理跨服务、长流程、需要持久化恢复的场景,但引入 MQ、状态机,复杂度高一个量级。LangChain4j 的补偿是进程内的、同步的、无状态的——适合「单应用内 Agent 调用外部系统」的中短流程。选型建议:

| 维度

|

@CompensateFor 补偿

|

Saga 分布式事务

适用范围

|

单 Agent 进程内的工具链

|

跨服务长流程

| |

复杂度

|

低(一个注解)

|

高(协调器+MQ+状态机)

| |

持久化

|

无(内存态)

|

有(可恢复)

| |

强一致

|

否(尽力而为)

|

否(最终一致)

| |

适合规模

|

中小团队、工具 3~10 个

|

大团队、跨团队协作

|

结论:大多数「Agent 调工具」场景,@CompensateFor 够用且省事;只有当补偿链跨多个独立部署的服务、且需要故障恢复能力时,才升级到 Saga。

六、总结

这篇文章讲了 LangChain4j 1.19.0 的工具动作补偿特性,解决 Agent「工具调用一半失败,前面成功的副作用没人管」的问题:

  • 问题本质

    :Agent 的工具调用横跨多个系统,传统事务管不了,部分成功会产生脏数据

  • 核心 API

    @CompensateFor("工具名") 声明补偿方法,AiServices.compensateOnToolErrors(true) 开启;失败时框架逆序执行所有成功的补偿动作

  • 关键细节

    :补偿参数按名字注入(要开 -parameters)、value 填工具名、有副作用的工具都要配补偿、补偿方法要幂等

  • 适用边界

    :进程内工具链用注解补偿;跨服务强一致场景才上 Saga

一个完整可运行的订餐 Agent 示例(创建订单 → 扣款 → 通知商家 → 通知失败自动退款取消订单)已经贴在上面,复制 pom.xml + 三个类 + 接口 + Main 就能跑。核心就一行开关 + 一对注解,比手写 try-catch 栈干净得多。

如果你正在用 LangChain4j 1.18 或更早版本,这个特性是 1.19.0 新增的(@Experimental 状态),升级后记得把有副作用的工具都补上补偿方法。