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 日志

光看文字不直观,现在就让你在日志里看到循环。这一步有个坑必须先说清楚,否则你会白等半小时:

实测(Spring AI 2.0.1):只把 org.springframework.ai.chat.client 开到 DEBUG,你什么都看不到。 根因:2.0 的 ToolCallingAdvisor 不打「第 N 次迭代」这类日志, DefaultToolCallingManager 也只在 WARN 级打「找不到该名字的工具」这类异常信息。 旧版教程里那种 [ToolCallingAdvisor] 第 1 次迭代:模型返回 tool_calls=[...] 的行, 在 2.0.1 里并不存在——那是示意性文案,不是真实输出。照抄只会以为「工具没生效」。

正确做法:显式挂一个 SimpleLoggerAdvisor。它是递归 advisor—— 工具循环每迭代一次,请求就会重新流经它一次,所以一次问答会打出两组 request/response:

ChatClient client = ChatClient.builder(chatModel)
        .defaultAdvisors(new SimpleLoggerAdvisor())   // ★ 排 ToolCallingAdvisor 下游,每轮都流经它
        .build();
# 触发一次带工具的问答(接口见 7.7)
curl -X POST http://localhost:8080/api/v1/assistant/ask \
  -H "Content-Type: application/json" \
  -d '{"question":"订单 2024001 什么时候发货?"}'

实测日志(一次 curl,节选,注意 request 出现两次):

20:11:03.349  SimpleLoggerAdvisor : request:  ChatClientRequest[...SystemMessage, UserMessage]
20:11:03.894  SimpleLoggerAdvisor : response: {...}
[OrderTools] orderNo=2024001(模型传参), userId=u1001(上下文注入)   ← 工具在这一步执行
20:11:03.927  SimpleLoggerAdvisor : request:  ChatClientRequest[... 多了一段工具结果]
20:11:04.306  SimpleLoggerAdvisor : response: {...}

第 2 次请求的 messages 里,能直接看到教材 7.2 那两段 JSON 的真实对应物: AssistantMessage[toolCalls=[ToolCall[id=call_00_..., name=queryOrderStatus, arguments={"orderNo":"2024001"}]]] 以及随后的 ToolResponseMessage。

看到什么算通过(可数,不用猜):一次 curl 对应的日志里,同一线程 request: 出现 2 次即为通过——1 次是「模型直接回答」,2 次才是「调了工具又问了一遍」。 配合 curl 返回了真实的发货说明(不是报错、不是空串),两条一起过才算真的跑通。

为什么这个判据比「看回答像不像」可靠:模型可以凭训练数据编出一段很像的发货话术。 但「request 出现了几次」是框架行为,编不出来。整章后面判断 returnDirect 是否生效,用的也是这一招 (普通工具 2 组、returnDirect 只有 1 组,见 7.5)。
报错: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.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Map;

@Component                              // 必须是 Spring bean,Spring AI 才能扫描到它
public class OrderTools {

    /** 演示用假数据;真实项目换成 Repository / Mapper。key = 订单号 */
    private static final Map<String, Order> DB = Map.of(
            "2024001", new Order("2024001", "u1001", "未发货", LocalDate.of(2026, 10, 28)),
            "2024002", new Order("2024002", "u1001", "已发货", LocalDate.of(2026, 10, 19)),
            "2024003", new Order("2024003", "u2002", "已发货", LocalDate.of(2026, 10, 20)));

    /**
     * 一个工具方法。三个要素决定模型能不能用好它:
     * ① 方法名     → 模型看到的工具名(尽量用动词+名词,语义清晰)
     * ② description → ★ 最重要!模型靠它决定"什么时候用这个工具、不用那个"
     * ③ 参数描述    → 模型靠它知道"这个参数从用户的话里怎么抽"
     */
    @Tool(description = """
            查询指定订单号的当前状态与预计发货时间。
            使用时机:当用户询问某个订单的发货情况、物流状态、是否已发货时调用。
            不要用于:查询商品信息、退款进度、账户余额。
            参数约束:orderNo 为纯数字订单号,例如 2024001。
            """)                          // 文本块(Java 15+),多行描述更清晰
    public OrderStatus queryOrderStatus(
            @ToolParam(description = "订单号,纯数字字符串,例如 2024001")
            String orderNo,                   // ← 模型能看见、能填的参数
            ToolContext toolContext) {        // ← 模型看不见的上下文(7.5 详讲)

        // ★★★ 这里就是安全边界!身份只从服务端上下文取,绝不作为工具参数让模型填
        String currentUser = (String) toolContext.getContext().get("userId");
        if (currentUser == null || currentUser.isBlank()) {
            // 工具内部的 null 检查是最后一道防线:调用方忘了传 toolContext 时,这是唯一的兜底
            return new OrderStatus(orderNo, "ERROR", null, "缺少登录身份,无法查询订单");
        }

        Order order = DB.get(orderNo);
        if (order == null || !order.ownerId().equals(currentUser)) {
            // 返回明确错误,而不是抛异常——让模型能理解并礼貌回复用户;
            // 越权时也回"不存在",不透露"这单存在但不是你的"(少给攻击者一分确认信息)
            return new OrderStatus(orderNo, "NOT_FOUND", null,
                    "系统中不存在该订单,或该订单不属于当前用户");
        }
        return new OrderStatus(order.orderNo(), order.status(), order.promisedDate(), "查询成功");
    }

    /** 无参工具也可以(模型不需要传任何参数)。典型场景:获取"今天日期"这类模型自己不知道的信息。 */
    @Tool(description = """
            获取当前的真实日期与时间。
            使用时机:用户说"今天""本周""最近""现在"等相对时间时必须先调用,否则模型会算错日期。
            不要用于:查询订单、商品等业务数据。
            """)
    public String currentDateTime() {
        return LocalDateTime.now()
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss EEEE"));
    }

    /** 工具的返回类型:建议用 record,字段名会进入模型的上下文 */
    public record OrderStatus(String orderNo, String status,
                                LocalDate promisedDate, String message) {}

    /** 内部数据载体,不暴露给模型 */
    private record Order(String orderNo, String ownerId, String status, LocalDate promisedDate) {}
}
为什么不用 SecurityContextHolder.currentUserId() + orderRepository? 这两个东西不在 Spring AI 里:前者要引入 Spring Security 且要有登录过滤器真的往里塞 Authentication, 后者要 JPA + 实体类 + 数据库表。本章是「纯 Spring AI」的自建工程,照抄那种写法会直接编译不过, 而且会把「工具调用」和「权限框架/持久层」两件事混在一起。 2.0 的原生答案是 ToolContext——服务端注入、模型看不见也改不了,7.5 专门讲它。 所以上面用 Map 当假数据库、用 ToolContext 拿身份:能跑、能验、不掺别的框架。

为什么这么写:@Tool 的 description 不是给人看的注释,而是会被序列化进发给模型的「工具定义」里——模型完全靠它来决策。所以描述要写成「给模型的说明书」:做什么、何时用、何时不用、参数长什么样。返回类型用 record 是因为它的字段名天然成为 JSON 的 key,进入模型上下文后语义清晰。

写工具描述的「四要素」模板

要素说明示例
做什么一句话说明功能,动词开头查询指定订单号的当前状态与预计发货时间。
何时用给模型明确的触发条件当用户询问发货情况、物流状态时调用。
何时不用★ 很多人漏掉,但极其重要不要用于查询商品信息、退款进度、账户余额。
参数约束格式、取值范围、必填性订单号为纯数字字符串,例如 2024001。
风格写法要点适用
@Tool 注解式在方法上加 @Tool / @ToolParam,Spring 自动扫成 ToolCallback。描述、参数约束都写在注解里,最直观✅ 大多数情况:工具就是你自己写的业务方法
FunctionToolCallback 函数式用 FunctionToolCallback.builder(name, fn).inputType(...) 把现成的 Function / lambda / 方法引用包成工具要暴露的不是 POJO 方法,而是已有函数、第三方 SDK 调用、方法引用
二者关系最终都产出 ToolCallback,走同一条 ToolCallingAdvisor 循环,可以混用。区别只在「怎么把一个函数变成工具」,不在「工具跑起来有何不同」。
工具越多,描述越要「互斥」。当你有 10 个工具时,最大的问题不是模型不会用,而是用错工具。 解决办法就是在每个描述里写清「不要用于……」,并保证工具职责不重叠。 如果两个工具功能高度相似,合并成一个带枚举参数的工具比拆成两个更好。

▌运行验证:编译通过只是「必要条件」,不是通过判据

# 在 pom.xml 所在目录执行(只编译,不运行,最快的反馈)
mvn -q compile

期望输出:命令结束、无红色报错(-q 下成功时几乎无输出)。

别把这一步当成「@Tool 被扫描到了」的证据。 mvn -q compile 只能证明「类写得合法」,它完全不知道工具会不会被注册、模型会不会调。 本章最常见的误判就是:编译过了、IDEA 没红线,就以为工具生效了,然后花一小时在一个根本没跑起来的东西上改描述。

真正的判据(装好 ChatClient 之后,见 7.7):看一次请求的「底层产物」。最省事的做法是在工具方法第一行打一行日志:

// OrderTools.queryOrderStatus 第一行
System.out.println("[OrderTools] orderNo=" + orderNo + ", userId=" + currentUser);

然后问一句「订单 2024001 什么时候发货?」——

报错:找不到符号 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。这不是优化,而是能不能用的问题。
教材边界(诚实标注):本章示例没有启用方式五,也没有实测它的省钱比例。 原因很直接——本章只有 3 个工具,按上面的判据属于「不要开」的区间,开了只会多一层检索延迟。 想自己验的话:先造 20 个以上工具,用同一组问题对比「开 / 不开」的 token 消耗与调用准确率, 再决定是否上生产。spring-ai-starter-tool-search-advisor 这个 artifact 确实在 2.0.1 的 BOM 里 (mvn dependency:tree 能看到同族 7 个 artifact),不是「文档里有、仓库里没有」的假货。

方式六:给工具调用加上次数上限(防失控)

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 章会讲)。
诚实标注(实测到哪一步):上面这几个配置项名逐一核对过—— max-calls-per-tool-default、max-total-tool-calls、on-limit-exceeded 都能被正常绑定(启动不报错就是证据),但本章没有真的跑到 40 次去触发 ToolCallLimitExceededException:正常业务里模型很难在一个问题里连调同一工具 40 次, 硬凑出来的演示没意义。
想亲眼看它长什么样,有个 10 秒的办法:把上限临时改小—— max-calls-per-tool-default: 1,再问一个需要连续查两单的问题, 就能在日志里撞到那个异常。看完记得改回去。

▌运行验证: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");
        if (userId == null || userId.isBlank()) {
            // 兜底:调用方没注入上下文时,宁可回错误,也不要"查所有人"
            return List.of(new OrderStatus("-", "ERROR", null, "缺少登录身份,无法查询订单列表"));
        }

        // 越权在这里天然不可能发生:过滤条件来自上下文,模型干预不了
        return DB.values().stream()
                .filter(o -> o.ownerId().equals(userId))
                .map(o -> new OrderStatus(o.orderNo(), o.status(), o.promisedDate(), ""))
                .toList();
    }

调用时由服务端把上下文注入进去(这一步发生在你的 Service 里,模型无感):

// 模拟"已登录用户"。真实项目里换成从登录态取(JWT / Session / 网关透传),
// 本章是纯 Spring AI 工程,没有引入 Spring Security,所以用一个常量占位——
// 关键是:这个值【从服务端代码流向 ToolContext】,而不是从 HTTP 请求体里读。
private static final String CURRENT_USER_ID = "u1001";
private static final String CURRENT_TENANT_ID = "t1001";

String answer = chatClient.prompt()
        .user("帮我看看我有哪些订单")
        // ★ 由服务端注入,模型看不到也改不了
        .toolContext(Map.of(
                "userId", CURRENT_USER_ID,
                "tenantId", CURRENT_TENANT_ID))
        .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)。 反之,如果希望模型把结果「翻译成人话」,就不要用。
实测代价:returnDirect 连「错误信息」也原样直出。 结果是「直接复制」给用户的,不经过模型那一层润色——所以工具返回什么,用户就看到什么。 本章 lab 实测:导出别人订单时,用户屏幕上直接出现 订单号,结果 / 2024003,不存在或无权访问 这种原始 CSV 串; 如果工具内部抛异常,异常原文也会被端上去。 结论:易失败的、错误文案不友好的工具,别用 returnDirect。 真要用,就把失败路径设计成「抛异常交给 7.6 的异常链路处理」,而不是返回一段原始文本。
怎么确认它真的生效(可数判据):挂 SimpleLoggerAdvisor 后数 request:—— 普通工具一次问答 2 组,returnDirect 工具只有 1 组(第二次模型调用被跳过了)。 只看「回答像不像原文」不可靠:模型有时会把 CSV 转述得和原文几乎一样,你以为生效了其实没生效。

▌运行验证:ToolContext 有两种失败形态,必须分清

mvn -q compile

编译只算必要条件。ToolContext 真正的判据在运行期,而且失败不是一种,是两种完全不同的形态——这是本章最容易记混的地方:

情况实测结果你会看到的现象
正常注入 ✅ 查询成功 日志 [OrderTools] orderNo=2024001, userId=u1001;只返回当前用户自己的订单
压根没写 .toolContext(...) ❌ HTTP 500 IllegalArgumentException: ToolContext is required by the method as an argument。
工具方法声明了这个参数,框架就必须给它一个值,给不出来当场炸
写了,但 key 拼错
(user_id vs userId)
⚠️ 静默 null 不报错、不 500。工具里 get("userId") 拿到 null,模型答「缺少登录身份」。这才是最难查的那种

看到什么算通过:三种情况各跑一次,结果与上表逐行对上。特别注意第二行——很多人以为「忘传」会静默取到 null, 实测是直接 500;真正静默的只有第三行(key 拼错)。把这两个混为一谈,排错时就会往错的方向找。

为什么工具里的 null 检查「必须」写:因为 key 拼错这类错误没有编译期保护—— Map.of("user_id", ...) 语法完全合法,编译器不会提醒你写错了。 所以工具第一行固定做三件事:get("userId") → 判 null/blank → 返回一条明确错误。 宁可让模型回「缺少登录身份」,也绝不能让它顺手去查全库。
更稳的工程做法(两招就够): ① 把 key 抽成常量(public static final String CTX_USER_ID = "userId"),注入处和读取处都用它; ② 抽一个 buildToolContext(...) 统一构造上下文,多个入口就不会漏传、也不会各拼各的。
一句话记忆:返回值写给模型看(用 record 别用内部实体),身份走 ToolContext 不走参数——「业务参数让模型传,身份信息服务端给」。

7.6参数校验、异常处理与安全边界

工具里的错误:返回给模型,而不是抛给用户

工具执行失败时的处理,决定了用户体验的成败。下面的代码把「参数校验 → 权限校验 → 业务兜底」三段都写成「返回结构化错误对象」而不是抛异常,这样模型能把错误翻译成人话。

/**
 * 工具执行失败时的两种处理方式,效果差别巨大:
 *
 * ❌ 抛异常 → Spring AI 会把异常信息带回给模型(或直接失败),
 *           用户看到的是"系统错误",体验极差。
 *
 * ✅ 返回结构化的错误对象 → 模型能"读懂"错误,并组织成礼貌的话回复用户,
 *           例如:"没有找到订单 2024001,请确认订单号是否正确?"
 */
@Tool(description = """
        查询指定订单号的当前状态与预计发货时间。
        使用时机:用户询问某个订单是否已发货、什么时候发货时调用。
        不要用于:查询商品信息、退款进度、账户余额。
        参数约束:orderNo 为纯数字订单号,例如 2024001。
        """)
public OrderStatus queryOrderStatus(
        @ToolParam(description = "订单号,纯数字字符串,例如 2024001") String orderNo,
        ToolContext toolContext) {              // ← 身份从上下文取,不从参数取

    // ---------- ① 参数格式校验:模型可能传 "订单2024001"、全角数字或空值 ----------
    if (orderNo == null || !orderNo.matches("\\d{6,20}")) {
        return new OrderStatus(orderNo, "INVALID", null,
                "订单号格式不正确,应为 6~20 位数字。请向用户确认订单号后重试。");
    }

    // ---------- ② 权限校验:绝不接受模型传来的用户身份 ----------
    String userId = (String) toolContext.getContext().get("userId");
    if (userId == null || userId.isBlank()) {
        return new OrderStatus(orderNo, "ERROR", null, "缺少登录身份,无法查询订单");
    }

    // ---------- ③ 业务异常兜底:把技术异常翻译成业务语言 ----------
    try {
        Order order = DB.get(orderNo);
        // ★ 越权时按"查不到"返回——不告诉对方"这单存在,只是不是你的"
        if (order == null || !order.ownerId().equals(userId)) {
            return new OrderStatus(orderNo, "NOT_FOUND", null,
                    "未查询到该订单。请确认订单号是否正确。");
        }
        return new OrderStatus(order.orderNo(), order.status(), 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 → 向上抛出,用户看到 HTTP 500。
这个划分是有意设计的:能自愈的错误交给模型,程序性错误绝不能藏起来。 但这两条都只在你「没有自定义 processor」时成立——一旦自定义,它就是最后一道闸门(见下文)。
★★ 实测:默认策略下「把异常消息发给模型」= 把内部细节发给用户。 本章 lab 造了一个必抛异常的工具,异常消息里带了一句假的连接串 jdbc:postgresql://10.0.0.5:5432/logistics, user=ops_reader。实测模型回答: 「物流查询接口报错,错误信息:jdbc:postgresql://10.0.0.5:5432/logistics, user=ops_reader, 建议检查数据库连接或稍后重试……」——内部 IP、库名、账号,一个不差地复述给了终端用户, 还贴心地包装成了"排查建议"。
这不是「可能泄露」,是必然泄露:只要异常消息里有敏感信息,模型就会把它当事实转述—— 它没有「这是机密」这个概念。
结论:默认策略只适合「异常消息本来就可以公开」的工具。 凡是会连数据库、内部服务、第三方系统的工具,必须上自定义 processor,且不回传异常原文。
知识拆解 · 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());

            // ★★ 关键:这里【绝不发回异常原文】。
            //   实测默认策略会把异常消息(含内部 IP / 库名 / 账号)发给模型,
            //   模型会一字不差地复述给用户(见上面那个 pit)。自定义处理器必须堵住这条路。
            return "该功能暂时不可用(内部错误已记录)。请告知用户稍后重试,本次不要重复调用此工具。";
        };
    }
}
★★ 上面这段代码有个隐藏陷阱,直接照抄会出事:自定义 processor 会吞掉受检异常。 同一个 lambda 里,如果无条件 return "...",那么不论 ToolExecutionException 里包的是 RuntimeException 还是 IOException,都到不了上层—— 前面刚讲的「受检异常始终冒泡」在你自定义之后就失效了。
实测:一个抛 IOException 的工具,用上面这种「无条件返回文案」的写法时不再返回 500, 而是安静地回了一句友好文案。磁盘写满、连接池耗尽这类真故障,监控上什么都看不见,用户反复重试也永远好不了。
正解:自己判类型——只翻译「业务可自愈」的错误,程序性错误原样抛出去:
@Bean
public ToolExecutionExceptionProcessor toolExecutionExceptionProcessor() {
    return exception -> {
        Throwable cause = exception.getCause();
        log.error("工具执行失败:工具={},原因={}",
                exception.getToolDefinition().name(), cause);

        // ★ 受检异常(IOException / SQLException 这类程序性错误)不吞,继续向上抛 → 500 + 监控告警
        if (cause != null && !(cause instanceof RuntimeException)) {
            throw exception;
        }

        // 业务可自愈的错误才翻译成人话,且绝不带内部细节
        return "该功能暂时不可用(内部错误已记录)。请告知用户稍后重试。";
    };
}
三种异常策略的实测对照(本章 lab 逐个跑过,可以直接背):
processorRuntimeException受检异常
默认 DefaultToolExecutionExceptionProcessor消息回给模型(含敏感信息)冒泡(500)
自定义(无条件返回文案)文案回给模型被吞掉,无 500 ← 最容易踩
自定义(判类型后再决定)文案回给模型冒泡(500)
spring.ai.tools.throw-exception-on-error=true 不是「更好的默认值」,是「全变 500」。 实测:设为 true 后,工具抛 RuntimeException 也从「回给模型」变成直接 HTTP 500。 它能彻底堵住泄露,代价是连「参数暂时不对、换个说法就能好」这类可自愈错误也变成一个红叉。
怎么选(按敏感程度):
  • 异常消息本来就能公开(如纯业务校验)→ 用默认 false;
  • 异常消息可能含内部信息,又想保留「模型能翻译」的能力 → ✅ 自定义 processor + 不回传原文(推荐);
  • 极端场景:宁可全部失败也不能有半点泄露 → 才设 true。
自定义 ToolCallback 时的注意事项: 如果你是自己实现 ToolCallback 而不是用 @Tool 注解, 执行失败时应该抛 ToolExecutionException——这是处理器期望的异常类型。 抛别的异常会绕过你的自定义逻辑。 排查步骤:若发现异常被静默吞掉或处理器没生效,① 确认你的异常类型是 ToolExecutionException 或 RuntimeException;② 检查是不是自定义 processor 把受检异常也吞了 (受检异常只在「默认 processor」下才冒泡,自定义之后你能拦下一切,见上文那张对照表); ③ 确认自定义 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}"))、 ② 权限校验(从 ToolContext 取身份,不是从方法参数取)、③ 异常兜底(try/catch 返回错误对象)。 三条齐了,才谈得上「工具不裸奔」。

真正的判据要跑起来看(三条,逐条可证伪):

判据怎么验通过的样子
① 参数校验生效 问「订单号 ABC-2024 到哪了」 模型礼貌回「订单号应为纯数字…请确认」,且日志里没有 [OrderTools] 行(说明在 ① 就返回了,没白查一次库)
② 越权被挡住 问一个存在但属于别人的订单号(见 7.7 的 2024003) 回答「未查询到该订单」,不出现任何对方的字段;日志里 userId 仍是当前用户
③ 异常策略符合预期 用「必坏工具」抛一次 RuntimeException,分别开 / 关自定义 processor 默认策略:模型复述内部信息(这是缺陷,别当成正常);自定义后:只有一句友好文案,内部信息只留在服务端日志
为什么要强调「存在但属于别人」:拿一个根本不存在的订单号(比如 9999999)去试, 走的是「查不到」分支,跟权限一点关系都没有——你把归属校验整段删掉,它照样返回「未查询到」。 必须用一个真实存在、但归属是别人的号,才能真正走到权限那行代码。这是本章最容易被糊弄过去的一个验收点。
一句话记忆:工具出错要「返回错误对象而非抛异常」让模型翻译;越权、注入、破坏性操作是三大红线,身份永远服务端定。

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   # ★ 实测对「看工具循环」没用,保留只为看 prompt 内容(原因见 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. 下面这版「本地就能跑」:不依赖任何外部天气 SDK,用一张假数据表模拟 API 返回。 真实项目里把 STUB.get(key) 换成一次 HTTP 调用即可——务必设连接/读取超时, 否则外部服务一卡,你的 Tomcat 线程池会跟着陪葬。
@Slf4j
@Component
public class WeatherTools {

    /** 本地假数据:城市 → 三天的 {天气, 最低温, 最高温, 湿度}。真实项目换成外部 API。 */
    private static final Map<String, List<String[]>> STUB = Map.of(
            "北京", List.of(new String[]{"晴", "12", "22", "45"},
                              new String[]{"多云", "14", "24", "50"},
                              new String[]{"小雨", "11", "19", "78"}),
            "上海", List.of(new String[]{"多云", "18", "25", "60"},
                              new String[]{"阴", "17", "23", "70"},
                              new String[]{"中雨", "16", "21", "85"}),
            "深圳", List.of(new String[]{"阵雨", "23", "29", "80"},
                              new String[]{"雷阵雨", "24", "30", "88"},
                              new String[]{"多云", "24", "31", "75"}));

    @Tool(description = """
            查询指定城市未来 3 天(今天、明天、后天)的天气预报。
            使用时机:用户询问天气、气温、是否下雨、穿衣建议时调用。
            不要用于:查询历史天气(本工具只支持未来 3 天)、查询空气质量指数。
            参数约束:city 为中文城市名,不要带"市"字后缀。
            """)
    public WeatherInfo queryWeather(
            @ToolParam(description = "城市名称,中文,例如 北京、上海、深圳;不要带'市'字后缀")
            String city) {

        // ---------- ① 城市名做白名单/格式校验,防止把用户输入拼进 URL 造成注入 ----------
        if (city == null || city.isBlank() || city.length() > 20) {
            return new WeatherInfo(city, List.of(), "城市名不合法,请向用户确认城市名称。");
        }
        // 模型经常不听 description 而带上"市",服务端顺手兜一下,提高命中率
        String key = city.endsWith("市") ? city.substring(0, city.length() - 1) : city;

        List<String[]> raw = STUB.get(key);
        if (raw == null) {
            // ★ 未收录也返回"人话结论",不要抛异常——模型会把 message 如实转述给用户
            return new WeatherInfo(city, List.of(),
                    "天气服务暂未收录「" + city + "」,目前仅支持北京、上海、深圳。请如实告知用户。");
        }

        try {
            // ★★ 关键:把返回值"翻译"成模型友好的中文,而不是原样丢 JSON
            LocalDate today = LocalDate.now();
            String[] labels = {"今天", "明天", "后天"};
            List<DailyWeather> days = IntStream.range(0, Math.min(3, raw.size()))
                    .mapToObj(i -> new DailyWeather(
                            today.plusDays(i).format(DateTimeFormatter.ofPattern("MM-dd")),
                            labels[i],                                    // ★ 服务端算好"今天/明天/后天"
                            raw.get(i)[0],                                // "晴" / "小雨"
                            raw.get(i)[1] + "~" + raw.get(i)[2] + "℃",
                            raw.get(i)[3] + "%"))
                    .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) {}

    /** ★ dayLabel 是本章加的一个小设计:见下面的说明 */
    public record DailyWeather(String date, String dayLabel, String weather,
                                    String temperature, String humidity) {}
}
为什么返回里要带 dayLabel(今天/明天/后天)? 因为用户问的是「明天」,而工具只给 10-22 这种日期时,模型得自己算出哪天是明天。 它上下文里未必有「今天是几号」(除非你另外挂了时间工具),算错一次,整段回答就跟着错。 服务端顺手把 LocalDate.now().plusDays(i) 换算成「今天/明天/后天」,模型直接照抄,零出错空间。
这和本章反复强调的是同一件事:工具返回的应该是「已经翻译成人话的结论」,不是让模型二次加工的原始数据。
换成真实天气 API 时要改的就三行: ① STUB.get(key) → restClient.get().uri("/weather?city={c}", city).retrieve().body(...); ② 构造 RestClient 时设超时(连接 2s / 读取 3s),别用默认值; ③ 保留现在这套「校验 → 翻译 → catch 兜底」的骨架——这部分才是工具代码的价值,API 叫什么名字并不重要。

② 装配带工具的 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 {

    // 模拟"已登录用户"。真实项目里换成从登录态取(JWT / Session / 网关透传),
    // 关键在于:这个值从服务端代码流向 ToolContext,而不是从 HTTP 请求体里读。
    private static final String CURRENT_USER_ID = "u1001";
    private static final String CURRENT_TENANT_ID = "t1001";

    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", CURRENT_USER_ID,
                        "tenantId", CURRENT_TENANT_ID))
                .call()
                .content();
    }

    /** 进阶:想手工拿到「模型这一轮调了哪些工具」——注意这条路在 2.0 里基本走不通,原因见下方 pit。 */
    public String askWithToolTrace(String question) {
        ChatResponse response = orderAssistantClient.prompt()
                .user(question)
                .toolContext(Map.of("userId", CURRENT_USER_ID))
                .call()
                .chatResponse();

        var output = response.getResult().getOutput();

        // 如果模型这一轮又发起了工具调用,会出现在这里(实测 2.0 通常是空的)
        if (output.getToolCalls() != null && !output.getToolCalls().isEmpty()) {
            output.getToolCalls().forEach(tc ->
                    log.info("模型请求调用工具:name={} arguments={} id={}",
                            tc.name(), tc.arguments(), tc.id()));
        } else {
            log.info("[trace] getToolCalls() 为空 —— 工具已在 ToolCallingAdvisor 内部执行完毕");
        }
        return output.getText();
    }
}
★★ 实测:这段 askWithToolTrace 在 2.0 里看不到任何工具调用,getToolCalls() 恒为空。 为什么?工具循环是在 ToolCallingAdvisor 内部跑完的(见 7.2): 等 .chatResponse() 把结果交到你的业务代码手里时,那已经是「工具结果消化完之后的最后一轮」—— 里面只有自然语言,没有 toolCalls。所以这段代码不报错,但也永远不会打印出东西, 别拿它当调试手段,否则你会误以为工具没被调用。
要看工具调用,只剩两条靠谱路:
  • 工具方法内部打日志(本章用的办法,最直白,能看到模型传进来的每个参数);
  • 挂 SimpleLoggerAdvisor 数 request: 组数(1 组 = 没进循环,2 组 = 调了 1 次工具)。

④ 接口与验证

  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);
    }
}
★ 一个 URI 只能有一个 handler,而且「能启动」不等于「打对了门」。 如果工程里已经为别的示例写过 @RequestMapping("/api/v1/assistant")(本章前面几节为了验证 @Tool、 ToolContext 很可能随手加过 Controller),再挂同名 /ask, 启动时会报 Ambiguous mapping——这个还算友好,至少你知道撞了。
真正阴的是两个 Controller 都活着,而你 curl 的那个 /ask 指向「没挂天气工具」的那个 client: HTTP 200、订单查得对、天气那半句答"我是订单助手,没有天气查询能力"。 看起来像天气工具没注册,实际上是请求进错了方法。本章实测就踩了这个坑。
三招确认「打的是不是正确的门」:
  • 看日志:命中总装 client 时,应该同时出现 [OrderTools] ... 与 [WeatherTools] ... 两行;只剩一行,就是进错门了;
  • 加自检接口:写个 @GetMapping("/endpoints") 列出「每个 URI 归哪个 service」——工程里有多套 ChatClient 时,这十行代码能省掉大量瞎猜;
  • 给旧入口改名:前面小节为演示写的简化入口,改名成 /ask-basic 之类,把 /ask 让给总装版本。两个语义不同的东西抢同一个 URI,迟早出一次这种事。

▌运行验证:把工程跑起来

# 在 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 什么时候发货?"}'

# 期望日志(工具方法内部那行,见 7.3):
# [OrderTools] orderNo=2024001, userId=u1001
# 期望回答:"订单 2024001 显示未发货,预计 2026-10-28 发出。"

# ② 越权测试:2024003 在库里【真实存在】,但属于另一个用户 u2002
curl -X POST http://localhost:8080/api/v1/assistant/ask \
  -H "Content-Type: application/json" \
  -d '{"question":"订单 2024003 发货了吗?"}'

# 期望:模型回答"未查询到该订单,或该订单不属于当前账号"
# ★ 关键:必须用 2024003 这种"存在但属于别人"的单号
#   用 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":"我的订单发货了吗?"}'

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

# ⑤ 工具不支持的城市 → 应如实转述,不许自己编天气
curl -X POST http://localhost:8080/api/v1/assistant/ask \
  -H "Content-Type: application/json" \
  -d '{"question":"成都今天天气如何?"}'

# 期望:"暂未收录「成都」,目前仅支持北京、上海、深圳"
# ★ 这一步验的是【工具返回的 message 字段】:把"查不到"写成人话,模型才会照实转述。
#   如果模型自己编了个"成都晴 20 度",说明工具返回得太模糊(比如只回了个空列表)。

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

为什么第 ② 步不能拿 9999999 试? 那是本章(也是很多教程)最容易糊弄自己的地方:9999999 在库里压根不存在, queryOrderStatus 走的是 if (order == null) → NOT_FOUND 这一支, 跟权限校验一行代码的关系都没有。你把 !order.ownerId().equals(userId) 整段删掉, 它依旧老老实实返回「未查询到」。要真正打到权限那一行,必须用一个存在、但归属是别人的订单号(本示例的 2024003)。
第 ② 步没挡住(返回了别人的订单数据)? 根因几乎一定是:工具方法里从模型传来的参数取了用户身份,而不是从 ToolContext 取。 修复:回到 7.5 与 7.6,确认 queryOrderStatus 内 ① 身份来自 toolContext.getContext().get("userId")、 ② 过滤条件是 order.ownerId().equals(userId)(服务端比对),③ 越权时返回「未查询到」而不是「这单不属于你」。 身份绝不能作为工具参数出现——一旦它进了 @ToolParam,模型就能替你填别人的 id。
第 ④ 步模型反而编了个订单号去查? 根因:系统提示约束不够强,或缺少参数时模型倾向于「先调了再说」。 修复:① 强化 defaultSystem 里「缺少参数先追问、不要编造」的指令(本示例已含);② 在工具内部做参数格式校验(7.6 的 matches("\\d{6,20}")),即便模型编了非法号也会返回友好错误而非误查;③ 把模型温度适当调低,减少「硬编」。
实测补充:工具集里若有「零参兜底工具」,第 ④ 步的表现会变。 本章 lab 的 OrderTools 里还有一个不需要任何参数的 queryMyOrders(查"我的全部订单",7.5)。 实测问「我的订单发货了吗」时,模型没有干巴巴地反问,而是答:「我需要您的订单号才能查询…… 如果您不记得,我也可以帮您查询名下的全部订单列表,需要吗?」——它自己找到了一条退路。
这体验其实更好,但要知道原因:挂上零参工具后,「缺参数必须追问」就不再是唯一行为了。 想强制它只追问,要么系统提示写死「缺参数时必须先问,禁止先查全量」,要么干脆不挂这类兜底工具。
第 ②、④ 步才是本章真正的考点。 很多同学跑通第 ① 步就以为学会了。 但生产环境的考验是:越权时能不能挡住?参数缺失时会不会编造? 这两个问题分别对应「安全」和「可靠性」,比「能不能调通」重要得多。
一句话记忆:完整示例跑通只是起点;第 ② 步挡越权、第 ④ 步防编造,才是工具调用真正落地的两道关。

7.8常见坑排错

现象原因解决
模型完全不用工具,直接回答工具描述太模糊;模型不支持 function calling完善描述(含「何时用/何时不用」);换支持工具调用的模型
模型用错工具工具职责重叠,描述有歧义合并相似工具;在描述里写明「不要用于……」
参数字段名对不上模型传的 key 与方法参数名不一致(如 order_no vs orderNo)在 @ToolParam 里描述清楚命名;或加多个可选参数做兼容
工具抛异常导致整个请求 500,或者反过来「异常被吞了、什么都没发生」异常策略没配对:默认 processor 下 RuntimeException 消息回给模型、受检异常冒泡;一旦自定义 processor,受检异常也可能被吞业务可预期错误 → 在工具里返回错误对象;程序性错误 → 自定义 processor 判类型后 throw。throw-exception-on-error=true 会让所有错误都变 500,慎用
报「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 里取不到值key 拼错(user_id vs userId);或压根没传 .toolContext(...)没传会直接 500(不是静默 null);key 拼错才静默取 null。抽常量统一 key,工具内必须判 null
HTTP 200,但模型说「我没有这个能力」
(比如问天气,它答「我是订单助手,没有天气查询能力」)
★ 打错门:这个 URI 挂在另一个没带该工具的 ChatClient 上。多 client 工程的头号坑,不是工具没写对 看日志有没有目标工具的打印行;加 GET /endpoints 自检表;给语义不同的入口改用不同 URI(见 7.7)
getToolCalls() 永远为空,明明工具执行了 2.0 的工具循环在 ToolCallingAdvisor 里就跑完了,业务层拿到的 ChatResponse 是最后一轮,不含 toolCalls 改用「工具方法内部打日志」或「数 SimpleLoggerAdvisor 的 request: 组数」
受检异常不再返回 500,被安静吞掉 自定义了 ToolExecutionExceptionProcessor,它无条件返回 String,成了最后一道闸门 在 processor 里判 getCause() 类型:非 RuntimeException 就 throw exception 继续抛(见 7.6)
排错总路线(按顺序查,别跳):
  1. 先确认「打的是不是那个门」:这个 URI 真的挂在「带了你想要的那些工具」的 ChatClient 上吗?多 client 工程的第一坑(见 7.7)。
  2. 再看工具有没有执行:工具方法里打的那行日志([OrderTools] ...)出现了没有。 出现 = 注册与路径都对,问题在工具内部;没出现 = 模型没调,或工具压根没注册上。
  3. 数循环次数:挂 SimpleLoggerAdvisor 数 request: 组数(1 组 = 没进循环,2 组 = 调了 1 次工具)。 别指望 DEBUG 日志——2.0.1 的 ToolCallingAdvisor 不打迭代日志(见 7.2)。
  4. 若模型没调工具:九成是描述写得不够「互斥 / 具体」,回到 7.3 四要素重写描述。
  5. 若模型调了但参数错:看 @ToolParam 描述是否给了示例值,工具内部是否做了格式校验。
  6. 若工具执行报错:业务可预期错误返回错误对象(让模型翻译成话术);要接数据库/内部服务的工具,必须自定义 processor 且不回传异常原文,别指望 throw-exception-on-error(它会让所有业务错误一起变 500)。

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,错误也用同结构。

Q6. 我在业务代码里 .call().chatResponse(),想打印模型这轮调了哪些工具,为什么 getToolCalls() 永远是空?工具明明执行了。

因为工具循环不在你的业务代码里跑。2.0 由 ToolCallingAdvisor 在 advisor 链内部把 「发请求 → 收到 tool_calls → 执行工具 → 再发一次请求」全部跑完,等你拿到 ChatResponse 时, 那已经是最后一轮自然语言回复,里面没有 toolCalls。
要看工具调用:① 在工具方法里打日志(最直接,还能看到模型传的参数);② 挂 SimpleLoggerAdvisor 数 request: 组数。

Q7. 验证「越权防护」时,我拿订单号 9999999 试,返回「未查询到」,是不是就证明权限校验生效了?

不是。9999999 在库里不存在,代码走的是 order == null → NOT_FOUND 这条分支, 和你写的归属校验一行代码都没关系——把 !order.ownerId().equals(userId) 删掉,结果一模一样。
正确验法:用一个真实存在、但属于其他用户的订单号(本章示例是 2024003,属 u2002)。 另外注意,越权时返回的文案应该是「未查询到该订单,或不属于当前账号」—— 不要返回「这是别人的订单」,那等于告诉对方「这单确实存在」,本身就是信息泄露。

7.10实操作业与本章验收

课堂练习(课上 30 分钟,分组完成):

  • 在 ch07 示例基础上,新增一个 @Tool 方法 queryOrderItems(orderNo),返回该订单的商品明细列表;先用第 ② 步越权用例验证「别人的订单查不到」。
  • 挂上 SimpleLoggerAdvisor(注意:只开 DEBUG 日志什么都看不到,见 7.2 的说明),故意问一个缺订单号的问题,观察模型是否追问;再故意把 orderNo 的 @ToolParam 描述删掉,对比模型抽参准确率的下降——直观感受「描述」的力量。
  • 把 defaultTools 换成 toolCallbacks(ToolCallbacks.from(...)),确认两种注册方式结果一致。

课后作业(独立完成,提交工程与运行截图):

  • 把第 7 章示例里的「天气工具」改造为带 returnDirect=true 的导出工具,验证结果跳过第二次模型调用直接返回—— 判据是日志里 request: 只有 1 组(普通工具是 2 组)。把 1 组与 2 组的截图放一起对比,才算证据。
  • 写一段 300 字说明:结合 7.6 的安全 Checklist,描述你会在自己的课程项目中如何给「创建订单」这类写操作工具做幂等与确认保护。