1. 为什么 Spring AI 2.0 的 Tool Calling 必须考虑进阶玩法
如果你只是写个 demo,把一个 @Tool 注解的方法抛给模型,那确实不需要看这篇文章。但一旦进入真实业务,工具数量超过十个、模型开始乱填参数、不同用户能调用的工具还不一样,你就会发现基础 Tool Calling 根本顶不住。
我最近在做的客服工单助手就是这个情况:工具列表从最初 3 个涨到 20 多个,Prompt 里塞满了工具描述,Token 消耗直线上升,更头疼的是模型经常把 userId、traceId 这种本该由服务端注入的参数当成可选项去问用户,或者干脆编一个不存在的工单号。后来我把 Spring AI 2.0 的动态模式、ToolContext 和隐式解析三个特性结合到一起,才把这个问题理顺。
先说结论:这三个特性不是互相独立的炫技功能,它们共同解决一个核心问题——让工具调用从"模型说了算"变成"业务规则说了算"。动态模式决定哪些工具可见,ToolContext 负责把请求级数据安全传到工具层,隐式解析则把模型不该碰的参数在调用前自动补齐。三个叠加之后,工具调用的稳定性和可维护性都上了一个台阶。
这篇内容适合已经跑通 Spring AI 基础 Tool Calling、正打算上生产环境的开发者。如果你还没碰过 Spring AI,建议先去官网把 ChatClient 和 @Tool 的基础用法过一遍,再回来看这篇,体验会好很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三个进阶特性的设计思路拆解
2.1 动态模式:让工具列表从编译期挪到运行期
传统写法里,工具列表基本是固定的。你写了一个 MethodToolCallbackProvider,把 bean 里的方法都注册进去,模型每次调用时面对的都是同一个工具全集。
这在工具少、权限模型简单的时候没问题。但真实场景往往是这样的:
- 管理员能查所有订单,普通用户只能查自己的订单
- 会员等级不同,可用的优惠工具不同
- 某个工具正在灰度,需要按用户比例放量
- 某个工具突然不稳定,需要开关一键下线
如果这些都靠改代码发版,运维成本会高到让人崩溃。动态模式解决的就是这个问题:工具注册表里可以放所有工具,但每次请求过来时,根据当前用户、当前上下文、当前开关状态,决定实际暴露给模型的工具子集。
Spring AI 2.0 里这个抽象变得更清晰了。工具不再是"注册进去就固定"的东西,而是可以通过 ToolCallbackRepository 在运行时查询、过滤、启停。你只需要提供一个过滤器或者一个路由逻辑,就能让不同请求看到不同的工具集合。
2.2 ToolContext:请求级数据的隐形通道
工具方法免不了要读一些上下文数据,最常见的就是当前登录用户、请求追踪 ID、语言偏好、权限标识。
很多人会把这些塞进 Prompt 里,让模型"记住",然后在工具方法里靠模型传参拿回来。这非常危险,因为模型可能记错、可能漏传,更可能把用户 A 的数据带到用户 B 的请求里。
ToolContext 解决的方式很直接:它不经过模型,而是在应用代码里建立一条通道。你在发起调用时通过 ChatClient 传入一个 ToolContext 对象,这个对象会沿着调用链一路传到工具方法内部,工具方法可以直接从参数里拿 ToolContext 实例,读取里面的数据。
这条通道的价值在于:它是线程封闭的、不经过模型生成的,所以数据不可能被模型篡改或幻觉。你甚至可以往里面塞业务对象,只要工具的输入输出不涉及它,模型就完全感知不到它的存在。
2.3 隐式解析:把模型不该碰的参数拦下来
工具方法的参数,并不都是该由模型生成的。
比如 getOrderDetail(orderId, currentUser),orderId 可以由模型从对话里提取,但 currentUser 绝对不该让模型猜,它应该来自登录态。再比如 createTicket(content, traceId),traceId 应该由服务端生成。
隐式解析要做的事,就是在工具真正执行之前,对参数做一次"预处理":对一部分参数标注来源,让框架自动从 ToolContext 或者其他数据源里取值,而不是等模型传参。
我用一个最朴素的比喻:你把模型当成一个实习生,你给它一份申请表,有些字段(问题描述、订单号)让它自己填,有些字段(申请人、部门、工号)你在后台直接盖好章。隐式解析就是这个盖章流程。
实践中,我通常是在工具方法签名上做标记,通过注解或者参数名约定,让 Spring AI 的 ToolCallingManager 在调用前完成参数填充。2.0 里这个链路更干净,参数解析可以由自定义 ToolParameterResolver 接管,不再需要把所有参数都交给模型。
2.4 三者如何配合:一个完整的调用链视角
把三个特性放在一起看,一次调用的完整链路是这样的:
- 请求到达,应用根据当前用户和业务场景,构建一个动态的工具集合(动态模式)
- 应用把当前用户、traceId、权限数据放进 ToolContext,随 ChatClient 发起调用
- 模型只看到"它对用户说的内容 + 当前可见的工具"
- 模型决定调用某个工具,Spring AI 开始解析工具参数
- 模型能生成的参数由模型生成,不能生成的参数由隐式解析从 ToolContext 里补齐
- 工具执行,需要上下文数据时直接从 ToolContext 读取
- 工具结果返回给模型,模型组织最终回复
这个链路的好处很明显:模型接触到的数据面最小,业务数据从源头就被隔离在 ToolContext 里,安全性和稳定性都同时得到了保障。
3. 核心细节解析与实操要点
3.1 工具声明方式对比:@Tool 注解 vs ToolCallback 编程式
Spring AI 里声明工具主要有两种方式,各有各的适用场景。
@Tool 注解适合大多数简单场景。你只要在 Spring Bean 的方法上打上注解,方法名加描述,框架会自动生成 schema。而且 2.0 对描述的支持更友好,你可以在描述里写清楚参数含义和注意事项,模型对工具的理解会更好。
java复制@Component
public class OrderTools {
@Tool(description = "根据订单号查询订单详情,仅限当前用户自己的订单")
public OrderDetail getOrderDetail(String orderId, ToolContext toolContext) {
// 从 toolContext 拿当前用户,校验权限
String currentUser = toolContext.getContext().get("currentUser").toString();
return orderService.getOrderDetail(orderId, currentUser);
}
}
ToolCallback 编程式则适合需要精细控制的场景。你可以完全掌控 JSON Schema 的生成、参数的解析逻辑,甚至可以动态决定这个工具是否应该被注册。对于动态模式来说,ToolCallback 是更灵活的底层抽象。
java复制ToolCallback dynamicTool = ToolCallbacks.builder()
.name("getWarehouseStock")
.description("查询仓库实时库存")
.inputType(WarehouseStockRequest.class)
.toolFunction((request, ctx) -> warehouseService.queryStock(request.skuId()))
.build();
我个人的建议是:核心且稳定的工具用 @Tool,方便维护;需要动态启停、需要特殊参数处理的工具用 ToolCallback,方便做成灵活的组件。
3.2 ToolContext 的传递链路:从 ChatClient 到工具方法
ToolContext 的传递路径其实很清晰,关键在于一开始就要传到 ChatClient 的调用方法里。
java复制ToolContext toolContext = ToolContext.builder()
.put("currentUser", "u_1024")
.put("traceId", "trace_abc_123")
.put("userLevel", "VIP")
.build();
String answer = chatClient.prompt()
.user("帮我查一下订单 SP20250101 的物流信息")
.tools(toolCallbacks) // 动态模式下这里传入过滤后的工具列表
.toolContext(toolContext)
.call()
.content();
只要你在调用时传入 toolContext,框架会把它一直传递到工具方法。工具方法这边不需要做额外配置,直接在参数列表里加上 ToolContext 类型的参数,框架就会自动注入。
java复制@Tool(description = "查询订单物流进度")
public String trackOrder(String orderId, ToolContext toolContext) {
String traceId = toolContext.getContext().get("traceId");
// 用 traceId 打日志,全链路可追踪
return logisticsService.track(orderId, traceId);
}
这里有一个非常容易被忽略的坑:不要试图在工具方法里修改 ToolContext 并期待它影响后续工具的调用。不同工具拿到的 ToolContext 实例在某些版本里可能不是同一个对象,即使同一个对象,修改后的数据也可能不会回传到下一次模型循环。我建议把 ToolContext 当作只读使用,工具之间需要共享数据时,让模型通过对话上下文传递。
3.3 隐式解析的实现方式:三种常见方案对比
隐式解析在 Spring AI 2.0 里的实现方式,我实际试下来有三种,各有取舍。
第一种最简单:在 @Tool 方法里直接从 ToolContext 拿参数,方法签名里不声明这个参数。这种方式不需要框架支持,但代价是工具描述里不能体现这些参数,如果模型需要知道"当前用户是谁"才能做判断,这种方式就行不通。
第二种是自定义 ToolParameterResolver。Spring AI 2.0 允许你在工具调用链里插入参数解析器,对模型即将生成的参数做预处理。你可以先给参数一个默认值,或者根据参数名直接从 ToolContext 取值覆写。
java复制public class ContextAwareResolver implements ToolParameterResolver {
@Override
public Map<String, Object> resolve(ToolParameterResolveContext context) {
Map<String, Object> resolved = new HashMap<>();
ToolContext toolContext = context.getToolContext();
if (toolContext != null && toolContext.getContext().containsKey("userLevel")) {
resolved.put("userLevel", toolContext.getContext().get("userLevel"));
}
return resolved;
}
}
第三种是灵活的混合策略,也是我目前在用的:把必须要模型生成的参数留在方法签名里,把服务端可以推导的参数统一放 ToolContext,同时在方法内部做兜底校验。这个方法最保守,但最不容易翻车。
| 解析方式 | 对模型的可见性 | 维护成本 | 适用场景 |
|---|---|---|---|
| 方法内读 ToolContext | 不可见 | 最低 | 参数只用于内部逻辑 |
| 自定义 Resolver | 部分可见 | 中 | 参数需要默认值或覆写 |
| 混合策略 | 全部可见 | 较高 | 生产环境,需要稳定性兜底 |
3.4 动态启停的几种手段:过滤器、运行时路由与开关
动态模式到底怎么实现,取决于你的业务复杂度。
最简单的做法:在调用前过滤工具列表。你准备一个全量 ToolCallback 列表,每次请求进来,根据用户权限、会员等级、灰度开关,筛选出本次请求可见的子集。
java复制List<ToolCallback> buildToolListForUser(User user) {
return allToolCallbacks.stream()
.filter(tool -> toolAvailableForUser(tool, user))
.collect(Collectors.toList());
}
private boolean toolAvailableForUser(ToolCallback tool, User user) {
// 根据工具名判断权限
if (tool.getToolName().equals("deleteOrder") && !user.isAdmin()) {
return false;
}
// 灰度开关
if (tool.getToolName().equals("newRecommendTool") && !grayService.enabledFor(user)) {
return false;
}
return true;
}
如果你的工具列表是动态增长的,比如运营后台可以随时新增工具,那么建议用 ToolCallbackRepository 配合数据库或者配置中心存储元信息。Spring AI 2.0 的 repository 抽象支持按名称查询、启停操作,业务代码只需要面向 repository 编程。
这里有一个性能细节值得注意:每次请求都重新构建工具列表,虽然看起来很合理,但对高频接口来说会有额外开销。实测下来,工具列表的构建耗时通常在毫秒级,大部分情况下可以直接忽略。但如果你的工具数量上百,建议加一层基于用户类型和等级的缓存,把工具列表缓存下来,而不是每次全量过滤。
4. 实战:搭建一个带权限控制的多工具客服助手
4.1 场景设定与工具清单
假设我们要做一个客服工单助手,规则是这样的:
- 普通用户能查自己的订单、创建工单
- 客服人员能查任意订单、能查询内部工单库
- 客服主管还能查看团队绩效
- 所有工具调用都需要记录当前用户和 traceId
我准备了四个工具,分别代表不同敏感级别的操作,方便演示动态模式怎么控制可见性。
| 工具名 | 功能 | 可见范围 |
|---|---|---|
| queryOwnOrder | 查询自己的订单 | 所有登录用户 |
| createTicket | 创建工单 | 所有登录用户 |
| queryInternalOrder | 内部订单查询 | 客服及以上 |
| viewTeamPerformance | 团队绩效 | 主管 |
4.2 动态模式:按角色过滤工具集合
核心思路是先注册全量工具,再用一个工厂类按角色过滤。
java复制@Service
public class ToolRoutingService {
private final List<ToolCallback> allTools;
public ToolRoutingService(OrderTools orderTools, TicketTools ticketTools, PerformanceTools performanceTools) {
this.allTools = List.of(
ToolCallbacks.from(orderTools, "queryOwnOrder", "createTicket"),
ToolCallbacks.from(ticketTools, "queryInternalOrder"),
ToolCallbacks.from(performanceTools, "viewTeamPerformance")
);
}
public ToolCallback[] resolveTools(ToolContext toolContext) {
String role = toolContext.getContext().getOrDefault("role", "USER").toString();
return allTools.stream()
.filter(tool -> isAllowed(role, tool.getToolName()))
.toArray(ToolCallback[]::new);
}
private boolean isAllowed(String role, String toolName) {
return switch (toolName) {
case "queryOwnOrder", "createTicket" -> true;
case "queryInternalOrder" -> "AGENT".equals(role) || "SUPERVISOR".equals(role);
case "viewTeamPerformance" -> "SUPERVISOR".equals(role);
default -> false;
};
}
}
这个设计的好处是:新增工具只需要在 allTools 里加一行,权限规则在 isAllowed 里集中管理。不需要改 ChatClient 的配置,也不需要动 Prompt。
4.3 ToolContext 注入:用户身份与链路追踪
在 Web 层,我从当前登录态构建 ToolContext,然后把相关数据放进去。
java复制@RestController
public class ChatController {
private final ChatClient chatClient;
private final ToolRoutingService toolRouting;
@PostMapping("/chat")
public ChatResponse chat(@RequestBody ChatRequest request) {
User currentUser = SecurityUtils.getCurrentUser();
ToolContext toolContext = ToolContext.builder()
.put("userId", currentUser.getId())
.put("role", currentUser.getRole())
.put("traceId", TraceIdUtils.generate())
.build();
ToolCallback[] tools = toolRouting.resolveTools(toolContext);
String content = chatClient.prompt()
.user(request.message())
.tools(tools)
.toolContext(toolContext)
.call()
.content();
return new ChatResponse(content);
}
}
工具方法内部读取上下文时,我统一封装了一个工具基类,避免每个工具方法都重复写从 ToolContext 取值的逻辑。
java复制public abstract class BaseToolSupport {
protected String currentUserId(ToolContext ctx) {
return ctx.getContext().getOrDefault("userId", "unknown").toString();
}
protected String role(ToolContext ctx) {
return ctx.getContext().getOrDefault("role", "USER").toString();
}
protected String traceId(ToolContext ctx) {
return ctx.getContext().getOrDefault("traceId", "unknown").toString();
}
}
4.4 隐式解析:让 currentUser 从上下文自动填充
这里我用自定义 ToolParameterResolver 来实现:只要参数名叫 currentUser,就不让模型生成,自动从 ToolContext 里取。
java复制public class CurrentUserParameterResolver implements ToolParameterResolver {
@Override
public Map<String, Object> resolve(ToolParameterResolveContext context) {
ToolContext toolContext = context.getToolContext();
if (toolContext == null || !toolContext.getContext().containsKey("userId")) {
return Map.of();
}
return Map.of("currentUser", toolContext.getContext().get("userId"));
}
}
然后在 ChatClient 构建时把这个 resolver 挂上去。
java复制ChatClient chatClient = ChatClient.builder(chatModel)
.defaultToolParameterResolvers(new CurrentUserParameterResolver())
.build();
这样无论模型在调用 queryOwnOrder 时是否传了 currentUser,最终执行前这个参数都会被填充成正确的用户 ID。如果模型恰好也生成了这个参数,以 resolver 的值为准,我更倾向于这样设计,保证权限边界不被模型突破。
工具方法就可以放心声明 currentUser 参数了:
java复制@Tool(description = "根据订单号查询当前用户自己的订单")
public OrderDetail queryOwnOrder(String orderId, String currentUser, ToolContext toolContext) {
String traceId = traceId(toolContext);
log.info("traceId={}, userId={}, query order={}", traceId, currentUser, orderId);
return orderService.getOwnOrder(orderId, currentUser);
}
整个链路跑通之后,你会发现 Prompt 里再也不需要写"你是客服助手,当前用户是xxx"这种话了,因为工具需要的身份信息全部通过 ToolContext 走隐式解析,模型只专注于理解用户意图和调用工具。
4.5 完整效果实测
我拿一个真实对话做验证。用户问:"帮我查一下订单 A10086 到哪了。"
普通用户身份下,模型只有 queryOwnOrder 和 createTicket 两个工具可选,它调用 queryOwnOrder(orderId="A10086"),框架自动填充 currentUser,查询正常。
如果把用户身份切换成客服主管,同样的问题,模型会先看用户是否有权限查这个订单,或者直接调用 queryInternalOrder。更关键的是,模型知道自己有 viewTeamPerformance 工具,当用户问"我们组这周处理了多少工单"时,它能正确路由到这个工具。
而如果是普通用户问"我们组这周处理了多少工单",模型看到的工具列表里根本没有绩效工具,它会明确告诉用户"你没有权限查看这个信息",而不是尝试调用一个不存在的工具然后报错。这就是动态模式最大的价值。
5. 常见问题与排查技巧实录
5.1 连接 DeepSeek 不输出 content 的排查实录
这是我在接入 DeepSeek 时被坑得最惨的一次。现象是:整个 Spring AI 服务一切正常,但模型调用后返回的 content 是 null,工具却执行了,甚至执行结果也拿回来了。
后来我排查出的核心原因有两个方向。
第一个方向是消息序列问题。Spring AI 在工具调用循环中会生成 assistant 消息和 tool 消息,如果模型对工具返回结果的格式敏感(DeepSeek 对某些工具返回的空字符串或者特殊字符会处理异常),它可能在下一轮对话中不生成 content,只生成空的 assistant 片段。解决办法是确保工具返回的内容始终是结构良好、非空的字符串,必要时 JSON 序列化后再返回。
第二个方向是 ToolContext 传参导致的开销问题。如果你在请求里传入了非常大的 ToolContext 对象,并且某些版本在构造模型消息时意外包含了多余信息,可能触发模型端对上下文的截断或不响应。我的建议是只放真正需要的数据,对象尽量精简。
我还整理了一份排查清单:
| 现象 | 排查方向 | 解决办法 |
|---|---|---|
| content 为 null,但工具执行成功 | 工具返回格式问题 | 工具返回统一 JSON 字符串,避免空串 |
| content 为 null,且工具未执行 | Prompt 破坏了模型输出格式 | 检查 Prompt 中是否有对抗性提示 |
| 流式响应不完整 | 超时或缓冲设置 | 调大响应超时,检查流式处理器 |
| 工具循环多轮后断掉 | 上下文长度超限 | 精简工具描述,增加历史消息压缩 |
5.2 工具调用陷入死循环怎么处理
有时候模型会反复调用同一个工具,比如查了订单 A 再查 B 再查 C,或者在一个结果上反复追问,导致整轮对话的 token 消耗暴增。
我的经验是两个手段配合使用。第一是在工具内部做幂等和记忆,同一个参数组合在短时间内只查询一次,后续直接返回缓存结果;第二是控制对话轮次,Spring AI 允许你限制最大的工具迭代次数,超过次数就中断循环,返回当前积累的信息。
yaml复制spring:
ai:
chat:
client:
max-tool-call-rounds: 5
这个参数在 debug 阶段特别好用,先设置 2 轮,观察模型调用工具的行为,稳定后再放宽。
5.3 ToolContext 在异步场景下丢失
如果你在工具方法里用了自己创建的线程池,或者通过 @Async 去执行子任务,ToolContext 很可能传不进去,因为它是线程绑定的。这会导致工具方法拿不到用户信息,甚至空指针。
我踩过这个坑后,解决方案是:在进入异步任务前,把 ToolContext 里需要的数据提取成普通 Java 对象,作为方法参数传递。不要试图把 ToolContext 直接传进异步线程,它本身不是为跨线程设计的。
java复制@Tool(description = "创建工单并提醒相关人")
public String createTicket(String title, String content, ToolContext toolContext) {
String currentUser = currentUserId(toolContext);
String traceId = traceId(toolContext);
// 异步通知,只传必要数据
notificationService.sendAsync(ticketId, currentUser, traceId);
return "工单已创建";
}
5.4 动态注册后工具不生效的缓存问题
动态模式下最常见的问题就是:工具列表改了,但模型还是能看到旧工具,或者看不到新工具。
原因是 Spring AI 在构建模型请求时,可能会对工具列表做一定程度的缓存,特别是你用了 ChatClient 的实例字段保存工具时。解决方法很简单:不要复用同一个不可变的 tools 数组,每次调用都通过路由服务重新生成。
另一个容易被忽略的点:ToolCallbackProvider 虽然叫 provider,但它本质上是启动时构建的。如果你想运行时动态修改工具集合,应该直接面向 List<ToolCallback> 编程,而不是依赖 provider。这也是我推荐用 ToolCallbackRepository 的原因,它在设计上就更贴近动态场景。
5.5 参数解析器的优先级问题
自定义了 ToolParameterResolver 之后,还要注意它和模型生成参数之间的优先级。不同版本的行为可能不同,有的版本会直接把 resolver 的结果覆盖掉模型生成的参数,有的版本只提供默认值。
我的建议是:像 currentUser 这种权限相关参数,应该无条件使用 resolver 的结果。你可以写一段防御性代码,在工具方法内部再校验一次:
java复制@Tool(description = "删除订单,仅限管理员")
public String deleteOrder(String orderId, String currentUser, ToolContext ctx) {
String role = ctx.getContext().getOrDefault("role", "USER").toString();
if (!"ADMIN".equals(role)) {
throw new BusinessException("无权限执行该操作");
}
// ...
}
记住了,ToolContext 里的 role 永远比模型传的 currentUser 可靠。校验逻辑以 ToolContext 为准,模型参数只能作为辅助判断。
6. 动态 Tool 调用的一些经验体会
最后再分享几个我在实际项目中沉淀下来的使用习惯,不一定适合所有团队,但至少可以帮你在选型时少走弯路。
第一,工具数量控制在八个以内,效果最稳定。工具太多,模型反而会"选择困难",或者为了调用工具而刻意歪曲用户意图。动态模式让你可以在全量工具很多的情况下,保证每次向模型暴露的都只有最相关的几个。
第二,工具描述是隐式 Prompt,要为它花时间。每个工具的 description 我都会反复打磨,尽量说清楚"什么时候用""什么时候不要用""参数格式要求"。实测下来,描述写清楚之后,模型调用工具的准确率能提升一大截,比在 System Prompt 里东拉西扯有用得多。
第三,逐步灰度动态模式。不要一次性把所有工具都切成动态路由。我当时的做法是:先保留一个静态工具列表做对照组,同时上线动态路由版本,通过流量对比观察工具调用成功率、平均 token 消耗和用户满意度,跑了大概一周确认稳定后,才把静态列表下线。
第四,做好全链路的日志和追踪。ToolContext 里的 traceId 不只是为了查日志,我还会让它串联模型请求 ID、工具执行记录和最终的响应内容。出问题时,从用户反馈到工具执行环节,每一步都能看到。
如果你正准备把 Spring AI 用到生产环境,我建议给动态模式和 ToolContext 多一些重视。它们一开始看起来是多出来的复杂度,但等工具数量上去了、权限模型复杂了,你就会感谢当初花了这点时间做了设计。
