
本文是「Spring Boot + AI 全栈后端」系列第 05 篇。第 04 篇解决了「模型输出没形状、下游用不了」,这一篇解决更尴尬的一种:模型压根没有你要的数据。示例基于 Spring AI 2.0 / Boot 4.1。
一个让客服背锅的场景
V哥 帮一家做家居电商的团队看售后系统,产品经理兴冲冲地说:我们上了 AI 助手,客户问「我的订单到哪了」,它答得可像样了。结果上线第三天就出事------客户问订单 SO2025001,AI 一本正经地回答「您的订单已于昨日发货,预计三天内送达」,而真实情况是这张单还卡在待付款。客户截图投诉,客服背锅。
问题不在模型笨,在于:模型脑子里根本没有你数据库里的那行数据。它只是在做「最像人话的续写」。
有人会想:那我把订单表导出成 prompt 塞进去行不行?三条理由就否掉了:
- 数据量不现实:订单几万条、库存实时变动,塞不进上下文窗口;
- 时效不成立:你塞进去的是快照,客户下一秒下单就过期了;
- 权限过不去:A 客户的订单不能出现在给 B 客户的回答里,把数据一股脑喂给模型等于放弃权限控制。
所以正解不是「把数据给模型」,而是给模型一个能查数据的能力,让它在需要的时候自己去查。这就是 Tool Calling(工具调用)。
解决思路:把 Java 方法注册成工具,让模型自己决定调不调
Tool Calling 的完整链路是这样一圈:
- 你把若干 Java 方法标记成「工具」,连同说明一起随请求发给模型;
- 模型判断「这个问题我得查一下」→ 返回一个工具调用请求(工具名 + 参数);
- Spring AI 找到对应的 Java 方法执行,拿到返回值;
- 返回值作为工具结果拼回对话,模型据此组织自然语言回答。
注意这圈里的关键转变:判断权在模型,执行权在你 。你不再写 if (问题里含"订单") { 查订单 } 这种if-else,而是把「能干什么」声明清楚,让模型自己挑。

第一步:把查询方法写成工具
用 @Tool 标记方法、@ToolParam 标记参数。这里的 description 不是给同事看的注释,是写给模型看的说明书------模型靠这段话决定「该不该调、什么时候调」。
java
@Component
public class OrderTools {
private static final Map<String, String> ORDERS = Map.of(
"SO2025001", "{\"orderId\":\"SO2025001\",\"status\":\"已发货\",\"express\":\"顺丰 SF1234567890\",\"eta\":\"2026-10-01\"}",
"SO2025002", "{\"orderId\":\"SO2025002\",\"status\":\"待付款\",\"express\":\"\",\"eta\":\"\"}",
"SO2025003", "{\"orderId\":\"SO2025003\",\"status\":\"已签收\",\"express\":\"中通 ZT9876543210\",\"eta\":\"\"}");
private static final Map<String, Integer> STOCK = Map.of(
"A1001", 37,
"A1002", 0,
"B2001", 128);
@Tool(description = "按订单号查询订单的最新状态和物流信息;订单号不存在时返回 NOT_FOUND")
public String getOrderStatus(
@ToolParam(description = "订单号,形如 SO2025001") String orderId) {
if (orderId == null || orderId.isBlank()) {
return "NOT_FOUND:订单号为空";
}
String normalized = orderId.trim().toUpperCase();
return ORDERS.getOrDefault(normalized, "NOT_FOUND:查不到订单 " + normalized);
}
@Tool(description = "按商品编码查询当前可售库存数量;编码不存在时返回 NOT_FOUND")
public String queryStock(
@ToolParam(description = "商品编码,形如 A1001") String sku) {
if (sku == null || sku.isBlank()) {
return "NOT_FOUND:商品编码为空";
}
String normalized = sku.trim().toUpperCase();
Integer count = STOCK.get(normalized);
return count == null
? "NOT_FOUND:查不到商品 " + normalized
: "{\"sku\":\"" + normalized + "\",\"stock\":" + count + "}";
}
}
这里用内存 Map 模拟数据源,真实项目把方法体换成 repository 查询、或者去调订单服务的接口即可,模型那一侧完全感知不到差别------这正是把「查数据」收在 Java 方法里的好处。
写工具有三条经验,V哥 在项目里是拿踩过的坑换来的:
- 查不到要返回明确的
NOT_FOUND,别返回空字符串 。空字符串在模型眼里是「查到了,结果是空的」,它会顺着编;NOT_FOUND才是「查不到,如实说」的明确信号; - 入参要做归一化 。用户会写
so2025001、会带空格,方法内部统一trim().toUpperCase(),别指望模型每次都传得规规矩矩; - description 要写清「什么时候用」和「找不到怎么办」。只写「查询订单」四个字,模型会在该用和不该用的时候乱用。
第二步:服务里把工具交给模型
java
@Service
public class OrderQueryService {
private static final String SYSTEM = """
你是电商售前/售后助手。凡是涉及订单状态、物流、库存这类实时数据,
必须先调用工具查询再回答,绝不允许凭印象编造;查不到就如实说查不到。
""";
private final ChatClient chatClient;
private final List<ToolCallback> toolCallbacks;
public OrderQueryService(ChatModel chatModel, OrderTools orderTools) {
this.chatClient = ChatClient.create(chatModel);
this.toolCallbacks = List.of(MethodToolCallbackProvider.builder()
.toolObjects(orderTools)
.build()
.getToolCallbacks());
}
public String ask(String question) {
return chatClient.prompt()
.system(SYSTEM)
.toolCallbacks(toolCallbacks)
.user(question)
.call()
.content();
}
}
三件事值得单独说:
MethodToolCallbackProvider负责把@Tool方法翻译成工具定义 ,包括从@ToolParam生成参数的 JSON Schema。你可以把它生成的东西打出来核对,中文说明确实在里面;.toolCallbacks(...)是工具随请求下发的入口 ,换成.tools(orderTools)直接传对象也一样,前者更显式;- 系统提示词里那句「必须先调用工具再回答」不是废话。没有这句约束,模型遇到订单号可能凭常识直接编------工具只是给了它能力,要不要用还得靠提示词立规矩。
第三步:控制器照旧,不碰模型
java
@RestController
@RequestMapping("/api/assistant")
public class OrderQueryController {
private final OrderQueryService orderQueryService;
public OrderQueryController(OrderQueryService orderQueryService) {
this.orderQueryService = orderQueryService;
}
@PostMapping("/ask")
public ResponseEntity<Map<String, String>> ask(@Valid @RequestBody AskRequest request) {
return ResponseEntity.ok(Map.of("answer", orderQueryService.ask(request.question())));
}
}
AskRequest 还是一句 record AskRequest(@NotBlank(message = "问题不能为空") String question) {},空问题在门口就被拦成 400。
真正的风险不在技术,在权限
到这里功能就通了,但 V哥 必须在评审会上多问一句:这个工具是以谁的身份在查?
工具方法跑在你的后端里,它拿到的参数是模型给的,而模型拿到的信息来自用户对话。如果不做校验,用户问一句「帮我查 SO2025001」而这张单属于别人,工具照样会查、照样会答------一次越权查询就这么发生了。
所以生产上至少要补这三条:
- 工具内部鉴权 :
getOrderStatus里除了订单号,还要校验当前登录用户是否是该订单的下单人,不是就返回NOT_FOUND:无权查看; - 只读优先:先上查询类工具。退货、改价、发货这类写操作工具,必须走「先返回操作预览 → 用户二次确认」的流程,绝不能让模型一句话就把订单退了;
- 限制调用轮次:设一个工具调用的最大轮数,防止模型和工具互相「踢皮球」把你的 token 烧穿。

怎么验证:离线也能跑通这一圈
没有真实密钥也能验证整条链路:用一个桩模型代替模型的大脑------它按两条正则判断「这是查订单还是查库存」,真正的工具注册、参数解析、方法执行、结果拼接都由框架完成。重点验证这几件事:
| 验证点 | 期望结果 |
|---|---|
@Tool 方法被注册成工具 |
生成 2 个工具回调,名字是 getOrderStatus / queryStock |
| 中文说明进 schema | 工具参数 schema 里能看到「订单号」「商品编码」 |
| 查存在的订单 | 返回状态、快递、预计到达时间 |
| 查不存在的订单 | 返回 NOT_FOUND,助手如实说「没查到」 |
| 查库存 | 返回真实数量;零库存如实返回 0 |
| 端到端接口 | 回答里带上工具查到的实时数据 |
| 空问题 | 400 校验拦截 |
桩模型只替掉了「模型脑子」那一步,其余都是框架真跑,所以这套验证足以说明工具注册和执行链路是对的。
落地要点
- 工具别贪多:一次挂 20 个工具,模型选择准确率会掉,还会白烧 token。按场景分组,一次请求挂 3-5 个最相关的;
- 返回值要短:工具返回的是要塞回对话的,返回整张订单的 50 个字段不如只返回「状态 + 快递 + 时间」三个;
- 工具要可观测:把「调了哪个工具、传了什么参数、返回了什么」记进日志,出问题时这是唯一的排错依据;
- 超时和重试要独立设置:工具查的是你自己的库,超时策略不该和模型调用绑在一起;
- 兜底话术:工具超时或报错时,回答要落到「系统暂时查不到,请稍后重试或联系人工客服」,别把异常抛给用户。
最后一句:别再把数据库塞进提示词------把查询能力包成工具交给模型,让它自己决定什么时候查、查什么,同时在工具里守住权限和只读边界,AI 助手才算有了「手」,而不是只有一张嘴。下一篇(06)V哥 带你解决最后一块拼图:模型不知道你们公司私有文档里写了什么,用 RAG 把知识库接到它身上。