【AI全栈12-05】Spring Boot 用 Tool Calling 让 AI 查实时订单与库存

本文是「Spring Boot + AI 全栈后端」系列第 05 篇。第 04 篇解决了「模型输出没形状、下游用不了」,这一篇解决更尴尬的一种:模型压根没有你要的数据。示例基于 Spring AI 2.0 / Boot 4.1。

一个让客服背锅的场景

V哥 帮一家做家居电商的团队看售后系统,产品经理兴冲冲地说:我们上了 AI 助手,客户问「我的订单到哪了」,它答得可像样了。结果上线第三天就出事------客户问订单 SO2025001,AI 一本正经地回答「您的订单已于昨日发货,预计三天内送达」,而真实情况是这张单还卡在待付款。客户截图投诉,客服背锅。

问题不在模型笨,在于:模型脑子里根本没有你数据库里的那行数据。它只是在做「最像人话的续写」。

有人会想:那我把订单表导出成 prompt 塞进去行不行?三条理由就否掉了:

  1. 数据量不现实:订单几万条、库存实时变动,塞不进上下文窗口;
  2. 时效不成立:你塞进去的是快照,客户下一秒下单就过期了;
  3. 权限过不去:A 客户的订单不能出现在给 B 客户的回答里,把数据一股脑喂给模型等于放弃权限控制。

所以正解不是「把数据给模型」,而是给模型一个能查数据的能力,让它在需要的时候自己去查。这就是 Tool Calling(工具调用)。

解决思路:把 Java 方法注册成工具,让模型自己决定调不调

Tool Calling 的完整链路是这样一圈:

  1. 你把若干 Java 方法标记成「工具」,连同说明一起随请求发给模型;
  2. 模型判断「这个问题我得查一下」→ 返回一个工具调用请求(工具名 + 参数);
  3. Spring AI 找到对应的 Java 方法执行,拿到返回值;
  4. 返回值作为工具结果拼回对话,模型据此组织自然语言回答。

注意这圈里的关键转变:判断权在模型,执行权在你 。你不再写 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();
    }
}

三件事值得单独说:

  1. MethodToolCallbackProvider 负责把 @Tool 方法翻译成工具定义 ,包括从 @ToolParam 生成参数的 JSON Schema。你可以把它生成的东西打出来核对,中文说明确实在里面;
  2. .toolCallbacks(...) 是工具随请求下发的入口 ,换成 .tools(orderTools) 直接传对象也一样,前者更显式;
  3. 系统提示词里那句「必须先调用工具再回答」不是废话。没有这句约束,模型遇到订单号可能凭常识直接编------工具只是给了它能力,要不要用还得靠提示词立规矩。

第三步:控制器照旧,不碰模型

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 把知识库接到它身上。

相关推荐
盖世英雄酱581361 小时前
格式化成日期为什么提前一天呢,同样的时间点还是非必现???
java·后端
Guarding and trust1 小时前
项目拆解(二):模型输出是‘野‘的——三层兜底+重分析,把AI变成可靠零件
java·人工智能·python·大模型·fastapi·ollama·错误兜底
用户298698530141 小时前
Python 实战:将 HTML 内容保存为图像文件
后端·python·html
春风霓裳1 小时前
0xc0000005 = 内存访问违规
java
挖掘狂人1 小时前
Java 26 深度解析:一次“性能兑现 + 契约收紧”的版本
javascript·后端·性能优化
威哥爱编程1 小时前
【AI全栈12-06】Spring Boot 用 RAG 搭企业私有知识库问答
java·spring boot·后端
迅猛龙办公室1 小时前
Python实现获取星期字符串
java·前端·python
小蒜学长1 小时前
基于微信小程序的医院医疗废弃物追溯管理系统(代码+数据库+LW)
java·spring boot·后端·微信小程序·医疗废弃物
掘金者阿豪1 小时前
SQL 编辑器技术实践:从写 SQL 到数据库治理,聊聊电科金仓 KSQL Developer 的实际价值
后端