一、💡 一句话理解
第 12 天核心结论
在 Spring AI 中,给 Java 方法加上
@Tool,再通过.tools(...)把工具对象注册给ChatClient,模型就能根据方法说明生成参数,由 Spring AI 调用这个 Java 方法。
本篇对应 0-学习路线 第 12 天,前置知识是 11.Function Calling、Tool Calling 原理。
今天要掌握的是“怎样定义工具”,不是继续重复工具调用原理:
普通 Java 方法
+ @Tool:说明这个方法能做什么
+ @ToolParam:说明每个参数表示什么
+ .tools(...):把工具提供给本次模型请求
= 模型可以申请调用的 Tool最重要的边界仍然是:模型生成工具名和参数,真正执行 Java 方法的是应用。
二、🧭 理论:Java 方法怎样变成 Tool
2.1 一个 Tool 由哪几部分组成
Spring AI 官方把模型能看到的工具说明称为 ToolDefinition。它主要包含三部分:
| 组成 | 大白话解释 | 当前示例 |
|---|---|---|
name | 工具叫什么,模型调用时用这个名字 | getCurrentDateTime |
description | 工具能做什么、什么时候应该使用 | 查询指定时区的当前时间 |
inputSchema | 参数有哪些、是什么类型、是否必填 | zoneId 是可选字符串 |
日常开发不用手写 ToolDefinition。通常使用 @Tool 和 @ToolParam,让 Spring AI 根据 Java 方法自动生成上面三个组成部分:
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
public class DateTimeTools {
@Tool(
// name:对应 ToolDefinition.name。
// 模型发起 Tool Call 时使用这个名称查找工具。
// 如果不写 name,默认使用 Java 方法名 getCurrentDateTime。
name = "getCurrentDateTime",
// description:对应 ToolDefinition.description。
// 模型根据这段说明判断工具能做什么、什么时候应该调用。
description = "查询指定时区的当前日期和时间;用户询问当前时间时使用"
)
public String getCurrentDateTime(
// 方法参数的类型、名称和 @ToolParam 共同生成 inputSchema。
@ToolParam(
// 这段说明会成为 inputSchema 中 zoneId 的 description。
description = "IANA 时区名称,例如 Asia/Shanghai",
// required = false 表示模型可以不传这个参数。
required = false
)
String zoneId) {
// 方法体是工具真正执行的业务逻辑,不属于 ToolDefinition。
return "当前时间查询结果";
}
}代码与三个组成部分的对应关系是:
ToolDefinition 组成 | 对应代码 |
|---|---|
name | @Tool(name = "getCurrentDateTime") |
description | @Tool(description = "...") |
inputSchema | Spring AI 根据方法参数、参数类型和 @ToolParam 自动生成 |
Spring AI 读取方法和注解后,会生成一份近似下面这样的说明发送给模型:
{
"name": "getCurrentDateTime",
"description": "查询指定时区的当前日期和时间;当用户询问现在几点、今天日期或当前时间时使用",
"inputSchema": {
"type": "object",
"properties": {
"zoneId": {
"type": "string",
"description": "IANA 时区名称,例如 Asia/Shanghai;用户未说明时区时可以不传"
}
}
}
}
inputSchema就是参数填写说明书它不是模型真正传入的参数,只是告诉模型“有哪些参数、参数是什么类型、应该填写什么内容”。
这段 Schema 可以简单理解为:
| 内容 | 大白话解释 |
|---|---|
最外层的 type: object | 所有工具参数放在一个 JSON 对象中 |
properties | 这个工具允许填写哪些参数 |
zoneId | 参数名,对应 Java 方法中的 String zoneId |
zoneId 里面的 type: string | 这个参数必须是字符串 |
description | 给模型看的填写提示 |
模型看完 inputSchema 后,才会生成真正的调用参数:
{
"zoneId": "Asia/Shanghai"
}Spring AI 再把它转换成 Java 方法调用:
getCurrentDateTime("Asia/Shanghai");可以把两者记成:
inputSchema = 空白表单,规定应该怎么填
arguments = 模型填写后的实际内容如果只想直观看清 ToolDefinition 三个字段,也可以手动构建一份工具说明:
import org.springframework.ai.tool.definition.ToolDefinition;
ToolDefinition toolDefinition = ToolDefinition.builder()
// 工具唯一名称。
.name("getCurrentDateTime")
// 工具用途和使用时机。
.description("查询指定时区的当前日期和时间;用户询问当前时间时使用")
// 模型调用工具时必须遵守的参数结构。
.inputSchema("""
{
"type": "object",
"properties": {
"zoneId": {
"type": "string",
"description": "IANA 时区名称,例如 Asia/Shanghai"
}
},
"required": []
}
""")
.build();手动创建的
ToolDefinition只是工具说明书它还没有绑定真正执行查询的 Java 方法。当前项目优先使用
@Tool和@ToolParam,再通过.tools(new DateTimeTools())同时提供工具说明与执行逻辑。
上面的 JSON 只是帮助理解的近似结构。模型不会收到完整 Java 源码,也看不到方法内部怎样读取时间。
2.2 @Tool:声明方法是工具
@Tool 标在方法上,作用是告诉 Spring AI:这个方法可以被包装成工具。
@Tool(description = "查询指定时区的当前日期和时间;当用户询问现在几点、今天日期或当前时间时使用")
public String getCurrentDateTime(String zoneId) {
// 真正的业务逻辑
}常用属性如下:
| 属性 | 作用 | 当前阶段怎么用 |
|---|---|---|
name | 自定义工具名;不写时默认使用 Java 方法名 | 入门阶段优先使用清晰的方法名 |
description | 告诉模型工具用途和使用时机 | 强烈建议始终填写 |
returnDirect | 是否把工具结果直接返回调用方,不再交给模型整理 | 当前保持默认 false |
resultConverter | 自定义工具返回值如何转换成字符串 | 当前使用默认转换器 |
description不是代码它只帮助模型做选择,不会执行权限校验、参数校验或设置默认值。真正的业务规则必须写在 Java 代码里。
一个好的工具描述至少要回答两个问题:
- 这个工具能做什么;
- 什么情况下应该使用它。
对比下面两个描述:
// 太模糊:模型不知道查询的是什么,也不知道何时调用。
@Tool(description = "查询数据")
// 更清楚:能力、输入对象和使用时机都明确。
@Tool(description = "根据订单编号查询当前订单状态;用户询问订单是否付款、发货或完成时使用;只读,不修改订单")2.3 @ToolParam:说明参数含义
@ToolParam 标在方法参数上,帮助 Spring AI 生成参数 JSON Schema。
public String getCurrentDateTime(
@ToolParam(
description = "IANA 时区名称,例如 Asia/Shanghai;用户未说明时区时可以不传",
required = false)
String zoneId) {
// ...
}| 配置 | 含义 |
|---|---|
description | 告诉模型参数表示什么、采用什么格式 |
required = true | 参数必填;这也是默认值 |
required = false | 参数可以不传,Java 代码必须正确处理 null 或空值 |
本例把 zoneId 设为可选,是因为用户可能只问“现在几点”,没有明确说时区。此时工具方法用 Asia/Shanghai 作为业务默认值。
不要把模型逼到“编一个值”
如果参数被标成必填,但用户问题中没有这个信息,模型为了满足 Schema 可能猜一个值。确实允许缺省的参数,应标成可选并由后端设置安全默认值;不能缺省的参数,应让应用要求用户补充。
2.4 Tool 不是加完注解就自动生效
只写 @Tool 并不会让所有模型请求都自动获得这个工具。还需要把工具对象注册给 ChatClient。
本次请求注册:
chatClient.prompt()
.user(question)
.tools(new DateTimeTools())
.call()
.content();默认注册:
ChatClient client = builder
.defaultTools(new DateTimeTools())
.build();两种方式的区别:
| 注册方式 | 生效范围 | 适合场景 |
|---|---|---|
.tools(...) | 只对当前这一次请求生效 | 按接口、用户权限或业务场景精确开放工具 |
.defaultTools(...) | 这个 ChatClient 的每次请求都可使用 | 每次请求都需要、风险较低的通用工具 |
本篇使用 .tools(...)。这样最容易看清楚“哪个接口开放了哪个工具”,也能减少模型误选工具和越权调用的风险。
Spring AI 2.0.1 的行为
.tools(...)会追加本次请求的工具,不会替换ChatClient上已有的默认工具。模型最终看到的是默认工具和本次工具的合集,因此工具名称必须唯一。
三、⚙️ 理论:从注解到方法执行发生了什么
3.1 官方推荐的声明式定义顺序
Spring AI 2.0.1 官方教程把“用方法定义工具”的入门流程组织为:
- 在 Java 方法上添加
@Tool; - 使用
@ToolParam描述参数; - 创建包含这些方法的对象;
- 通过
.tools(...)或.defaultTools(...)注册; - 调用
ChatClient,由工具调用循环完成模型请求和方法执行。
当前代码对应关系如下:
DateTimeTools#getCurrentDateTime
↓ @Tool、@ToolParam
Spring AI 生成 ToolDefinition 和参数 Schema
↓ .tools(new DateTimeTools())
工具说明随用户问题一起发送给模型
↓
模型返回 getCurrentDateTime 的 Tool Call
↓
Spring AI 把 JSON 参数转换为 Java 参数并执行方法
↓
方法结果交回模型,模型生成最终回答3.2 @Tool、.tools(...) 和 .call() 各自负责什么
这三个位置最容易混淆:
| 代码 | 真正职责 | 不负责什么 |
|---|---|---|
@Tool | 声明方法可作为工具,并提供工具说明 | 不注册工具,不执行方法 |
.tools(new DateTimeTools()) | 把该对象中的工具提供给本次请求 | 不保证模型一定调用 |
.call() | 发起同步模型调用,并由 Spring AI 驱动工具调用循环 | 不替代方法内部的校验和权限控制 |
一句话记忆:
@Tool 负责“定义”
.tools(...) 负责“上架”
.call() 负责“启动本轮流程”3.3 方法参数怎样传进来
假设用户问:
纽约现在几点?模型根据工具说明生成类似下面的调用请求:
{
"name": "getCurrentDateTime",
"arguments": {
"zoneId": "America/New_York"
}
}Spring AI 找到同名工具后,把 JSON 中的 zoneId 转成 Java 的 String 参数,然后执行:
getCurrentDateTime("America/New_York");所以参数并不是 Controller 手动传入的,也不是 @ToolParam 自动生成的;它是模型根据用户问题和参数 Schema 生成,再由 Spring AI 绑定到 Java 方法上的。
3.4 方法返回值去了哪里
当前方法返回一个 String:
return now.format(DateTimeFormatter.ISO_ZONED_DATE_TIME);默认情况下,Spring AI 会把这个结果作为 Tool Result 再交给模型。模型看到真实时间后,才生成更自然的中文回答。
Java 方法返回:2026-09-24T15:30:00+08:00[Asia/Shanghai]
↓
模型最终回答:上海当前时间是 2026 年 9 月 24 日 15:30。工具也可以返回 Java 对象。Spring AI 默认会使用 Jackson 把结果序列化为模型可以读取的字符串。入门阶段优先返回结构清楚、字段稳定的 record 或简单对象,不要把实体对象、异常堆栈或敏感字段直接暴露给模型。
3.5 方法定义的限制
Spring AI 2.0.1 的方法工具不支持把下面这些类型直接作为方法参数或返回值:
Optional;CompletableFuture、Future;Mono、Flux等响应式类型;Function、Supplier、Consumer等函数类型。
可选参数使用 @ToolParam(required = false) 或可空标记表达,不要使用 Optional。需要异步、响应式或复杂执行控制时,应把这些细节留在业务服务内部,对工具暴露普通、稳定、可序列化的输入输出。
四、🚀 实践:定义并调用时间工具
4.1 前置准备
4.1.1 当前项目环境
本篇直接沿用当前项目:
- Java 21;
- Spring Boot 4.1.1;
- Spring AI 2.0.1;
- Maven 3.9.16;
- OpenAI 兼容模型配置;
- 模型本身支持 Tool Calling。
pom.xml 已有依赖:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>不需要为了 @Tool 再添加一个独立 Starter。
4.1.2 模型配置
src/main/resources/application.properties 沿用已有配置:
# API Key 从环境变量读取,不写进配置文件或 Git。
spring.ai.openai.api-key=${DEEPSEEK_API_KEY}
spring.ai.openai.base-url=https://api.deepseek.com
spring.ai.openai.chat.model=deepseek-v4-flash模型名称和能力可能变化。如果普通聊天能成功,但工具始终没有执行,应先确认当前模型及其兼容接口是否支持 Tool Calling。
4.2 可以拿来干什么
4.2.1 把现有只读查询包装成 Tool
用途:把订单查询、物流查询、库存查询等现有 Java Service 方法提供给模型,让用户可以用自然语言查询真实数据。
前置:查询方法必须已有明确输入输出,当前用户也必须有权读取这份数据。工具层不能因为模型传来了订单号就跳过业务鉴权。
@Tool(description = "根据订单编号查询当前订单状态;用户询问订单是否付款、发货或完成时使用;只读,不修改订单")
public OrderStatusResult queryOrderStatus(
@ToolParam(description = "订单编号,例如 A1001")
String orderId) {
// orderService 来自现有业务系统;工具只负责提供受控入口。
return orderService.queryStatus(orderId);
}输入是模型从用户问题中提取的订单编号,输出是结构化订单状态。适合只读、可审计、结果明确的查询能力。
4.2.2 只给某个接口开放需要的工具
用途:不同接口只开放自己需要的工具,避免把所有系统能力一次性暴露给模型。
前置:已经创建工具对象;如果工具依赖 Service,应由 Spring 注入,而不是在每次请求中手动创建复杂依赖。
return chatClient.prompt()
.user(question)
// tools:本次请求只能看到时间工具,其他 Tool 不会自动出现。
.tools(new DateTimeTools())
.call()
.content();输入是自然语言问题;Spring AI 会把当前工具说明和问题一起交给模型。适合按场景控制工具集合,也是后续做权限隔离的基础。
4.3 完整实践:查询指定时区的当前时间
4.3.1 定义 DateTimeTools
文件位置:
src/main/java/com/afyke/ai/tool/DateTimeTools.java当前项目的完整代码如下:
package com.afyke.ai.tool;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
public class DateTimeTools {
// @Tool:把下面的 Java 方法声明为一个可供模型选择的工具。
// 只有当 DateTimeTools 对象通过 .tools(...) 或 .defaultTools(...) 注册后,
// Spring AI 才会读取这个注解,并把工具说明发送给模型。
//
// description:告诉模型这个工具能做什么、什么时候应该使用。
// 它会影响模型是否选择该工具,但不会执行 Java 代码,也不负责参数校验。
@Tool(description = "查询指定时区的当前日期和时间;当用户询问现在几点、今天日期或当前时间时使用")
public String getCurrentDateTime(
// @ToolParam:描述 zoneId 参数的含义和格式。
// Spring AI 会用它生成工具参数的 JSON Schema,帮助模型正确传参。
//
// required = false:告诉模型这个参数可以不传。
// 注意:它只把参数标记为可选,不会自动把参数设置成 Asia/Shanghai。
@ToolParam(
description = "IANA 时区名称,例如 Asia/Shanghai;用户未说明时区时可以不传",
required = false)
String zoneId) {
// description 只是给模型看的说明,不会自动生成 Java 默认值。
// 因此参数为空时,仍要由业务代码真正设置默认时区。
String actualZoneId = (zoneId == null || zoneId.isBlank())
? "Asia/Shanghai"
: zoneId;
// ZoneId.of:把最终时区字符串转换为 Java 时区对象。
// 如果模型传入不存在的时区,这里会抛出 DateTimeException;第 13 天再完善参数校验。
ZoneId zone = ZoneId.of(actualZoneId);
// ZonedDateTime.now:由 Java 应用读取指定时区的真实时间,不让模型猜测。
ZonedDateTime now = ZonedDateTime.now(zone);
// 临时学习日志:通过控制台确认 Java 方法确实执行了。
System.out.println("执行 getCurrentDateTime,zoneId=" + zoneId);
// format:生成稳定的 ISO 时间字符串,再交给模型组织最终回答。
return now.format(DateTimeFormatter.ISO_ZONED_DATE_TIME);
}
}这段代码的输入、过程和输出分别是:
| 阶段 | 内容 |
|---|---|
| 输入 | 模型生成的 zoneId,例如 America/New_York |
| 处理 | 设置默认时区、解析时区、读取真实当前时间 |
| 输出 | ISO 格式时间字符串,交回模型整理 |
4.3.2 在 Controller 中注册工具
文件位置:
src/main/java/com/afyke/ai/controller/toolController/ToolCallingController.java当前项目的完整代码如下:
package com.afyke.ai.controller.toolController;
import com.afyke.ai.tool.DateTimeTools;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/tool")
public class ToolCallingController {
private final ChatClient chatClient;
public ToolCallingController(ChatClient.Builder builder) {
// build:使用 Spring Boot 已经配置好的 ChatModel 创建 ChatClient。
// API Key、模型名和服务地址仍由 application.properties 提供。
this.chatClient = builder.build();
}
@GetMapping("/time")
public String queryCurrentTime(
@RequestParam(defaultValue = "上海现在几点?请先使用工具查询,不要猜测。")
String question) {
return chatClient
// prompt:开始组织本次模型请求。
.prompt()
// user:放入用户的自然语言问题。
.user(question)
// tools:只把这个工具对象暴露给本次请求。
// Spring AI 会读取对象中带 @Tool 的方法并生成 ToolDefinition。
.tools(new DateTimeTools())
// call:发起同步调用。
// 如果模型返回 Tool Call,Spring AI 会执行工具并再次请求模型。
.call()
// content:取出整个工具调用循环结束后的最终文本回答。
.content();
}
}4.3.3 启动项目
先在 IntelliJ IDEA 的 AiApplication 运行配置中设置环境变量:
DEEPSEEK_API_KEY=你的真实密钥然后使用 IDEA 的绿色运行按钮启动 AiApplication。也可以在项目根目录执行:
/Volumes/data/develop/apache-maven-3.9.16/bin/mvn spring-boot:run启动成功后,应用监听 8080 端口。
4.3.4 发送验证请求
使用浏览器或 HTTP 客户端访问:
GET http://localhost:8080/api/tool/time?question=纽约现在几点?请使用工具查询命令行验证:
curl --get 'http://localhost:8080/api/tool/time' \
--data-urlencode 'question=纽约现在几点?请使用工具查询'预期发生的过程:
模型选择 getCurrentDateTime
→ 生成 zoneId=America/New_York
→ Spring AI 执行 Java 方法
→ 控制台打印“执行 getCurrentDateTime...”
→ 模型根据真实时间生成中文回答最终回答的措辞不固定,可能类似:
纽约当前时间是 2026 年 9 月 24 日 03:52,时区为 America/New_York。时间数值必须以实际请求时的工具结果为准,不能照抄示例。
4.3.5 再验证一次“不需要工具”的问题
发送:
GET http://localhost:8080/api/tool/time?question=请解释什么是Java集合模型通常会直接回答,不执行 getCurrentDateTime。控制台不应出现工具日志。
这说明 .tools(...) 只是提供候选工具,不是强制每个问题都调用工具。
4.4 关键报错排查
4.4.1 普通聊天成功,但工具没有执行
按顺序检查:
- 当前模型和服务接口是否支持 Tool Calling;
- 请求链中是否真的调用了
.tools(new DateTimeTools()); - 方法上是否有
org.springframework.ai.tool.annotation.Tool; @Tool描述是否明确说明了用途和调用时机;- 用户问题是否确实需要实时数据;
- 查看控制台是否出现工具执行日志。
4.4.2 出现 Unknown time-zone ID
原因:模型生成了 Java 不认识的时区名称,ZoneId.of(...) 解析失败。
当前章节先用它说明“模型参数是不可信输入”。下一篇“工具参数设计和校验”再增加白名单、异常转换和让用户补充信息的处理。
4.4.3 出现工具名称冲突
同一次模型请求中,每个工具名称必须唯一。如果两个方法都通过 @Tool(name = "queryData") 暴露,就需要修改名称,使它们能被明确区分。
4.4.4 为什么工具类没有加 @Component
当前代码使用:
.tools(new DateTimeTools())因此由业务代码直接创建工具对象,不需要 Spring 管理它。
如果工具需要注入 OrderService、InventoryService 等业务依赖,更合适的做法是把工具类交给 Spring 管理,再通过构造方法注入到 Controller。不要在 Controller 中手动 new 一个依赖复杂的工具。
另外,Spring AI 官方说明:构建 GraalVM Native Image 时,包含 @Tool 方法的类应成为 Spring Bean,或者显式注册反射信息。当前普通 JVM 学习项目暂时不涉及这个限制。
五、📌 总结
@Tool把 Java 方法声明为模型可申请调用的工具。@ToolParam描述参数的含义、格式和是否必填。.tools(...)只为当前请求注册工具;.defaultTools(...)为同一个ChatClient的所有请求注册默认工具。- 注解说明只影响模型选择,不能替代 Java 代码中的默认值、校验、鉴权和异常处理。
- 工具参数由模型生成,再由 Spring AI 转成 Java 参数,所以必须按不可信输入处理。
- 默认情况下,Java 方法结果会先交回模型,再由模型整理成最终回答。
记忆句
@Tool负责定义,.tools(...)负责上架,.call()启动流程,Java 方法负责真正办事。
下一步:学习“工具参数设计和校验”,解决必填与可选参数、格式约束、错误提示和模型乱传值的问题。