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

模型本身不能查库、发邮件、调内部接口。它能做的是:在回答里带上「请调用某某工具、参数是这些」;真正执行发生在你的 JVM 里,结果再写回对话,模型据此继续推理或给出最终文本。
这个过程可以记成:
bash
用户问题
→ 模型返回带 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 |
ToolCallingAdvisor |
ChatClient 默认工具循环 |
ToolSearchTool / ToolIndex / ToolSearchToolCallingAdvisor |
海量工具检索与渐进披露 |
依赖上:普通 @Tool + ChatClient,通常有 model / chat client starter 就够;Tool Search 再引入对应的 tool-search 与 advisor starter。MCP 也可以作为一条 ToolCallback 供应线,本章只把它当挂载来源,不展开传输与 Server 细节。
8.3 用 @Tool 声明工具
java
@Component
public class WeatherTools {
@Tool(description = "查询城市当前气温,城市名用中文或英文均可")
public String getWeather(
@ToolParam(description = "城市名,如 北京") String city) {
return "{\"city\":\"" + city + "\",\"tempC\":28}";
}
}
要点:
-
name默认用方法名;跨模型兼容时尽量只用字母数字、下划线、连字符、点(如get_weather、search-docs) -
description/@ToolParam写清楚,比在 system 里「拜托记得调某某工具」有效得多 -
必填 / 可选会影响 JSON Schema;再叠上供应商
strict时,可选字段更容易触发 400
返回值默认会经 ToolCallResultConverter 转成模型可读字符串。自定义转换器适合脱敏、摘要、统一错误包装。原则是:给模型看的内容要短、稳、可解析,不要把堆栈、PII、二进制原样灌进去。
8.4 挂到 ChatClient:tools(...) 怎么分发
ChatClient 2.0 的统一入口是 tools(...) / defaultTools(...)。旧的 toolCallbacks(...) 已废弃。
java
@Configuration
class AiConfig {
@Bean
ChatClient chatClient(ChatClient.Builder builder, WeatherTools weatherTools) {
return builder
.defaultTools(weatherTools)
.build();
}
}
// 单次请求再挂别的工具时:
String answer = chatClient.prompt()
.user("北京今天多少度?")
.tools(weatherTools) // 见下文「覆盖」语义
.call()
.content();
tools(Object...) 的分发规则:
-
ToolCallback→ 直接注册 -
ToolCallbackProvider→ 懒解析成一组回调 -
数组 / Collection → 展开后再按上面规则分发
-
其它对象 → 当作带
@Tool的 POJO;若没有任何注解方法则抛异常
覆盖语义很重要:

请求级 tools(...) 会整组覆盖 该次请求的 defaultTools,不是 merge。需要「默认里的 A、B,再临时加一个 C」时,把 A、B、C 完整列表一起传入。
部分 Provider Options 也能带工具相关字段。建议只选一条主路径:优先把工具挂在 ChatClient DSL 上,避免 Options 与 DSL 两边同时塞、分不清谁覆盖谁。
8.5 ToolContext:给应用看,不给模型看
java
chatClient.prompt()
.user("查一下订单 10086")
.tools(orderTools)
.toolContext(Map.of(
"tenantId", tenantId,
"userId", userId))
.call()
.content();
工具方法可通过框架支持的方式读取上下文,例如注入 ToolContext 参数:
java
@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 已废弃)。它在链里大致做:
-
调模型
-
若有 toolCalls:交给
ToolCallingManager执行 → 得到更新后的对话历史 → 再调模型 -
累加 Usage(多轮 tool 通常按整次 ChatClient 调用累计)
-
直到无 toolCalls,或某工具
returnDirect,或触达上限
也可自定义 Manager、order,以及 eligibility checker------在「模型想调、但业务不允许」时拦一刀。
调用上限
DefaultToolCallingManager 默认有保险丝(数量以 2.0.1 默认为准,可配置):
-
每个 tool 大约最多 40 次
-
总计大约 150 次
-
超限抛出
ToolCallLimitExceededException(也可配置成返回错误响应而非抛异常)
调高上限前,先检查是不是工具描述诱导模型反复重试,或参数校验失败被模型理解成「再调一次」。
解析 fallback 默认关闭
2.0.1 起,工具解析 fallback 默认关闭:通常只执行当前请求 / defaultTools 上挂的工具。若出现「容器里有 Tool Bean,模型也选了名字,但没执行」,先确认是否显式挂载,再考虑:
bash
spring.ai.tools.resolution.fallback.enabled=true
默认改成显式挂载,是为了避免容器里躺着的危险工具被模型名一撞就执行。开启 fallback 前要清楚:解析器能看到的工具,都可能被执行。
自管循环(可选)
需要逐步审批、把中间轮次推给前端、或在两轮之间插入业务判断时,可以关掉自动 Advisor,自己驱动循环。普通业务优先用默认 Advisor;自管时不要和默认循环叠两套。
单次请求关闭自动注册:
java
.advisors(AdvisorParams.toolCallingAdvisorAutoRegister(false))
全局关闭可用:
java
spring.ai.chat.client.tool-calling.enabled=false
理解用的同步骨架(与官方文档同构;生产请按场景补错误处理与超时):
java
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 = chatClient.prompt()
.messages(prompt.getInstructions())
.options(options)
.advisors(AdvisorParams.toolCallingAdvisorAutoRegister(false))
.call()
.chatClientResponse();
int guard = 0;
while (response.chatResponse() != null
&& response.chatResponse().hasToolCalls()
&& guard++ < 8) {
ToolExecutionResult result =
toolCallingManager.executeToolCalls(prompt, response.chatResponse());
if (result.returnDirect()) {
// 工具结果即最终响应,按业务取 conversationHistory 末尾即可
break;
}
prompt = new Prompt(result.conversationHistory(), options);
response = chatClient.prompt()
.messages(result.conversationHistory())
.options(options)
.advisors(AdvisorParams.toolCallingAdvisorAutoRegister(false))
.call()
.chatClientResponse();
}
String answer = response.chatResponse().getResult().getOutput().getText();
要点:executeToolCalls 返回的是 ToolExecutionResult,下一轮 Prompt 用 result.conversationHistory() ,不要自己零散拼 Assistant / Tool 消息却丢了 id。自管时记得设 maxRounds(上面的 guard),避免和 Manager 上限两套逻辑打架时不好排查。
8.8 Tool Search 与渐进披露

工具一多,每个 schema 都会进上下文:更贵、更慢,也更容易选错。Tool Search 的思路是渐进披露:
java
先暴露元工具 toolSearchTool(+ 少数常驻工具)
→ 模型调用搜索
→ 动态加入相关业务工具的完整定义
→ 再发起真正的业务调用
常见索引实现:
| 实现 | 特点 | 更适合 |
|---|---|---|
RegexToolIndex |
规则匹配 | 目录可控、要确定性 |
LuceneToolIndex |
全文检索描述 | 中等规模、关键词明确 |
VectorToolIndex |
语义检索 | 描述口语化、同义表达多 |
会话里搜出来的工具会缓存;还要有 eviction 策略,避免候选工具无限堆积、把窗口再次撑爆。上图里的「回灌」在工程上主要指:工具结果回到对话、以及会话内候选集的维护,不是每次调用都自动重训索引。索引质量靠 description 与选型,目录变更时重建 Index。
和「每次请求手工 tools(subset)」相比:
-
手工筛选:简单可控,但筛选逻辑容易散落在业务代码
-
Tool Search:目录可更大,模型自助查找,但多一轮 search,索引要维护
实践上常见组合是:先按租户 / 权限砍可见工具集,再对可见集建 Index,最后用 ToolSearchToolCallingAdvisor 渐进披露。
写 description 时要让工具「可被搜到」:说清做什么、不做什么、输入单位、失败时返回什么;避免十个工具都叫「处理订单」。
装配示意(类名与 Bean 以你引入的 starter 为准):
java
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,并可做过滤。
java
chatClient.prompt()
.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,把完整列表传入 |
| 调了但参数乱 / 400 | schema 与 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。