一文讲透 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 语法问题”,它实际上同时面对两类读者:

  1. Java 编译器
  2. 大模型

你的代码不仅要让程序能运行,还要让模型“看得懂、选得准、传参不出错”。


@Tool 注解:最常用的 Tool 定义方式

@Tool 标注的方法可以是:

  • 静态方法
  • 非静态方法
  • 任意可见性(publicprivate 等)

也就是说,从 Java 语法层面,LangChain4j 对 Tool 方法相对宽松。真正决定调用质量的,不是方法能不能被标注,而是它的语义描述是否足够清楚


@Tool 的几个关键参数:模型为什么能“知道该调谁”

LLM 并不是通过方法名反射调用 Tool,而是通过自然语言理解决定要不要调用、该调用哪个 Tool。
因此,@Tool 上的元信息,本质上是在给模型提供“决策线索”。

核心参数一览

参数 作用 如何理解
name Tool 名称 工具的对外标识
value Tool 描述及适用场景 告诉 LLM“这个工具是干什么的、什么时候该用”
returnBehavior 返回行为 Tool 结果是继续给 LLM 推理,还是直接返回用户
searchBehavior 工具搜索参与方式 规定该 Tool 如何参与工具搜索
metadata 元数据(有效 JSON 字符串) 给工具附加结构化信息

其中最重要的,通常是 valuereturnBehavior


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 方法可以接受的参数类型相当丰富,包括:

  • 基本类型:intdouble
  • 包装类型与常见对象:StringIntegerDouble
  • 自定义 POJO(可包含嵌套 POJO)
  • enum
  • 多态类型
  • List<T> / Set<T>
  • Map<K, V>

为什么复杂参数也能工作

因为框架并不是把参数当成“字符串拼接”处理,而是会把它们描述成结构化约束。
这就是为什么一个复杂请求对象,只要描述得好,模型也能正确构造。


参数名称很重要:别让模型看到 arg0

默认情况下,如果 @P 没有显式指定参数名,LangChain4j 会通过反射获取参数名称。

问题在于:
如果编译时没有开启 -parameters,Java 反射拿到的可能不是 customerIdorderId 这种有语义的名字,而是:

  • arg0
  • arg1

对于人类来说,这只是“不够优雅”;
对于 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 应该是什么类型
  • 这个参数适用于什么语义场景

参数是否必填

有两种方式表达“可缺省”:

  1. @P#required,默认是 true
  2. 使用 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);
}

为什么它很关键

对于人类开发者而言,fromtodate 很直观;
但对模型来说,如果没有额外描述,它未必能稳定区分:

  • from 是出发地还是来源渠道?
  • date 是出发日期、到达日期,还是下单日期?

所以,@Description 本质上是在减少语义歧义


Tool 返回值怎么处理:不是都当字符串扔回去

@Tool 方法可以返回任何类型,但不同类型的返回方式不同。

返回类型规则

  • void:会把字符串 "success" 发送给 LLM
  • String:原样发送给 LLM
  • 其他类型:先转换为 JSON 字符串,再发送给 LLM
  • 图像和多模态内容:作为多模态内容发送,而不是序列化成 JSON 文本

多模态返回支持

包括:

  • Image
  • ImageContent
  • Content
  • List<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 设计成“原子能力”通常更好。
例如:

  • queryOrderStatus
  • exportCustomerReport
  • searchFlight

通常都比一个“万能业务入口”更适合模型使用。

3. 让参数结构贴近业务语义

比起一堆模糊字符串,语义明确的 POJO + 字段描述 更利于模型稳定调用。

4. 尽量减少歧义字段

例如:

  • id 不如 customerId
  • date 不如 departureDate
  • type 最好配合可选值说明

5. Tool 错误信息要“可理解但不泄密”

错误要足够明确,让模型知道该如何调整;
但又不能暴露内部实现细节。

6. 把上下文和模型输入分开

用户身份、租户信息、权限令牌这类内容,更适合通过 InvocationParameters / InvocationContext 注入,而不是让模型“猜”或“看到”。

7. 提前想清楚返回的是“素材”还是“最终答案”

这会直接决定是否使用 TO_LLM 还是 IMMEDIATE


从工程视角再看一遍:Tool 其实是 Agent 的“协议层”

如果把 Agent 系统拆开来看,大致有三层:

  1. 语言理解层:LLM 负责理解用户意图
  2. 能力执行层:Tool 负责调用外部世界
  3. 编排治理层:框架负责调度、异常处理、并发与上下文传递

而 Tool 恰好位于中间,承担“把语言意图翻译成可执行动作”的职责。
这也是为什么 Tool 设计会同时影响:

  • 模型准确率
  • 工具调用成功率
  • 系统安全性
  • 可观测性
  • 最终用户体验

从这个意义上说,Tool 不是一个注解技巧,而是一层接口协议设计


总结:真正成熟的 Agent,往往赢在 Tool 设计

LangChain4j 的 Tool 机制看似只是给方法加一个 @Tool,但它背后其实包含了完整的系统设计问题:

  • 如何让 LLM 正确理解工具能力
  • 如何通过参数和描述减少调用歧义
  • 如何决定结果是继续推理还是直接返回
  • 如何注入上下文而不泄露给模型
  • 如何在错误、并发和审计层面做到生产可用

如果只把 Tool 当成“可调用方法”,你得到的通常只是一个勉强能跑的 Demo。
但如果把 Tool 当成LLM 与业务系统之间的结构化契约来设计,你才能真正做出稳定、透明、可扩展的 Agent 应用。

一个非常值得记住的结论是:
LLM 的上限,常常取决于 Tool 的设计质量。
模型决定“要不要调用”,但 Tool 决定“调用后能不能把事办好”。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐