Spring AI 2.0工具调用重构
最近帮一个团队排查 agent 的线上问题,场景很典型:他们的聊天机器人接了十几个工具,想让模型每次调工具的时候都留下入参、出参和耗时的记录,再套一层简单的限流。需求听着一点都不复杂,结果卡了两天。问题不在业务代码,而在框架本身。Spring AI 1.x 里,工具调用循环是每个模型实现各管各的,想拦,找不到地方拦。官方博客后来对这段历史的总结很直接:"You could call tools; you could not build on top of tool calling.",能调用工具,但无法在工具调用之上构建东西。
6 月 29 日 GA 的 Spring AI 2.0,基于 Spring Boot 4.0/4.1 和 Spring Framework 7.0 构建,官方博客 6 月 12 日就预告过。这次最大的架构变化,就是把上面那句话翻了过来:工具调用循环从各家模型实现里抽出来,放进了 ChatClient 的 advisor 链。
1.x 的工具调用:能调,但建不起东西
1.x 里注册工具很容易,ChatClient 上声明 toolCallbacks,模型就会在对话中自己决定什么时候调用。问题出在"模型决定调用之后"这段:谁去执行工具?执行结果怎么塞回上下文?要不要再问一次模型?这段循环在不同模型的 ChatModel 实现里各写了一份,逻辑相似但互不通用。你想在循环外面加审计日志、加熔断、把工具执行换成异步队列,都得去翻模型实现的源码,找到那个循环,然后祈祷它给你留了扩展点。
更难受的是,这些循环是私有的,它属于模型实现,不属于你的应用。想在两个模型之间复用同一套工具策略,做不到;想观测它,日志散落在各家 SDK 里,格式都不一样。
2.0 把循环搬进了 advisor 链
2.0 的做法是承认一个事实:工具调用往返是 agent 应用的核心流程,它该是一等公民,而不是模型实现里的一段内部逻辑。循环被整体移入 ChatClient 的 advisor 链,ToolCallAdvisor 默认自动注册,处理完整的工具调用往返:模型返回工具调用请求,它执行工具,把结果拼回消息,再带着新上下文问一次模型,直到模型给出最终回答。默认顺序在 HIGHEST_PRECEDENCE + 300,不需要你写一行代码。
想自己控制每一次迭代,路也留了。全局用配置 spring.ai.chat.client.tool-calling.enabled=false 关掉自动注册,单次调用可以在 .advisors() 里传 AdvisorParams.toolCallingAdvisorAutoRegister(false),然后自己用 ToolCallingManager 驱动循环,把每一步暴露给前端展示。关键在于,循环的每一次往返现在都从你的 advisor 链里穿过,你在链上的任何位置都能看到它、改它、拦它。
写一个自定义 advisor 现在跟写一个拦截器差不多,接口在 org.springframework.ai.chat.client.advisor.api 包里:
// Spring AI 2.0,记录每一轮工具调用往返的耗时
@Component
publicclass ToolCallLogAdvisorimplementsCallAdvisor{
privatestaticfinalLoggerlog=LoggerFactory.getLogger(ToolCallLogAdvisor.class);
@Override
publicChatClientResponseadviseCall(ChatClientRequestrequest,CallAdvisorChainchain){
longstart=System.nanoTime();
ChatClientResponseresponse=chain.nextCall(request);
log.info("一轮模型往返耗时 {}ms",(System.nanoTime()-start)/1_000_000);
returnresponse;
}
@Override
publicStringgetName(){
return"toolCallLogAdvisor";
}
@Override
publicintgetOrder(){
// 大于 ToolCallAdvisor 默认顺序,落在工具循环内部,每轮迭代都执行
returnToolCallAdvisor.DEFAULT_ORDER+100;
}
}
注意 getOrder() 这一行。排在 ToolCallAdvisor.DEFAULT_ORDER 之前的 advisor 每个请求只跑一次,排在它之后的会在每一轮工具迭代里反复执行。想观测工具调用,就得把 advisor 放进循环内部,这个自由度在 1.x 里是不存在的。
工具多起来之后
模型每轮请求都要把工具定义塞进上下文,几十个函数还好,上百个之后,光工具定义就能吃掉一大块 token。2.0 新增的 ToolSearchToolCallingAdvisor 就是冲这个场景来的:每个会话对全量工具集索引一次,之后模型按需检索相关工具,只把命中的定义发给模型,官方管这个叫渐进式工具披露(progressive tool disclosure)。它现在在独立的 spring-ai-tool-search-advisor 模块里,加 starter、开 spring.ai.chat.client.tool-search-advisor.enabled=true 就启用,索引支持正则、Lucene 和向量三种模式,量级小的用正则就够了。
另一个新 advisor 是 StructuredOutputValidationAdvisor,针对结构化输出最磨人的场景:模型返回的 JSON 映射不到你的 Java 对象。以前遇到这种失败只能重试或者写一堆容错代码,现在映射失败、输出不合规时,advisor 会自动走一轮自纠错。这类能力放在 1.x 里无处安放,有了链才算有了归宿。
MCP 从协议变成了注解
MCP 在 2.0 里也主线化了。MCP Java SDK 2.0.0 由 Spring 团队维护,@McpTool、@McpResource、@McpPrompt 这几个注解直接折进了 Spring AI:你的 Spring 服务想在 MCP server 上暴露能力,写一个带注解的方法就行,方法参数自动生成 JSON Schema。以前要自己理解的协议握手、消息格式,现在被收进框架,业务侧只剩注解和普通方法。
升级到 2.0 的代码改动
如果你在 1.x 里已经用上了工具调用,这次升级不是换个版本号的事,按重大升级对待比较稳妥。官方升级文档列了一长串 breaking changes,挑几个最常踩的:toolCallbacks() 和 defaultToolCallbacks() 改名为 tools() 和 defaultTools();ChatClientCustomizer 改名 ChatClientBuilderCustomizer;ChatModel.getDefaultOptions() 改成 getOptions();PromptChatMemoryAdvisor 改名 MessageChatMemoryAdvisor;spring-ai-advisors-vector-store 改名 spring-ai-vector-store-advisor;spring-ai-openai-sdk 改名 spring-ai-openai,Azure OpenAI 模块整个移除。还有 internalToolExecutionEnabled 这个开关被删了,模型内部的工具执行整个不存在了,开关自然失去意义,这恰好印证了循环搬家这件事。
2.0 顺手做的一批零碎改动也别忽略:全代码库补了 null-safety 注解,JSON 序列化处理重做,options 配置扁平化,去掉 .options. 中缀。单个看都不大,合在一起就是为什么说它是重构而不是小升级。
我的判断
把工具调用循环搬进 advisor 链,意义比表面上大。1.x 的 agent 是能用,你能让模型调工具,但循环是模型实现给你的一次性服务,你只能在它给的边界内使用。2.0 之后它是可构建的:循环变成应用层的管线,日志、观测、限流、缓存、权限校验这些工程化的东西第一次有了统一的落点,而且可以自由组合。agent 从 demo 走到生产,差的正是这一层。
给团队一个务实的建议:别把 2.0 顺手升了,当一次独立的迁移排期。先跑工具调用相关的回归,确认 tools() 改名和 advisor 默认行为带来的变化符合预期,再动其余部分。还没上 agent 的团队,直接以 2.0 起步,1.x 那套循环在模型里的心智模型,不值得再学一遍。
工具调用从模型内部的事变成你应用里的事,这个转变值得单独拎出来说。模型厂商还会继续在能力上卷,而应用层能卷的地方,工具编排、观测、护栏,2.0 算是把地基铺好了。接下来写 agent,拼的不再是谁更懂某个模型 SDK 的内部循环,而是谁更会用这条链。
夜雨聆风