最近在把一个老项目从 Spring AI 1.0 往 2.0 迁移,最头疼的其实不是模型接入,而是 Tool Calling 这层。1.0 时代写好一套 @Tool 注解基本就完事了,到了 2.0 发现 API 变了、参数解析逻辑变了,连工具上下文传递方式都要重新设计。查了一圈资料,发现很多文章还是在拿 1.0 的写法套 2.0,踩坑记录特别少。所以这篇想把 2.0 里 Tool Calling 的三个进阶点——动态模式、ToolContext、隐式解析——一次性说透,给同样在迁移或者准备用 2.0 做 agent 开发的同学一份能直接照搬的参考。
先说下这套内容适合谁。如果你正在用 Spring AI 2.0 做多工具编排,或者遇到了"工具明明注册了,模型就是不调用""参数传进去是字符串,类型对不上""上下文拿不到用户信息"这类问题,那这篇就是给你写的。如果你是刚接触 Spring AI,从 2.0 直接入手的,也建议把基础 Tool Calling 用法过一遍再来看进阶内容,不然节奏会有点快。
1. Spring AI 2.0 里 Tool Calling 到底改了什么
1.1 从 1.0 到 2.0,真正影响你的是这三处
先说结论:Spring AI 2.0 不是简单换了个版本号,而是把整个模型接入层做了一次大重构。和 Tool Calling 强相关的有三个方面你要重点关注。
第一,依赖坐标变了。1.0 时代你用的是 spring-ai-openai、spring-ai-ollama 这种按厂商拆分的包,2.0 开始收拢成 spring-ai-model、spring-ai-commons,OpenAI 兼容接口统一走 spring-ai-client-chat。这个变更直接影响你写工具回调时引的类,比如 ToolCallback、ToolCallingManager 这些核心接口的包路径全挪了。我一开始没注意,直接从 1.0 项目复制代码过来,结果编译期一堆红色报错。
第二,ToolCallback 的构建方式更灵活了。1.0 里你基本是靠 @Tool 注解加 Spring AOP 自动探测,2.0 把底层抽象成了 ToolCallbackProvider 和 MethodToolCallback,你可以在运行时动态决定暴露哪些工具给模型,而不是启动时写死。这就是"动态模式"的由来。
第三,工具参数解析多了"隐式解析"这个环节。1.0 里模型返回的工具参数必须是严格 JSON,否则直接抛异常;2.0 允许模型返回描述性的自然语言,然后由框架再调一次模型把描述转成结构化 JSON。这个能力在真实场景里太有用了,因为 DeepSeek、Qwen 这类模型的工具调用并不总是严格遵循 JSON Schema 输出,尤其 temperature 调高之后。
1.2 为什么动态模式是这次升级的重头戏
动态模式,英文叫 Dynamic Tool Calling,核心思路是让工具的注册从"编译期固定"变成"运行时可变"。这个需求在真实项目里很常见:比如你有一个问答机器人,普通用户只能查天气、查日历,管理员还能查订单、改配置。如果用 1.0 的静态注解,你要么把所有工具都暴露给所有人,要么在工具方法内部做权限判断,两种方式都很别扭。
2.0 的做法是提供一个 ToolCallbackProvider,让每个请求进来时根据当前用户角色动态组装工具列表。这样模型能看到的工具集合是每次请求独立的,既减少了无效工具对模型决策的干扰,也天然实现了权限隔离。我实际测试下来,工具数量从十几个精简到五六个之后,模型调用工具的准确率明显提升,误调用次数少了很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动态模式实战:从固定工具到运行时注册
2.1 静态 @Tool 的局限在哪里
先看一段 1.0 风格的代码,这是大多数人熟悉的写法:
java复制@Component
public class WeatherTools {
@Tool(name = "getWeatherByCity", description = "根据城市名称查询当前天气")
public String getWeatherByCity(String city) {
// 调用天气服务
return weatherService.query(city);
}
}
这种写法本身没问题,但它的工具列表是在应用启动时通过扫描 @Tool 注解一次性注册的。你想做精细化控制时就会发现三个痛点:
一是无法按用户维度裁剪工具。不管是普通用户还是管理员,看到的都是同一份工具清单,模型拿到什么工具就调什么工具,你只能靠工具方法内部的权限校验兜底,但模型已经把不该调的工具名暴露在对话里了。
二是无法在运行时新增工具。假设系统上线后要接一个第三方物流查询 API,传统做法是改代码、加注解、重新发布。但动态模式下,工具信息可以来自数据库、配置中心、甚至另一个服务,你可以在不重启应用的情况下把新工具注册进去。
三是工具描述无法动态拼装。比如查询天气的工具,描述里如果带上当前时区、单位制式,模型理解会更准确,但 @Tool 注解的 description 是编译期常量,没法按请求动态改。
2.2 用 ToolCallbackProvider 实现按请求裁剪工具
2.0 里推荐的做法是实现 ToolCallbackProvider 接口,在运行时返回当前请求所需的工具数组。看这个例子:
java复制@Component
public class DynamicToolCallbackProvider implements ToolCallbackProvider {
private final WeatherService weatherService;
private final AdminService adminService;
public DynamicToolCallbackProvider(WeatherService weatherService, AdminService adminService) {
this.weatherService = weatherService;
this.adminService = adminService;
}
@Override
public ToolCallback[] getToolCallbacks() {
// 实际项目中这里可以从 SecurityContext 或请求头拿用户角色
UserRole role = UserContextHolder.getCurrentUser().role();
if (role == UserRole.ADMIN) {
return new ToolCallback[]{
MethodToolCallback.builder()
.toolName("getWeatherByCity")
.description("根据城市名称查询当前天气")
.method(weatherService, "getWeatherByCity", String.class)
.build(),
MethodToolCallback.builder()
.toolName("listAllOrders")
.description("列出系统全部订单,仅管理员可用")
.method(adminService, "listAllOrders")
.build()
};
}
return new ToolCallback[]{
MethodToolCallback.builder()
.toolName("getWeatherByCity")
.description("根据城市名称查询当前天气")
.method(weatherService, "getWeatherByCity", String.class)
.build()
};
}
}
关键点在于 MethodToolCallback.builder()。它允许你完全绕开注解,直接用反射调用目标类的方法。method 方法里传的是方法名加参数类型列表,框架会帮你做参数绑定。我实际用下来,这个 API 对方法重载的解析也做得很细,两个同名方法只要参数类型不同,都能正确映射。
然后注册到 ChatClient 里:
java复制ChatClient chatClient = ChatClient.builder(chatModel)
.defaultTools(dynamicToolCallbackProvider)
.build();
注意 .defaultTools() 接收的是 ToolCallbackProvider 接口,所以每次调用 ChatClient 时,框架会自动调用 getToolCallbacks() 获取当前工具列表。这就是"动态"的入口。
2.3 运行时手动构建 ToolCallback 的完整例子
除了实现 Provider 接口,你还可以在代码里手动决定注册哪些工具,适合做"按流程分支加载工具"的场景。比如流程引擎里,第一步需要收集用户信息,第二步需要查询库存,第三步需要创建订单,每一步的工具集合都不同。
java复制public ToolCallback inventoryCheckTool() {
return MethodToolCallback.builder()
.toolName("checkInventory")
.description("检查指定商品在当前仓库的库存数量")
.method(inventoryService, "checkInventory", String.class, Integer.class)
.build();
}
public ToolCallback createOrderTool() {
return MethodToolCallback.builder()
.toolName("createOrder")
.description("创建一笔新的销售订单,返回订单号")
.method(orderService, "createOrder", String.class, Integer.class, String.class)
.build();
}
然后在每个流程节点调用时会带上不同的 tools 参数:
java复制String step1Result = chatClient.prompt()
.tools(inventoryCheckTool())
.user("查一下SKU为A100的商品库存")
.call()
.content();
String step2Result = chatClient.prompt()
.tools(inventoryCheckTool(), createOrderTool())
.user("用户要买5件,帮我创建订单")
.call()
.content();
这种用法在官方文档里着墨不多,但实际做 agent 流程编排时非常顺手。我建议你把"按流程分支加载工具"设计成一个小工具类,集中管理所有的 ToolCallback 构建逻辑,避免在业务代码里到处 new。
3. ToolContext:把上下文传进工具方法的正确姿势
3.1 没有 ToolContext 时你只能硬编码
做 agent 开发时,工具方法经常需要拿到调用方的上下文信息,比如用户ID、会话ID、请求的唯一追踪ID。没有 ToolContext 之前,你只能靠"全局变量 + ThreadLocal"或者每个工具方法都加参数去实现。
全局变量的方案有一个致命问题:Spring AI 的异步执行和响应式场景下,请求不一定会绑定在同一个线程里,ThreadLocal 随时可能取到别人的数据,排查起来极其痛苦。我见过线上偶发的"用户A的请求查出了用户B的数据",最后定位就是 ThreadLocal 串了。
给每个工具方法加参数也不现实。你先得在 UserMessage 里拼接这些信息,然后在每个 @Tool 方法里解析字符串,工具多了以后维护成本高得离谱。ToolContext 就是专门解决这个问题的。
3.2 ToolContext 的完整用法
ToolContext 是 Spring AI 2.0 提供的一个上下文容器,可以在发起 chat 请求时传入,并在工具方法执行时自动注入。它本身是一个简单的 Map 包装类,你可以往里放任意键值对。
先看发起端怎么传:
java复制ToolContext toolContext = new ToolContext(Map.of(
"userId", "U12345",
"sessionId", "S98765",
"traceId", UUID.randomUUID().toString()
));
String result = chatClient.prompt()
.tools(weatherProvider)
.toolContext(toolContext)
.user("帮我查一下上海的天气")
.call()
.content();
然后在工具方法里接收。这里有个细节:ToolContext 不需要手动声明成工具参数,它会被 Spring AI 自动注入。工具方法只需要在参数列表里加上 ToolContext 即可:
java复制@Tool(name = "getWeatherByCity", description = "根据城市名查询天气")
public String getWeatherByCity(String city, ToolContext toolContext) {
String userId = toolContext.getContext().get("userId");
String traceId = toolContext.getContext().get("traceId");
// 结合用户ID做个性化查询,或者写入审计日志
weatherService.query(city, userId);
return "城市 " + city + " 当前天气:晴,25°C";
}
如果你用的是上面提到的 MethodToolCallback 动态构建方式,同样支持 ToolContext 注入,不需要额外配置。我在代码里踩过一个坑:MethodToolCallback 的 method 方法参数列表要把返回类型对应的方法参数写完整,但 ToolContext 不用写进参数类型数组里。也就是说,方法签名是 (String, ToolContext) 时,MethodToolCallback.builder().method(service, "methodName", String.class) 就够了,框架会自动把 ToolContext 传给第二个参数。
3.3 线程安全与生命周期注意事项
ToolContext 是一个请求级别的对象,生命周期从发起 chat 请求开始,到工具调用结束为止。它不会被模型序列化,也不会出现在和历史模型的对话上下文中,纯粹是"应用层到工具层"的私有通道。这个设计我认为非常干净。
但使用时有几个注意点:
其一,不要在工具方法里长期持有 ToolContext 引用。异步任务、消息队列消费者这类场景,如果任务在线程池里排队,ToolContext 里的用户信息可能已经过期,再拿去查库会拿到错误结果。正确做法是先取出需要的数据,再提交异步任务。
其二,ToolContext 不是安全边界。它只是普通 Map,调用方可以往里塞任意数据,工具方法拿到的值可能为 null 或者类型不对。所以工具方法内部要做判空和类型校验,别直接强转。
其三,不要在工具方法里修改 ToolContext 的内容然后指望它影响后续工具。ToolContext 不是共享内存,它只负责从应用传到工具,工具之间的状态共享可以用返回结果传递,或者你自定义一个业务上下文对象。
4. 隐式解析:当模型不按 schema 返回参数时怎么办
4.1 隐式解析到底解决了什么问题
先描述一个现象。在 Spring AI 1.0 时代,工具调用要求模型返回的参数必须是和工具 JSON Schema 严格匹配的结构化 JSON。但实际使用中,尤其是一些中文优化过的模型,它们偶尔会返回这种内容:
json复制{
"arguments": "用户希望查询上海今天的天气"
}
这种"自然语言描述"而不是"结构化 JSON"的返回,老版本直接解析失败,然后整个工具调用流程中断。用户看到的就是模型答非所问,或者反复报错。
Spring AI 2.0 的隐式解析(Implicit Param Resolving)解决的就是这个问题。当框架检测到工具调用的参数不是合法 JSON 时,会调用一个指定的补充模型,把自然语言描述"翻译"成符合工具 Schema 的 JSON 参数。整个过程对用户透明,你只需要配置一下。
这个功能在模型切换场景里尤其有价值。比如你平时用 DeepSeek,某个渠道临时切换成 Qwen,不同模型对工具参数格式的遵从度差异很大。开了隐式解析之后,兼容性明显提升,工具调用失败的报错基本绝迹。
4.2 配置与代码实现
隐式解析的开关在 application.yml 里设置:
yaml复制spring:
ai:
tool-calling:
implicit-param-resolving-enabled: true
implicit-param-resolving-model: deepseek-chat
注意 implicit-param-resolving-model 配置的是模型名称,但这个模型必须是通过 ChatClient.builder 注册过的。也就是说,你的应用里要先有一个可以调用的 ChatModel Bean,然后这里填它的模型 ID。
如果项目里只配置了一个模型,可以在代码里这样设置:
java复制@Configuration
public class ToolCallingConfig {
@Bean
public ToolCallingManager toolCallingManager(ChatModel chatModel) {
ToolCallingManager.builder()
.implicitParamResolvingEnabled(true)
.implicitParamResolvingChatModel(chatModel)
.build();
}
}
从 ToolCallingManager 的 API 设计上能看出,Spring AI 团队是把隐式解析当成一个独立的可选组件来做的。隐式解析的底层逻辑是这样的:
- 模型返回的 tool call 参数不是合法 JSON;
- 框架把原始参数、工具的名称、工具的 JSON Schema、以及一段"请将用户的自然语言描述转换为符合上述参数结构的 JSON"的指令拼装成一次新的模型请求;
- 补充模型返回合法 JSON;
- 框架用这个 JSON 继续正常的工具调用流程。
整个过程会多一次模型调用的开销,所以如果你的模型工具调用表现一直很稳定,可以考虑关闭这个特性,省一点 token 费。我个人的做法是先开一个月观察,确认某个模型稳定之后,再针对路由到该模型的请求关闭隐式解析。
4.3 参数解析的顺序和优先级
Spring AI 2.0 的参数解析有一套完整的优先级逻辑,理解它对你排查问题非常有帮助。
根据我阅读源码和实际测试的结果,工具方法参数解析会按以下顺序进行:
- 如果模型返回的参数已经是合法 JSON,且能映射到工具方法参数,直接用;
- 如果 JSON 合法但类型不匹配(比如 schema 要求 integer,模型返回了字符串 "25"),Spring AI 会尝试类型转换;
- 如果参数是自然语言描述,且隐式解析未开启,抛出异常;
- 如果参数是自然语言描述,且隐式解析开启,调用补充模型做转换。
类型转换这步有一个很实用的细节:Spring AI 2.0 内置了字符串到数字、布尔、枚举的转换器。也就是说,模型返回 "25" 而不是 25,只要你的方法参数是 int,框架能自动转。但对象嵌套转换的支持还是有限,如果你的参数是复杂对象,最好确保模型返回的就是完整 JSON。
我在生产环境遇到过一个典型的隐式解析关联问题:工具方法参数是枚举类型,例如天气单位是摄氏还是华氏。模型返回的不是合法枚举值而是"用户没说,默认摄氏",这种描述触发了隐式解析,补充模型会补出参数。但如果补充模型的温度参数设置得很高,转换出的枚举值不稳定,偶发出现"华氏"。解决办法是在工具描述里明确枚举的可选值和默认值,让主模型和补充模型都能理解约束。
5. 真实项目里的踩坑记录与排查清单
5.1 springai 连 deepseek 不输出 content 的排查过程
这个在社区里问的人特别多,搜索热词里也带上了,我直接说结论:大多数情况下,不是 Spring AI 连不上 DeepSeek,而是 DeepSeek 的某些模型在工具调用场景下把内容放到了 reasoning 字段里,Spring AI 的客户端读取的是 content 字段,所以拿不到输出。
我排查时走的路径可以给大家一个参考。
先确认模型有没有真的被调用。在 application.yml 里打开 spring.ai.chat.client.observations 或者加一个拦截器,观察日志里有没有 Request 和 Response 的完整记录。如果 Response 里能看到 candidates 里有内容,只是 content 字段为空,那问题基本锁定在字段读取上。
然后检查你用的是哪个模型名。DeepSeek 的 deepseek-reasoner 是专门做推理的模型,它的输出会带 reasoning_content 字段,内容放在 reasoning_content 里而不是 content,Spring AI 的 OpenAI 兼容客户端默认只读 content,于是拿到空结果。
解决办法有两个方向。一是换用 deepseek-chat 模型,它就是标准的生成模型,content 字段正常返回。二是如果你业务上必须用 deepseek-reasoner,那就不要依赖 Spring AI 默认的响应解析,自己写一个 ResponseExtractor,把 reasoning_content 字段读出来拼接。但这样改动较大,我建议能换模型就换模型。
另一个隐藏坑是:即使模型返回 content 正常,如果开启了隐式解析,补充模型的调用也可能出现 content 为空,因为补充模型有时会返回思维链内容而不是直接给 JSON。这种情形下你应该检查隐式解析配置的模型是否设置了足够低的 temperature,并且确认它支持严格的 JSON 输出。
5.2 工具调用超时、参数类型转换失败
工具调用超时是我在做多工具 agent 时遇到得最多的问题。原因是模型在生成 tool call 的时候会先输出一段推理过程,这段过程也被计入响应时间。如果你的工具本身还要调外部 API,整体耗时叠加很容易超过默认的 60 秒超时。
我建议从三层去调:第一层调模型客户端的超时时间,OpenAI 兼容客户端一般支持 connectTimeout、readTimeout;第二层调 Spring AI 的响应超时;第三层给工具方法内部调外部 API 设置独立超时,避免下游慢接口把整个工具调用拖死。这三层缺一不可,我曾经只调了模型超时,结果工具方法里调第三方接口卡了 90 秒,整个请求直接失败。
参数类型转换失败一般是模型返回的 JSON 字段类型和工具方法签名不匹配。日志里会有清晰的报错,比如 Failed to convert value of type 'java.lang.String' to required type 'java.lang.Integer'。遇到这种问题,先别急着改代码,先确认工具的 JSON Schema 是否生成了正确的类型描述。用调试模式把请求发给模型的完整 payload 打出来,看看 tools 定义里的参数类型是不是 integer。如果 tools 定义正确但模型仍然返回字符串,那就是模型遵从度的问题,可以尝试在工具描述里加一句"参数均为JSON原始类型,不要使用字符串"。
自动类型转换其实能处理很多情况,但有三种情况它处理不了:JSON 字符串里嵌了对象、数组元素类型不匹配、枚举值拼写错误。这三类建议你在工具方法内部自己做防御式转换。
5.3 一份可以直接用的排查速查表
我把实际项目中遇到的工具调用问题整理成一张速查表,遇到问题先对照排查:
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 模型完全不调用工具 | 工具 Schema 描述不清晰 | 打印发给模型的完整 tools 定义 | 重写 description,增加触发条件说明 |
| 模型调用了工具但报参数缺失 | 必填参数没有标注 required | 查看工具 Schema 的 required 字段 | 给参数加 @ToolParam(required = true) |
| 工具执行成功但结果没返回给模型 | 工具方法返回类型不被支持 | 查看日志中 tool execution 阶段输出 | 确保工具方法返回 String 或可序列化对象 |
| 多工具场景下工具被串行调用 | 模型决策大量工具需要连续调用 | 观察模型输出的 tool call 数量 | 使用并行工具调用能力,或简化工具粒度 |
| 模型返回 content 为空 | 模型字段差异或 reasoning 模式 | 抓取完整响应日志 | 更换模型或自定义 ResponseExtractor |
| 工具调用偶尔失败且无规律 | 隐式解析触发额外请求导致超时 | 统计一次工具调用的实际耗时 | 关闭隐式解析或优化工具描述 |
| 动态模式下工具列表未更新 | Provider 被缓存了 | 检查 ToolCallbackProvider 是否单例 | 改成每次请求动态获取工具列表 |
还有一个小技巧:在开发环境把 spring.ai.tool-calling 相关的日志级别调到 DEBUG,可以看到工具注册、工具参数解析、工具调用结果的全过程。我排查问题基本都靠这个日志,比各种调试器都好使。
6. 迁移到 2.0 时你要特别注意的几件事
最后再分享几个迁移过程中容易被忽略的细节,每一个都是我在项目里实际验证过或者踩过坑的。
第一,若你之前用 1.0 的注解创建工具,在 2.0 中不要急着把所有工具方法全改成 MethodToolCallback。框架对 @Tool 注解仍然有良好支持,你可以保留注解的简洁性,只在需要动态裁剪的场景切换到 ToolCallbackProvider。两种方式可以混用,defaultTools 和 prompt().tools() 可以同时存在。
第二,2.0 里 ChatClient 的 defaultSystem 参数和工具描述可能会在 token 使用上有冲突。工具描述越长,模型对话可用的上下文窗口越短。如果你的模型上下文窗口本来就小,动态裁剪工具的优势会非常明显——不要让 20 个工具的描述塞满上下文。
第三,关于 ToolContext,建议你设计一个全局的上下文工具类,统一管理 key 的命名,避免在不同工具方法里各写各的 "userId"、"uid"、"user_id",否则迁移三个月后你自己都分不清哪个工具取的是哪个值。我在项目里维护了一个常量类,专门定义 ToolContext 的 key,成本很低,收益却很实在。
第四,升级后留意一下 spring-ai-model 和实际的 model client 版本是否一致,maven 依赖冲突会导致部分 ToolCalling 接口行为异常。我在 2.0.0 正式版和 2.0.0-M 系列混合使用时遇到过隐式解析不生效的问题,统一版本后恢复正常。
从我个人的实际体验来看,Spring AI 2.0 的 Tool Calling 设计思路是清晰且面向生产环境的。动态模式解决了工具数量和权限控制的矛盾,ToolContext 解决了上下文传递的脏活,隐式解析则给那些没法严格输出 JSON 的模型兜了底。三者组合使用,才算是把 2.0 的 Tool Calling 真正吃到透。
