Spring AI 应用开发实践课程教导站 · Spring AI 2.0 课程首页 速查
课程首页 / 第 7 章

CH 07工具调用(Tool Calling)

本章让模型从「只能说话」变成「能干活」——学会用工具调用把查库、算价、调接口这类真实业务能力交给 AI,并在身份、权限与安全边界上守得住。请跟着每一节的「动手」一步步建出可运行示例,并在每个「运行验证」节点确认通过。

教学目标
让模型安全调用业务方法,理解工具循环与权限边界
教学重点
@Tool 声明、六种注册方式、ToolContext 安全传参
教学难点
2.0 工具循环上移(ToolCallingAdvisor)、越权防护与工具数量治理
建议学时
3 学时(2 学时讲 + 1 学时实操)

教学设计说明(教师参考):本章建议采用「先破误区、再给机制、最后上机」的顺序——先用 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 Calling)是什么 → 为什么 → 没有会怎样 → 类比
  • 是什么:工具调用是模型在回答时「声明」要执行某个外部函数并给出参数,由你的程序真正执行后再把结果喂回模型的一种协议。模型产出的是 tool_calls(工具名 + 参数 JSON),不是最终答案。
  • 为什么:模型的训练数据有截止日期、没有你的私有业务数据、也不能直接改数据库。把「查库、算价、发短信」这类动作交给你写好的 Java 方法,模型就具备了实时、私有、可写的能力。
  • 没有会怎样:模型只能凭训练记忆「编」出看似合理但可能错误/过时的答案(幻觉);涉及你公司订单、库存、天气等数据时完全无能为力。
  • 类比:模型是「参谋」,工具是「士兵」。参谋出主意、下指令(调哪个工具、传什么参),士兵去前线真刀真枪执行并把战报带回来。参谋从不下场,所以安全校验这层责任永远在士兵(你的代码)身上。

为什么这么写(设计意图):这个比喻不是修辞,而是对 Spring AI 工具调用架构的精确描述。模型的输出里只含「我要调用某个工具、参数是这些」,真正的执行发生在你的 Java 代码里。理解这一点,后面所有的权限、异常处理、成本控制才站得住脚。很多初学者把模型当成「安全边界」,根源就是没建立这个心智模型。

模型负责(3 件事)你负责(4 件事)
  1. 判断「这个问题需不需要调工具」
  2. 选择调用哪个工具
  3. 从用户的话里抽出参数(可能不全,会追问)
  1. 真正执行那个方法(查库、调接口)
  2. 权限校验!模型说查就查?绝对不行
  3. 参数校验与兜底
  4. 把结果(或错误)格式化后回传
最危险的理解误区:把模型当成了「安全边界」。 有同学会想「我在工具描述里写『只能查询当前用户的订单』,模型就会守规矩」——这是完全错误的。模型可以被提示词注入攻击绕过,会凭空编造用户 ID。 排查 / 处置步骤:
  1. 审查工具实现:是否从模型传入的参数里取了 userId?若是,立即改为从服务端登录态取。
  2. 在工具内部打印「模型实际传来的参数」与「服务端识别的身份」,对比二者是否一致——不一致就是越权信号。
  3. 用第 ④ 步的越权用例(查别人的订单)做回归:期望返回「未查询到该订单」,而不是别人的数据。
正确做法:工具实现内部必须从服务端的登录态取用户身份,绝不接受模型传来的 userId。这一条在第 9 章 Agent 里会更重要,因为 Agent 会连续调用多个工具。
一句话记忆:模型只会「举手要调工具」,真正执行和把关永远在你的 Java 代码里——把模型当参谋,别当保安。

7.2完整时序:为什么一次问答会有两次模型调用

下面这张图描述一次「订单发货时间」问答在 Spring AI 2.0 内部的完整流转。注意:对调用方(你的 Controller)而言只写了一次 .call(),但底层发生了多次模型请求。

用户:"订单 2024001 什么时候发货?" │ ▼ ① ToolCallingAdvisor 把请求 + 全部工具定义发给模型 模型返回:我不直接回答,我要调工具 tool_calls = [{ name: "queryOrderStatus", arguments: {"orderNo":"2024001"} }] │ ▼ ② Advisor 把工具调用交给 ToolCallingManager 执行 ├─ 校验参数 ├─ 校验权限(这个订单属于当前用户吗?) ├─ 查询数据库 └─ 得到结果:{"status":"未发货","promisedDate":"2024-03-08"} │ ▼ ③ 把结果追加进对话历史,重新调用模型 模型返回:"您的订单 2024001 目前显示未发货,预计 3 月 8 日发出。" │ ▼ ④ 模型不再请求工具 → 循环结束,返回给调用方 │ ▼ 用户看到最终回答 ★ 关键:整个过程对调用方是透明的,你只写一个 .call(), 但底层发生了 2 次(或更多次)模型请求,Token 消耗也是双份。 这是"工具调用比普通问答贵"的根本原因。

▌动手补充:模型返回的 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\"}"
}
怎么用这个认知:调试工具调用时,真正要看的就是这两段 JSON——模型有没有产出 tool_calls(第①步),以及你的工具返回被正确地塞进了 role=tool 消息(第③步)。哪一段不对,循环就在哪一段断掉。这也是 7.2「开 debug 日志看两次模型请求」时能观察到的底层内容。
2.0 把上面这个循环「搬」到了哪里? 在 Spring AI 1.x,这个 while 循环是写死在每个 ChatModel 实现内部的——OpenAI 一套、Anthropic 一套, 你既看不到中间过程,也没法在循环里插入逻辑。
2.0 把它提升成了 Advisor 链里的一等公民 ToolCallingAdvisor: 它是个「递归 Advisor」,每迭代一次就重新进入下游 Advisor 链一次。 这意味着你现在可以:观察每一次迭代、在迭代之间做审批、替换整个执行策略、把工具调用和记忆/日志/评估组合起来。
这个变化是 2.0 做 Agent 应用的地基,第 9 章会围绕它展开。
知识拆解 · ToolCallingAdvisor 是什么 → 为什么 → 没有会怎样 → 类比
  • 是什么:一个挂在 Advisor 链里的「递归 Advisor」。它负责:把工具定义发给模型 → 执行模型点名的工具 → 把结果回灌进对话历史 → 再进入下一轮(递归),直到模型不再请求工具为止。
  • 为什么:2.0 把 1.x 写死在各 ChatModel 实现内部的工具循环抽出来,做成了 Advisor。于是循环对你可见、可插拔、可组合——能配合记忆 / 日志 / 评估一起用。
  • 没有会怎样(1.x 的痛点):循环焊死在模型实现里,你既看不到中间过程,也没法在循环里插入审批或换策略;每换一个模型厂商就得重新理解一套循环。
  • 类比:它是「项目里的循环调度员」;1.x 是把调度员焊死在每台机器内部,2.0 把它做成可替换的插件。
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,你需要自己接管循环)。
详见第 9 章 9.4 节。
维度自动接管(默认,推荐)手动接管(进阶)
怎么开什么都不用做,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。
一句话记忆:2.0 的工具循环被 ToolCallingAdvisor 自动接管,你只写一次 .call(),底层却可能跑两轮模型——贵就贵在这。

7.3@Tool 注解:把 Java 方法暴露给模型

把一个普通 Java 方法变成模型可用的工具,核心就是 @Tool 注解(包 org.springframework.ai.tool.annotation.Tool)。下面这个例子包含本章最重要的三个安全要点:描述怎么写、参数怎么描述、身份怎么取。

▌动手:创建 OrderTools 类

  1. 在 IDEA 左侧 Project 视图,展开 src/main/java/com/example/springai。若没有 ch07 包,右键 com.example.springai → New → Package,输入 ch07 回车。
  2. 右键 ch07 包 → New → Java Class,名字精确填 OrderTools,回车。
  3. 清空自动生成的内容,把下方整段代码粘贴进去(含 import),按 Ctrl/Cmd+S 保存。
  4. 确认 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 循环,可以混用。区别只在「怎么把一个函数变成工具」,不在「工具跑起来有何不同」。
工具越多,描述越要「互斥」。当你有 10 个工具时,最大的问题不是模型不会用,而是用错工具。 解决办法就是在每个描述里写清「不要用于……」,并保证工具职责不重叠。 如果两个工具功能高度相似,合并成一个带枚举参数的工具比拆成两个更好。

▌运行验证:编译通过,且 @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

  1. 右键 ch07 包 → New → Java Class,名字填 AssistantConfig。
  2. 粘贴下方「方式三(默认挂载)」代码(它最贴近生产写法),保存。
  3. 确认 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(...)✅ 生产代码业务代码零污染
2.0 的行为变化,写业务代码前必须知道三条:
  1. ChatClient 会自动注册 ToolCallingAdvisor,位置固定在 Ordered.HIGHEST_PRECEDENCE + 300。你不需要(也不应该)自己再注册一个—— 2.0 强制链中只能有一个工具 Advisor,重复注册会直接报错。 想换掉它,就提供一个自己的 ToolCallingAdvisor.Builder<?> bean。
  2. 每次调用的 .tools() 是追加,不是替换:它会把工具加到 defaultTools 之上,而不是覆盖。
  3. toolNames() 被删除了。1.x 那套「注册一个裸 Function bean, 再用名字解析」的机制(SpringBeanToolCallbackResolver)在 2.0 中已完全移除, 工具必须作为显式的 ToolCallback 注册。见下面的「方式四」。
知识拆解 · ToolCallback 是什么 → 为什么 → 没有会怎样 → 类比
  • 是什么:工具在框架内部的统一表示:一个携带「工具名 + 描述 + 参数 schema + 执行逻辑」的对象。@Tool 注解式、MethodToolCallback、FunctionToolCallback 三种风格最终都产出它。
  • 为什么:框架只认 ToolCallback 这一种形状。统一形状后,循环、注册、限流、检索才能对「任意工具」一视同仁。
  • 没有会怎样:若像 1.x 那样允许「裸 Function bean + 名字字符串」随意注册,框架就无法统一校验与治理,且 toolNames() 解析失败时会静默回退、放大越权风险(2.0 已删除这条路)。
  • 类比:ToolCallback 是「所有工具插进同一块插座的插头标准」——不管工具来自注解、方法还是函数,插上都通电。
工具不是越多越好。每注册一个工具,都会把它的名字、描述、参数 schema 塞进提示词, 消耗 Token 并干扰模型判断。实测经验:单次请求的工具数控制在 5~10 个以内; 超过 15 个时模型选错工具的概率显著上升。
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())✅ 大多数情况,最省事
MethodToolCallbackMethodToolCallback.builder().toolDefinition(...).toolMethod(...).toolObject(...)方法源码不受你控制、需运行时动态构建工具
FunctionToolCallbackFunctionToolCallback.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);
}
这条保护在 1.x 里是没有的。1.x 时代一个失控的 Agent 可以在一轮里把同一个工具调用几百次, 你只能靠自己写循环上限来兜。2.0 把「刹车片」做成了框架默认能力—— 但请注意:它的计数范围是「当前这一轮对话」(从最后一条 user 消息往后算), 跨请求的总量控制仍然要你自己做(第 9 章会讲)。

▌运行验证: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)在调用时注入,模型既看不到也无法伪造。

知识拆解 · ToolContext 是什么 → 为什么 → 没有会怎样 → 类比
  • 是什么:一个在每次 .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();
记住这条安全设计原则: 凡是「身份、权限、租户」类信息,一律走 ToolContext;凡是「业务参数」类信息(订单号、日期、关键词),才让模型传。 这样即使模型被注入攻击,攻击者也只能控制业务参数,无法越权。

returnDirect:让工具结果直接返回给用户

/**
 * 有些工具的返回值本身就是最终答案,不需要模型再加工一遍。
 * 例如"导出一份明细报表"——加工反而会改动数据、增加成本。
 * returnDirect = true 时,工具结果会跳过第二次模型调用,直接返回。
 */
@Tool(description = "导出指定订单的完整明细报表(CSV 文本)", returnDirect = true)
public String exportOrderDetail(@ToolParam(description = "订单号") String orderNo) {
    return reportService.toCsv(orderNo);
}
知识拆解 · returnDirect 是什么 → 为什么 → 没有会怎样 → 类比
  • 是什么:@Tool 的一个布尔开关(默认 false)。为 true 时,工具结果跳过第二次模型调用,直接作为最终回答返回给用户。
  • 为什么:有些工具的结果本身就是最终答案(导出报表、返回 SQL、返回代码),再让模型「翻译」一遍既浪费 Token,又可能改坏格式。
  • 没有会怎样:结果会被送回模型再生成一遍——多一次请求、多一份延迟与成本,且结构化 / 敏感内容可能被模型改写。
  • 类比:像「快递直送」vs「先回总部盖章再送」——能直送的就别绕路。
什么时候用 returnDirect? ① 结果是结构化数据、模型不该改写;② 想省一次模型调用(省钱省延迟); ③ 结果对格式极其敏感(如 SQL、代码、JSON)。 反之,如果希望模型把结果「翻译成人话」,就不要用。

▌运行验证:编译通过,且 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 常量,避免字符串硬拼写出错;③ 若多个入口都调用,抽一个统一入口方法负责注入,防止遗漏。
一句话记忆:返回值写给模型看(用 record 别用内部实体),身份走 ToolContext 不走参数——「业务参数让模型传,身份信息服务端给」。

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/受检异常仍冒泡你自己完全掌控返回内容,最稳
何时用几乎不主动用;仅程序性错误让它冒泡✅ 所有业务可预期错误(参数错、无权限、依赖暂时不可用)

全局异常处理器:统一决定「异常要不要抛」

2.0 把默认行为改对了,大多数项目不需要再写任何配置。 DefaultToolExecutionExceptionProcessor 现在的默认行为是:
· 任意 RuntimeException → 把异常消息包装后发回给模型, 让模型有机会向用户解释、换参数重试或换一个工具;
· 受检异常(checked exception)和 Error → 始终向上抛出,不会被吞掉。
这个划分是有意设计的:能自愈的错误交给模型,程序性错误绝不能藏起来。
知识拆解 · ToolExecutionExceptionProcessor 是什么 → 为什么 → 没有会怎样 → 类比
  • 是什么:一个函数式接口,决定「工具执行抛出的异常,最终变成什么返回给模型」。返回值会作为「工具执行结果」回灌给模型。
  • 为什么:默认策略把 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 条」,完整数据走另一条接口
一个必须避免的设计错误:工具数量爆炸 + 描述模糊。 常见于「把 Service 层的所有方法都加 @Tool」。结果模型面对 40 个工具, 选错率飙升,还平白消耗大量 Token。正确做法是为 AI 场景单独设计一层「精简工具」, 例如把 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 依赖(自建工程的根基)

  1. 打开工程根目录的 pom.xml,确认 <parent> 指向 spring-boot-starter-parent 且版本是 4.1.x(如 4.1.1)。
  2. 在 <dependencyManagement> 里引入 spring-ai-bom,版本锁 2.0.1——这是全课程版本基准的开关。
  3. 在 <dependencies> 里至少加入下面三个 starter:Web(提供 @RestController)、Spring AI 的 OpenAI 模型 starter(提供 ChatModel 与工具能力)、Lombok(可选,本示例用 @Slf4j 需要)。
  4. 保存后,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(密钥占位符)

  1. 打开 src/main/resources/application.yml(没有就 New → File 创建)。
  2. 粘贴下方配置,保存。注意 api-key 用的是 ${DEEPSEEK_API_KEY:} 占位符,绝不硬编码真实 key。
  3. 确认环境变量 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 用过)
src/main/java/com/example/springai/ch07/ ├── OrderTools.java # 订单查询工具(含权限校验) ├── WeatherTools.java # 天气查询工具(模拟外部 API) ├── AssistantConfig.java # 装配带工具的 ChatClient ├── OrderAssistantService.java # 业务服务 └── OrderAssistantController.java # HTTP 接口

① 天气工具(演示外部 API 调用 + 超时保护)

  1. 右键 ch07 包 → New → Java Class,名字填 WeatherTools,粘贴下方代码,保存。
  2. 确认引用到的 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

  1. 右键 ch07 包 → New → Java Class,名字填 AssistantConfig,粘贴下方代码,保存。
  2. 确认它用 @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 的注入)

  1. 右键 ch07 包 → New → Java Class,名字填 OrderAssistantService,粘贴下方代码,保存。
  2. 确认构造器注入了带 @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();
    }
}

④ 接口与验证

  1. 右键 ch07 包 → New → Java Class,名字填 OrderAssistantController,粘贴下方代码,保存。
  2. 确认类上有 @RestController + @RequestMapping("/api/v1/assistant"),且注入了 OrderAssistantService。
  3. 全部文件就位后,执行下面的「▌运行验证」把工程跑起来,再用 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":"我的订单发货了吗?"}'

# 期望:模型回答"请提供订单号"
# 如果它随便编了一个订单号去查 → 你的系统提示约束不够强

看到什么算通过(逐条):

第 ② 步没挡住(返回了别人的订单数据)? 根因几乎一定是:工具方法里从模型传来的参数取了用户身份,而不是从 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(...)检查每次调用是否注入了上下文;或写个统一入口方法
排错总路线(按顺序查,别跳):
  1. 先看工具有没有注册上:在启动日志里找「已从 MCP/本地加载 N 个工具」,或临时打印 mcpTools.length。
  2. 再看模型有没有发起调用:开 debug 日志看 tool_calls 有没有出现。
  3. 若模型没调工具:九成是描述写得不够「互斥 / 具体」,回到 7.3 四要素重写描述。
  4. 若模型调了但参数错:看 @ToolParam 描述是否给了示例,工具内部是否做了格式校验。
  5. 若工具执行报错:先用「返回错误对象」而非抛异常,把错误话术写给模型看;敏感异常再开 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,描述你会在自己的课程项目中如何给「创建订单」这类写操作工具做幂等与确认保护。