CH 07工具调用(Tool Calling)
本章让模型从「只能说话」变成「能干活」——学会用工具调用把查库、算价、调接口这类真实业务能力交给 AI,并在身份、权限与安全边界上守得住。请跟着每一节的「动手」一步步建出可运行示例,并在每个「运行验证」节点确认通过。
教学设计说明(教师参考):本章建议采用「先破误区、再给机制、最后上机」的顺序——先用 7.1 的「举手/动手」比喻和权限误区把学生的安全弦绷起来(这是最容易在期末项目翻车的地方),再讲 7.2 的时序与 2.0 循环上移,让学生明白「一次问答为什么有两次模型请求」。实操环节重点盯 7.7 的第 ②、④ 步验证(越权与缺参),这两步才是本章真正的考点,比「能不能调通」重要得多。
本章新增「易掉队点」(教师务必在课堂上点名,学生极易在此翻车):
- 2.0 已删除两个 1.x 高频 API,学生复制旧教程必错:①
.internalToolExecutionEnabled(false)(连同对应配置属性)在 2.0 已移除,编译直接报错;②toolNames("...")按名字解析工具的能力已删除,同样编译不过。课堂上必须一开始就明示「这两个写法在 2.0 不存在」,并给出替代(见 7.2、7.4)。 - 学生误以为 2.0 还要自己写工具循环:2.0 把循环上移到
ToolCallingAdvisor,ChatClient 自动注册,业务代码里再也看不到 while 循环。若学生还想手写循环,纠正为「关掉自动注册 + 自己接管」二选一(见 7.2)。 - 越权根因是「把 userId 当工具参数传」:务必强调身份类信息必须走
ToolContext,绝不能作为工具参数让模型填。 - 版本基准错配:网上大量 1.x 教程复制过来即用不了。上机前先让学生对照 ch00 的版本表(Spring AI 2.0.1 + Boot 4.1.x + Java 17),凡看到
spring-ai-bom是 1.x 开头的代码一律视为失效。 - 密钥硬编码:自建工程时反复提醒
application.yml里只能用${DEEPSEEK_API_KEY:}占位符,key 绝不进源码。
本章概念地图(先建立全局印象,再逐节深挖):
- 工具调用 Tool Calling:模型声明「要调哪个函数、传什么参」,由你的 Java 代码真正执行(7.1)。
- ToolCallingAdvisor:2.0 自动接管工具循环的「递归 Advisor」,藏在 ChatClient 里,你通常无感(7.2)。
- @Tool / @ToolParam:把 Java 方法挂牌给模型的注解(包
org.springframework.ai.tool.annotation)(7.3)。 - ToolCallback:所有工具不管怎么定义,最终都变成它,走同一条循环(7.4)。
- ToolContext:服务端注入、模型看不见的上下文(身份/租户),安全的关键(7.5)。
- Exception Processor:决定工具异常是回给模型还是直接失败(7.6)。
7.1工具调用的本质:模型只会「举手」,不会「动手」
先用一个比喻理解:模型像一位坐在办公室里的顾问,他知道很多东西,但不能出门。当需要查实时数据时,他会写一张便条(工具名 + 参数)递给你,你跑出去把事情办完,把结果写回来给他,他再基于结果给你最终答复。
- 是什么:工具调用是模型在回答时「声明」要执行某个外部函数并给出参数,由你的程序真正执行后再把结果喂回模型的一种协议。模型产出的是
tool_calls(工具名 + 参数 JSON),不是最终答案。 - 为什么:模型的训练数据有截止日期、没有你的私有业务数据、也不能直接改数据库。把「查库、算价、发短信」这类动作交给你写好的 Java 方法,模型就具备了实时、私有、可写的能力。
- 没有会怎样:模型只能凭训练记忆「编」出看似合理但可能错误/过时的答案(幻觉);涉及你公司订单、库存、天气等数据时完全无能为力。
- 类比:模型是「参谋」,工具是「士兵」。参谋出主意、下指令(调哪个工具、传什么参),士兵去前线真刀真枪执行并把战报带回来。参谋从不下场,所以安全校验这层责任永远在士兵(你的代码)身上。
为什么这么写(设计意图):这个比喻不是修辞,而是对 Spring AI 工具调用架构的精确描述。模型的输出里只含「我要调用某个工具、参数是这些」,真正的执行发生在你的 Java 代码里。理解这一点,后面所有的权限、异常处理、成本控制才站得住脚。很多初学者把模型当成「安全边界」,根源就是没建立这个心智模型。
| 模型负责(3 件事) | 你负责(4 件事) |
|---|---|
|
|
- 审查工具实现:是否从模型传入的参数里取了
userId?若是,立即改为从服务端登录态取。 - 在工具内部打印「模型实际传来的参数」与「服务端识别的身份」,对比二者是否一致——不一致就是越权信号。
- 用第 ④ 步的越权用例(查别人的订单)做回归:期望返回「未查询到该订单」,而不是别人的数据。
7.2完整时序:为什么一次问答会有两次模型调用
下面这张图描述一次「订单发货时间」问答在 Spring AI 2.0 内部的完整流转。注意:对调用方(你的 Controller)而言只写了一次 .call(),但底层发生了多次模型请求。
▌动手补充:模型返回的 tool_calls 到底长什么样
上面图里第 ① 步说「模型返回:我要调工具」。它返回的其实是一段结构化 JSON(不是自然语言)。看清它,你才能真正理解「模型只举手、不动手」:
# 第 ① 步:模型不直接回答,而是返回 tool_calls(注意 content 是 null)
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "queryOrderStatus",
"arguments": "{\"orderNo\":\"2024001\"}"
}
}
]
}
# 第 ③ 步:你的代码把工具结果包成一条 role=tool 消息回灌
{
"role": "tool",
"tool_call_id": "call_abc123",
"content": "{\"status\":\"未发货\",\"promisedDate\":\"2024-03-08\"}"
}
tool_calls(第①步),以及你的工具返回被正确地塞进了 role=tool 消息(第③步)。哪一段不对,循环就在哪一段断掉。这也是 7.2「开 debug 日志看两次模型请求」时能观察到的底层内容。
2.0 把它提升成了 Advisor 链里的一等公民
ToolCallingAdvisor:
它是个「递归 Advisor」,每迭代一次就重新进入下游 Advisor 链一次。
这意味着你现在可以:观察每一次迭代、在迭代之间做审批、替换整个执行策略、把工具调用和记忆/日志/评估组合起来。这个变化是 2.0 做 Agent 应用的地基,第 9 章会围绕它展开。
- 是什么:一个挂在 Advisor 链里的「递归 Advisor」。它负责:把工具定义发给模型 → 执行模型点名的工具 → 把结果回灌进对话历史 → 再进入下一轮(递归),直到模型不再请求工具为止。
- 为什么:2.0 把 1.x 写死在各 ChatModel 实现内部的工具循环抽出来,做成了 Advisor。于是循环对你可见、可插拔、可组合——能配合记忆 / 日志 / 评估一起用。
- 没有会怎样(1.x 的痛点):循环焊死在模型实现里,你既看不到中间过程,也没法在循环里插入审批或换策略;每换一个模型厂商就得重新理解一套循环。
- 类比:它是「项目里的循环调度员」;1.x 是把调度员焊死在每台机器内部,2.0 把它做成可替换的插件。
.internalToolExecutionEnabled(false)。
该方法连同 spring.ai.*.chat.internal-tool-execution-enabled 属性在 2.0 中已被彻底移除,
各 ChatModel 内部的工具执行循环也不再存在。想自己控制循环(例如要在每次工具调用前做审批),改用:
- 单次调用级别:
AdvisorParams.toolCallingAdvisorAutoRegister(false); - 全局级别:
spring.ai.chat.client.tool-calling.enabled=false(关闭后 ChatClient 不再自动挂 ToolCallingAdvisor,你需要自己接管循环)。
| 维度 | 自动接管(默认,推荐) | 手动接管(进阶) |
|---|---|---|
| 怎么开 | 什么都不用做,ChatClient 自动挂 ToolCallingAdvisor | 先 AdvisorParams.toolCallingAdvisorAutoRegister(false) 或 spring.ai.chat.client.tool-calling.enabled=false 关掉自动注册,再自己写循环调 ToolCallingManager |
| 循环写在哪 | 框架内部,业务代码无感 | 你自己的代码里(while + 手动调用 manager) |
| 适合 | 绝大多数场景:普通问答、单轮多工具、带记忆的助手 | 要在每次工具调用前做人工审批、做自定义中断/续跑、或替换整条执行策略的 Agent |
| 风险 | 无(框架默认就安全) | 容易写出无限循环;必须自己加次数上限(见 7.4 方式六) |
▌运行验证:开启 debug 日志,亲眼看「两次模型请求」
光看文字不直观,现在就让你在日志里看到循环的存在。本章自建工程(见 7.7)跑起来后,打开 DEBUG 日志:
# 临时在 application.yml 打开 Spring AI 的 DEBUG 日志(只看工具相关足够)
logging:
level:
org.springframework.ai.chat.client: DEBUG
# 触发一次带工具的问答(命令见 7.7,这里先认识输出长什么样)
curl -X POST http://localhost:8080/api/v1/assistant/ask \
-H "Content-Type: application/json" \
-d '{"question":"订单 2024001 什么时候发货?"}'
期望在控制台看到(节选,顺序即循环):
... [ToolCallingAdvisor] 第 1 次迭代:模型返回 tool_calls=[queryOrderStatus]
... [ToolCallingManager] 执行工具 queryOrderStatus(orderNo=2024001)
... [ToolCallingAdvisor] 第 2 次迭代:模型返回最终文本(无 tool_calls)
看到什么算通过:日志里出现至少两轮「模型请求」痕迹(一次返回 tool_calls、一次返回最终文本),且最终 curl 正常返回了发货说明文字,而不是报错或空串。
The following classes could not be found: internalToolExecutionEnabled ... 或编译报「找不到符号」。
根因:你或学生复制了 1.x 教程里的 .internalToolExecutionEnabled(false),而该方法在 Spring AI 2.0 中已被彻底移除,不存在了。
修复:① 直接删除该行;② 若你确实要手动控制循环,按上文改用 AdvisorParams.toolCallingAdvisorAutoRegister(false) 或全局属性 spring.ai.chat.client.tool-calling.enabled=false;③ 在 pom 里确认 spring-ai-bom 是 2.0.1 而非 1.x。
ToolCallingAdvisor 自动接管,你只写一次 .call(),底层却可能跑两轮模型——贵就贵在这。
7.3@Tool 注解:把 Java 方法暴露给模型
把一个普通 Java 方法变成模型可用的工具,核心就是 @Tool 注解(包 org.springframework.ai.tool.annotation.Tool)。下面这个例子包含本章最重要的三个安全要点:描述怎么写、参数怎么描述、身份怎么取。
▌动手:创建 OrderTools 类
- 在 IDEA 左侧 Project 视图,展开
src/main/java/com/example/springai。若没有ch07包,右键com.example.springai→ New → Package,输入ch07回车。 - 右键
ch07包 → New → Java Class,名字精确填OrderTools,回车。 - 清空自动生成的内容,把下方整段代码粘贴进去(含 import),按 Ctrl/Cmd+S 保存。
- 确认 IDEA 没有红色波浪线:
@Tool、@ToolParam能从org.springframework.ai.tool.annotation解析到(若报红,多半是依赖或版本不对,回头对照 ch00 版本表)。
package com.example.springai.ch07;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
@Component // 必须是 Spring bean,Spring AI 才能扫描到它
public class OrderTools {
/**
* 一个工具方法。三个要素决定模型能不能用好它:
* ① 方法名 → 模型看到的工具名(尽量用动词+名词,语义清晰)
* ② description → ★ 最重要!模型靠它决定"什么时候用这个工具、不用那个"
* ③ 参数描述 → 模型靠它知道"这个参数从用户的话里怎么抽"
*/
@Tool(description = """
查询指定订单号的当前状态与预计发货时间。
使用时机:当用户询问某个订单的发货情况、物流状态、是否已发货时调用。
不要用于:查询商品信息、退款进度、账户余额。
""") // 文本块(Java 15+),多行描述更清晰
public OrderStatus queryOrderStatus(
@ToolParam(description = "订单号,纯数字字符串,例如 2024001")
String orderNo) { // 参数描述越具体,模型抽参越准
// ★★★ 这里就是安全边界!绝不能相信模型传来的 orderNo 就是"当前用户的订单"
String currentUser = SecurityContextHolder.currentUserId(); // 从服务端登录态取,不是从参数取
if (!orderRepository.belongsTo(orderNo, currentUser)) {
// 返回明确错误,而不是抛异常——让模型能理解并礼貌回复用户
return new OrderStatus(orderNo, "NOT_FOUND", null,
"系统中不存在该订单,或该订单不属于当前用户");
}
Order order = orderRepository.findByNo(orderNo);
return new OrderStatus(order.orderNo(), order.status(),
order.promisedDate(), "查询成功");
}
/** 无参工具也可以(模型不需要传任何参数)。典型场景:获取"今天日期"这类模型自己不知道的信息。 */
@Tool(description = "获取当前的日期与时间。当用户说'今天''本周''最近'等相对时间时,必须先调用此工具获取真实日期。")
public String currentDateTime() {
return LocalDate.now() + " " + java.time.LocalTime.now().withNano(0);
}
/** 工具的返回类型:建议用 record,字段名会进入模型的上下文 */
public record OrderStatus(String orderNo, String status,
LocalDate promisedDate, String message) {}
}
为什么这么写:@Tool 的 description 不是给人看的注释,而是会被序列化进发给模型的「工具定义」里——模型完全靠它来决策。所以描述要写成「给模型的说明书」:做什么、何时用、何时不用、参数长什么样。返回类型用 record 是因为它的字段名天然成为 JSON 的 key,进入模型上下文后语义清晰。
写工具描述的「四要素」模板
| 要素 | 说明 | 示例 |
|---|---|---|
| 做什么 | 一句话说明功能,动词开头 | 查询指定订单号的当前状态与预计发货时间。 |
| 何时用 | 给模型明确的触发条件 | 当用户询问发货情况、物流状态时调用。 |
| 何时不用 | ★ 很多人漏掉,但极其重要 | 不要用于查询商品信息、退款进度、账户余额。 |
| 参数约束 | 格式、取值范围、必填性 | 订单号为纯数字字符串,例如 2024001。 |
| 风格 | 写法要点 | 适用 |
|---|---|---|
| @Tool 注解式 | 在方法上加 @Tool / @ToolParam,Spring 自动扫成 ToolCallback。描述、参数约束都写在注解里,最直观 | ✅ 大多数情况:工具就是你自己写的业务方法 |
| FunctionToolCallback 函数式 | 用 FunctionToolCallback.builder(name, fn).inputType(...) 把现成的 Function / lambda / 方法引用包成工具 | 要暴露的不是 POJO 方法,而是已有函数、第三方 SDK 调用、方法引用 |
| 二者关系 | 最终都产出 ToolCallback,走同一条 ToolCallingAdvisor 循环,可以混用。区别只在「怎么把一个函数变成工具」,不在「工具跑起来有何不同」。 | |
▌运行验证:编译通过,且 @Tool 被扫描到
# 在 pom.xml 所在目录执行(只编译,不运行,最快的反馈)
mvn -q compile
期望输出:命令结束、无红色报错,最后一行应是构建成功(-q 下成功时几乎无输出)。
看到什么算通过:终端回到可输入状态且没有 BUILD FAILURE;IDEA 里 OrderTools 的 @Tool 不再报红。此时工具还没被注册到 ChatClient,所以还不能调,但「类本身是对的」这一步先钉死,避免后面问题混在一起。
找不到符号 Tool / ToolParam,或 package org.springframework.ai.tool.annotation does not exist。
根因(按概率排序):① spring-ai-bom 不是 2.0.1(用了 1.x,包路径/类名不同);② 没引入任何模型或工具 starter,注解类不在 classpath。
修复:① 打开 pom.xml 确认 spring-ai-bom 版本是 2.0.1;② 确认引入了 spring-ai-starter-model-openai(工具能力随模型 starter 一并可用)。改完重开终端执行 mvn -q compile。
@Tool 是「把 Java 方法挂牌给模型」,description 是模型唯一的说明书——写得越清楚,模型越不会用错。
7.4注册工具的三种方式
工具类(带 @Tool 的 bean)写好后,还要把它「挂到」某个 ChatClient 上,模型才能用到。注意:标题虽写「三种方式」,但生产里实际有六种粒度可选——前三种是注册入口的区别,后三种是 2.0 新增或进阶能力,都讲清楚,方便你按场景选。
▌动手:创建 AssistantConfig 装配 ChatClient
- 右键
ch07包 → New → Java Class,名字填AssistantConfig。 - 粘贴下方「方式三(默认挂载)」代码(它最贴近生产写法),保存。
- 确认
OrderTools、DateTimeTools能注入——即这两个类都已用@Component标注且被 Spring 扫描到。
// ---------- 方式一:直接传对象(最简,适合手工指定) ----------
String answer = chatClient.prompt()
.user("订单 2024001 发货了吗?")
.tools(new OrderTools(), new DateTimeTools()) // 传实例
.call()
.content();
// ---------- 方式二:用 ToolCallbacks 显式转换(可控性更强) ----------
import org.springframework.ai.support.ToolCallbacks;
ToolCallback[] callbacks = ToolCallbacks.from(new OrderTools(), new DateTimeTools());
String answer2 = chatClient.prompt()
.user("订单 2024001 发货了吗?")
.toolCallbacks(callbacks)
.call()
.content();
// ---------- 方式三:默认挂载(推荐,业务代码最干净) ----------
@Bean
public ChatClient orderChatClient(ChatModel chatModel,
OrderTools orderTools,
DateTimeTools dateTimeTools) {
return ChatClient.builder(chatModel)
// 所有通过这个 client 发起的请求都能用这些工具
.defaultTools(orderTools, dateTimeTools)
.defaultSystem("你是订单助手。涉及订单数据时必须调用工具查询,禁止凭记忆回答。")
.build();
}
三种方式的选择
| 方式 | 适用 | 特点 |
|---|---|---|
.tools(bean) | 临时实验、工具数量少 | 最直观 |
.toolCallbacks(...) | 需要动态决定注册哪些工具(例如按权限) | 最灵活 |
defaultTools(...) | ✅ 生产代码 | 业务代码零污染 |
- ChatClient 会自动注册
ToolCallingAdvisor,位置固定在Ordered.HIGHEST_PRECEDENCE + 300。你不需要(也不应该)自己再注册一个—— 2.0 强制链中只能有一个工具 Advisor,重复注册会直接报错。 想换掉它,就提供一个自己的ToolCallingAdvisor.Builder<?>bean。 - 每次调用的
.tools()是追加,不是替换:它会把工具加到defaultTools之上,而不是覆盖。 toolNames()被删除了。1.x 那套「注册一个裸Functionbean, 再用名字解析」的机制(SpringBeanToolCallbackResolver)在 2.0 中已完全移除, 工具必须作为显式的ToolCallback注册。见下面的「方式四」。
- 是什么:工具在框架内部的统一表示:一个携带「工具名 + 描述 + 参数 schema + 执行逻辑」的对象。
@Tool注解式、MethodToolCallback、FunctionToolCallback三种风格最终都产出它。 - 为什么:框架只认
ToolCallback这一种形状。统一形状后,循环、注册、限流、检索才能对「任意工具」一视同仁。 - 没有会怎样:若像 1.x 那样允许「裸
Functionbean + 名字字符串」随意注册,框架就无法统一校验与治理,且toolNames()解析失败时会静默回退、放大越权风险(2.0 已删除这条路)。 - 类比:ToolCallback 是「所有工具插进同一块插座的插头标准」——不管工具来自注解、方法还是函数,插上都通电。
2.0 给了这个问题的官方解法(见 7.4.1 的「方式五」): 接了很多 MCP Server、工具上百个时,打开
ToolSearchToolCallingAdvisor 做渐进式工具披露,
官方实测能省 34%~64% 的 Token 且准确率没有可测量的下降。
方式四(2.0 新增重点):函数式工具用 FunctionToolCallback
如果你要暴露的不是一个带 @Tool 的 POJO 方法,而是一个现成的 Function、lambda 或方法引用,
2.0 用 FunctionToolCallback。这是 1.x 里 FunctionCallback + toolNames() 的替代品:
// ❌ 1.x 的写法(2.0 已删除,编译不过)
// @Bean @Description("Get the weather in location")
// Function currentWeather() { return weatherService::getWeather; }
// chatClient.prompt().toolNames("currentWeather"); ← toolNames 没了
// ✅ 2.0:注册成显式的 ToolCallback bean
@Configuration(proxyBeanMethods = false)
class WeatherToolsConfig {
@Bean
ToolCallback currentWeather(WeatherService weatherService) {
return FunctionToolCallback.builder("currentWeather", weatherService::getWeather)
.description("查询指定城市的当前天气")
// ★ inputType 决定生成的 JSON Schema,必须给,否则模型不知道该传什么
.inputType(WeatherRequest.class)
.build();
}
}
// 注入后按名字使用(不再靠字符串解析)
@Autowired ToolCallback currentWeather;
chatClient.prompt()
.user("北京现在天气怎么样?")
.tools(currentWeather) // 直接传工具对象
.call()
.content();
三种定义风格最终都产出 ToolCallback,走同一条循环,可以随意混用:
| 风格 | 写法 | 适用 |
|---|---|---|
@Tool 声明式 | 在方法上加注解,.tools(new MyTools()) | ✅ 大多数情况,最省事 |
MethodToolCallback | MethodToolCallback.builder().toolDefinition(...).toolMethod(...).toolObject(...) | 方法源码不受你控制、需运行时动态构建工具 |
FunctionToolCallback | FunctionToolCallback.builder(name, fn).inputType(...) | 暴露现成的 Function / lambda / 方法引用 |
方式五:工具太多时,打开渐进式工具披露
这是 2.0 最实用的新能力之一。原理:不再把全部工具定义一次性塞给模型,
而是给模型一个内置的 toolSearchTool,让它用自然语言先"检索"出相关工具,
再只把这几个工具的定义发过去。
<!-- 加一个 starter 即可,不需要改任何 Java 代码 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-tool-search-advisor</artifactId>
</dependency>
# 打开开关
spring:
ai:
chat:
client:
tool-search-advisor:
enabled: true
# 索引类型:
# regex —— 默认,零额外依赖,按关键词匹配
# lucene —— 需要 org.apache.lucene:lucene-core,检索质量更好
# vector —— 需要 classpath 上有 VectorStore,语义检索最准
tool-index-type: regex
- 工具数 < 15:不要开。多一层检索反而增加延迟和不确定性。
- 工具数 15~50:可以试,用评测集对比开了之后准确率有没有下降。
- 工具数 > 50,或接了很多 MCP Server:✅ 强烈建议开。 官方给的场景是「一个会话聚合成百上千个 MCP 工具定义」,不开的话每次请求光工具描述就要吃掉 10~21K token。这不是优化,而是能不能用的问题。
方式六:给工具调用加上次数上限(防失控)
2.0 的 DefaultToolCallingManager 内置了调用次数保护,默认就已生效:
单个工具每轮最多 40 次,所有工具合计最多 150 次。超过会抛 ToolCallLimitExceededException。
# 用配置调整
spring:
ai:
tools:
limits:
max-calls-per-tool-default: 40 # -1 表示不限制
max-total-tool-calls: 150
on-limit-exceeded: THROW # 或 RETURN_ERROR_RESPONSE(把错误当结果回给模型)
// 或者编程式精细控制
ToolCallingManager manager = ToolCallingManager.builder()
.maxCallsPerTool(40)
.maxCallsPerTool("search", 10) // 给某个工具单独设更小的上限
.excludeToolFromLimit("getCurrentDateTime") // 这个工具不限制
.maxTotalToolCalls(150)
.build();
// 装到自己的 ToolCallingAdvisor 上(会替换掉自动注册的那个)
@Bean
ToolCallingAdvisor.Builder<?> toolCallingAdvisorBuilder() {
return ToolCallingAdvisor.builder()
.toolCallingManager(manager)
.advisorOrder(Ordered.HIGHEST_PRECEDENCE + 300);
}
▌运行验证:ChatClient 装配成功(编译 + 启动自检)
# 先确认编译通过(最便宜的反馈)
mvn -q compile
# 再启动,看 ChatClient bean 能不能装配起来
mvn spring-boot:run
期望启动日志(节选):
...
2025-xx-xx INFO ... Started SpringaiLabApplication in 3.x seconds
...
看到什么算通过:控制台出现 Started ... in X seconds 且无 APPLICATION FAILED TO START;特别地,没有出现 Consider defining a bean of type 'org.springframework.ai.chat.client.ChatClient' 这类提示。此时还没有 HTTP 接口,curl 会报连接拒绝,这是正常的——这一步只验证「装配对不对」。
Consider defining a bean of type 'ChatClient' 或 No qualifying bean of type 'ChatModel'。
根因:① 没有 @Bean 返回 ChatClient(你漏了方式三里的 AssistantConfig);② 更底层是 ChatModel 没被自动装配,通常是没引模型 starter 或 application.yml 里 api-key 占位符不对导致自动配置跳过。
修复:① 确认 AssistantConfig 被 @Configuration 标注且被扫描到;② 确认引入了 spring-ai-starter-model-openai;③ 确认 application.yml 配了 spring.ai.openai.api-key: ${DEEPSEEK_API_KEY:} 且环境变量已设置、IDEA 已重启。
Chain already contains a ToolCallingAdvisor(或类似「已存在工具 Advisor」)。
根因:你既让 ChatClient 自动注册了一个 ToolCallingAdvisor(2.0 默认),又自己手动 .advisors(new ToolCallingAdvisor(...)) 注册了第二个——2.0 强制链里只能有一个。
修复:删掉你手动注册的那个;若要定制,按方式六提供自己的 ToolCallingAdvisor.Builder<?> bean 去替换自动配置,而不是叠加。
ToolCallback 才挂得上 ChatClient;toolNames() 在 2.0 已删除,函数式请用 FunctionToolCallback。
工具设计模式速查(动手前先想清楚这一类):
- 查询型工具(Query):只读、幂等、返回模型友好的 DTO。如
queryOrderStatus、queryWeather。最常用,直接挂上即可。 - 动作型工具(Action):会改数据(下单、退款)。必须幂等(唯一键 / 幂等 token),且破坏性动作要「先确认、后执行」(见 7.6 Checklist 与第 9 章 Human-in-the-loop)。
- 确认型工具(Confirmation):不直接干活,只返回「即将执行 X,确认吗?」,由上层拿到用户确认后再调真正的执行工具。它是动作型工具的安全搭档。
- 直出型工具(Direct):结果本就是最终答案(导出、SQL、代码),用
returnDirect=true跳过二次模型调用(见 7.5)。
7.5返回值、ToolContext 与「结果直出」
工具返回值怎么设计
工具返回什么,直接决定了模型能「看懂」多少。原则一句话:返回值是写给模型看的,不是写给数据库看的。
// ❌ 不好:返回一个复杂的内部实体,字段名模型看不懂
public OrderEntity query(String no) { return repo.find(no); }
// 返回 {"id":1,"ordNo":"2024001","sta":2,"gmtCreate":1709...}
// 模型看到 sta=2 完全不知道是什么意思
// ✅ 好:返回语义清晰的 DTO,字段名自解释,必要时附带说明文本
public record OrderStatus(
String orderNo, // 订单号
String status, // 已翻译成中文:未发货/已发货/已完成
String promisedDate, // 预计发货日期 yyyy-MM-dd
String message // 一句话说明,便于模型组织语言
) {}
| 对比 | 返回 String(纯文本) | 返回对象 / record(结构化) |
|---|---|---|
| 模型拿到什么 | 一段自由文本,例如 "订单2024001未发货,预计3月8日" | 带字段名的 JSON,例如 {"orderNo":"2024001","status":"未发货","promisedDate":"2024-03-08"} |
| 模型理解难度 | 要自己解析自然语言,偶尔误读 | 字段名即语义,几乎不会误读 |
| 适合场景 | 结果本就是给人读的一段话(如导出的报表正文) | ✅ 绝大多数:订单、天气、库存等需要模型再组织语言的业务数据 |
| 写法建议 | 用 returnDirect=true 直接回给用户最自然 | 用 record,字段名当 JSON key,附一个 message 字段帮模型组织话术 |
2 翻译成「已发货」,把时间戳格式化成 2024-03-08,
把金额带上单位。这一步做得越贴心,模型最终的回答就越准确。
ToolContext:把上下文信息传进工具(不经过模型)
ToolContext 是工具安全设计的核心机制。它让你把「不能也不应该让模型看到/控制」的信息(当前用户 ID、租户 ID、请求追踪 ID)在调用时注入,模型既看不到也无法伪造。
- 是什么:一个在每次
.call()时由你的服务端代码注入的键值容器(Map),工具方法声明一个ToolContext参数即可读取。 - 为什么:身份、租户这类信息绝不能让模型替你决定。走 ToolContext 等于从机制上把「谁能查谁的数据」锁死在服务端。
- 没有会怎样:若把 userId 作为工具参数(让模型填),模型被注入攻击或自己编造 userId 时,就会发生越权读取他人数据——这是最典型的安全事故。
- 类比:ToolContext 是你递给士兵的「任务令」(上面盖了章写着「只查张三的数据」),而不是让参谋自己填「去查谁」。
import org.springframework.ai.chat.model.ToolContext;
/**
* ★★ ToolContext 是一个非常重要的机制:
* 它允许你把「不能也不应该让模型看到/控制」的信息传给工具,
* 例如:当前登录用户的 ID、租户 ID、请求追踪 ID。
*
* 这些值由你的代码在调用时注入,模型无法伪造——这是安全性的关键。
*/
@Tool(description = "查询当前用户自己的全部订单列表")
public List<OrderStatus> queryMyOrders(ToolContext toolContext) {
// 从上下文取当前用户,而不是从模型的参数里取!
String userId = (String) toolContext.getContext().get("userId");
String tenantId = (String) toolContext.getContext().get("tenantId");
return orderRepository.findByUser(tenantId, userId).stream()
.map(o -> new OrderStatus(o.orderNo(), o.status(),
String.valueOf(o.promisedDate()), ""))
.toList();
}
调用时由服务端把上下文注入进去(这一步发生在你的 Service 里,模型无感):
String answer = chatClient.prompt()
.user("帮我看看我有哪些订单")
// ★ 由服务端注入,模型看不到也改不了
.toolContext(Map.of(
"userId", SecurityContextHolder.currentUserId(),
"tenantId", SecurityContextHolder.currentTenantId()))
.call()
.content();
returnDirect:让工具结果直接返回给用户
/**
* 有些工具的返回值本身就是最终答案,不需要模型再加工一遍。
* 例如"导出一份明细报表"——加工反而会改动数据、增加成本。
* returnDirect = true 时,工具结果会跳过第二次模型调用,直接返回。
*/
@Tool(description = "导出指定订单的完整明细报表(CSV 文本)", returnDirect = true)
public String exportOrderDetail(@ToolParam(description = "订单号") String orderNo) {
return reportService.toCsv(orderNo);
}
- 是什么:
@Tool的一个布尔开关(默认false)。为true时,工具结果跳过第二次模型调用,直接作为最终回答返回给用户。 - 为什么:有些工具的结果本身就是最终答案(导出报表、返回 SQL、返回代码),再让模型「翻译」一遍既浪费 Token,又可能改坏格式。
- 没有会怎样:结果会被送回模型再生成一遍——多一次请求、多一份延迟与成本,且结构化 / 敏感内容可能被模型改写。
- 类比:像「快递直送」vs「先回总部盖章再送」——能直送的就别绕路。
▌运行验证:编译通过,且 ToolContext 用法自洽
mvn -q compile
看到什么算通过:编译成功,且 queryMyOrders(ToolContext) 方法签名与调用处 .toolContext(Map.of(...)) 的 key 命名一致(如都叫 userId、tenantId)。这一步还没法运行时验证 ToolContext 的取值,要等 7.7 整体跑起来后用越权用例(第 ② 步)才能证明它真的「挡住了越权」。
ToolContext 里 get("userId") 取到 null。
根因:调用处忘了 .toolContext(Map.of("userId", ...)),或 key 拼写不一致(一处 userId 一处 user_id)。
修复:① 在 Service 的每次 .call() 前确认都注入了 toolContext;② 全局统一 key 常量,避免字符串硬拼写出错;③ 若多个入口都调用,抽一个统一入口方法负责注入,防止遗漏。
7.6参数校验、异常处理与安全边界
工具里的错误:返回给模型,而不是抛给用户
工具执行失败时的处理,决定了用户体验的成败。下面的代码把「参数校验 → 权限校验 → 业务兜底」三段都写成「返回结构化错误对象」而不是抛异常,这样模型能把错误翻译成人话。
/**
* 工具执行失败时的两种处理方式,效果差别巨大:
*
* ❌ 抛异常 → Spring AI 会把异常信息带回给模型(或直接失败),
* 用户看到的是"系统错误",体验极差。
*
* ✅ 返回结构化的错误对象 → 模型能"读懂"错误,并组织成礼貌的话回复用户,
* 例如:"没有找到订单 2024001,请确认订单号是否正确?"
*/
@Tool(description = "查询指定订单号的当前状态与预计发货时间")
public OrderStatus queryOrderStatus(
@ToolParam(description = "订单号,纯数字字符串,例如 2024001") String orderNo) {
// ---------- ① 参数格式校验:模型可能传 "订单2024001" 或空值 ----------
if (orderNo == null || !orderNo.matches("\\d{6,20}")) {
return new OrderStatus(orderNo, "INVALID", null,
"订单号格式不正确,应为 6~20 位数字。请向用户确认订单号后重试。");
}
// ---------- ② 权限校验:绝不接受模型传来的用户身份 ----------
String userId = SecurityContextHolder.currentUserId();
if (!orderRepository.belongsTo(orderNo, userId)) {
// ★ 注意这里不返回"订单属于别人"——那等于泄露了"这个订单存在"的信息
return new OrderStatus(orderNo, "NOT_FOUND", null,
"未查询到该订单。请确认订单号是否正确。");
}
// ---------- ③ 业务异常兜底:把技术异常翻译成业务语言 ----------
try {
Order order = orderRepository.findByNo(orderNo);
if (order == null) {
return new OrderStatus(orderNo, "NOT_FOUND", null, "未查询到该订单。");
}
return new OrderStatus(order.orderNo(), order.status(),
String.valueOf(order.promisedDate()), "");
} catch (Exception e) {
// 内部异常详情记日志,返回给模型的是"可读的错误说明"
log.error("查询订单 {} 失败", orderNo, e);
return new OrderStatus(orderNo, "ERROR", null,
"订单系统暂时不可用,请稍后重试或转人工客服。");
}
}
| 对比 | 抛异常(❌) | 返回结构化错误对象(✅) |
|---|---|---|
| 模型拿到什么 | 原始异常堆栈/错误消息,往往含内部细节 | 你设计的 message 字段,话术友好、可行动 |
| 用户体验 | 请求可能直接 500,用户看到「系统错误」 | 模型把错误翻译成「未找到该订单,请确认订单号」 |
| 默认行为(2.0) | RuntimeException 会被 DefaultToolExecutionExceptionProcessor 包成消息回给模型;Error/受检异常仍冒泡 | 你自己完全掌控返回内容,最稳 |
| 何时用 | 几乎不主动用;仅程序性错误让它冒泡 | ✅ 所有业务可预期错误(参数错、无权限、依赖暂时不可用) |
全局异常处理器:统一决定「异常要不要抛」
DefaultToolExecutionExceptionProcessor 现在的默认行为是:· 任意
RuntimeException → 把异常消息包装后发回给模型,
让模型有机会向用户解释、换参数重试或换一个工具;· 受检异常(checked exception)和
Error → 始终向上抛出,不会被吞掉。这个划分是有意设计的:能自愈的错误交给模型,程序性错误绝不能藏起来。
- 是什么:一个函数式接口,决定「工具执行抛出的异常,最终变成什么返回给模型」。返回值会作为「工具执行结果」回灌给模型。
- 为什么:默认策略把
RuntimeException的消息回给模型(让模型向用户解释 / 换参重试),而受检异常和Error始终冒泡(程序性错误不能藏)。 - 没有会怎样(不设自定义):就用框架默认,多数项目够用;若完全不处理,异常可能直接变成 500 抛给用户。
- 类比:它是「异常翻译官」——把技术黑话翻成模型能懂、能转述给用户的人话。
import org.springframework.ai.tool.execution.ToolExecutionException;
import org.springframework.ai.tool.execution.ToolExecutionExceptionProcessor;
/**
* 只有当默认策略不满足需求时,才需要自定义。
* 它是一个函数式接口,只有一个方法,所以可以直接写成 lambda。
*/
@Configuration
public class ToolConfig {
@Bean
public ToolExecutionExceptionProcessor toolExecutionExceptionProcessor() {
return exception -> {
// exception 里带着原始异常、工具定义等信息
log.error("工具执行失败:工具={} 原因={}",
exception.getToolDefinition().name(), exception.getMessage());
// ★ 返回值会作为"工具执行结果"回给模型。
// 所以这里的话术是"写给模型看的",要清楚、可行动,不要甩堆栈。
return "工具执行失败:" + exception.getMessage()
+ "。请向用户说明该功能暂时不可用,不要重复调用此工具。";
};
}
}
spring.ai.tools.throw-exception-on-error=true(默认 false)。
什么时候该设成 true?当工具的错误信息里可能含敏感内容时——
因为默认行为会把异常消息发给模型,而模型可能把它复述给用户。
例如数据库连接串、内部服务地址这类信息绝不能通过这条路泄露出去。
ToolCallback 时的注意事项:
如果你是自己实现 ToolCallback 而不是用 @Tool 注解,
执行失败时应该抛 ToolExecutionException——这是处理器期望的异常类型。
抛别的异常会绕过你的自定义逻辑。
排查步骤:若发现异常被静默吞掉或处理器没生效,① 确认你的异常类型是
ToolExecutionException 或 RuntimeException;② 检查是否误把受检异常抛出了(受检异常默认会冒泡);
③ 确认自定义 ToolExecutionExceptionProcessor bean 名字/类型是否被别的配置覆盖。
工具的安全边界 Checklist
| 风险 | 错误做法 | 正确做法 |
|---|---|---|
| 越权读取 | 接受模型传入的 userId 去查数据 |
从 ToolContext / 登录态取用户身份,服务端强制过滤 |
| 提示词注入 | 把用户输入直接当指令执行 | 工具只做「参数化查询」,绝不拼接 SQL / 绝不做文件路径拼接 |
| 误删误改 | 提供 deleteOrder(orderNo) 这类破坏性工具直接给模型 |
破坏性操作必须「两步走」:先返回确认信息,用户确认后再执行(第 9 章 Human-in-the-loop) |
| 资源耗尽 | 工具里做全表扫描、调用无超时的外部接口 | 工具内部设超时(如 3 秒)、限制返回条数(如最多 50 条) |
| 重复执行 | 模型在 Agent 循环里反复调用「创建工单」 | 写操作工具要幂等:用业务唯一键去重,或引入幂等 token |
| 成本失控 | 工具返回 1 万条记录塞进上下文 | 工具只返回「摘要 + 前 N 条」,完整数据走另一条接口 |
findByNo + findItems + findLogistics 三个方法合并成一个
getOrderOverview(orderNo),一次调用返回模型需要的全部信息。
▌运行验证:编译通过,安全校验逻辑就位
mvn -q compile
看到什么算通过:编译成功;并且你能在代码里逐行指出:① 参数格式校验(matches("\\d{6,20}"))、② 权限校验(从 SecurityContextHolder 取身份而非参数)、③ 异常兜底(try/catch 返回错误对象)。这三条都齐,才说明「工具不会裸奔」。真正的运行时越权拦截要到 7.7 第 ② 步用 curl 证明。
7.7完整示例:订单与天气查询助手
下面把前面所有点串成一个可运行的示例:一个订单 + 天气查询助手。先看工程结构,再逐文件讲解,最后给出可复制的验证步骤。本章无配套 lab 代码,请按「自建」口径在 com.example.springai.ch07 包下依次创建这些文件。
▌动手·前置 1:确认 pom.xml 依赖(自建工程的根基)
- 打开工程根目录的
pom.xml,确认<parent>指向spring-boot-starter-parent且版本是 4.1.x(如 4.1.1)。 - 在
<dependencyManagement>里引入spring-ai-bom,版本锁 2.0.1——这是全课程版本基准的开关。 - 在
<dependencies>里至少加入下面三个 starter:Web(提供@RestController)、Spring AI 的 OpenAI 模型 starter(提供ChatModel与工具能力)、Lombok(可选,本示例用@Slf4j需要)。 - 保存后,IDEA 右下角 Maven 面板点「刷新」,等依赖下载完、无红线。
<!-- 版本基准:Boot 4.1.x + Java 17 + Spring AI 2.0.1 -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<dependencyManagement>
<dependencies>
<!-- ★ 锁死 Spring AI 版本,避免混用 1.x -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>2.0.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
<!-- 提供 ChatModel、工具调用、ToolCallingAdvisor 等全部能力 -->
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-starter-model-openai</artifactId></dependency>
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency>
</dependencies>
▌运行验证:依赖齐了,先编译一次
# 在 pom.xml 所在目录执行(最先、最便宜的反馈)
mvn -q compile
看到什么算通过:命令结束无 BUILD FAILURE;特别地,不会出现 Cannot resolve org.springframework.ai:spring-ai-starter-model-openai:2.0.1 这类「找不到依赖」报错。若报找不到,回头检查 spring-ai-bom 的版本与引入方式(必须是 import 作用域的 pom)。这一步把「工程地基」钉死,后面所有文件出问题才不会和依赖混在一起。
Cannot resolve ... spring-ai-starter-model-openai:2.0.1 或下载卡死。
根因:① spring-ai-bom 没正确 import,导致该 starter 的版本解析不到;② 没配国内镜像,依赖从国外中央仓库下载超时(见 ch00 的 0.5 节)。
修复:① 确认 <dependencyManagement> 里 spring-ai-bom 的 <type>pom</type> 和 <scope>import</scope> 都写了;② 在 ~/.m2/settings.xml 配好阿里云镜像后重开终端再 mvn -q compile。
▌动手第 0 步:先配好 application.yml(密钥占位符)
- 打开
src/main/resources/application.yml(没有就 New → File 创建)。 - 粘贴下方配置,保存。注意 api-key 用的是
${DEEPSEEK_API_KEY:}占位符,绝不硬编码真实 key。 - 确认环境变量
DEEPSEEK_API_KEY已设置,且 IDEA 是在设置之后启动的(否则进程读不到,见 ch00 的「进程继承」说明)。
spring:
ai:
openai:
base-url: https://api.deepseek.com # DeepSeek 兼容 OpenAI 协议
api-key: ${DEEPSEEK_API_KEY:} # ★ 占位符,key 来自环境变量,不进源码
chat:
options:
model: deepseek-chat # 默认模型
server:
port: 8080
logging:
level:
org.springframework.ai.chat.client: DEBUG # 方便看工具循环(7.2 用过)
① 天气工具(演示外部 API 调用 + 超时保护)
- 右键
ch07包 → New → Java Class,名字填WeatherTools,粘贴下方代码,保存。 - 确认引用到的
WeatherApi、WeatherApiResponse、DailyWeather等类型:本示例为聚焦工具调用,假设这些外部类型已存在;若你想纯本地跑,可把weatherApi.get(city)替换成一个返回固定WeatherApiResponse的本地 stub(不影响工具调用教学)。
@Slf4j
@Component
public class WeatherTools {
@Tool(description = """
查询指定城市未来 3 天的天气预报。
使用时机:用户询问天气、气温、是否下雨、穿衣建议时调用。
不要用于:查询历史天气(本工具只支持未来 3 天)。
""")
public WeatherInfo queryWeather(
@ToolParam(description = "城市名称,中文,例如 北京、上海、深圳;不要带'市'字后缀")
String city) {
// 城市名做白名单/格式校验,防止把用户输入拼进 URL 造成注入
if (city == null || city.isBlank() || city.length() > 20) {
return new WeatherInfo(city, List.of(), "城市名不合法,请向用户确认城市名称。");
}
try {
// 外部 API 调用必须设超时,否则会把整个请求拖死
WeatherApiResponse resp = weatherApi.get(city);
// ★ 关键:把返回值"翻译"成模型友好的中文,而不是原样丢 JSON
List<DailyWeather> days = resp.getDaily().stream()
.limit(3) // 最多 3 天,避免上下文过长
.map(d -> new DailyWeather(
d.getDate(),
d.getTextDay(), // "晴" / "小雨"
d.getTempMin() + "~" + d.getTempMax() + "℃",
d.getHumidity() + "%"))
.toList();
return new WeatherInfo(city, days, "");
} catch (Exception e) {
log.error("查询 {} 天气失败", city, e);
return new WeatherInfo(city, List.of(), "天气服务暂时不可用,请稍后重试。");
}
}
public record WeatherInfo(String city, List<DailyWeather> days, String message) {}
public record DailyWeather(String date, String weather,
String temperature, String humidity) {}
}
② 装配带工具的 ChatClient
- 右键
ch07包 → New → Java Class,名字填AssistantConfig,粘贴下方代码,保存。 - 确认它用
@Configuration标注,且OrderTools、WeatherTools两个 bean 能被注入(它们都带@Component)。
@Configuration
public class AssistantConfig {
@Bean("orderAssistantClient") // 用限定名区分多个 ChatClient
public ChatClient orderAssistantClient(ChatModel chatModel,
OrderTools orderTools,
WeatherTools weatherTools) {
return ChatClient.builder(chatModel)
.defaultSystem("""
你是"智汇"公司的智能助手,可以查询订单状态和天气。
工作准则:
1. 凡是涉及订单数据、实时天气的问题,必须调用工具查询,
禁止凭已有知识或猜测回答。
2. 调用工具前如果缺少必要参数(如订单号、城市),
先向用户追问,不要编造参数。
3. 工具返回错误信息时,用友好、明确的语气告知用户,
并给出下一步建议。
4. 回答控制在 150 字以内。
""")
.defaultTools(orderTools, weatherTools) // 把工具挂到这个 client 上
.build();
}
}
③ 业务服务(注意 ToolContext 的注入)
- 右键
ch07包 → New → Java Class,名字填OrderAssistantService,粘贴下方代码,保存。 - 确认构造器注入了带
@Qualifier("orderAssistantClient")的 ChatClient。
@Slf4j
@Service
public class OrderAssistantService {
private final ChatClient orderAssistantClient;
public OrderAssistantService(
@Qualifier("orderAssistantClient") ChatClient orderAssistantClient) {
this.orderAssistantClient = orderAssistantClient;
}
/** 带工具的问答。注意 ToolContext 里只放"服务端可信"的身份信息。 */
public String ask(String question) {
return orderAssistantClient.prompt()
.user(question)
// ★ 身份信息走 ToolContext,模型看不见也改不了
.toolContext(Map.of(
"userId", SecurityContextHolder.currentUserId(),
"tenantId", SecurityContextHolder.currentTenantId()))
.call()
.content();
}
/** 进阶:手工查看模型到底调用了哪些工具(调试时有价值)。 */
public String askWithToolTrace(String question) {
ChatResponse response = orderAssistantClient.prompt()
.user(question)
.toolContext(Map.of("userId", SecurityContextHolder.currentUserId()))
.call()
.chatResponse();
var output = response.getResult().getOutput();
// 如果模型这一轮又发起了工具调用,会出现在这里
if (output.getToolCalls() != null && !output.getToolCalls().isEmpty()) {
output.getToolCalls().forEach(tc ->
log.info("模型请求调用工具:name={} arguments={} id={}",
tc.name(), tc.arguments(), tc.id()));
}
return output.getText();
}
}
④ 接口与验证
- 右键
ch07包 → New → Java Class,名字填OrderAssistantController,粘贴下方代码,保存。 - 确认类上有
@RestController+@RequestMapping("/api/v1/assistant"),且注入了OrderAssistantService。 - 全部文件就位后,执行下面的「▌运行验证」把工程跑起来,再用 4 个 curl 场景逐一验证。
@RestController
@RequestMapping("/api/v1/assistant")
public class OrderAssistantController {
private final OrderAssistantService service;
public OrderAssistantController(OrderAssistantService service) {
this.service = service;
}
@PostMapping("/ask")
public Map<String, String> ask(@RequestBody Map<String, String> body) {
String answer = service.ask(body.get("question"));
return Map.of("answer", answer);
}
}
▌运行验证:把工程跑起来
# 在 pom.xml 所在目录执行(先确认 application.yml 已配好 DeepSeek key 占位符)
mvn spring-boot:run
期望启动日志(节选):
...
2025-xx-xx INFO ... Started SpringaiLabApplication in 3.2 seconds
...
看到什么算通过:控制台最后出现 Started ... in X seconds,且没有红色 BUILD FAILURE / APPLICATION FAILED TO START;之后浏览器或 curl 访问 http://localhost:8080 不报「连接拒绝」。此时服务已就绪,可以进 4 个场景验证了。
APPLICATION FAILED TO START ... Failed to configure ChatModel / api-key must not be empty。
根因:${DEEPSEEK_API_KEY:} 占位符取不到值——环境变量没设,或 IDEA 在 setx 之前就启动了(进程没继承到)。
修复:① 终端执行 echo $env:DEEPSEEK_API_KEY(PowerShell)确认非空;② 若为空,重开终端 + 重启 IDEA 后再跑;③ 也可在 Run Configuration → Environment variables 里手动加 DEEPSEEK_API_KEY=sk-... 临时验证。
▌运行验证:四个关键场景(前三个看「调通」,第 ②、④ 个看「安全与可靠」)
把示例跑起来,用 curl 验证四个关键场景。前三个场景对应「能不能调通」,第 ②、④ 个才是本章真正的考点(安全与可靠性)。
# ① 明确引用订单号 → 应触发 queryOrderStatus 工具
curl -X POST http://localhost:8080/api/v1/assistant/ask \
-H "Content-Type: application/json" \
-d '{"question":"订单 2024001 什么时候发货?"}'
# 期望日志(askWithToolTrace 或 debug 日志):
# 模型请求调用工具:name=queryOrderStatus arguments={"orderNo":"2024001"}
# 期望回答:"您的订单 2024001 显示未发货,预计 3 月 8 日发出。"
# ② 故意查一个不属于当前用户的订单 → 应返回友好提示而非数据
curl -X POST http://localhost:8080/api/v1/assistant/ask \
-H "Content-Type: application/json" \
-d '{"question":"订单 9999999 发货了吗?"}'
# 期望:模型回答"未查询到该订单,请确认订单号是否正确"
# ★ 这一步验证的是权限校验,而不是模型的"自觉"
# ③ 混合任务:订单 + 天气(会触发两次工具调用)
curl -X POST http://localhost:8080/api/v1/assistant/ask \
-H "Content-Type: application/json" \
-d '{"question":"我的订单 2024001 发货了吗?另外北京明天天气怎么样?"}'
# 期望:模型分别调用两个工具,最后合并成一段回答
# ④ 缺少参数 → 应追问而不是编造
curl -X POST http://localhost:8080/api/v1/assistant/ask \
-H "Content-Type: application/json" \
-d '{"question":"我的订单发货了吗?"}'
# 期望:模型回答"请提供订单号"
# 如果它随便编了一个订单号去查 → 你的系统提示约束不够强
看到什么算通过(逐条):
- ① 日志出现
name=queryOrderStatus,且返回的是真实发货说明(不是「我不知道」)。 - ② 返回「未查询到该订单」,且绝不是别人的订单数据——这一条没过就是越权事故。
- ③ 一次回答里同时出现了订单状态和天气,日志里能看到两个工具都被调用。
- ④ 模型回问「请提供订单号」之类,没有凭空编造一个订单号去查。
SecurityContextHolder / ToolContext 取。
修复:回到 7.5 与 7.6,确认 queryOrderStatus 内用 SecurityContextHolder.currentUserId() 取身份、用 orderRepository.belongsTo(orderNo, userId) 做服务端过滤,且身份不来自任何工具参数。
defaultSystem 里「缺少参数先追问、不要编造」的指令(本示例已含);② 在工具内部做参数格式校验(7.6 的 matches("\\d{6,20}")),即便模型编了非法号也会返回友好错误而非误查;③ 把模型温度适当调低,减少「硬编」。
7.8常见坑排错
| 现象 | 原因 | 解决 |
|---|---|---|
| 模型完全不用工具,直接回答 | 工具描述太模糊;模型不支持 function calling | 完善描述(含「何时用/何时不用」);换支持工具调用的模型 |
| 模型用错工具 | 工具职责重叠,描述有歧义 | 合并相似工具;在描述里写明「不要用于……」 |
| 参数字段名对不上 | 模型传的 key 与方法参数名不一致(如 order_no vs orderNo) | 在 @ToolParam 里描述清楚命名;或加多个可选参数做兼容 |
| 工具抛异常导致整个请求 500 | 没配 ToolExecutionExceptionProcessor,异常直接冒泡 | 2.0 默认行为:RuntimeException 的错误消息会回给模型;受检异常和 Error 始终重抛。可用 spring.ai.tools.throw-exception-on-error=true 改成全部抛出 |
| 报「Chain already contains a ToolAdvisor」 | 自己注册了 ToolCallingAdvisor,而 2.0 已自动注册一个 | 删掉自己的注册;要定制就提供 ToolCallingAdvisor.Builder<?> bean 替换自动配置的 |
internalToolExecutionEnabled 编译不过 | 该 API 与属性在 2.0 已移除 | 改用 AdvisorParams.toolCallingAdvisorAutoRegister(false) 或全局属性 spring.ai.chat.client.tool-calling.enabled=false |
toolNames(…) 编译不过 | 按名字解析工具的能力已删除 | 改用 FunctionToolCallback.builder(name, fn).inputType(...) 注册显式 ToolCallback bean |
| 模型请求了不存在的工具 | 2.0 默认关闭了「解析回退」 | 确认工具是通过 .tools()/.defaultTools()/toolCallbacks() 显式附加的。不要为此打开 spring.ai.tools.resolution.fallback.enabled(会放大越权风险) |
| 模型用不存在的参数调用工具 | 参数描述不完整,模型猜测 | 参数描述里给示例值;工具内部做格式校验并返回可读错误 |
| 工具被反复调用,成本飙升 | 模型没拿到预期结果,循环重试;或 Agent 循环没设上限 | 工具返回明确成功/失败标识;Agent 循环设最大轮次(第 9 章) |
| 同一个写操作被执行多次 | 模型重复调用创建类工具 | 写操作做幂等(唯一键 / 幂等 token) |
| 响应很慢 | 工具内部慢(外部 API 无超时、全表扫描) | 工具内部设超时、加索引、限制返回条数 |
| 工具返回的内容把上下文撑爆 | 一次返回上千条记录 | 只返回摘要 + 前 N 条;详细数据另开接口分页 |
ToolContext 里取不到值 | 调用时忘记 .toolContext(...) | 检查每次调用是否注入了上下文;或写个统一入口方法 |
- 先看工具有没有注册上:在启动日志里找「已从 MCP/本地加载 N 个工具」,或临时打印
mcpTools.length。 - 再看模型有没有发起调用:开 debug 日志看
tool_calls有没有出现。 - 若模型没调工具:九成是描述写得不够「互斥 / 具体」,回到 7.3 四要素重写描述。
- 若模型调了但参数错:看
@ToolParam描述是否给了示例,工具内部是否做了格式校验。 - 若工具执行报错:先用「返回错误对象」而非抛异常,把错误话术写给模型看;敏感异常再开
throw-exception-on-error。
7.9自测题
Q1. 一次带工具调用的问答,底层发生了几次模型请求?对成本有什么影响?
通常两次(多轮工具调用则更多):第一次模型返回「我要调用某工具」, 你的代码执行工具后,第二次把结果回传给模型生成最终回答。 成本影响:每次请求都要发送完整上下文(含工具定义),所以 Token 消耗接近翻倍,延迟也叠加。
Q2. 为什么身份信息要走 ToolContext 而不是作为工具参数?
因为工具参数由模型生成,而模型可以被提示词注入诱导、也可能自己编造。 把 userId 作为参数,等于让模型来决定「查谁的数据」——这是致命的越权漏洞。 ToolContext 由服务端代码注入,模型既看不到也无法修改,从机制上杜绝了越权。
Q3. 工具里遇到业务错误,应该抛异常还是返回错误对象?为什么?
应该返回结构化的错误对象。因为模型拿到「可读的错误说明」后, 可以组织成友好的话术回复用户(「未找到该订单,请确认订单号」),甚至换一个工具重试; 而抛异常会让请求直接失败,用户看到的是系统错误。技术异常细节则记日志,不要返回给模型。
Q4. 为什么「把所有 Service 方法都加 @Tool」是个坏主意?
三个原因:① 工具数量多会把大量描述塞进提示词,消耗 Token 并干扰模型判断; ② 职责重叠导致选错率上升;③ Service 方法往往面向内部调用,参数复杂、返回内部实体, 对模型不友好。正确做法是为 AI 场景单独设计一层精简、语义清晰、返回值友好的工具。
Q5.【动手题】设计一个「提交退款申请」工具,要求满足:参数校验、幂等、需要用户确认、返回结构友好。写出方法签名与关键逻辑。
要点:
· 签名:String submitRefund(String orderNo, String reason, ToolContext ctx);
· 幂等:用 ctx 里的 userId + orderNo 作为唯一键,先查是否已有进行中的退款单;
· 确认:不要直接提交,先返回「即将为订单 X 提交退款申请,金额 Y 元,确认吗?」,
由上层(Agent 或前端)拿到用户确认后再调用真正的 confirmRefund(refundId);
· 返回:统一返回含 status 和 message 的 record,错误也用同结构。
7.10实操作业与本章验收
课堂练习(课上 30 分钟,分组完成):
- 在 ch07 示例基础上,新增一个
@Tool方法queryOrderItems(orderNo),返回该订单的商品明细列表;先用第 ② 步越权用例验证「别人的订单查不到」。 - 打开 debug 日志,故意问一个缺订单号的问题,观察模型是否追问;再故意把
orderNo的@ToolParam描述删掉,对比模型抽参准确率的下降——直观感受「描述」的力量。 - 把
defaultTools换成toolCallbacks(ToolCallbacks.from(...)),确认两种注册方式结果一致。
课后作业(独立完成,提交工程与运行截图):
- 把第 7 章示例里的「天气工具」改造为带
returnDirect=true的导出工具,验证结果跳过第二次模型调用直接返回(截图日志中「只发生一次模型请求」的证据)。 - 写一段 300 字说明:结合 7.6 的安全 Checklist,描述你会在自己的课程项目中如何给「创建订单」这类写操作工具做幂等与确认保护。
- 能口述工具调用完整时序,说清为什么会有两次(或多次)模型调用,以及它对成本/延迟的影响。
- 能写出一个带「四要素描述」的
@Tool方法,并知道用FunctionToolCallback注册函数式工具、toolNames()已在 2.0 删除。 - 越权查询返回友好提示(不返回他人数据、不抛 500);参数缺失时模型会追问而非编造。
- 能说出至少四条工具安全设计原则,并解释为什么身份类信息必须走
ToolContext而非工具参数。