上章思考题答案护栏order改成 -1、日志保持 0 时拒答计数仍是 0。数字更小的顾问先进入链。护栏先执行并直接返回不再nextCall所以日志顾问的adviseCall不会执行模型也不会被调用。「进货价」不包含「内部成本价」词表会放行模型调用次数是 1。同义改写不要靠词表无限堆词。词表只挡写死的短语改写和策略留到后面的章节。1. 项目背景1.1 业务场景邻里仓的店规可以回答「未发货能不能退」回答不了「订单 LC202610060018 到哪了」。发货状态在订单服务里不在提示词里。用户问到哪了客服必须查这一单再把结果说成人话。模型如果凭空说「已发货明天到」用户会拿这句话去驿站找不到包裹就会投诉。本章给模型一个函数query_order_status。模型只负责决定要不要查、订单号是什么。真正的查询由应用执行。查完把结果送回模型模型再用这句话回答。多跳循环和次数上限不在本节展开。本节只走一轮模型发出调用应用执行一次再请模型说人话。邻里仓先只覆盖两个真实单号和一个未知单号。未发货的LC202610060018必须说未发货已发货的LC202610060099必须说已发货。未知单号不许被说成运输中。这样测试不用理解整段客服修辞只核对状态词和函数计数。1.2 痛点把订单状态写进系统提示词只能覆盖示例那一单。真实用户的单号不在提示词里模型就会编。有人让模型「根据常识判断物流」未发货的单被说成已签收。也有人自己在控制器里用正则抠订单号再查库模型根本没参与可是用户换一种说法「帮我看看那单」时正则失败接口又退化成散文。没有工具时的路径状态写死在提示词 → 只能回答示例单或正则抠单号 → 说法一变就失败或让模型直接编 → 投诉。投诉单上若写着「客服说已签收」值班的人无法区分是订单服务真的签收了还是模型看了店规就编的。函数计数和工具返回的原句要一起留在日志里。只有人话、没有计数的回答不能当作查过单。渲染错误:Mermaid 渲染失败: Parse error on line 4: ...l_call 订单号] call -- app[应用执行函数] ... ----------------------^ Expecting SEMI, NEWLINE, SPACE, EOF, subgraph, end, acc_title, acc_descr, acc_descr_multiline_value, AMP, COLON, STYLE, LINKSTYLE, CLASSDEF, CLASS, CLICK, DOWN, DEFAULT, NUM, COMMA, NODE_STRING, BRKT, MINUS, MULT, UNICODE_TEXT, direction_tb, direction_bt, direction_rl, direction_lr, direction_td, got CALLBACKNAME2. 项目设计小胖说「这不就是食堂问窗口窗口再打电话给后厨吗客人不用进后厨。我把订单表贴在提示词上不就完了」小白问「表贴得下几千单吗模型看见表会不会把别人的单号念出来它说已发货的时候你怎么证明它打过电话而不是看了眼表就编的」小胖又说「那我让它输出 JSON我再解析。函数那些注解太重了。」小白追问「JSON 是它说要查还是它已经查完了这两件事要是混在一段正文里解析失败你分不清是没查还是查了但话没说完。」大师把角色切开「菜单上的菜名是ToolDefinition有名字、描述和参数的 JSON Schema。Tool标在 Java 方法上MethodToolCallbackProvider把它收成可调用对象。不想写方法时用FunctionToolCallback.builder包一个函数inputType指向一个小 record。模型返回的不是最终答案而是AssistantMessage.getToolCalls()。应用用ToolCallingManager.executeToolCalls执行结果放进ToolResponseMessage再作为下一轮消息送回。DeepSeekChatModel.call本身不会把这个循环跑完。在 2.0 里自动循环放在ToolCallingAdvisor由ChatClient.tools带上。本章为了看清一轮不用顾问手写两次call。」技术映射菜单是ToolDefinition打电话的是应用后厨回的短信是ToolResponseMessage窗口再说人话是第二次模型调用。小白又问第一次响应「助手正文是空的算失败吗用户已经在等我要不要把空字符串显示出去再在后台补查」小胖说「空的就显示正在查询。」小白说「正在查询如果是模型编的和真的发出了 tool call 不是一回事。空正文必须配合getToolCalls()非空才进入执行。两者都空才是失败。」大师补上一拍「第一次call的成功标准是工具调用列表里有query_order_status并且参数里的订单号等于用户给出的那一个。正文可以为空不要把空正文重试成第二次闲聊。重试前先看是不是已经点了菜。点了菜就去执行不要再问一遍模型『你要不要查』。」小白问执行权「函数若在模型那边跑订单库的地址不就暴露了超时、权限、查无此单是模型决定还是我们决定」小胖说「查无此单就让模型说运输中免得用户失望。」小白反对「查无此单必须说未找到。编成运输中和编已发货是同一种事故。」大师规定返回值「函数只返回订单服务的原话例如『未发货』或『未找到订单』。不要在函数里写客服修辞。找不到就返回『未找到订单』禁止返回一个猜出来的状态。returnDirect保持默认 false。它为 true 时工具结果会直接给用户用户看到的是原始字符串不是人话。本章要的是人话所以必须再送回模型。第二次调用不要再带工具避免模型又发起一轮。多轮循环不在本章。」技术映射后厨回的是事实短句窗口翻译成人话。returnDirect为 true 等于把后厨的字条直接递到客人手上。小胖问名字「方法叫queryOrderStatus不就行了描述空着模型也猜得到。」小白问「名字里有空格或者两个工具同名会怎样模型调用了一个我们没注册的名字executeToolCalls是返回空还是抛异常测试怎么证明函数真的执行了而不是模型自己说了未发货」大师把契约说死「工具名只用字母、数字和下划线本章用query_order_status。描述要写明『按订单号查询发货状态订单号必须来自用户』。同名工具在建造选项时会被拒绝。模型若调用了未注册的名字executeToolCalls抛IllegalStateException信息里有No ToolCallback found。测试在函数内部加计数。回答含『未发货』但计数为 0判失败。固定单号LC202610060018的真值是未发货LC202610060099是已发货。不在表里的单号返回未找到计数仍然加一因为函数执行了。」技术映射菜名必须和后厨挂牌一致。计数器证明电话打过文案证明不了。测试要同时看原句和人话人话不得把未发货说成已签收。小白还问了一句日志「tool call 的参数会不会把用户整段话都传给订单服务订单服务只认订单号。整段话里如果有手机号算不算把隐私打进了下游」大师说「参数 schema 只有orderId。下游日志只记订单号和返回的状态词。用户原话留在对话里不进订单服务。模型若塞了别的字段这次工具调用按失败处理不要悄悄忽略后继续查一张更大的表。」3. 项目实战3.1 环境准备沿用已有的DeepSeekChatModel。工具相关类在spring-ai-modelstarter 已经带上。不新增订单中间件用一个内存Map当订单服务。密钥仍是DEEPSEEK_API_KEY模型deepseek-v4-flash。不挂ToolCallingAdvisor这样第一次call会停在 tool call而不是自动跑完。本地测试直接callback.call不需要密钥。连网测试才让模型决定调用。内存表里只放两个真实单号。不要把生产订单库的地址写进工具描述描述只说明「按订单号查询发货状态」。函数内部的计数器在每次测试前归零或者每个测试新建一个OrderStatusTool。共用一个计数器会让第二条用例的期望从 1 变成 2看起来像模型多查了一次。3.2 分步实现步骤 1订单函数与工具定义步骤目标不经过模型也能按订单号查到状态。packagecom.linlicang.aftersale;importjava.util.Map;importjava.util.concurrent.atomic.AtomicInteger;importorg.springframework.ai.tool.annotation.Tool;importorg.springframework.ai.tool.annotation.ToolParam;publicclassOrderStatusTool{privatefinalMapString,StringordersMap.of(LC202610060018,未发货,LC202610060099,已发货);privatefinalAtomicIntegercallsnewAtomicInteger();publicintcalls(){returnthis.calls.get();}Tool(namequery_order_status,description按订单号查询发货状态。订单号必须来自用户不要编造。)publicStringquery(ToolParam(description订单号)StringorderId){this.calls.incrementAndGet();returnthis.orders.getOrDefault(orderId,未找到订单);}}同一份逻辑再用函数式工具包一层方便手写回填时传入toolCallbacks。输入用 record避免一个裸字符串让模型猜字段名。publicrecordOrderQuery(StringorderId){}varcallbackFunctionToolCallback.builder(query_order_status,(OrderQueryquery)-newOrderStatusTool().query(query.orderId())).description(按订单号查询发货状态。订单号必须来自用户不要编造。).inputType(OrderQuery.class).build();上面为了示意新建了工具对象计数会和业务里的实例分开。正式代码让两个入口共用同一个OrderStatusTool实例计数才有意义。运行结果文字描述callback.call({\orderId\:\LC202610060018\})返回「未发货」计数为 1。未知单号返回「未找到订单」计数变为 2。空字符串call会断言失败因为toolInput必须有文本。可能的坑inputType忘了设置build()直接失败。另一个坑是Tool的name和FunctionToolCallback的名字不一致模型按菜单点了菜执行时找不到回调。两处都写query_order_status。步骤 2第一次调用只拿到 tool call步骤目标看见模型要查哪一单此时还没有人话。varoptionsDeepSeekChatOptions.builder().model(deepseek-v4-flash).toolCallbacks(callback).build();PromptpromptnewPrompt(List.of(newSystemMessage(发货状态只能来自工具结果。不要编造已发货或未发货。),newUserMessage(订单 LC202610060018 到哪了)),options);ChatResponsefirstchatModel.call(prompt);vartoolCallsfirst.getResult().getOutput().getToolCalls();运行结果文字描述toolCalls非空名字是query_order_status参数 JSON 里有LC202610060018。助手正文经常是空的。finishReason常与工具调用有关不要把空正文当成失败。此时计数仍是 0因为模型只是点了菜函数还没执行。可能的坑挂上ChatClient且默认带了ToolCallingAdvisor这一次call().content()会直接变成人话你看不到 tool call。本章用DeepSeekChatModel.call。另一个坑是系统提示词里写了「未发货可退」模型不再调用工具直接用店规回答。查状态的系统句不要写死某一单的状态。步骤 3执行函数并回填第二次调用说人话步骤目标回答来自工具结果函数计数为 1。ToolCallingManagermanagerToolCallingManager.builder().build();ToolExecutionResultexecutedmanager.executeToolCalls(prompt,first);StringspokenchatModel.call(newPrompt(executed.conversationHistory(),DeepSeekChatOptions.builder().model(deepseek-v4-flash).build())).getResult().getOutput().getText();运行结果文字描述计数为 1。spoken包含「未发货」不包含「已签收」。conversationHistory的最后一条是ToolResponseMessage里面的文本是「未发货」。第二次请求没有再带工具模型不应再出现新的 tool call。可能的坑第二次仍传入带toolCallbacks的选项模型可能再查一次计数变成 2费用也变成两次工具加三次对话。本章固定只回填一轮。另一个坑是工具抛异常后仍把堆栈塞给用户。executeToolCalls会把异常收成一条工具结果业务函数自己返回「未找到订单」不要抛用来表示查无此单的异常。步骤 4已发货与未知单号步骤目标三种状态都来自函数不来自文案模板。已发货单号LC202610060099走同一条路径回答包含「已发货」。未知单号回答包含「未找到」并且不包含「运输中」。本地不连网的测试只调用callback.call断言这三句返回值。连网测试至少覆盖未发货这一单。运行结果文字描述三句返回值分别是未发货、已发货、未找到订单。连网回答里的状态词与返回值一致。可能的坑未知单号的函数返回空字符串模型自行补成「正在配送」。空结果要改成明确的「未找到订单」。回填这一轮要留三样东西给测试工具名、函数返回的原句、第二次人话。人话必须包含原句里的状态词。原句是「未发货」人话里出现「已签收」就算计数为 1 也失败因为修辞改写了事实。日志可以打工具名和状态词不要打用户手机号。订单函数的参数只有订单号不要把整段用户原话传进订单服务。模型若在参数里塞了多余字段record 只声明orderId多余字段应被忽略或导致这次调用失败不能据此去查别的表。本地三态跑完再打开密钥。没有密钥时如果连网测试失败不要把未知单号的期望改成「模型爱怎么说就怎么说」。期望来自Map不来自某一次生成。连网测试只是证明模型愿意把这句原话说出来并且没有多执行一轮。3.3 完整代码清单OrderStatusTool、OrderQuery、回填服务和测试。源码对照spring-ai-model/.../tool/function/FunctionToolCallback.javaspring-ai-model/.../tool/definition/ToolDefinition.javaspring-ai-model/.../model/tool/ToolCallingManager.javaspring-ai-model/.../tool/annotation/Tool.javaChatClient.tools会自动挂上顾问并把循环跑完。那是便利写法本章先看手动的一轮。循环细节留到第 22 章和第 35 章。3.4 测试验证TestvoidlocalLookupDoesNotInventStatus(){OrderStatusTooltoolnewOrderStatusTool();assertThat(tool.query(LC202610060018)).isEqualTo(未发货);assertThat(tool.query(NO-SUCH)).isEqualTo(未找到订单);assertThat(tool.calls()).isEqualTo(2);}TestEnabledIfEnvironmentVariable(namedDEEPSEEK_API_KEY,matches.)voidmodelUsesToolResult(){Stringspokenservice.whereIs(LC202610060018);assertThat(service.calls()).isEqualTo(1);assertThat(spoken).contains(未发货);}mvn -DtestOrderStatusToolTest test无密钥时只跑本地查询。有密钥时再跑回填。预期本地两条状态准确连网回答含「未发货」且计数为 1。连网失败时先看第一次响应里有没有 tool call。没有 tool call是提示词让模型直接回答了。有 tool call 但计数为 0是executeToolCalls没有执行到这个回调。人话与原句不一致是第二次生成改写了事实。三处分开看不要合成一句「工具不好用」。本地用例的订单号就用这两个不要再从生产库抽一单来凑数。生产单号会变测试就会跟着变。4. 项目总结4.1 优点与缺点对比项把状态写进提示词一轮 tool call数据从哪来提示词副本订单函数能否证明查过不能函数计数未知订单容易编造返回未找到调用次数一次对话两次对话加一次函数循环没有本章只做一轮优点发货状态来自订单服务不来自模型记忆。计数能证明函数执行过。未知订单有固定句子不会被说成运输中。工具的名字和参数是契约测试可以不连网就验证。人话和事实分开事实在函数里修辞在第二次调用里。缺点一轮回填至少两次模型调用时延和费用都上去。模型可能不调用工具直接用店规回答。工具名不一致时执行阶段才抛异常。本章看不到多轮工具例如先查状态再查地址。returnDirect开错会把原始结果直接给用户。4.2 适用场景适用按订单号查发货状态。需要证明回答来自一次真实查询。查无此单必须明确失败不能修辞成成功。团队第一次接工具想看清 tool call 和回填。本地用假订单表做回归。不适用一次回答要连续查库存、地址、优惠。那需要循环不是本章这一轮。没有订单服务、只背店规的问题。不要为了退款规则去注册一个空函数。4.3 注意事项工具名使用query_order_status不要用空格或中文。第二次调用去掉toolCallbacks避免无意的第二轮。查无此单返回明确句子不要返回空串或抛业务异常。不要把真实订单库的地址和密钥放进工具描述。描述只说明参数含义。4.4 常见踩坑回答是已发货计数却是 0。根因是模型没调用工具店规或历史里出现了状态词。处理系统提示词禁止编造状态测试断言计数为 1。用户看到一串 JSON。根因是returnDirect设成了 true。处理保持 false让第二次调用说人话。生产报 No ToolCallback found。根因是模型调用的名字和注册名字差一个下划线。处理名字只定义一处菜单和回调共用这个常量。4.5 思考题函数返回「未发货」第二次调用的人话却说「预计明天送达」。你先查计数还是先查系统提示词里有没有物流承诺怎样让测试挡住这句承诺同一订单号连续问两次「到哪了」。若没有记忆工具会执行几次若把结果写进第 9 章的窗口记忆第二次还该不该再查答案在第 12 章开头。延伸阅读与资源10倍开发者的 Dify 魔法书从零构建全栈 AI 应用后端工程师转型AI第一课-Ollama 与私有化大模型实战大型语言模型(LLM) vLLM 高性能推理落地实战Agent开发之LlamaIndex 实战修炼与源码进阶大语言模型Transformers 实战修炼与源码剖析