Spring AI 2.0 学习指南企业级智能应用开发实践 环境准备 速查 ⌂ 全部教程
首页 / Spring AI 指南 / 第 7 章

CH 07工具调用(Tool Calling)

这一章让模型从「只能说话」变成「能干活」。工具调用是 RAG 之后第二大核心能力: RAG 让模型「知道得更多」,工具调用让模型「能做得更多」——查数据库、算价格、发邮件、调第三方接口。 但请记住一条铁律:模型永远只负责「决定调用什么」,真正的执行永远由你的 Java 代码负责。

学习目标
让模型安全地调用业务方法
前置要求
第 3、6 章
本章产出
订单 / 天气查询工具
建议学时
4–5 小时

7.1工具调用的本质:模型只会「举手」,不会「动手」

先用一个比喻理解:模型像一位坐在办公室里的顾问, 他知道很多东西,但不能出门。当需要查实时数据时,他会写一张便条(工具名 + 参数)递给你, 你跑出去把事情办完,把结果写回来给他,他再基于结果给你最终答复。

模型负责(3 件事)

  1. 判断「这个问题需不需要调工具」
  2. 选择调用哪个工具
  3. 从用户的话里抽出参数(可能不全,会追问)

你负责(4 件事)

  1. 真正执行那个方法(查库、调接口)
  2. 权限校验!模型说查就查?绝对不行
  3. 参数校验与兜底
  4. 把结果(或错误)格式化后回传
最危险的理解误区:把模型当成了「安全边界」。 有同学会想「我在工具描述里写『只能查询当前用户的订单』,模型就会守规矩」—— 这是完全错误的。模型可以被提示词注入攻击绕过,会凭空编造用户 ID。 正确做法:工具实现内部必须从服务端的登录态取用户身份,绝不接受模型传来的 userId。 这一条在第 9 章 Agent 里会更重要,因为 Agent 会连续调用多个工具。

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

用户:"订单 2024001 什么时候发货?" │ ▼ ① ToolCallingAdvisor 把请求 + 全部工具定义发给模型 模型返回:我不直接回答,我要调工具 tool_calls = [{ name: "queryOrderStatus", arguments: {"orderNo":"2024001"} }] │ ▼ ② Advisor 把工具调用交给 ToolCallingManager 执行 ├─ 校验参数 ├─ 校验权限(这个订单属于当前用户吗?) ├─ 查询数据库 └─ 得到结果:{"status":"未发货","promisedDate":"2024-03-08"} │ ▼ ③ 把结果追加进对话历史,重新调用模型 模型返回:"您的订单 2024001 目前显示未发货,预计 3 月 8 日发出。" │ ▼ ④ 模型不再请求工具 → 循环结束,返回给调用方 │ ▼ 用户看到最终回答 ★ 关键:整个过程对调用方是透明的,你只写一个 .call(), 但底层发生了 2 次(或更多次)模型请求,Token 消耗也是双份。 这是"工具调用比普通问答贵"的根本原因。
2.0 把上面这个循环「搬」到了哪里? 在 Spring AI 1.x,这个 while 循环是写死在每个 ChatModel 实现内部的——OpenAI 一套、Anthropic 一套, 你既看不到中间过程,也没法在循环里插入逻辑。
2.0 把它提升成了 Advisor 链里的一等公民 ToolCallingAdvisor: 它是个「递归 Advisor」,每迭代一次就重新进入下游 Advisor 链一次。 这意味着你现在可以:观察每一次迭代、在迭代之间做审批、替换整个执行策略、把工具调用和记忆/日志/评估组合起来。
这个变化是 2.0 做 Agent 应用的地基,第 9 章会围绕它展开。
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(全局)——详见 9.4 节。

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

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.math.BigDecimal;
import java.time.LocalDate;

@Component
public class OrderTools {

    /**
     * 一个工具方法。三个要素决定模型能不能用好它:
     * ① 方法名     → 模型看到的工具名(尽量用动词+名词,语义清晰)
     * ② description → ★ 最重要!模型靠它决定"什么时候用这个工具、不用那个"
     * ③ 参数描述    → 模型靠它知道"这个参数从用户的话里怎么抽"
     */
    @Tool(description = """
            查询指定订单号的当前状态与预计发货时间。
            使用时机:当用户询问某个订单的发货情况、物流状态、是否已发货时调用。
            不要用于:查询商品信息、退款进度、账户余额。
            """)
    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) {}
}

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

要素说明示例
做什么一句话说明功能,动词开头查询指定订单号的当前状态与预计发货时间。
何时用给模型明确的触发条件当用户询问发货情况、物流状态时调用。
何时不用★ 很多人漏掉,但极其重要不要用于查询商品信息、退款进度、账户余额。
参数约束格式、取值范围、必填性订单号为纯数字字符串,例如 2024001。
工具越多,描述越要「互斥」。当你有 10 个工具时,最大的问题不是模型不会用,而是用错工具。 解决办法就是在每个描述里写清「不要用于……」,并保证工具职责不重叠。 如果两个工具功能高度相似,合并成一个带枚举参数的工具比拆成两个更好。

7.4注册工具的三种方式

// ---------- 方式一:直接传对象(最简,适合手工指定) ----------
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 注册。见下面的「方式四」。
工具不是越多越好。每注册一个工具,都会把它的名字、描述、参数 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 章会讲)。

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           // 一句话说明,便于模型组织语言
) {}
一个反复被验证的经验:工具的返回值要「为模型而设计」,不是「为你而设计」。 把数据库里的状态码 2 翻译成「已发货」,把时间戳格式化成 2024-03-08, 把金额带上单位。这一步做得越贴心,模型最终的回答就越准确。

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();
    }

调用时把上下文注入进去:

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? ① 结果是结构化数据、模型不该改写;② 想省一次模型调用(省钱省延迟); ③ 结果对格式极其敏感(如 SQL、代码、JSON)。 反之,如果希望模型把结果「翻译成人话」,就不要用。

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,
                "订单系统暂时不可用,请稍后重试或转人工客服。");
    }
}

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

2.0 把默认行为改对了,大多数项目不需要再写任何配置。 DefaultToolExecutionExceptionProcessor 现在的默认行为是:
· 任意 RuntimeException → 把异常消息包装后发回给模型, 让模型有机会向用户解释、换参数重试或换一个工具;
· 受检异常(checked exception)和 Error → 始终向上抛出,不会被吞掉。
这个划分是有意设计的:能自愈的错误交给模型,程序性错误绝不能藏起来。
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——这是处理器期望的异常类型。 抛别的异常会绕过你的自定义逻辑。

工具的安全边界 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),一次调用返回模型需要的全部信息。

7.7完整示例:订单与天气查询助手

src/main/java/com/example/springai/ch07/ ├── OrderTools.java # 订单查询工具(含权限校验) ├── WeatherTools.java # 天气查询工具(模拟外部 API) ├── AssistantConfig.java # 装配带工具的 ChatClient ├── OrderAssistantService.java # 业务服务 └── OrderAssistantController.java # HTTP 接口

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

@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

@Configuration
public class AssistantConfig {

    @Bean("orderAssistantClient")
    public ChatClient orderAssistantClient(ChatModel chatModel,
                                             OrderTools orderTools,
                                             WeatherTools weatherTools) {
        return ChatClient.builder(chatModel)
                .defaultSystem("""
                        你是"智汇"公司的智能助手,可以查询订单状态和天气。

                        工作准则:
                        1. 凡是涉及订单数据、实时天气的问题,必须调用工具查询,
                           禁止凭已有知识或猜测回答。
                        2. 调用工具前如果缺少必要参数(如订单号、城市),
                           先向用户追问,不要编造参数。
                        3. 工具返回错误信息时,用友好、明确的语气告知用户,
                           并给出下一步建议。
                        4. 回答控制在 150 字以内。
                        """)
                .defaultTools(orderTools, weatherTools)
                .build();
    }
}

③ 业务服务(注意 ToolContext 的注入)

@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();
    }

    /**
     * 进阶:手工查看模型到底调用了哪些工具。
     * 调试工具调用时非常有价值——第 10 章的 Trace 就是基于这些数据做的。
     */
    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();
    }
}

④ 接口与验证

@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) {
        return Map.of("answer", service.ask(body.get("question")));
    }
}
# ① 明确引用订单号 → 应触发 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":"我的订单发货了吗?"}'

# 期望:模型回答"请提供订单号"
# 如果它随便编了一个订单号去查 → 你的系统提示约束不够强
第 ②、④ 步才是本章真正的考点。 很多同学跑通第 ① 步就以为学会了。 但生产环境的考验是:越权时能不能挡住?参数缺失时会不会编造? 这两个问题分别对应「安全」和「可靠性」,比「能不能调通」重要得多。

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(...)检查每次调用是否注入了上下文;或写个统一入口方法

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

本章验收标准:
  • 能描述工具调用的完整时序,并说明为什么会有两次模型调用。
  • 能写出一个带「四要素描述」的工具方法。
  • 能用 ToolContext 传递用户身份,并说明为什么必须这么做。
  • 越权查询返回友好提示(而不是返回别人的数据,也不是 500)。
  • 参数缺失时模型会追问而不是编造。
  • 能说出至少四条工具的安全设计原则。