做AI Agent的朋友应该都有这种体会:模型能力跑通不是难点,难的是“换模型、叠加模型、让模型在Java服务里稳定落地”。最近我在梳理一组“AI Agent基础”的实战笔记,这一篇重点聊Spring AI的集成方式,以及它如何帮我们在一个应用里同时支撑多种模型。如果你正好在Spring Boot项目里做Agent服务,或者厌倦了每个模型单独写一套HTTP调用、签名、重试和解析,那这篇很适合你。
我不会把Spring AI夸成银弹,但它的设计思路确实解决了一个很实际的问题:把模型API的差异挡在业务外面。你只需要面向一套统一接口编写代码,切换模型时改配置或改路由,而不是重写业务逻辑。下面我会从API设计、多模型实现原理、完整实操代码、踩坑排查四个角度展开,途中会穿插一些我实际项目里的取舍和教训。
1. 为什么需要Spring AI这类集成层
1.1 不集成模型时的“散装”开发模式
最早我做Java接大模型,是直接拼HTTP请求。模型A返回JSON结构是一种风格,模型B返回格式又是另一套,模型C还需要本地推理进程的端口约定。每个模型都要处理鉴权、超时、重试、流式响应、异常结构,最后业务代码里充满各种if-else分支,指定“走哪家模型就走哪段逻辑”。
更头疼的是Agent场景。Agent需要多轮对话、需要工具调用、要动态决定调用哪个模型。散装调用意味着每次新增模型都要重写一轮基础设施代码。也就是说,模型能力本身不是瓶颈,模型接入的重复劳动才是瓶颈。这个痛点,正是类似Spring AI这类框架想要替代的。
1.2 Spring AI给出的解题思路
Spring AI的核心思路并不复杂:把“模型调用”抽象成统一接口,然后提供针对不同模型厂商的适配实现。你在业务代码里面对的是ChatModel、EmbeddingModel这样稳定的抽象,至于背后到底接的是云端API还是本地模型推理,最终由配置和Bean装配决定。
这个套路在Java生态里很常见,比如JdbcTemplate对不同数据库的包装。但放到AI场景后,价值被放大了。因为Agent应用不是只用一个模型,它往往同时使用文本生成、嵌入向量、工具调用等等能力。如果每次换模型都要动业务层,系统就很难演进。Spring AI把“接口稳定”和“实现可替换”这两件事分开,项目结构立刻清晰起来。
1.3 适合哪些场景,不适合哪些场景
如果你的项目只是固定接一个模型,而且需求未来也不打算换,那直接调用HTTP不丢人,甚至更轻量。Spring AI的价值要在“多模型、可替换、Agent组合能力”出现后才凸显。
适合的典型场景:
- 演示原型需要快速切换不同模型对比效果。
- 生产环境同一份代码部署到不同客户侧,客户有的能访问云端服务,有的只能用私有化模型。
- Agent应用需要同时使用“轻量模型做意图识别、重量级模型做最终生成”。
- 团队希望把工具调用、上下文管理、向量库等能力统一封装,沉淀成内部框架。
不适合的场景是:单模型死磕、延迟极度敏感、需要极致定制底层协议。那种情况下,集成层反而会带来额外的抽象损耗。
2. Spring AI多模型支持的核心设计拆解
2.1 必须掌握的四个核心API
看Spring AI文档时,第一眼容易被大量类名吓到,但真正需要吃透的就是几个接口。
ChatModel是最核心的接口,它封装了文本生成能力,类似以前的“语言模型客户端”。ChatClient则是更偏向业务开发者的流式API入口,它提供了链式调用风格,比如chatClient.prompt("xxx").call().content(),上手成本比直接在ChatModel上写代码低很多。如果你用过Spring WebClient,你会觉得这套API很熟悉。
Prompt和Message是数据模型。Prompt是一组消息和参数的封装,Message区分系统消息、用户消息、助手消息等角色。它们解决的是“对话历史如何表达”这件事。多模型切换时,最容易出问题的地方就在这里,因为不同模型对系统提示词的敏感度、对多轮消息的处理能力都不一样。
EmbeddingModel负责文本向量化,这是Agent做语义检索、长期记忆时的关键依赖。Spring AI对这个接口也有统一抽象,你在业务层不太需要关心到底用的是哪家的embedding服务。
这几类接口之间不是平级关系。ChatModel是底层能力,ChatClient是高层封装,Prompt/Message是传输数据结构,EmbeddingModel是另一条能力线。理解它们的关系后,再看具体代码就不会迷路。
2.2 多模型是如何被“藏”进配置里的
Spring AI的多模型支持并不靠运行时的大路由魔法,而是靠Spring Boot的自动装配机制。它把每个模型厂家的适配都放进了独立Starter,比如云端服务一个Starter、本地推理框架一个Starter、国内兼容服务一个Starter。
你在pom.xml引入哪个Starter,哪个实现类就会被装配成Bean。默认情况下,Spring AI会读取配置项里的“当前激活模型提供商”来决定创建哪个ChatModel实例。如果你引入了多个Starter,就需要通过配置文件明确指定使用哪个,或者用@Primary标记主Bean。
这意味着多模型支持分成两层:
- 启动期层面:通过配置和Bean装配决定默认模型。
- 运行期层面:如果你需要同时持有多个模型,可以显式注入多个
ChatModel,再用自定义路由做请求级分发。
这两种方式并不互斥。实际项目中,启动期选默认模型,运行期再按请求参数动态选择具体模型,是更灵活的方案。后面第三节我会给出这种路由写法。
2.3 模型选型对比,从Agent视角看
在Spring AI里接入某个模型,本质上是选择一种适配实现。但从Agent业务视角,真正要关注的是模型所在环境的差异。我用一种脱离具体品牌的描述方式,给你一张对比表。
| 模型接入类型 | 部署形态 | 成本特点 | 数据私密性 | 延迟体验 | 适用场景 |
|---|---|---|---|---|---|
| 云端全能型 | 外部API调用 | 按量计费,没有硬件成本 | 数据出内网,需合规评估 | 百毫秒到秒级,受网络影响 | 通用对话、复杂推理、高质量生成 |
| 本地开源型 | 内网GPU/CPU推理 | 硬件成本高,调用基本免费 | 数据不出内网,安全性好 | 取决于硬件,中低端机器偏慢 | 私有化部署、敏感数据场景、离线环境 |
| 国内兼容型 | 云端API或专有云 | 计费方式灵活 | 数据留在国内服务商,合规需自查 | 国内网络延迟较低 | 中文场景、合规敏感业务、跨国网络不稳 |
| 混合路由型 | 上述组合 | 综合优化 | 按路由规则分级 | 可针对场景优化 | Agent多级调度、降级容灾 |
这张表不是让你选“最好的模型”,而是提醒你:Spring AI集成不只是一个技术问题,更是部署策略和成本控制问题。比如本地模型可以在断网演练时做降级,云端模型可以在本地算力不足时兜底。两个都接上,系统才真正具备抗风险能力。
3. 实操:接多个模型并实现动态路由
3.1 初始化工程与依赖管理
先提个醒:Spring AI版本迭代很快,我在项目里使用的是当前稳定版本。引入依赖时建议直接用官方BOM,避免手动管理一堆子版本。如果你只用文本生成,最小依赖就是Spring Boot Web和Spring AI的ChatClient相关Starter。
以Maven为例,大致结构是这样:
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>${spring-ai.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-chat-client</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-xxx</artifactId> </dependency> </dependencies>这里有一个容易踩的坑:不同Starter之间的版本必须和Spring Boot版本兼容。我见过同事直接把最新Spring AI版本塞进老Spring Boot项目,启动时各种NoClassDefFoundError。稳妥做法是先看官方发布说明里的“兼容矩阵”,选定一版Spring Boot版本后再配Spring AI版本。
3.2 先把一个模型跑通
一开始不要贪多,先让一个模型在工程里跑起来。配置文件里需要设置模型提供商以及相关参数,比如API Key和endpoint。注意不要把这些信息硬编码进配置提交到仓库,我在内部项目里会用环境变量占位。
spring: ai: model: chat: provider: model-cloud options: api-key: ${MODEL_CLOUD_API_KEY} endpoint: ${MODEL_CLOUD_ENDPOINT} temperature: 0.7 max-tokens: 1024对应写一个只负责对话的Service:
@Service public class ChatService { private final ChatClient chatClient; public ChatService(ChatClient.Builder builder) { this.chatClient = builder.build(); } public String chat(String userMessage) { return chatClient.prompt(userMessage) .call() .content(); } }然后暴露一个简单接口给前端调用。这个阶段的目标是验证依赖、配置、网络都正常。我在实际测试时建议先用一条很短的指令测通,再测试流式和多轮。曾经有一次我直接测长文档生成,结果超时报错,第一反应以为配置错了,排查半天才发现是请求体太大。
3.3 多模型同时注入,做请求级路由
当同一套代码需要支持“客户A走云端模型,客户B走本地模型”时,默认配置切换已经不够用了。你需要同时持有多个ChatModel,再根据请求参数决定使用哪个。
Spring AI允许多个模型实现同时存在,前提是你要主动注入它们。我在工程里的做法是创建一个路由适配器,把所有模型放进一个注册表。
先定义一个简单的模型路由枚举:
public enum ModelProvider { MODEL_CLOUD, LOCAL_FRAMEWORK, CN_COMPATIBLE }然后定义路由服务,构造器直接注入所有可用模型实现。
@Service public class ModelRouter { private final Map<ModelProvider, ChatModel> modelMap; public ModelRouter( @Qualifier("modelCloudChatModel") ChatModel modelCloud, @Qualifier("localFrameworkChatModel") ChatModel localFramework, @Qualifier("cnCompatibleChatModel") ChatModel cnCompatible) { modelMap = new HashMap<>(); modelMap.put(ModelProvider.MODEL_CLOUD, modelCloud); modelMap.put(ModelProvider.LOCAL_FRAMEWORK, localFramework); modelMap.put(ModelProvider.CN_COMPATIBLE, cnCompatible); } public String chat(ModelProvider provider, String message) { ChatModel model = modelMap.get(provider); if (model == null) { throw new IllegalArgumentException("不支持的模型提供商: " + provider); } var response = model.call(new Prompt(message)); return response.getResult().getOutput().getContent(); } }这里的关键点在于@Qualifier指定的Bean名称。Spring AI每个模型Starter都会创建对应的ChatModelBean,Bean名称遵循一定规则。如果你不确定名称,可以在启动日志里查看ChatModel相关的Bean注册信息,或者用ApplicationContext列出所有ChatModel类型Bean。
我自己的经验是:为了易维护,不要依赖隐式名称,建议在配置类里手动定义每个模型的ChatModelBean,并显式给Bean命名。这样路由代码的意图更清楚,别人接手也不会靠猜。
3.4 让Agent具备工具调用与上下文记忆
多模型支持不只是来回发文本。真正的Agent应用,需要模型在对话中决定调用哪个工具,也需要跨多轮记住上下文。Spring AI对这两个需求都有支持。
工具调用方面,你只要在一个普通Bean方法上加上@Tool注解,并在提示词中描述它的作用和参数。调用ChatClient时,模型会自动判断是否需要触发工具。写个简单示例:
@Component public class WeatherTool { @Tool(description = "查询指定城市的天气") public String getWeather(String city) { return city + ":晴,26摄氏度"; } }接着在对话服务里启用工具列表:
String answer = chatClient.prompt(userMessage) .tools("getWeather") .call() .content();这里的坑是多模型对工具调用的格式要求不完全一致。云端模型通常能直接吃工具描述,本地小模型可能对工具格式理解不到位,需要少传一些复杂字段。我在集成时,会把工具描述文本控制在简洁范围,并在切换模型后先做一轮工具调用的冒烟测试。
上下文记忆方面,Spring AI提供ChatMemory接口。你可以把多轮消息存到MessageWindowChatMemory中,再配合ChatClient的memory方法实现有状态对话。但要注意:不同模型对上下文窗口的敏感度不一样,切换模型时最好按会话重新评估历史长度。曾经我在一个长对话里,从大窗口模型切到小窗口模型,结果直接把历史全量塞进去,请求直接报错。后来我在路由层增加了历史消息压缩逻辑,才彻底解决。
3.5 结构化输出,避免解析乱撞
Agent服务的很多输出不是给人看的,而是要交给下游系统处理。这时候模型返回的内容必须满足固定JSON结构。Spring AI提供了BeanOutputConverter,你可以把输出直接绑定到Java对象上。
record WeatherResponse(String city, String temperature, String advice) {} BeanOutputConverter<WeatherResponse> converter = new BeanOutputConverter<>(WeatherResponse.class); String response = chatClient.prompt("北京天气如何?请按格式输出") .options(converter.getResponseFormat()) .call() .content(); WeatherResponse result = converter.convert(response);这个设计很实用。但我也遇到过模型偶尔在JSON外层包一个Markdown代码块,导致转换失败。稳妥做法是在解析前做一次清洗,把首尾的```标记去掉。Spring AI有些版本内置了容错解析,但不要完全依赖,我始终会在转换外加一层异常兜底。
4. 常见问题与排障实录
4.1 启动失败:找不到唯一的ChatModel Bean
如果你在工程里引入了多个模型Starter,Spring Boot启动时可能会因为存在多个ChatModel类型的Bean而报NoUniqueBeanDefinitionException。这是因为Spring AI默认期望一个主模型Bean。
解决办法有几种:
- 在配置文件中明确指定当前主模型提供商,让自动配置只生成一个默认
ChatModel。 - 为某一实例加
@Primary,告诉Spring优先注入它。 - 如果你确实需要同时注入多个模型,就不要用
ChatModel类型直接注入,而是用@Qualifier显式指定Bean名称。
我实际项目里用的是第三种,因为这本来就是“多模型并存”的设计需求。启动时不要把多个模型的Bean都交给Spring AI自动配置,建议手动定义,避免模棱两可。
4.2 默认超时和第三方限流导致间歇性失败
与大模型API交互,最难排查的往往是“一会儿超时,一会儿成功”。我之前遇到过一个现象:早上高峰时段有一半请求报超时,本地测试却全部正常。后来发现是第三方服务的限流策略加上默认超时太短叠加导致的。
Spring AI可以给底层RestClient设置连接超时和读取超时,建议不要用默认值:
@Component public class ClientConfig { @Bean public ChatClient customChatClient(ChatClient.Builder builder) { // 如果版本支持,可以在Builder上通过customizers配置超时 return builder.build(); } }不同版本配置方式有差异,但核心思路一致:把连接超时设为3到5秒,读取超时设为60秒以上,再结合Spring Retry做指数退避重试。限流报错时,不要立刻重试,等待几秒可能更有效。
4.3 Token超限与输出被截断
多轮Agent对话中,prompt里带了大量历史消息和工具返回结果,很容易触达模型的上下文窗口上限。报错信息一般会明确提示maximum context length,但更隐蔽的是输出被截断但不报错。
我建议为每个模型单独设置maxTokens,不要用一个全局值。同时在业务侧做一个“对话历史摘要”策略:当历史消息超过预设阈值时,把早期消息压缩成一两句话摘要,再拼入当前请求。这个做法比无脑截断效果好得多。
另一个细节是:maxTokens既要考虑模型输入Token,也要考虑输出Token的配额。有些平台的API用的是同一个Token总数限制,输出留得太小,长文生成就会中途断掉。我会根据场景给出一个安全系数,比如总上限的百分之八十作为输出预算。
4.4 多模型返回格式不一致导致解析失败
切换模型后,最容易暴露的问题是返回格式不稳定。同一个提示词,模型A乖乖输出JSON,模型B可能多输出一段解释,模型C还会用Markdown包裹。这时候解析器就会崩溃。
解决思路有两个:一个是在提示词中强化格式约束,另一个是使用Spring AI的结构化输出转换能力。前者成本低,但不是百分百可靠;后者更稳,适合正式链路。我通常两者结合,先用提示词约束,再做转换容错。如果你对格式要求特别严格,建议在解析失败后增加一次“修正请求”,把模型输出的原始内容送给模型,让其改写成合法JSON。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 处理建议 |
|---|---|---|
| 启动报多个ChatModel Bean冲突 | 同时引入多套模型Starter且未指定主Bean | 用@Primary或@Qualifier显式选择,或手动定义Bean |
| 请求间歇超时 | 第三方限流、默认超时过短 | 调大读取超时,增加退避重试,控制并发 |
| 提示上下文超限 | 多轮历史过长 | 历史摘要压缩,按模型单独控制Token预算 |
| 模型输出多出Markdown包裹 | 提示词约束不足 | 清洗首尾标记,或使用结构化输出转换 |
| 切模型后多轮对话变“失忆” | 历史消息格式或窗口不一致 | 会话级重置或压缩历史,换模型时单独测试 |
| 本地模型响应突然卡死 | 显存不足或推理并发过高 | 限制并发,使用队列,监控GPU资源 |
这张表算是我实际踩坑后的浓缩版。每次遇到诡异问题,我都会先对照排查,多数情况下问题都出在“版本、配置、限制”三件事上。
写在后面
坦白讲,Spring AI还处于快速成长期,一些API在版本升级后有变动,这也劝退了一部分Java团队。不过我个人体会是:如果你的目标是做AI Agent,而且大概率要在多个模型之间做对比、降级、私有化交付,这个集成层是值得投入的。比起到处散落HTTP调用,统一的抽象和可控的路由策略,会让后续维护舒服很多。
最后分享一个小技巧:在做多模型路由时,在请求链路里增加一个modelProvider字段,日志打印、监控指标、成本统计都带上这个维度。排查故障时你会感谢这个字段的。先把手里的一个模型跑顺,再去扩展路由,这是最稳的上手路径。