Appearance
@Tool 工具调用:让模型能调用你的 Java 方法
1. 本节产出
一个会主动调用工具的应用:问「北京今天天气怎么样」时,模型不再编造,而是触发你写的 WeatherTools.getWeather("北京"),拿到真实数据后再组织语言回答。同时具备工具异常处理与调用日志。
2. 前置依赖
- 01-02 第一个 ChatClient:已有 ChatClient Bean
- 01-03 国内多模型统一适配层:已确认厂商模型支持工具调用
- 01-01 LLM 基础概念与选型:理解模型是「续写」而非「查询」
3. 为什么模型必须能调工具
回到 01-01 的核心结论:模型只会续写,不会查询。所以:
| 用户问 | 不给工具时模型会怎样 | 给工具后 |
|---|---|---|
| 北京今天天气? | 编一个「晴,25 度」,看起来很像真的 | 调用天气 API,返回真实数据 |
| 订单 SO12345 到哪了? | 编造物流状态 | 查数据库返回真实状态 |
| 帮我把这张工单转成待处理 | 说「好的已处理」,其实什么也没做 | 调用写接口真正改状态 |
前两种情况叫幻觉,第三种更糟——模型撒谎说自己做了事。在生产环境里,第三种会直接造成业务事故。
工具调用的本质:模型不执行任何代码,它只是输出一段「我想调用 getWeather,参数是 {city: 北京}」的 JSON。真正执行的是你的 Java 代码。理解这一点,后面所有关于工具的设计问题(参数怎么定、错误怎么回、权限怎么控)都迎刃而解。
4. 核心原理
4.1 一次工具调用的完整链路
1. 请求发出
你 → 模型:用户问题 + 【工具清单 JSON Schema】
├─ name: getWeather
├─ description: 查询指定城市当前天气
└─ parameters: {city: string}
2. 模型决策
模型 → 你:{"tool":"getWeather","args":{"city":"北京"}}
3. 本地执行
你的 Java 方法被调用 → 返回 "北京,晴,26℃"
4. 结果回填
你 → 模型:工具结果(作为一条 tool 消息)
5. 最终回答
模型 → 你:「北京今天晴,26℃,适合出行。」注意第 3 步:执行发生在你的 JVM 里,用的是你配置的超时、连接池、权限。这意味着工具可以做任何事——查库、写文件、调外部 API、甚至删数据。这就是为什么 03B-11 工具权限沙箱是必讲内容。
4.2 描述(description)决定调用准确率
模型靠 description 判断该不该调用、传什么参数。描述写得含糊,调用就随机。
| 差的描述 | 好的描述 |
|---|---|
查询天气 | 查询指定中国城市的当前天气,返回温度、天气状况和风力。仅支持中国大陆城市名,如"北京"、"深圳"。不支持国外城市。 |
查询订单 | 根据订单号查询订单当前状态与物流信息。订单号格式以 SO 开头加数字,如 SO12345。查不到时返回 NOT_FOUND,不要猜测。 |
好描述的三个要素:能做什么、参数什么格式、边界在哪(不支持什么/失败时怎样)。把描述当成给新同事写的需求说明,而不是给 IDE 看的注释。
4.3 参数设计:少而确定
| 原则 | 反例 | 正例 |
|---|---|---|
| 参数尽量少 | queryOrder(userId, orderId, startDate, endDate, status) | queryOrder(orderId) |
| 用枚举限定取值 | status: string | status: enum[PENDING, PAID, SHIPPED] |
| 必填性明确 | 全部可选,模型乱传 | 只给真正需要的 |
| 字段名用业务语言 | p1, p2 | city, orderId |
参数越多,模型出错概率指数上升。超过 3 个参数就该考虑拆成多个工具。
4.4 工具返回什么
返回给模型的内容会被放进上下文,每一字节都花钱。所以:
- 返回结构化但精简的结果,不要返回整个数据库对象;
- 查不到就明确返回「未找到」,让模型去问用户,而不是返回空字符串让它编;
- 不要把工具的堆栈异常原样丢给模型——它看不懂,还会学着在回答里复述堆栈。
5. 代码走查
5.1 定义工具类
java
// ch01-basics/src/main/java/com/aitech/basics/tool/WeatherTools.java
@Component
public class WeatherTools {
private final WeatherApi api;
public WeatherTools(WeatherApi api) { this.api = api; }
@Tool(description = """
查询指定中国城市的当前天气,返回温度、天气状况和风力。
仅支持中国大陆城市名,例如"北京"、"深圳"、"杭州"。
不支持国外城市与区县级地名。查询失败时返回错误信息文本。
""")
public String getWeather(
@ToolParam(description = "城市名,例如:北京。不含「市」字。")
String city) {
try {
Weather w = api.query(city);
return String.format("%s:%s,%d℃,风力 %s",
city, w.condition(), w.temp(), w.wind());
} catch (CityNotFoundException e) {
return "NOT_FOUND:未找到该城市,请向用户确认城市名是否正确。";
} catch (Exception e) {
// 关键:异常转成人话返回,不要抛出去
log.error("weather query failed: {}", city, e);
return "ERROR:天气服务暂时不可用,请告知用户稍后重试。";
}
}
}异常处理这一段是本节最值钱的部分。工具方法抛异常时,不同版本行为不一:有的会把堆栈塞回上下文,有的直接中断整个调用。统一转成「人能读懂的字符串返回」是最稳的做法——模型拿到 NOT_FOUND 会去问用户,拿到堆栈只会照着念。
5.2 注册工具
java
// ch01-basics/src/main/java/com/aitech/basics/config/ChatConfig.java
@Bean
public ChatClient chatClient(ChatClient.Builder builder, WeatherTools weatherTools) {
return builder
.defaultSystem("你是助手。涉及天气、订单等实时信息时必须调用工具,"
+ "不得凭记忆回答。工具返回 NOT_FOUND 时向用户确认。")
.defaultTools(weatherTools) // 注册整个类的所有 @Tool 方法
.build();
}也可以按方法粒度注册:
java
.defaultTools(MethodToolCallbackProvider.builder()
.toolObjects(weatherTools) // 扫描对象上的 @Tool 方法
.build())提示:Spring AI 1.x 里的
.defaultFunctions(...)在 2.x 已改为.defaultTools(...)。如果你的代码里还在用functions,编译时可能不报错但工具不生效——这是版本升级时最隐蔽的坑之一。
5.3 单次调用时临时挂载工具
java
// 只在某类请求上开放工具,避免权限过大
public String chatWithTools(String message, Set<String> allowed) {
return chatClient.prompt()
.user(message)
.tools(MethodToolCallbackProvider.builder()
.toolObjects(selectTools(allowed)) // 按用户权限筛选
.build())
.call()
.content();
}这是权限控制的第一道门:不要把所有工具一次性挂到全局 ChatClient 上,而是按用户角色/场景动态挂载。完整的分级方案见 03B-11。
5.4 观察调用过程
java
// 打开工具调用日志
logging:
level:
org.springframework.ai: DEBUG或者自己记录:
java
ChatResponse resp = chatClient.prompt()
.user(message)
.tools(weatherTools)
.call()
.chatResponse();
// 查看模型是否触发了工具
boolean hasToolCall = resp.getResult().getOutput().getToolCalls() != null
&& !resp.getResult().getOutput().getToolCalls().isEmpty();
log.info("tool invoked: {}", hasToolCall);6. 跑起来
bash
git checkout ch01-07-tool-calling
mvn spring-boot:run验证一:正常触发
bash
curl -X POST http://localhost:8080/api/chat \
-H "Content-Type: text/plain" -d "北京今天天气怎么样?"期望:日志出现工具调用记录,回答包含真实数值(不是编造的)。
验证二:边界测试(本节核心)
bash
# 1. 不支持的城市 → 模型应回复「未找到,请确认城市名」
curl -X POST http://localhost:8080/api/chat \
-H "Content-Type: text/plain" -d "纽约今天天气怎么样?"
# 2. 不需要工具的问题 → 不应触发工具
curl -X POST http://localhost:8080/api/chat \
-H "Content-Type: text/plain" -d "什么是依赖注入?"
# 3. 需要两个工具的问题 → 应连续调用
curl -X POST http://localhost:8080/api/chat \
-H "Content-Type: text/plain" -d "北京和深圳今天哪里更热?"| 检查项 | 通过标准 |
|---|---|
| 正常触发 | 日志有工具调用,回答是真实数据 |
| 越界参数 | 模型回复「不支持」而不是编造 |
| 无需工具时 | 不触发工具,避免过度调用 |
| 多工具 | 能连续调用两个城市并比较 |
| 异常路径 | 模拟 API 挂掉,回答为「稍后重试」而非堆栈 |
异常路径必须真的模拟一次:把 WeatherApi 改成抛异常跑一遍,看模型拿到的是不是你写的那句人话。这是很多教程不讲、但生产一定会遇到的情况。
7. 生产避坑
- 绝大多数「工具不生效」问题,根因是模型不支持或 description 写得含糊。排查顺序:先用最简工具(返回固定字符串)确认模型能触发 → 再换真实工具 → 最后调描述。不要一上来就改框架配置,那通常不是原因。
- 工具方法抛异常会让整轮对话失败。必须把所有异常转成可读文本返回,让模型有机会向用户解释或换一种方式。特别是涉及外部 API 的工具,超时和网络异常是常态而不是例外。
- 工具返回值会进上下文,别返回大对象。有人直接把 JPA 实体 toString 返回,一次调用几千 Token,多轮之后直接爆窗口。做法是定义一个专门的 DTO 只放模型需要的字段——给模型看的返回值和给前端看的返回值,本来就不该是同一个对象。
8. 延伸与锚点
- 思考题:模型编造了一个不存在的工具名来调用,你的代码会怎样?(提示:这是真实会发生的事,需要在执行层做白名单校验——答案在 03B-11 权限沙箱)
- 代码锚点:
git checkout ch01-07-tool-calling - 下一课时:01-08 结构化输出
- 对应课件:L01-07 @Tool 工具调用