本文是「Spring Boot AI 全栈后端」系列第 05 篇。第 04 篇解决了「模型输出没形状、下游用不了」这一篇解决更尴尬的一种模型压根没有你要的数据。示例基于 Spring AI 2.0 / Boot 4.1全部已通过测试。一个让客服背锅的场景V哥 帮一家做家居电商的团队看售后系统产品经理兴冲冲地说我们上了 AI 助手客户问「我的订单到哪了」它答得可像样了。结果上线第三天就出事——客户问订单 SO2025001AI 一本正经地回答「您的订单已于昨日发货预计三天内送达」而真实情况是这张单还卡在待付款。客户截图投诉客服背锅。问题不在模型笨在于模型脑子里根本没有你数据库里的那行数据。它只是在做「最像人话的续写」。有人会想那我把订单表导出成 prompt 塞进去行不行三条理由就否掉了数据量不现实订单几万条、库存实时变动塞不进上下文窗口时效不成立你塞进去的是快照客户下一秒下单就过期了权限过不去A 客户的订单不能出现在给 B 客户的回答里把数据一股脑喂给模型等于放弃权限控制。所以正解不是「把数据给模型」而是给模型一个能查数据的能力让它在需要的时候自己去查。这就是 Tool Calling工具调用。解决思路把 Java 方法注册成工具让模型自己决定调不调Tool Calling 的完整链路是这样一圈你把若干 Java 方法标记成「工具」连同说明一起随请求发给模型模型判断「这个问题我得查一下」→ 返回一个工具调用请求工具名 参数Spring AI 找到对应的 Java 方法执行拿到返回值返回值作为工具结果拼回对话模型据此组织自然语言回答。注意这圈里的关键转变判断权在模型执行权在你。你不再写if (问题里含订单) { 查订单 }这种if-else而是把「能干什么」声明清楚让模型自己挑。第一步把查询方法写成工具用Tool标记方法、ToolParam标记参数。这里的 description不是给同事看的注释是写给模型看的说明书——模型靠这段话决定「该不该调、什么时候调」。ComponentpublicclassOrderTools{privatestaticfinalMapString,StringORDERSMap.of(SO2025001,{\orderId\:\SO2025001\,\status\:\已发货\,\express\:\顺丰 SF1234567890\,\eta\:\2026-10-01\},SO2025002,{\orderId\:\SO2025002\,\status\:\待付款\,\express\:\\,\eta\:\\},SO2025003,{\orderId\:\SO2025003\,\status\:\已签收\,\express\:\中通 ZT9876543210\,\eta\:\\});privatestaticfinalMapString,IntegerSTOCKMap.of(A1001,37,A1002,0,B2001,128);Tool(description按订单号查询订单的最新状态和物流信息订单号不存在时返回 NOT_FOUND)publicStringgetOrderStatus(ToolParam(description订单号形如 SO2025001)StringorderId){if(orderIdnull||orderId.isBlank()){returnNOT_FOUND订单号为空;}StringnormalizedorderId.trim().toUpperCase();returnORDERS.getOrDefault(normalized,NOT_FOUND查不到订单 normalized);}Tool(description按商品编码查询当前可售库存数量编码不存在时返回 NOT_FOUND)publicStringqueryStock(ToolParam(description商品编码形如 A1001)Stringsku){if(skunull||sku.isBlank()){returnNOT_FOUND商品编码为空;}Stringnormalizedsku.trim().toUpperCase();IntegercountSTOCK.get(normalized);returncountnull?NOT_FOUND查不到商品 normalized:{\sku\:\normalized\,\stock\:count};}}这里用内存Map模拟数据源真实项目把方法体换成 repository 查询、或者去调订单服务的接口即可模型那一侧完全感知不到差别——这正是把「查数据」收在 Java 方法里的好处。写工具有三条经验V哥 在项目里是拿踩过的坑换来的查不到要返回明确的NOT_FOUND别返回空字符串。空字符串在模型眼里是「查到了结果是空的」它会顺着编NOT_FOUND才是「查不到如实说」的明确信号入参要做归一化。用户会写so2025001、会带空格方法内部统一trim().toUpperCase()别指望模型每次都传得规规矩矩description 要写清「什么时候用」和「找不到怎么办」。只写「查询订单」四个字模型会在该用和不该用的时候乱用。第二步服务里把工具交给模型ServicepublicclassOrderQueryService{privatestaticfinalStringSYSTEM 你是电商售前/售后助手。凡是涉及订单状态、物流、库存这类实时数据 必须先调用工具查询再回答绝不允许凭印象编造查不到就如实说查不到。 ;privatefinalChatClientchatClient;privatefinalListToolCallbacktoolCallbacks;publicOrderQueryService(ChatModelchatModel,OrderToolsorderTools){this.chatClientChatClient.create(chatModel);this.toolCallbacksList.of(MethodToolCallbackProvider.builder().toolObjects(orderTools).build().getToolCallbacks());}publicStringask(Stringquestion){returnchatClient.prompt().system(SYSTEM).toolCallbacks(toolCallbacks).user(question).call().content();}}三件事值得单独说MethodToolCallbackProvider负责把Tool方法翻译成工具定义包括从ToolParam生成参数的 JSON Schema。你可以把它生成的东西打出来核对中文说明确实在里面.toolCallbacks(...)是工具随请求下发的入口换成.tools(orderTools)直接传对象也一样前者更显式系统提示词里那句「必须先调用工具再回答」不是废话。没有这句约束模型遇到订单号可能凭常识直接编——工具只是给了它能力要不要用还得靠提示词立规矩。第三步控制器照旧不碰模型RestControllerRequestMapping(/api/assistant)publicclassOrderQueryController{privatefinalOrderQueryServiceorderQueryService;publicOrderQueryController(OrderQueryServiceorderQueryService){this.orderQueryServiceorderQueryService;}PostMapping(/ask)publicResponseEntityMapString,Stringask(ValidRequestBodyAskRequestrequest){returnResponseEntity.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 助手才算有了「手」而不是只有一张嘴。下一篇06V哥 带你解决最后一块拼图模型不知道你们公司私有文档里写了什么用 RAG 把知识库接到它身上。