Spring AI 工具调用:@Tool 让大模型自己查订单查库存
作者:鱼宵 | Spring AI 实战精通营 · 第 4 篇
上一课模型会算钱了,但你发现没有——那个价目表是我们手写死在 system 里的假数。真实业务哪有这好事:客户在客服窗口问"我订单 1001 现在什么状态",模型知道你库里这条单发没发货吗?它的训练数据里根本没有你的私有数据。你让它直接答,它要么礼貌拒绝,要么——更完蛋——张嘴瞎编一个物流单号出来。
这就是第 4 课要收拾的事:给模型装上"手"。在你的 Java 方法上加个 @Tool 注解,模型自己判断"这问题得查数据",自己决定调哪个方法、传什么参数,框架真去执行你的 Java 方法,把结果喂回去,它再组织成人话回答。代码全在仓库 lesson-04/ 目录,clone 下来重跑一遍,盯着另一个窗口的日志,你会亲眼看到 ===== 工具被调用 → getOrderStatus ===== 这行——那就是模型自己拿主意的现场。
一、核心原理:模型只会动嘴,工具是它的手
1. 模型不执行代码,它只负责"点菜"
大模型本质是个文本概率生成器:给它上文,它吐出"最像人话"的下一段文字。它不能真去查你的数据库——没有你的私有数据,也没有执行代码的能力。
类比一个背了 100 本书的实习生:知识渊博、嘴皮子利索,但手无缚鸡之力,不能开电脑、不能查系统。客户问"我订单到哪了",他总不能瞎编吧?解法是给他配个对讲机:查到知识盲区时,对着对讲机喊"帮我查订单 1001"(输出一段结构化调用请求),真实员工(你的 Java 方法)去系统里查完,把结果递回来,他再组织成体面的回答。这个"对讲机+跑腿员工"的机制,就是工具调用(Function Calling)。
2. 一次 call() 背后的四步循环
① 用户提问:"订单1001什么状态?"
↓
② 第一轮请求发给模型(附带"工具说明书":你有哪些工具、参数是什么)
↓
模型判断:这问题我得查数据 → 输出结构化调用意图(不是人话,是 JSON:
{"name":"getOrderStatus","arguments":{"orderId":"1001"}})
↓
③ 框架截获这个意图 → 真正执行 Java 方法 OrderTools.getOrderStatus("1001")
→ 拿到结果:"已发货,物流单号 SF1234567890"
↓
④ 把工具执行结果作为新消息拼回对话,再问模型一次
→ 模型这次拿到了真实数据 → 输出人话回答:"您的订单1001已发货……"
关键点:②~④ 这个循环对业务代码透明。你的 Controller 里只写了一次 .call(),但它内部可能发了两三次模型请求——日志时间戳会告诉你发了几轮。
3. @Tool:写给大模型看的岗位 JD
@Tool 写在 Spring Bean 的方法上,两个核心属性:description(这工具干嘛的)+ 方法参数上的 @ToolParam(description=...)(每个参数是什么)。
类比岗位 JD:这注解不是写给程序员看的,是写给大模型看的。description 写得好不好,直接决定模型"什么时候想起用这个工具"。你不用手写任何 schema 文件——Spring AI 扫描方法签名(方法名、参数类型 String/int、参数描述),自动翻译成 JSON Schema 塞进发给模型的请求里。
注册方式(1.0.9 本机实测):
| 方式 | 写法 | 本课采用 |
|---|---|---|
| 按请求挂 Bean | chatClient.prompt().tools(orderTools).user(msg).call() | ✅ 这条路 |
| Builder 默认挂 | Builder 上 defaultTools(...) | 备选,全局生效 |
| 手工 ToolCallback | MethodToolCallback.builder(...) | 粒度细但啰嗦 |
面试被问"@Tool 怎么被发现",标准答案:不是 starter 魔法扫描你的 Bean,而是你在 prompt() 链上显式把工具对象交出去,框架当场扫描方法转成 Schema。
二、动手:跑通智能查单
环境:Windows + JDK 17 + Maven 3.9+,跑完前三课再来。
第 1 步:检查环境。
java -version # True
[bool][Environment]::GetEnvironmentVariable('DEEPSEEK_API_KEY') # True
Get-NetTCPConnection -LocalPort 8096 -State Listen -ErrorAction SilentlyContinue # 无输出=空闲
第 2 步:编译 + 启动。
cd spring-ai-journey\lesson-04
$env:JAVA_HOME="C:\Program Files\Java\jdk-17"
mvn clean install -DskipTests
mvn spring-boot:run # Tomcat started on port 8096 即成功
第 3 步:调接口,同时盯着另一个窗口的日志。
$q = [uri]::EscapeDataString('订单1001现在什么状态?商品A002库存还剩多少件?')
Invoke-RestMethod "http://localhost:8096/order/ask?msg=$q"
浏览器直接开 http://localhost:8096/order/ask?msg=订单1001现在什么状态 也行。重点看另一个窗口日志里 ===== 工具被调用 → 这行——看到它,就说明模型自己拿主意调了你的 Java 方法。
三、关键代码:工具就是个普通 Bean
第一段:OrderTools.java——两个被 @Tool 标注的方法,完整可运行。
package com.springai.lesson04;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* 工具集:模拟企业里真实存在的两个后端系统——订单系统、库存系统。
* 生产环境这里应该调订单服务 RPC / 查订单表,Spring AI 只关心方法签名+注解描述。
*/
@Component
public class OrderTools {
private static final Logger log = LoggerFactory.getLogger(OrderTools.class);
/** 模拟订单库:订单号 -> 状态描述。生产环境换成真实 DB 查询。 */
private static final Map<String, String> ORDER_DB = Map.of(
"1001", "已发货,物流单号 SF1234567890,预计明天送达",
"1002", "已签收,2026-09-30 由本人签收",
"1003", "退款审核中,预计 1~3 个工作日原路退回付款账户"
);
/** 模拟库存库:商品编码 -> 剩余件数。生产环境换成真实 DB 查询。 */
private static final Map<String, Integer> STOCK_DB = Map.of(
"A001", 23,
"A002", 0,
"B101", 57
);
/**
* 查订单状态。description 是写给模型看的"工具说明书"——写得越准,模型越知道什么时候该调它。
*/
@Tool(description = "根据订单号查询订单当前状态。当用户提到订单号、发货、物流、签收、退款时使用本工具。")
public String getOrderStatus(@ToolParam(description = "订单号,例如 1001") String orderId) {
// 打日志:这行一出现,就说明模型自主决定调用我们的 Java 方法了——本课现场证据
log.info("===== 工具被调用 → getOrderStatus(orderId={}) =====", orderId);
String status = ORDER_DB.getOrDefault(orderId,
"未找到订单 " + orderId + ",请确认订单号是否正确");
log.info("===== 工具返回 → {} =====", status);
return status;
}
/**
* 查库存。返回类型写 int,框架照样自动生成 type:integer 的 Schema。
*/
@Tool(description = "根据商品编码查询库存剩余件数。当用户问某商品还有多少件、能不能下单、库存多少时使用本工具。")
public int getStock(@ToolParam(description = "商品编码,例如 A001") String productCode) {
log.info("===== 工具被调用 → getStock(productCode={}) =====", productCode);
// -1 = 商品不存在的哨兵值
int stock = STOCK_DB.getOrDefault(productCode, -1);
log.info("===== 工具返回 → {} =====", stock);
return stock;
}
}
注意三个细节:类上就是个普通 @Component,没有任何特殊父类;getOrDefault 的兜底返回人话字符串,模型才能把它组织成"未找到订单"的客服话术;参数 orderId="1001" 是模型自己从中文问题里抽出来的,没人教它。
第二段:OrderToolController.java——一行 .tools() 开启工具调用。
package com.springai.lesson04;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 订单客服控制器:本课唯一的 HTTP 入口。
* GET http://localhost:8096/order/ask?msg=订单1001现在什么状态?
* 关键一行是 .tools(orderTools):把工具 Bean 交给 ChatClient。
*/
@RestController
public class OrderToolController {
private static final Logger log = LoggerFactory.getLogger(OrderToolController.class);
private final ChatClient chatClient;
/** 直接注入我们的工具 Bean(Spring 容器管理) */
private final OrderTools orderTools;
public OrderToolController(ChatClient.Builder builder, OrderTools orderTools) {
this.chatClient = builder.build();
this.orderTools = orderTools;
}
/**
* 智能客服一问一答:msg 里是自然语言问题。
* 模型自己判断要不要查数据 → 生成调用意图 → 框架调 OrderTools 方法
* → 结果回填 → 模型拿到真实数据后组织成自然语言回答。
*/
@GetMapping("/order/ask")
public String ask(@RequestParam("msg") String msg) {
log.info("收到用户提问: {}", msg);
String answer = chatClient.prompt()
// 把工具 Bean 交给模型:这一行是"开启工具调用"的总开关
.tools(orderTools)
.user(msg)
// call() 内部可能发生多轮"模型→工具→模型"循环,这里一次等全部结束
.call()
.content();
log.info("模型最终回答: {}", answer);
return answer;
}
}
第三段:Lesson04Application.java——启动类。
package com.springai.lesson04;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 第 4 课启动类。启动后访问:
* GET http://localhost:8096/order/ask?msg=订单1001现在什么状态?
* 观察日志里 "===== 工具被调用 =====" 这些行,那就是工具调用链的现场。
*/
@SpringBootApplication
public class Lesson04Application {
public static void main(String[] args) {
SpringApplication.run(Lesson04Application.class, args);
}
}
第四段:application.yml——事实查询,温度压到 0。
server:
port: 8096 # 端口按课程分配表:spring-ai 系列 lesson-04 = 8096
spring:
ai:
openai:
base-url: ${LLM_BASE_URL:https://api.deepseek.com}
api-key: ${DEEPSEEK_API_KEY}
chat:
options:
model: ${LLM_MODEL:deepseek-chat}
max-tokens: 300 # 工具课可能多轮生成,每轮都受这个上限约束
temperature: 0.0 # 查单查库存是事实查询,严格不发挥
四、实测输出:一句话问两个数,模型自己连调两个工具
以下是 2026-10-05 本机真实运行(DeepSeek,端口 8096,HTTP 200)。请求一句话问两个问题:
请求 msg=订单1001现在什么状态?商品A002库存还剩多少件?
HTTP 200
查询结果如下:
**订单 1001**
- 状态:已发货
- 物流单号:SF1234567890
- 预计明天送达
**商品 A002 库存**
- 剩余:0 件(目前无货,暂时无法下单)
需要我帮你查其他订单或商品的库存吗?
服务端日志里的工具调用链,时间戳是看点:
10:48:55.612 OrderToolController : 收到用户提问: 订单1001现在什么状态?商品A002库存还剩多少件?
10:48:56.903 OrderTools : ===== 工具被调用 → getOrderStatus(orderId=1001) =====
10:48:56.903 OrderTools : ===== 工具返回 → 已发货,物流单号 SF1234567890,预计明天送达 =====
10:48:56.904 OrderTools : ===== 工具被调用 → getStock(productCode=A002) =====
10:48:56.904 OrderTools : ===== 工具返回 → 0 =====
10:48:57.663 OrderToolController : 模型最终回答: 查询结果如下:……
读日志三个看点:55.6→56.9(1.3 秒)第一轮模型请求完成,吐出两个工具调用意图——参数 orderId=1001、productCode=A002 是模型自己从中文里抽的;两个工具 1 毫秒内连调(56.903→56.904),同一次模型输出里的多个调用被批量执行;56.9→57.7(0.8 秒)带着工具结果的第二轮请求,模型组织成上面那段带排版的回答。
边界案例:查一个不存在的订单,看兜底话术。
请求 msg=我的订单9999发货了吗?
HTTP 200
抱歉,没有查询到订单 9999,系统提示「未找到该订单,请确认订单号是否正确」。
建议您:核对订单号是否输入有误;如果确认无误,可能需要联系人工客服进一步核实。
模型依然先调了 getOrderStatus("9999") → 拿到我们兜底的"未找到"字符串 → 自己加了礼貌建议。工具返回什么,模型就基于什么说话——这就是"模型不编数据"的关键。
五、挑战题:改参数,看看会怎样
- ⭐ 关掉工具:把
.tools(orderTools)那一行从 Controller 里删掉,重启,再问"订单1001现在什么状态"——对比有工具和没工具时模型的回答。答案就在 OrderToolController.java,跑一遍你就懂"没有工具模型只能编或只能拒"。 - ⭐⭐ 看温度表演:yml 里把
temperature改成1.2,同一个问题问三遍——观察模型组织回答的口吻差异(注意:数据本身不该变,工具结果是死的)。 - ⭐ 加一个工具:照
getOrderStatus的样子,给 OrderTools 加个getProductPrice(String productCode)(Map 里放 3 条价格),description 写清触发场景,然后问"商品 B101 多少钱"——验证模型会不会主动调你的新方法。
六、生产环境进阶:三个加分项
1. 安全红线别松。 工具方法内部就是 Java 代码,用户间接可控的参数(比如订单号字符串)进 SQL/RPC 之前,照常做参数校验和权限控制——模型不是免责任的,它传什么你都照单执行,等于把数据库暴露给了一段自然语言。
2. 成本按"轮次"算。 一次 .call() 背后可能 2~N 轮模型请求,token 账单和延迟都按轮次叠。生产要监控单次问答的实际轮次,别被"一次接口调用"的错觉骗了。
3. description 就是提示词。 "用户提到订单号、物流、退款时使用"比干巴巴写"查订单状态"召回率高得多。工具说明书写得模糊,模型该用的时候想不起来——这和提示词工程是同一件事。
七、面试回答模板
面试官:Function Calling 到底是谁调谁?模型自己执行代码吗?
一句话:模型不执行代码,只输出结构化调用意图;执行、回填是框架干的,循环多轮直到模型给出人话回答。展开说:用户提问 → 带工具说明书请求模型 → 模型输出 JSON 调用意图 → 框架真正执行 Java 方法 → 结果拼回对话再问一次。整个循环对业务代码透明,你只写了一次
.call()。(指向本课第一节)
追问:@Tool 是怎么被发现的?starter 自动扫描我的 Bean 吗?
一句话:不是魔法扫描,是你在 prompt() 链上显式
.tools(工具Bean)交出去,框架当场扫描 @Tool 方法转成 ToolCallback 和 JSON Schema。展开说:方法名、参数类型 String/int、@ToolParam 描述全部自动翻译成 Schema;也可以在 Builder 上defaultTools(...)全局挂。(指向本课第一节)
追问:一次 call() 发了几次模型请求?成本怎么算?
一句话:可能 2~N 轮,日志时间戳能数出来。展开说:第一轮模型要决定调哪个工具,工具结果回来后还要再请求一次组织回答;本课实测一句话问两个数,55.6→56.9→57.7 两轮搞定。token 和延迟都按轮次算,生产要监控。(指向本课第四节)
八、总结表
| 坑 | 现象 | 解法 |
|---|---|---|
| 漏写 .tools() | 模型拒答或瞎编订单状态 | 请求链上显式挂 .tools(orderTools) |
| description 写太简略 | 模型该用工具时想不起来 | 写清触发场景("提到物流、退款时使用") |
| max-tokens 没按轮次预算 | 回答被截断,别误判工具坏了 | 工具调用是多轮,按轮次调大 |
| 温度用高值 | 模型在工具结果之外"补"不存在的数据 | 事实查询 temperature 压到 0.0 |
| 端口占用 | Port 8096 already in use | Get-NetTCPConnection -LocalPort 8096 查占用 |
| 工具参数不校验 | 自然语言参数直进 SQL/RPC | 照常做参数校验和权限控制 |
九、关于这个系列
本文是「Java 后端实战精通营」系列第 4 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都可以亲手验证。
👉 Spring AI 实战精通营(10 课):gitee.com/j67mk2/spri…
- 本文对应源码位置:
lesson-04/(内含OrderTools两个 @Tool 方法——查订单/查库存,配OrderToolController一行.tools()开启工具调用)
系列文章一览(按发布顺序):
| 篇 | 主题 |
|---|---|
| 1 | Spring AI 初体验:配好 yml 就能聊,ChatClient 四步链式调用 |
| 2 | Spring AI 提示词模板:{变量} 参数化 + few-shot,一条提示词反复用 |
| 3 | Spring AI 结构化输出:entity() 把模型回答解析成 JavaBean,别再手撕 JSON |
| 4 | Spring AI 工具调用:@Tool 让大模型自己查订单查库存 |
| 5 | Spring AI 流式输出:Flux + SSE 打字机,回答不再干等三秒 |
| 6 | Spring AI 多模态:给大模型一双眼睛,图片它也能看懂 |
| 7 | Spring AI 向量检索:本地 ONNX 嵌入,文本秒变坐标,知识库零成本起步 |
| 8 | Spring AI RAG 问答助手:回答带引用,AI 不再睁眼说瞎话 |
| 9 | Spring AI Advisor 编排:记忆 + 工具 + RAG 三合一,一个接口全搞定 |
| 10 | Spring AI 企业智能客服:RAG + 工具 + 记忆 + 流式 + 兜底,十课收官 |
下一篇预告:《Spring AI 流式输出:Flux + SSE 打字机,回答不再干等三秒》——到现在为止,你的回答都是"憋大招":模型把整段话生成完才一次性返回(就是日志里 0.8 秒干等的那一段)。下一课改成
stream()一个字一个字往外吐,配网页做出打字机效果。
跑完有任何报错,把终端输出发评论区,一起排查。
标签建议:SpringAI、FunctionCalling、工具调用 摘要建议(≤256 字):大模型只负责"点菜"不执行代码,查订单查库存这种活得交给真实 Java 方法。本文在 Spring Bean 方法上加 @Tool,模型自己决定调 getOrderStatus/getStock、自己抽参数 orderId=1001,框架执行完回填结果,实测日志里一次问答两轮模型请求连调两个工具。逐行拆解 Function Calling 四步循环与 .tools() 注册方式,附 3 道挑战题与面试回答模板,源码在 gitee lesson-04 可 clone 直接跑。