第 8 章:Tool Calling 调用链与 Tool Search 机制

作者:袖梨 2026-09-13

大模型可以判断何时需要外部能力,却不会亲自查询数据库、发送邮件或调用业务接口。要让一次对话真正完成这些动作,应用需要接住模型生成的工具请求,在 JVM 中执行对应逻辑,再把结果送回模型继续推理。围绕这条链路,工具声明、ChatClient 挂载、循环控制和海量工具检索都是必须厘清的关键环节。

版本:Spring AI 2.0.1

目标:弄清工具如何声明、如何挂到 ChatClient、运行时如何循环执行,以及工具很多时怎样用 Tool Search 做渐进披露。

模型本身不能查库、发邮件、调内部接口。它能做的是:在回答里带上「请调用某某工具、参数是这些」;真正执行发生在你的 JVM 里,结果再写回对话,模型据此继续推理或给出最终文本。

这个过程可以记成:

用户问题
  → 模型返回带 toolCalls 的 AssistantMessage
  → 本地执行工具(或 MCP 等供应线)
  → ToolResponseMessage 回灌
  → 再调模型
  → … 直到纯文本回答,或触达调用上限

用 ChatClient 时,这个 while 通常藏在默认的 ToolCallingAdvisor 里。工具特别多时,循环外还可能先走一层 Tool Search:只把「元工具 + 搜到的少量工具」暴露给模型,而不是一次塞进几百个 schema。

本章顺序:为什么要工具 → 声明与挂载 → 上下文与 returnDirect → 运行时循环与上限 → Tool Search → 常见坑。


8.1 为什么需要工具

没有工具时,模型只能根据 Prompt 里已有的信息编答案。问「订单 10086 现在什么状态」,若 Prompt 里没有这笔订单,它只能猜或拒答。

挂上工具之后,分工变成:

做什么
模型决定要不要调工具、调哪个、参数怎么填;最后组织自然语言
应用按定义执行工具、做鉴权与审计、把结果变成模型可读的字符串

消息层的形态在第 3 章已经见过,这里再对齐一次,避免和「ChatClient 里自动循环」脱节:

注意:ToolResponseMessage 的 id 必须和对应 ToolCall.id 对齐,否则供应商侧对不上轮次。Advisor 路径一般会帮你处理好;自管循环时不要手滑丢掉 id。


8.2 你会碰到的关键类型

类型作用
@Tool / @ToolParam声明式暴露方法
ToolCallback / ToolCallbackProvider运行时回调,或懒提供一组回调
ToolDefinition / ToolMetadata名称、描述、schema、returnDirect 等元数据
ToolCallingManager真正执行本轮 toolCalls
ToolCallingAdvisorChatClient 默认工具循环
ToolSearchTool / ToolIndex / ToolSearchToolCallingAdvisor海量工具检索与渐进披露

依赖上:普通 @Tool + ChatClient,通常有 model / ch@t client starter 就够;Tool Search 再引入对应的 tool-search 与 advisor starter。MCP 也可以作为一条 ToolCallback 供应线,本章只把它当挂载来源,不展开传输与 Server 细节。


8.3 用 @Tool 声明工具

@Component
public class WeatherTools {
​
    @Tool(description = "查询城市当前气温,城市名用中文或英文均可")
    public String getWeather(
            @ToolParam(description = "城市名,如 北京") String city) {
        return "{"city":"" + city + "","tempC":28}";
    }
}

要点:

  • name 默认用方法名;跨模型兼容时尽量只用字母数字、下划线、连字符、点(如 get_weathersearch-docs

  • description / @ToolParam 写清楚,比在 system 里「拜托记得调某某工具」有效得多

  • 必填 / 可选会影响 JSON Schema;再叠上供应商 strict 时,可选字段更容易触发 400

返回值默认会经 ToolCallResultConverter 转成模型可读字符串。自定义转换器适合脱敏、摘要、统一错误包装。原则是:给模型看的内容要短、稳、可解析,不要把堆栈、PII、二进制原样灌进去。


8.4 挂到 ChatClient:tools(...) 怎么分发

ChatClient 2.0 的统一入口是 tools(...) / defaultTools(...)。旧的 toolCallbacks(...) 已废弃。

@Configuration
class AiConfig {
​
    @Bean
    ChatClient ch@tClient(ChatClient.Builder builder, WeatherTools weatherTools) {
        return builder
                .defaultTools(weatherTools)
                .build();
    }
}
​
// 单次请求再挂别的工具时:
String answer = [email protected]()
        .user("北京今天多少度?")
        .tools(weatherTools)   // 见下文「覆盖」语义
        .call()
        .content();

tools(Object...) 的分发规则:

  1. ToolCallback → 直接注册

  2. ToolCallbackProvider → 懒解析成一组回调

  3. 数组 / Collection → 展开后再按上面规则分发

  4. 其它对象 → 当作带 @Tool 的 POJO;若没有任何注解方法则抛异常

覆盖语义很重要:

请求级 tools(...)整组覆盖该次请求的 defaultTools,不是 merge。需要「默认里的 A、B,再临时加一个 C」时,把 A、B、C 完整列表一起传入。

部分 Provider Options 也能带工具相关字段。建议只选一条主路径:优先把工具挂在 ChatClient DSL 上,避免 Options 与 DSL 两边同时塞、分不清谁覆盖谁。


8.5 ToolContext:给应用看,不给模型看

[email protected]()
        .user("查一下订单 10086")
        .tools(orderTools)
        .toolContext(Map.of(
                "tenantId", tenantId,
                "userId", userId))
        .call()
        .content();

工具方法可通过框架支持的方式读取上下文,例如注入 ToolContext 参数:

@Component
public class OrderTools {
​
    @Tool(description = "按订单号查询状态;租户从上下文读取,不要让用户传入租户 ID")
    public String findOrder(
            @ToolParam(description = "订单号") String orderId,
            ToolContext ctx) {
        String tenantId = String.valueOf(ctx.getContext().get("tenantId"));
        // 按 tenantId + orderId 查库,返回简短 JSON
        return "{"orderId":"" + orderId + "","status":"SHIPPED"}";
    }
}

原则:

  • 敏感租户 / 用户身份不进模型参数 schema,放进 ToolContext

  • 进程级常量可用 defaultToolContext;每请求变化的值用请求级 toolContext

  • ToolContext 不是给模型填的字段,模型看不到这份 Map


8.6 returnDirect:要不要再回灌模型

@Tool(returnDirect = true)(或元数据里等价配置)表示:工具结果直接作为对用户的响应,不再回灌模型润色。

适合:

  • 结果已经是最终 JSON / 文件 URL

  • 延迟敏感,不需要模型改写

  • 合规要求原始结果不得经模型二次改写

默认 false 更常见:结果回灌后,由模型组织最终回答。若 returnDirect=true 又想得到 DTO,应在应用层自行解析工具返回值,而不是指望 call().entity(...) 再走一遍结构化抽取。

同一轮里多个工具都 returnDirect 时,框架会按「全部为 true 才直接返回」一类规则收敛;写业务时尽量避免一轮里混用「有的要润色、有的要直出」,排障会轻松很多。


8.7 运行时:Advisor 循环、上限与 fallback

默认路径:ToolCallingAdvisor

ChatClient 默认常会装配 ToolCallingAdvisor(旧名 ToolCallAdvisor 已废弃)。它在链里大致做:

  1. 调模型

  2. 若有 toolCalls:交给 ToolCallingManager 执行 → 得到更新后的对话历史 → 再调模型

  3. 累加 Usage(多轮 tool 通常按整次 ChatClient 调用累计)

  4. 直到无 toolCalls,或某工具 returnDirect,或触达上限

也可自定义 Manager、order,以及 eligibility checker——在「模型想调、但业务不允许」时拦一刀。

调用上限

DefaultToolCallingManager 默认有保鲜丝(数量以 2.0.1 默认为准,可配置):

  • 每个 tool 大约最多 40 次

  • 总计大约 150 次

  • 超限抛出 ToolCallLimitExceededException(也可配置成返回错误响应而非抛异常)

调高上限前,先检查是不是工具描述诱导模型反复重试,或参数校验失败被模型理解成「再调一次」。

解析 fallback 默认关闭

2.0.1 起,工具解析 fallback 默认关闭:通常只执行当前请求 / defaultTools 上挂的工具。若出现「容器里有 Tool Bean,模型也选了名字,但没执行」,先确认是否显式挂载,再考虑:

spring.ai.tools.resolution.fallback.enabled=true

默认改成显式挂载,是为了避免容器里躺着的危险工具被模型名一撞就执行。开启 fallback 前要清楚:解析器能看到的工具,都可能被执行。

自管循环(可选)

需要逐步审批、把中间轮次推给前端、或在两轮之间插入业务判断时,可以关掉自动 Advisor,自己驱动循环。普通业务优先用默认 Advisor;自管时不要和默认循环叠两套。

单次请求关闭自动注册:

.advisors(AdvisorParams.toolCallingAdvisorAutoRegister(false))

全局关闭可用:

[email protected]=false

理解用的同步骨架(与官方文档同构;生产请按场景补错误处理与超时):

ToolCallingManager toolCallingManager = ToolCallingManager.builder().build();
ToolCallback[] tools = ToolCallbacks.from(new WeatherTools());
ChatOptions options = ToolCallingChatOptions.builder()
        .toolCallbacks(tools)
        .build();
​
Prompt prompt = new Prompt(
        List.of(new UserMessage("北京和上海今天气温?")),
        options);
​
ChatClientResponse response = [email protected]()
        .messages(prompt.getInstructions())
        .options(options)
        .advisors(AdvisorParams.toolCallingAdvisorAutoRegister(false))
        .call()
        .ch@tClientResponse();
​
int guard = 0;
while (response.ch@tResponse() != null
        && response.ch@tResponse().hasToolCalls()
        && guard++ < 8) {
    ToolExecutionResult result =
            toolCallingManager.executeToolCalls(prompt, response.ch@tResponse());
    if (result.returnDirect()) {
        // 工具结果即最终响应,按业务取 conversationHistory 末尾即可
        break;
    }
    prompt = new Prompt(result.conversationHistory(), options);
    response = [email protected]()
            .messages(result.conversationHistory())
            .options(options)
            .advisors(AdvisorParams.toolCallingAdvisorAutoRegister(false))
            .call()
            .ch@tClientResponse();
}
​
String answer = response.ch@tResponse().getResult().getOutput().getText();

要点:executeToolCalls 返回的是 ToolExecutionResult,下一轮 Prompt 用 result.conversationHistory(),不要自己零散拼 Assistant / Tool 消息却丢了 id。自管时记得设 maxRounds(上面的 guard),避免和 Manager 上限两套逻辑打架时不好排查。


8.8 Tool Search 与渐进披露

工具一多,每个 schema 都会进上下文:更贵、更慢,也更容易选错。Tool Search 的思路是渐进披露

先暴露元工具 toolSearchTool(+ 少数常驻工具)
  → 模型调用搜索
  → 动态加入相关业务工具的完整定义
  → 再发起真正的业务调用

常见索引实现:

实现特点更适合
RegexToolIndex规则匹配目录可控、要确定性
LuceneToolIndex全文检索描述中等规模、关键词明确
VectorToolIndex语义检索描述口语化、同义表达多

会话里搜出来的工具会缓存;还要有 eviction 策略,避免候选工具无限堆积、把窗口再次撑爆。上图里的「回灌」在工程上主要指:工具结果回到对话、以及会话内候选集的维护,不是每次调用都自动重训索引。索引质量靠 description 与选型,目录变更时重建 Index。

和「每次请求手工 tools(subset)」相比:

  • 手工筛选:简单可控,但筛选逻辑容易散落在业务代码

  • Tool Search:目录可更大,模型自助查找,但多一轮 search,索引要维护

实践上常见组合是:先按租户 / 权限砍可见工具集,再对可见集建 Index,最后用 ToolSearchToolCallingAdvisor 渐进披露。

写 description 时要让工具「可被搜到」:说清做什么、不做什么、输入单位、失败时返回什么;避免十个工具都叫「处理订单」。

装配示意(类名与 Bean 以你引入的 starter 为准):

ToolIndex index = /* Regex / Lucene / Vector 之一,写入可见工具集 */;
​
ChatClient client = builder
        .defaultAdvisors(
                ToolSearchToolCallingAdvisor.builder()
                        .toolIndex(index)
                        // 按需:eviction、maxResults、session 键等
                        .build())
        .build();

失败时先看这几类原因:

  • 模型从不 search:system 未说明用法,或又把全量工具挂回了 default

  • 搜到了仍调错:描述撞车,或向量索引质量不够

  • 上下文仍爆:eviction / 窗口裁剪未跟上


8.9 MCP 作为工具源(挂载视角)

MCP 可以把远程或本地 Server 上的 tools 桥成 Spring AI 的 ToolCallback / ToolCallbackProvider,并可做过滤。

[email protected]()
        .user(question)
        .tools(mcpToolCallbackProvider)  // 可与本地 @Tool POJO 混合
        .call()
        .content();

把 MCP 目录放进 ToolIndex,就能同时做「远程工具 + 渐进披露」;远端目录变更时重建索引即可。本章只需记住:对 ChatClient 来说,MCP 首先是一条 ToolCallback 供应线,挂法与本地工具同一套 tools(...) 语义。


8.10 常见坑

现象先看什么
模型从不调工具description 是否清楚;工具是否挂到当前请求 / defaultTools;模型是否支持 tool calling
Bean 在容器里却不执行2.0.1 fallback 默认关;是否显式 .tools(...) / defaultTools
请求级加了一个工具,默认工具全没了tools(...) 是覆盖不是 merge,把完整列表传入
调了但参数乱 / 400schema 与 strict;可选字段;参数名是否稳定
死循环或很快超限description 是否诱导重试;校验失败是否返回了可理解的错误串;是否调高了无意义的上限
returnDirect 后拿不到润色文案预期如此;要自然语言就别开 returnDirect,或应用层自己拼
租户串了是否误把 tenant 放进模型参数;ToolContext 是否每请求传入
Tool Search 从不触发是否仍挂了全量 tools;system 是否说明先 search;advisor 是否为 Search 版
自管循环消息错乱是否使用 conversationHistory();Tool 响应 id 是否与 ToolCall 对齐;是否叠了两套循环

8.11 小结

工具把「模型决定调用」和「应用负责执行」拆开。日常路径是:@Tool 声明 → defaultTools / tools 挂载 → ToolCallingAdvisor 自动循环。先把挂载、覆盖语义、ToolContext 和调用上限搞对,再考虑 returnDirect 与 Tool Search。目录一大就渐进披露,并且继续用权限裁剪可见集,而不是把整库 schema 一次性塞进 Prompt。

相关文章

精彩推荐