场景引入:你做了一个订餐 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 执行一轮任务是这样的:
这个循环在 LangChain4j 里由 AiServices 编排。AiServices 是 1.x 的核心入口,它把你定义的接口(比如 Assistant)动态实现成 Agent,把带 @Tool 注解的方法注册成工具。
1.19.0 在 AiServices 上加了一个开关方法:
AiServices.builder(Assistant.class)
.chatModel(chatModel)
.tools(agentTools) // 注册工具
.compensateOnToolErrors(true) // 开启补偿
.build();
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 包):
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Experimental
public @interface CompensateFor {
/** 指定要补偿的原始工具动作名 */
String value();
}
用法:在普通工具方法(带 @Tool)旁边,再写一个补偿方法,方法上标 @CompensateFor("原工具名")。注意 value() 里填的是原工具的 name,不是方法名。@Tool 默认用方法名当工具名,如果 @Tool("cancelOrder") 显式指定了,@CompensateFor 就要填 cancelOrder。
补偿方法本身可以带参数(比如订单号),框架会把原工具调用时的参数自动注入到补偿方法里(通过参数名匹配)。这非常关键:补偿方法不需要自己再去查一遍「刚才下单用的什么参数」,框架记得。
还有一个细节:@CompensateFor 标记为 @Experimental,说明官方还在打磨这个 API,生产环境用的话要留意后续版本变更,但 1.19.0 已经 GA 可用。
整体时序图可以这样理解:
用户提问
↓
[Agent 循环开始]
↓ 第1轮
createOrder(商品A) ──成功──▶ 记录"已下单"
↓ 第2轮
deductBalance(订单1) ──成功──▶ 记录"已扣款"
↓ 第3轮
notifyWarehouse(订单1) ──异常💥
↓
[框架捕获异常,触发补偿,逆序执行]
deductBalance 的补偿 → refund(订单1) (撤销扣款)
createOrder 的补偿 → cancelOrder(订单1) (撤销下单)
↓
Agent 抛出/返回错误信息
三、实战:手把手写代码
下面用一个「订餐 Agent」完整演示。场景:用户说「帮我订一份 58 元的牛肉面,送到公司」,Agent 依次执行:创建订单 → 扣款 → 通知商家。其中「通知商家」这个工具我故意让它抛异常(模拟第三方接口故障),验证补偿机制会不会自动把订单取消、把钱退掉。
先看完整项目结构:
agent-compensation-demo/
├── pom.xml
└── src/main/java/com/example/agent/
├── AgentCompensationApplication.java (启动类)
├── OrderService.java (业务服务,模拟下单/扣款/通知)
├── OrderAgentTools.java (Agent 工具 + 补偿方法)
├── Assistant.java (AiServices 接口)
└── Main.java (测试入口)
3.1 创建 Maven 项目(pom.xml)
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>agent-compensation-demo</artifactId>
<version>1.0.0</version>
<name>agent-compensation-demo</name>
<description>LangChain4j 1.19.0 Agent 工具失败自动补偿示例</description>
<properties>
<java.version>21</java.version>
<langchain4j.version>1.19.0</langchain4j.version>
</properties>
<dependencies>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<!– 日志 –>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<release>21</release>
<parameters>true</parameters>
</configuration>
</plugin>
</plugins>
</build>
</project>
说明几个点:
- 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
package com.example.agent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* 模拟真实业务系统:订单、余额、商家通知。
* 真实项目里这些方法内部会调用 RPC/数据库,这里用日志代替。
*/
public class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
/** 模拟创建订单,返回订单号 */
public String createOrder(String itemName, double price) {
String orderId = "ORDER-" + System.currentTimeMillis() % 1000000;
log.info("【业务】创建订单成功: orderId={}, 商品={}, 价格={}元", orderId, itemName, price);
return orderId;
}
/** 模拟扣款 */
public void deductBalance(String orderId, double price) {
log.info("【业务】扣款成功: orderId={}, 金额={}元", orderId, price);
}
/** 模拟通知商家——这里故意抛异常,模拟第三方接口故障 */
public void notifyRestaurant(String orderId) {
throw new RuntimeException("通知商家接口调用失败: 第三方服务 500 (orderId=" + orderId + ")");
}
/** 取消订单(createOrder 的补偿动作) */
public void cancelOrder(String orderId) {
log.info("【业务】补偿: 取消订单 orderId={}", orderId);
}
/** 退款(deductBalance 的补偿动作) */
public void refund(String orderId, double price) {
log.info("【业务】补偿: 退款 orderId={}, 金额={}元", orderId, price);
}
}
这段代码没有魔法:createOrder 返回订单号,deductBalance 扣钱,notifyRestaurant 故意抛异常模拟故障,cancelOrder 和 refund 就是补偿动作。真实项目中这些方法对接的是支付网关、订单库,逻辑一样,只是把日志换成真实调用。
3.3 Agent 工具类 OrderAgentTools(核心)
package com.example.agent;
import dev.langchain4j.agent.tool.CompensateFor;
import dev.langchain4j.agent.tool.Tool;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Agent 的工具集合。
* 每个 @Tool 方法对应一个「动作」,配一个 @CompensateFor 方法对应「撤销动作」。
*/
public class OrderAgentTools {
private static final Logger log = LoggerFactory.getLogger(OrderAgentTools.class);
private final OrderService orderService;
public OrderAgentTools(OrderService orderService) {
this.orderService = orderService;
}
/**
* 动作1:创建订单。
* 工具名默认是方法名 createOrder,所以补偿注解 value 也填 createOrder。
*/
@Tool("创建订单,参数:itemName 商品名,price 价格(元)")
public String createOrder(String itemName, double price) {
log.info("【工具】createOrder 被调用: itemName={}, price={}", itemName, price);
return orderService.createOrder(itemName, price);
}
/**
* createOrder 的补偿动作:取消订单。
* 框架会逆序执行补偿,并把原工具调用参数按名字注入进来。
*/
@CompensateFor("createOrder")
public String cancelOrder(String orderId) {
log.info("【补偿】cancelOrder 被调用: orderId={}", orderId);
orderService.cancelOrder(orderId);
return "订单已取消: " + orderId;
}
/**
* 动作2:扣款。
* 注意这里参数名是 orderId 和 price,正好和 createOrder 的返回值、入参对应。
*/
@Tool("根据订单号扣款,参数:orderId 订单号,price 金额(元)")
public void deductBalance(String orderId, double price) {
log.info("【工具】deductBalance 被调用: orderId={}, price={}", orderId, price);
orderService.deductBalance(orderId, price);
}
/**
* deductBalance 的补偿动作:退款。
*/
@CompensateFor("deductBalance")
public void refund(String orderId, double price) {
log.info("【补偿】refund 被调用: orderId={}, price={}", orderId, price);
orderService.refund(orderId, price);
}
/**
* 动作3:通知商家。故意抛异常,模拟第三方故障。
*/
@Tool("通知商家备餐,参数:orderId 订单号")
public void notifyRestaurant(String orderId) {
log.info("【工具】notifyRestaurant 被调用: orderId={}", orderId);
orderService.notifyRestaurant(orderId);
}
}
关键点讲解:
3.4 Assistant 接口 + 组装入口
package com.example.agent;
import dev.langchain4j.service.AiServices;
import dev.langchain4j.service.SystemMessage;
/**
* 定义 Agent 的对外接口。AiServices 会动态实现它。
*/
public interface Assistant {
@SystemMessage("你是订餐助手。为用户完成订餐流程:先创建订单,再扣款,最后通知商家。")
String chat(String userMessage);
}
Assistant 接口只有一个 chat 方法,@SystemMessage 定义 Agent 的「人设和流程要求」。AiServices.builder() 会生成它的实现类,方法返回值 String 表示这是同步问答。
package com.example.agent;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.openai.OpenAiChatModel;
import dev.langchain4j.service.AiServices;
public class Main {
public static void main(String[] args) {
// 1. 构造 ChatModel(OpenAI 兼容接口,可换任意厂商)
ChatModel chatModel = OpenAiChatModel.builder()
.apiKey(System.getenv("OPENAI_API_KEY")) // 环境变量配 key
.modelName("gpt-4o-mini")
.build();
// 2. 构造业务服务 + Agent 工具
OrderService orderService = new OrderService();
OrderAgentTools tools = new OrderAgentTools(orderService);
// 3. 组装 Assistant:关键一行 compensateOnToolErrors(true)
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(chatModel)
.chatMemory(MessageWindowChatMemory.withMaxMessages(20))
.tools(tools)
.compensateOnToolErrors(true)
.build();
// 4. 跑一个注定失败的场景:notifyRestaurant 会抛异常
String answer = assistant.chat("帮我订一份58元的牛肉面,送到公司");
System.out.println("=== Agent 最终答复 ===");
System.out.println(answer);
}
}
运行前设置环境变量 OPENAI_API_KEY=sk-xxx,然后 mvn -q compile exec:java 或直接运行 Main。预期日志顺序:
【工具】createOrder 被调用: itemName=牛肉面, price=58.0
【业务】创建订单成功: orderId=ORDER-123456, …
【工具】deductBalance 被调用: orderId=ORDER-123456, price=58.0
【业务】扣款成功: orderId=ORDER-123456, …
【工具】notifyRestaurant 被调用: orderId=ORDER-123456
【业务】通知商家接口调用失败: 第三方服务 500 …
【补偿】refund 被调用: orderId=ORDER-123456, price=58.0 ← 逆序:先退钱
【业务】补偿: 退款 orderId=ORDER-123456, 金额=58.0元
【补偿】cancelOrder 被调用: orderId=ORDER-123456 ← 再取消订单
【业务】补偿: 取消订单 orderId=ORDER-123456
看到没: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 调用外部系统」的中短流程。选型建议:
| 适用范围 | 单 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 状态),升级后记得把有副作用的工具都补上补偿方法。



