一文讲透 LangChain4j 的 Tool 机制:从 `@Tool` 注解到参数设计、异常处理与并发执行
摘要(149字): LangChain4j的Tool机制是连接大模型与外部能力的核心桥梁,通过@Tool注解将Java方法转化为模型可理解的结构化指令。本文深入解析了Tool设计的关键要素:1)ReturnBehavior决定结果是否需二次推理;2)参数命名与@P注解直接影响模型调用准确性;3)多态参数需明确子类型约束;4)生产环境需处理并发与异常。特别强调参数设计需同时满足Java编译器和大模型
一文讲透 LangChain4j 的 Tool 机制:从 @Tool 注解到参数设计、异常处理与并发执行
全文概览: 在 LangChain4j 中,**Tool(工具)**本质上是“暴露给大模型调用的 Java 方法”。本文不只解释 @Tool 怎么写,更会讲清楚:LLM 为什么需要 Tool、Tool 是如何被描述成可理解的结构、参数和返回值为什么会直接影响模型调用质量,以及在生产环境中如何处理异常、上下文和并发执行。如果你希望把“能跑”的 Agent 做成“稳定、可控、可维护”的 Agent,Tool 设计就是最关键的一层。
为什么 LLM 需要 Tool:它会说话,但默认不会“做事”
很多人第一次接触 Agent 时,会把 Tool 理解成“给模型接个 API”。这个理解不算错,但还不够深入。
更准确地说,Tool 是把外部能力接入 LLM 决策循环的桥梁。
大模型擅长的是:
- 理解自然语言
- 做语义推理
- 生成文本
- 在不确定信息下做近似判断
但它默认不具备这些能力:
- 查询你公司的数据库
- 调用订单系统
- 导出报表
- 获取用户身份
- 访问企业内部服务
- 执行有副作用的业务动作
于是,Agent 的基本工作流就变成了:
用户提出请求
↓
LLM 判断:是否需要调用外部能力?
↓
如果需要,就选择合适的 Tool
↓
框架执行 Tool
↓
把 Tool 结果交回 LLM,或直接返回用户
从这个角度看,Tool 不是“附属功能”,而是 Agent 从“会聊天”进化到“会办事”的核心机制。
LangChain4j 里的 Tool 到底是什么
在 LangChain4j 中,Tool 通常就是一个被框架识别并注册的 Java 方法。
你可以用两种方式定义它:
- 使用
@Tool注解标记方法 - 使用编码方式手动注册
其中,@Tool 是最常见、也最符合 Java 开发习惯的方式。
一个很重要的理解是:
LangChain4j 会把 Java 方法签名、参数类型、注解描述等信息,转换成一份给 LLM 看的工具说明。
你可以把它理解成:
Java 方法 → 结构化描述(类似 JSON Schema)→ 拼接进 Prompt → 交给 LLM 决策是否调用
这意味着,Tool 设计并不只是“Java 语法问题”,它实际上同时面对两类读者:
- Java 编译器
- 大模型
你的代码不仅要让程序能运行,还要让模型“看得懂、选得准、传参不出错”。
@Tool 注解:最常用的 Tool 定义方式
被 @Tool 标注的方法可以是:
- 静态方法
- 非静态方法
- 任意可见性(
public、private等)
也就是说,从 Java 语法层面,LangChain4j 对 Tool 方法相对宽松。真正决定调用质量的,不是方法能不能被标注,而是它的语义描述是否足够清楚。
@Tool 的几个关键参数:模型为什么能“知道该调谁”
LLM 并不是通过方法名反射调用 Tool,而是通过自然语言理解决定要不要调用、该调用哪个 Tool。
因此,@Tool 上的元信息,本质上是在给模型提供“决策线索”。
核心参数一览
| 参数 | 作用 | 如何理解 |
|---|---|---|
name |
Tool 名称 | 工具的对外标识 |
value |
Tool 描述及适用场景 | 告诉 LLM“这个工具是干什么的、什么时候该用” |
returnBehavior |
返回行为 | Tool 结果是继续给 LLM 推理,还是直接返回用户 |
searchBehavior |
工具搜索参与方式 | 规定该 Tool 如何参与工具搜索 |
metadata |
元数据(有效 JSON 字符串) | 给工具附加结构化信息 |
其中最重要的,通常是 value 和 returnBehavior。
Tool 的核心分水岭:返回的是“素材”,还是“最终答案”
这是 Tool 设计里最容易被忽略、但最影响体验的问题。
1. ReturnBehavior.TO_LLM:先给模型,再由模型组织答案
这是默认行为。
流程如下:
User
↓
LLM
↓
Tool
↓
Tool Result
↓
LLM(二次推理)
↓
Final Response
它的含义是:
- Tool 先返回原始结果
- LLM 再“看一眼”这个结果
- 最后决定如何组织最终回复
这属于工具结果参与推理。
适合什么场景?
当 Tool 返回的是素材、证据、原始数据、中间结果时,适合用它。例如:
- 查询数据库后返回订单明细
- 搜索知识库后返回文档片段
- 获取天气原始数据后让模型总结出行建议
- 查库存后让模型解释是否适合下单
2. ReturnBehavior.IMMEDIATE:Tool 输出就是最终输出
流程更短:
User
↓
LLM
↓
Tool
↓
直接返回用户
也就是说,不再经过 LLM 的二次包装。
例如导出报表:
@Tool(
value = "导出客户报表",
returnBehavior = ReturnBehavior.IMMEDIATE
)
public String exportReport(String customerId) {
return "下载地址:https://...";
}
这个场景中,Tool 给出的下载链接已经是最终结果。
如果再交给 LLM 处理,反而可能多一层不必要的改写,甚至引入歧义。
怎么选?一个非常实用的判断标准
看 Tool 返回的是“答案”还是“素材”。
- 如果是素材给 LLM 继续理解 →
TO_LLM - 如果已经是最终答案,直接回用户即可 →
IMMEDIATE
一个容易踩坑的限制
IMMEDIATE 仅支持 AI Service 方法返回 Result<T> 的场景。
如果在其他返回类型的 AI Service 上使用它,会触发 IllegalConfigurationException。
这背后的原因不难理解:
当框架需要“截断”正常的 LLM 输出链路,直接把 Tool 结果交给调用方时,它需要一个能承载更多执行信息的返回容器,而 Result<T> 就承担了这个角色。
Tool 参数设计:写给 Java,也写给 LLM
Tool 调用质量,很大程度上取决于参数定义是否清晰。
对于模型来说,参数不是“代码细节”,而是“它要不要敢调用、会不会传错”的关键线索。
支持哪些参数类型
LangChain4j 的 @Tool 方法可以接受的参数类型相当丰富,包括:
- 基本类型:
int、double等 - 包装类型与常见对象:
String、Integer、Double等 - 自定义 POJO(可包含嵌套 POJO)
enum- 多态类型
List<T>/Set<T>Map<K, V>
为什么复杂参数也能工作
因为框架并不是把参数当成“字符串拼接”处理,而是会把它们描述成结构化约束。
这就是为什么一个复杂请求对象,只要描述得好,模型也能正确构造。
参数名称很重要:别让模型看到 arg0
默认情况下,如果 @P 没有显式指定参数名,LangChain4j 会通过反射获取参数名称。
问题在于:
如果编译时没有开启 -parameters,Java 反射拿到的可能不是 customerId、orderId 这种有语义的名字,而是:
arg0arg1
对于人类来说,这只是“不够优雅”;
对于 LLM 来说,这可能意味着语义信息直接丢失。
Maven 编译配置示例
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>17</source>
<target>17</target>
<parameters>true</parameters>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
经验法则: 对 Tool 而言,参数名不是可有可无的装饰,而是模型理解意图的一部分。
@P:给参数补上“自然语言说明书”
如果说参数名告诉模型“这是什么”,那么 @P 描述告诉模型“它该怎么填”。
参数描述
使用 @P#value 描述参数:
@Tool("按条件查询客户")
public String queryCustomers(
@P("""
查询条件映射。
key 必须是:
name, mobile, level, city
value 为字符串
""")
Map<String, String> filters
) {
return filters.toString();
}
这段描述的价值非常大,因为 Map<String, String> 这种结构虽然灵活,但也天然缺少边界感。@P 实际上是在告诉模型:
- 哪些 key 合法
- value 应该是什么类型
- 这个参数适用于什么语义场景
参数是否必填
有两种方式表达“可缺省”:
@P#required,默认是true- 使用
Optional<T>
例如:
Optional<String> unit
这会比“约定俗成地传空字符串”更清晰,也更利于模型理解。
多态参数:让模型知道“你到底允许哪几种形态”
当参数是多态类型时,模型最容易犯的错误是:
它知道要构造一个对象,但不知道这个对象具体该是哪一种子类型。
密封类 / 密封接口
如果使用的是 Sealed interfaces / Sealed classes(密封接口/类),通常不需要额外的 @JsonSubTypes。
普通抽象类或接口
则需要按照 Jackson 规范声明子类型:
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "type"
)
@JsonSubTypes({
@JsonSubTypes.Type(
value = CreditCardPayment.class,
name = "creditCard"
),
@JsonSubTypes.Type(
value = WalletPayment.class,
name = "wallet"
)
})
public interface PaymentRequest {
}
这相当于告诉模型:
- 这个参数不是任意对象
- 它必须带有
type type=creditCard时对应哪种结构type=wallet时对应哪种结构
从工具调用角度看,多态声明不是“序列化细节”,而是“让模型少犯错的结构化提示”。
@Description 的真正作用:它不是注释,而是给 Schema 增加语义约束
这是很多人第一次用 Tool 时会低估的点。
LangChain4j 的 Tool 调用可以理解为:
Java 方法
↓
转换成结构化 Schema
↓
拼接进 Prompt
↓
交给 LLM 决策
因此,@Description 的价值不在于“给程序员看”,而在于给模型补充自然语言约束。
@Description 适合放在哪里
1. 方法上:描述 Tool 的用途
@Tool
@Description("查询指定订单的物流状态")
public String queryOrder(String orderId) {
return orderService.getStatus(orderId);
}
2. 复杂参数对象及字段上:描述每个字段的真实语义
public class FlightRequest {
@Description("出发城市,例如北京")
private String from;
@Description("到达城市,例如深圳")
private String to;
@Description("出发日期,格式 yyyy-MM-dd")
private String date;
}
@Tool
@Description("查询航班信息")
public List<Flight> searchFlight(FlightRequest request) {
return flightService.search(request);
}
为什么它很关键
对于人类开发者而言,from、to、date 很直观;
但对模型来说,如果没有额外描述,它未必能稳定区分:
from是出发地还是来源渠道?date是出发日期、到达日期,还是下单日期?
所以,@Description 本质上是在减少语义歧义。
Tool 返回值怎么处理:不是都当字符串扔回去
@Tool 方法可以返回任何类型,但不同类型的返回方式不同。
返回类型规则
void:会把字符串"success"发送给 LLMString:原样发送给 LLM- 其他类型:先转换为 JSON 字符串,再发送给 LLM
- 图像和多模态内容:作为多模态内容发送,而不是序列化成 JSON 文本
多模态返回支持
包括:
ImageImageContentContentList<Content>/Content[]
这背后的设计逻辑是什么
因为 LLM 最擅长处理的是有结构的上下文。
String适合最终文本或简单结果- JSON 适合结构化数据
- 多模态内容适合图文混合模型进一步理解
如果你的 Tool 返回的是复杂业务对象,让框架转成 JSON 往往比自己拼字符串更稳,因为结构更清晰,模型也更容易继续推理。
Tool 调用上下文:有些信息要给 Tool,但不该给 LLM
这是生产环境里非常实用的一点。
有时候 Tool 需要额外信息,例如:
- 当前登录用户 ID
- 租户信息
- 请求链路追踪 ID
- 权限上下文
- 地区、环境、灰度标记
这些信息对 Tool 很重要,但不一定应该暴露给大模型。
InvocationParameters
@Tool
@Description("查询航班信息")
public List<Flight> searchFlight(FlightRequest request, InvocationParameters param) {
return flightService.search(request);
}
InvocationContext
@Tool
@Description("查询航班信息")
public List<Flight> searchFlight(FlightRequest request, InvocationContext ctx) {
return flightService.search(request);
}
这两者都能携带额外上下文,且有一个关键特点:
LLM 并不知道这些参数的内容。它们只对 LangChain4j 和你的业务代码可见。
这非常重要,因为它让你可以把模型决策与系统运行上下文分离开。
@ToolMemoryId
如果调用入口使用了 @MemoryId 参数,那么 Tool 方法里可以通过 @ToolMemoryId 获取对应值。
这让 Tool 能与对话记忆、会话身份建立关联。
异常处理:Tool 不是“能跑就行”,而是要“可控地失败”
在真实系统里,Tool 出错并不罕见。
关键不在于是否出错,而在于出错后系统如何表现。
LangChain4j 的 Tool 异常处理可以分成三层。
第一层:Tool 名称错误
有时模型会“幻觉式”地调用一个并不存在的 Tool。
默认情况下,LangChain4j 会抛出异常。
你也可以自定义处理逻辑:
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(chatModel)
.tools(new AssistantTool())
.hallucinatedToolNameStrategy(req -> ToolExecutionResultMessage.from(
req, "Error: there is no tool called " + req.name()))
.build();
这类处理的意义在于:
与其让整个链路直接失败,不如把错误反馈给模型,让它有机会修正工具选择。
第二层:Tool 参数错误
模型可能选对了 Tool,却传错了参数。
默认情况下,这会抛异常并导致失败。
也可以配置 ToolArgumentsErrorHandler:
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(chatModel)
.tools(tools)
.toolArgumentsErrorHandler((error, errorContext) -> ...)
.build();
更合理的做法通常是:
返回一段足够明确但不过度暴露内部细节的错误文本,让 LLM 据此重试并修正参数。
第三层:Tool 执行错误
这是最常见的一类,比如:
- 数据库超时
- 下游 API 失败
- 权限不足
- 文件不存在
- 网络波动
默认情况下,如果 @Tool 方法抛出 Exception,它的 e.getMessage() 会作为工具执行结果发给 LLM。
这样做的好处是:模型可能据此纠错并重试。
但生产环境里,这也有明显风险。
为什么不能把原始异常直接发给 LLM
原始错误信息里可能包含:
- 堆栈跟踪
- 文件路径
- 内部服务名
- 数据库表名
- 下游响应内容
- 凭据片段
- 个人身份信息(PII)
因此,更安全的方式是配置 ToolExecutionErrorHandler:
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(chatModel)
.tools(tools)
.toolExecutionErrorHandler((error, errorContext) ->
ToolErrorHandlerResult.text("Tool execution failed."))
.build();
最佳实践: 对 LLM 返回“可理解但脱敏”的错误,对日志与可观测系统保留完整细节。
这是一种典型的生产级设计思路:
让模型知道失败了,但不要让它知道太多不该知道的内部信息。
Tool 并发执行:多个工具能不能一起跑
默认情况下,即使 LLM 一次决定调用多个 Tool,LangChain4j 也会按顺序执行。
如果你希望并发执行,可以在构建 Agent 时使用:
executeToolsConcurrently()executeToolsConcurrently(Executor)
非流式与流式调用下,并发行为有什么不同
非流式调用
- 当 LLM 同时调用多个 Tool 时,会使用
Executor的线程并发执行 - 当只调用单个 Tool 时,会直接在调用者线程中执行
- 这样做是为了避免无意义的线程切换和资源浪费
流式调用
在流式场景下:
- 多个 Tool 会使用
Executor在不同线程并发执行 - 每个 Tool 在
StreamingChatResponseHandler.onCompleteToolCall(CompleteToolCall)后会立即执行 - 不需要等其他 Tool 或整个响应流结束
这意味着,流式 + 并发 更适合那种“多个外部查询可以同时发起”的场景,比如:
- 同时查天气、机票、酒店
- 同时访问多个内部微服务
- 同时对多个数据源做聚合
但也要注意:
并发并不总是更好。如果 Tool 具有副作用、依赖顺序,或者访问的是同一共享资源,就需要谨慎设计。
如何拿到已执行的 Tool 记录
有时候,你不只关心最终回答,还关心:
- 模型实际调用了哪些 Tool
- 调用了几次
- 用了什么参数
- 每一步怎么走到结果的
这对调试、审计、可观测性都很重要。
你可以让 AI Service 方法返回 Result<T>:
interface Assistant {
Result<String> chat(String userMessage);
}
然后读取 Tool 执行记录:
List<ToolExecution> toolExecutions = result.toolExecutions();
这相当于给 Agent 调用链路加上了一层“黑匣子”。
在开发阶段,它帮助你调试模型行为;在生产阶段,它帮助你做审计、质量分析和性能定位。
一套实用的 Tool 设计原则:决定模型是否“用得聪明”
写 Tool 时,真正高水平的差异,不在于注解会不会写,而在于接口是否足够适合 LLM 调用。
1. 名称清晰,描述具体
不要只写“查询”“处理”“执行”这类过于宽泛的描述。
应明确写出:
- 查询什么
- 适用于什么场景
- 输入需要哪些信息
- 输出是什么
2. 一个 Tool 只做一件高内聚的事
把 Tool 设计成“原子能力”通常更好。
例如:
queryOrderStatusexportCustomerReportsearchFlight
通常都比一个“万能业务入口”更适合模型使用。
3. 让参数结构贴近业务语义
比起一堆模糊字符串,语义明确的 POJO + 字段描述 更利于模型稳定调用。
4. 尽量减少歧义字段
例如:
id不如customerIddate不如departureDatetype最好配合可选值说明
5. Tool 错误信息要“可理解但不泄密”
错误要足够明确,让模型知道该如何调整;
但又不能暴露内部实现细节。
6. 把上下文和模型输入分开
用户身份、租户信息、权限令牌这类内容,更适合通过 InvocationParameters / InvocationContext 注入,而不是让模型“猜”或“看到”。
7. 提前想清楚返回的是“素材”还是“最终答案”
这会直接决定是否使用 TO_LLM 还是 IMMEDIATE。
从工程视角再看一遍:Tool 其实是 Agent 的“协议层”
如果把 Agent 系统拆开来看,大致有三层:
- 语言理解层:LLM 负责理解用户意图
- 能力执行层:Tool 负责调用外部世界
- 编排治理层:框架负责调度、异常处理、并发与上下文传递
而 Tool 恰好位于中间,承担“把语言意图翻译成可执行动作”的职责。
这也是为什么 Tool 设计会同时影响:
- 模型准确率
- 工具调用成功率
- 系统安全性
- 可观测性
- 最终用户体验
从这个意义上说,Tool 不是一个注解技巧,而是一层接口协议设计。
总结:真正成熟的 Agent,往往赢在 Tool 设计
LangChain4j 的 Tool 机制看似只是给方法加一个 @Tool,但它背后其实包含了完整的系统设计问题:
- 如何让 LLM 正确理解工具能力
- 如何通过参数和描述减少调用歧义
- 如何决定结果是继续推理还是直接返回
- 如何注入上下文而不泄露给模型
- 如何在错误、并发和审计层面做到生产可用
如果只把 Tool 当成“可调用方法”,你得到的通常只是一个勉强能跑的 Demo。
但如果把 Tool 当成LLM 与业务系统之间的结构化契约来设计,你才能真正做出稳定、透明、可扩展的 Agent 应用。
一个非常值得记住的结论是:
LLM 的上限,常常取决于 Tool 的设计质量。
模型决定“要不要调用”,但 Tool 决定“调用后能不能把事办好”。
更多推荐



所有评论(0)