☰
第11章:Spring AI 2.0 Tool Calling 入门
2026/10/13 15:48:51 网站建设 项目流程

上章思考题答案

  1. 护栏order改成 -1、日志保持 0 时,拒答计数仍是 0。数字更小的顾问先进入链。护栏先执行并直接返回,不再nextCall,所以日志顾问的adviseCall不会执行,模型也不会被调用。
  2. 「进货价」不包含「内部成本价」,词表会放行,模型调用次数是 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 'CALLBACKNAME'

2. 项目设计

小胖说:「这不就是食堂问窗口,窗口再打电话给后厨吗?客人不用进后厨。我把订单表贴在提示词上不就完了?」

小白问:「表贴得下几千单吗?模型看见表,会不会把别人的单号念出来?它说已发货的时候,你怎么证明它打过电话,而不是看了眼表就编的?」

小胖又说:「那我让它输出 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-model,starter 已经带上。不新增订单中间件,用一个内存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{privatefinalMap<String,String>orders=Map.of("LC202610060018","未发货","LC202610060099","已发货");privatefinalAtomicIntegercalls=newAtomicInteger();publicintcalls(){returnthis.calls.get();}@Tool(name="query_order_status",description="按订单号查询发货状态。订单号必须来自用户,不要编造。")publicStringquery(@ToolParam(description="订单号")StringorderId){this.calls.incrementAndGet();returnthis.orders.getOrDefault(orderId,"未找到订单");}}

同一份逻辑再用函数式工具包一层,方便手写回填时传入toolCallbacks。输入用 record,避免一个裸字符串让模型猜字段名。

publicrecordOrderQuery(StringorderId){}varcallback=FunctionToolCallback.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

步骤目标:看见模型要查哪一单,此时还没有人话。

varoptions=DeepSeekChatOptions.builder().model("deepseek-v4-flash").toolCallbacks(callback).build();Promptprompt=newPrompt(List.of(newSystemMessage("发货状态只能来自工具结果。不要编造已发货或未发货。"),newUserMessage("订单 LC202610060018 到哪了?")),options);ChatResponsefirst=chatModel.call(prompt);vartoolCalls=first.getResult().getOutput().getToolCalls();

运行结果(文字描述):toolCalls非空,名字是query_order_status,参数 JSON 里有LC202610060018。助手正文经常是空的。finishReason常与工具调用有关,不要把空正文当成失败。此时计数仍是 0,因为模型只是点了菜,函数还没执行。

可能的坑:挂上ChatClient且默认带了ToolCallingAdvisor,这一次call().content()会直接变成人话,你看不到 tool call。本章用DeepSeekChatModel.call。另一个坑是系统提示词里写了「未发货可退」,模型不再调用工具,直接用店规回答。查状态的系统句不要写死某一单的状态。

步骤 3:执行函数并回填,第二次调用说人话

步骤目标:回答来自工具结果,函数计数为 1。

ToolCallingManagermanager=ToolCallingManager.builder().build();ToolExecutionResultexecuted=manager.executeToolCalls(prompt,first);Stringspoken=chatModel.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.java
  • spring-ai-model/.../tool/definition/ToolDefinition.java
  • spring-ai-model/.../model/tool/ToolCallingManager.java
  • spring-ai-model/.../tool/annotation/Tool.java

ChatClient.tools会自动挂上顾问并把循环跑完。那是便利写法,本章先看手动的一轮。循环细节留到第 22 章和第 35 章。

3.4 测试验证

@TestvoidlocalLookupDoesNotInventStatus(){OrderStatusTooltool=newOrderStatusTool();assertThat(tool.query("LC202610060018")).isEqualTo("未发货");assertThat(tool.query("NO-SUCH")).isEqualTo("未找到订单");assertThat(tool.calls()).isEqualTo(2);}@Test@EnabledIfEnvironmentVariable(named="DEEPSEEK_API_KEY",matches=".+")voidmodelUsesToolResult(){Stringspoken=service.whereIs("LC202610060018");assertThat(service.calls()).isEqualTo(1);assertThat(spoken).contains("未发货");}
mvn -Dtest=OrderStatusToolTest test

无密钥时只跑本地查询。有密钥时再跑回填。预期本地两条状态准确,连网回答含「未发货」且计数为 1。连网失败时先看第一次响应里有没有 tool call。没有 tool call,是提示词让模型直接回答了。有 tool call 但计数为 0,是executeToolCalls没有执行到这个回调。人话与原句不一致,是第二次生成改写了事实。三处分开看,不要合成一句「工具不好用」。本地用例的订单号就用这两个,不要再从生产库抽一单来凑数。生产单号会变,测试就会跟着变。


4. 项目总结

4.1 优点与缺点

对比项把状态写进提示词一轮 tool call
数据从哪来提示词副本订单函数
能否证明查过不能函数计数
未知订单容易编造返回未找到
调用次数一次对话两次对话加一次函数
循环没有本章只做一轮

优点:

  1. 发货状态来自订单服务,不来自模型记忆。
  2. 计数能证明函数执行过。
  3. 未知订单有固定句子,不会被说成运输中。
  4. 工具的名字和参数是契约,测试可以不连网就验证。
  5. 人话和事实分开,事实在函数里,修辞在第二次调用里。

缺点:

  1. 一轮回填至少两次模型调用,时延和费用都上去。
  2. 模型可能不调用工具,直接用店规回答。
  3. 工具名不一致时,执行阶段才抛异常。
  4. 本章看不到多轮工具,例如先查状态再查地址。
  5. returnDirect开错会把原始结果直接给用户。

4.2 适用场景

适用:

  1. 按订单号查发货状态。
  2. 需要证明回答来自一次真实查询。
  3. 查无此单必须明确失败,不能修辞成成功。
  4. 团队第一次接工具,想看清 tool call 和回填。
  5. 本地用假订单表做回归。

不适用:

  1. 一次回答要连续查库存、地址、优惠。那需要循环,不是本章这一轮。
  2. 没有订单服务、只背店规的问题。不要为了退款规则去注册一个空函数。

4.3 注意事项

  • 工具名使用query_order_status,不要用空格或中文。
  • 第二次调用去掉toolCallbacks,避免无意的第二轮。
  • 查无此单返回明确句子,不要返回空串或抛业务异常。
  • 不要把真实订单库的地址和密钥放进工具描述。描述只说明参数含义。

4.4 常见踩坑

  1. 回答是已发货,计数却是 0。根因是模型没调用工具,店规或历史里出现了状态词。处理:系统提示词禁止编造状态,测试断言计数为 1。
  2. 用户看到一串 JSON。根因是returnDirect设成了 true。处理:保持 false,让第二次调用说人话。
  3. 生产报 No ToolCallback found。根因是模型调用的名字和注册名字差一个下划线。处理:名字只定义一处,菜单和回调共用这个常量。

4.5 思考题

  1. 函数返回「未发货」,第二次调用的人话却说「预计明天送达」。你先查计数,还是先查系统提示词里有没有物流承诺?怎样让测试挡住这句承诺?
  2. 同一订单号连续问两次「到哪了」。若没有记忆,工具会执行几次?若把结果写进第 9 章的窗口记忆,第二次还该不该再查?

答案在第 12 章开头。

延伸阅读与资源

10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战
大型语言模型(LLM) vLLM 高性能推理落地实战
Agent开发之LlamaIndex 实战修炼与源码进阶
大语言模型Transformers 实战修炼与源码剖析

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询