数据截至 (上游 commit daa7624a2755)
工具调用:从 @Tool 反射到 round-trip 循环
30 秒导读: 大模型只会输出文字,不会真的查数据库、发邮件。这一章讲 LangChain4j 怎么把一个普通 Java 方法变成模型能「调用」的工具:先用反射把方法签名翻成 JSON Schema 发给模型,再用一个循环把模型吐出的「调用请求」落成真实的方法调用,并把结果塞回对话继续下一轮。
同组其它章:核心抽象层 讲模型接口与消息模型,AiServices 讲一个 Java 接口怎么变成一次 LLM 调用,本章是 AiServices 里「工具」那一支的深挖。
1. 这是什么(零基础也能懂)
一句话定义: 工具调用(tool calling / function calling)= 让模型在回答之前,先说「我要调用 getWeather("上海")」,由框架真的去执行这个方法,再 把结果喂回模型。
解决什么问题。 模型的知识是训练时冻结的,而且它没有手脚。你问「上海今天几度」,它只能编。给它装一个 getWeather 工具,它就能先要数据、再回答。
给谁用。 任何写 Java 后端、想让 LLM 触碰真实系统(数据库、内部 API、文件、第三方 SaaS)的人。
它能做什么(功能清单):
- 把
@Tool注解的方法自动变成模型能看见的工具描述。 - 自动解析模型返回的 JSON 参数,强转成 Java 类型,调用方法。
- 自动跑多轮:模型可以连着调好几个工具,直到它满意为止。
- 工具报错时把错误文本回喂给模型,让它自己纠错重试。
- 工具太多时,让模型先「搜工具」再调工具。
- 工具不一定来自你的代码——可以来自 MCP 服务器,或来自 Agent Skills。
用起来什么样。 一个最小可用例子:
// 示意,非源码
class WeatherTools {
@Tool("返回某个城市当前气温,单位摄氏度")
int getTemperature(@P("城市名,如 Shanghai") String city) {
return 27; // 真实实现会去查 API
}
}
interface Assistant {
String chat(String userMessage);
}
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(model)
.tools(new WeatherTools()) // 就这一行,方法变成工具
.build();
assistant.chat("上海今天热吗?");
你写的只有 @Tool 那一行。剩下的——生成 schema、发给模型、解析模型的调用请求、反射调用、把结果写回对话——全在框架里。
一句话直觉。 把工具调用想成远程过程调用,只不过调用方是个不太靠谱的实习生:它可能把参数名拼错、可能调一个根本不存 在的方法、可能一次调五个。所以框架的绝大部分代码不是「怎么调」,而是「它乱来时怎么办」。
2. 顶层全景(它大概怎么转)
整条链路分两个阶段:装配期(把工具变成 schema)和运行期(round-trip 循环)。
怎么读下面这张图:从左到右是装配期,工具从三种来源汇入同一个 ToolServiceContext;右边的循环是运行期。
装配期(每次 AI Service 调用开始时跑一遍)
┌──────────────┐
│ ① 你的 @Tool │──反射──┐
│ 注解方法 │ │
└──────────────┘ │
┌──────────────┐ ├──▶ ┌───────────────────┐
│ ② ToolProvider│───────┤ │ ToolServiceContext │
│ (MCP/Skills)│ │ │ effectiveTools │ ← 这轮真发给模型的
└──────────────┘ │ │ availableTools │ ← 全部候选(可被搜到)
┌──────────────┐ │ │ toolExecutors │ ← 名字 → 执行器
│ ③ 手动注册的 │───────┘ │ returnBehaviors │ ← 名字 → 返回策略
│ Spec+Executor│ └───────────────────┘
└──────────────┘
运行期(executeInferenceAndToolsLoop)
┌───────────────────────────────────────────┐
│ │
▼ │
发 ChatRequest ──▶ 模型回 AiMessage ──▶ 有工具调用? ─否─▶ 结束,返回最终回答
▲ │是
│ ▼
│ 执行工具(顺序或并发)
│ │
│ ▼
│ 结果写回 chatMemory
│ │
│ ▼
│ 该提前返回吗?─是─▶ 直接把工具结果给调用方
└──── 重算 effectiveTools ◀────否───┘
部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
ToolSpecifications | 反射:@Tool 方法 → ToolSpecification + JSON Schema | langchain4j-core/src/main/java/dev/langchain4j/agent/tool/ToolSpecifications.java |
ToolService | 装配所有工具来源 + 跑 round-trip 循环 | langchain4j/src/main/java/dev/langchain4j/service/tool/ToolService.java |
ToolServiceContext | 一次调用内的工具快照(effective / available / executors) | .../service/tool/ToolServiceContext.java |
DefaultToolExecutor | 把 JSON 参数强转成 Java 参数并反射调用 | .../service/tool/DefaultToolExecutor.java |
ToolSearchService | 工具太多时,让模型先搜再用 | .../service/tool/search/ToolSearchService.java |
McpToolProvider | 从 MCP 服务器拉工具 | langchain4j-mcp/src/main/java/dev/langchain4j/mcp/McpToolProvider.java |
Skills | 把 Agent Skills 变成 activate_skill 等工具 | langchain4j-skills/src/main/java/dev/langchain4j/skills/Skills.java |
主线走一遍(高层)。 DefaultAiServices 在发第一个请求前调 ToolService.createContext(...) 拿到工具快照(DefaultAiServices.java:283-284),把 effectiveTools() 塞进 ChatRequestParameters;拿到第一个 ChatResponse 后,交给 executeInferenceAndToolsLoop 接管(DefaultAiServices.java:345-354),循环结束后再走解析/护栏那一套。
3. 核心原理
3.1 注解到 schema:反射怎么写出 JSON Schema
要解决的小问题。 模型只认 JSON Schema。你写的是 Java 方法签名。中间要有一次翻译。
思路。 一个 @Tool 方法 = 工具名 + 描述 + 一个 JsonObjectSchema 参数对象。方法名当工具名,注解文本当描述,每个参数当 schema 的一个 property。
入口是 toolSpecificationFrom(ToolSpecifications.java:126-133),四件事一次做完:
return ToolSpecification.builder()
.name(getName(tool, method)) // 注解没写 name 就用方法名
.description(getDescription(tool)) // @Tool 的 value() 用 \n 拼接
.parameters(parametersFrom(method.getParameters()))
.metadata(getMetadata(tool)) // 解析 @Tool(metadata="{...}") JSON
.build();
真正有意思的是 parametersFrom(ToolSpecifications.java:153-243)。它做三件事,依次讲。
第一件:跳过框架注入的参数。 有些参数不是给模型填的,是框架自己塞的,不能出现在 schema 里(ToolSpecifications.java:161-165):
| 参数形态 | 谁来填 |
|---|---|
@ToolMemoryId Object memoryId | 框架填当前 chat memory id |
InvocationParameters 及其子类 | 框架填本次调用的自定义参数 |
LangChain4jManaged 的实现类 | 框架从 context.managedParameters() 取 |
InvocationContext | 框架填整个调用上下文 |
对应的填值逻辑在 DefaultToolExecutor.prepareArguments(DefaultToolExecutor.java:211-261),两边一一对应。
第二件:必填性怎么算。 这是最容易踩的一条规则,源码只有五行(ToolSpecifications.java:168-175):
boolean isOptional = Optional.class.equals(parameter.getType());
boolean hasDefaultValue = pAnnotation != null && !P.NO_DEFAULT.equals(pAnnotation.defaultValue());
boolean isRequired = !isOptional && !hasDefaultValue
&& Optional.ofNullable(pAnnotation).map(P::required).orElse(true);
翻成表:
| 参数写法 | 出现在 required 数组里吗 | 模型不给值时 |
|---|---|---|
String city(无注解) | 是 | 对象类型传 null;原始类型抛异常 |
@P(required = false) Integer n | 否 | 传 null |
Optional<String> unit | 否 | 传 Optional.empty() |
@P(defaultValue = "5") int limit | 否 | 传解析后的默认值 5 |
三条要点:
@P.required默认是true(P.java:102),所以不写注解 = 必填。defaultValue一旦设置就等价于 required=false——它只是把「缺省时填什么」从null换成你给的值。哨兵常量是P.NO_DEFAULT(P.java:139),一串带\0的怪字符串,用来区分「没设默认值」和「默认值是空串」。- 官方明说 1.x 有一个不对称:必填参数缺失时,原始类型会抛
ToolArgumentsException,对象类型只是悄悄传null(P.java:88-98的 javadoc),计划在 2.0 统一。
第三件:参数名与递归类型。 参数名优先取 @P(name=...),否则取反射拿到的名字(ToolSpecifications.java:177-180)。 这里有个 Java 特有的坑:不加 -parameters 编译选项时,反射只能拿到 arg0、arg1,模型会一脸茫然;Quarkus / Spring 默认开了这个选项,裸 Maven 项目往往没开(P.java:30-40)。
每个参数的类型翻译交给 jsonSchemaElementFrom(ToolSpecifications.java:244-277)。它先处理描述——@P 的 value() 和 description() 是同一件事的两个别名,同时写两个直接抛异常(ToolSpecifications.java:250-254);再拆 Optional<T> 的泛型参数,拿到真实类型(ToolSpecifications.java:265-274);最后委托给 JsonSchemaElementUtils.jsonSchemaElementFrom(签名见 langchain4j-core/src/main/java/dev/langchain4j/internal/JsonSchemaElementUtils.java:48-53)去递归展开 POJO。
递归展开要处理自引用类型(Node 里有 Node parent)。办法是一张 visited 表:VisitedClassMetadata 上有个 recursionDetected 标记(JsonSchemaElementUtils.java:579-588),一旦某个类被检测到递归,就把它提到 schema 的 definitions 里,用 $ref 引用(ToolSpecifications.java:195-209)。
两个收尾细节:
- 一个参数都没有(或全被跳过)时,
parameters返回null而不是空对象(ToolSpecifications.java:202-203)——无参工具的 schema 是「没有参数」,不是「有一个空参数对象」。 - 同一个类里两个工具重名会在
validateSpecifications里抛IllegalArgumentException(ToolSpecifications.java:107-124)。