Spring AI 1.1.3接入实践:在Spring Boot 3项目中集成多家AI供应商的真实经历
我们公司做的是一套企业级低代码平台,技术栈是 Spring Boot 3.4 + Java 21。去年Q4产品经理甩过来一个需求:平台要支持13家AI供应商,让企业客户自己选模型——OpenAI、Azure OpenAI、智谱、DeepSeek、通义千问、文心一言、讯飞星火、Gemini、Ollama、豆包、MiniMax、Moonshot、Claude,一个都不能少。
需求评审会上我问了一句"为啥要接这么多",PM回我"客户有的用OpenAI,有的只能用国产模型,有的要私有部署Ollama,你能让客户只选一个吗"。我想了想,确实没法反驳。于是这个活儿就落到了我头上,前前后后折腾了将近三周,这篇文章把选型、踩坑、架构设计都记录下来,给后面要做类似事情的同行一个参考。
一、选型纠结:Spring AI 还是 LangChain4j
接手这个需求第一周没写一行代码,全在选型。2025年下半年Java生态做AI集成,主流就两个框架:Spring AI 和 LangChain4j。我当时两个都试了,说说我的判断。
LangChain4j 的优势是功能全,Agent、Tool、Memory、RAG 一整套都封装好了,上手快,社区也活跃。但我看了眼它的模型适配方式,每家供应商一个 XxxChatModel 类,配置散在代码里或者YAML里,想做"运行时动态切换供应商"这件事,得自己包一层。而且它的 Spring Boot Starter 集成感比较弱,更像是"一个独立的库硬塞进 Spring"。
Spring AI 不一样,它是 Spring 亲儿子,从设计上就是按 Spring 的套路来的——ChatClient 是核心接口,自动配置、@Configuration、Bean 注入这套东西和 Spring Boot 无缝衔接。我们平台本来就是 Spring Boot 3,选 Spring AI 等于顺着生态走,团队学习成本最低。
还有一点很关键:Spring AI 的 ChatClient 是流式API,Builder 模式构造请求,调用方式统一,不管底下是 OpenAI 还是 Ollama,上层代码长得一样。这一点对我们"13家供应商统一接入"的需求来说太重要了。
最后定 Spring AI 1.1.3。选这个版本而不是 1.0 GA,是因为 1.1.x 修了不少自动配置的 bug,对国产模型的 OpenAI 兼容接口支持也更好。当时 1.1.3 是最新 patch 版本,发布说明里修了一个 OpenAiChatAutoConfiguration 的 Bean 冲突问题,这个后面我会提到。
Maven 依赖(Spring AI BOM + 多供应商 Starter):
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.1.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-vertex-ai-gemini-spring-boot-starter</artifactId>
</dependency>
</dependencies>
二、ChatClient 基本用法和第一个坑
Spring AI 的用法本身不复杂,核心就是 ChatClient。通过 ChatClient.Builder 注入,调用 .prompt().user("问题").call().content() 就能拿到结果。听起来很美好,实际接的时候坑不少。
第一个坑是 starter 的版本冲突。我们项目里同时引入了 spring-ai-openai-spring-boot-starter、spring-ai-azure-openai-spring-boot-starter、spring-ai-ollama-spring-boot-starter、spring-ai-vertex-ai-gemini-spring-boot-starter,编译期没事,一启动就报 NoSuchBeanDefinitionException。排查了半天才发现,spring-ai-core 在不同 starter 里被传递依赖的版本不一致,有的带 1.1.2,有的带 1.1.3,Maven 的依赖调解机制挑了个错版本。
解决方式很土但有效:在根 pom.xml 里用 <dependencyManagement> 把 spring-ai-bom 整个锁死到 1.1.3,所有 starter 都从 BOM 继承版本,不允许子模块自己指定。这一步做完,编译和启动都正常了。
第二个坑更隐蔽,是自动配置类的覆盖问题。Spring AI 的每个 starter 都会注册一个 XxxChatClientAutoConfiguration,这些配置类里会往容器里塞 ChatClient.Builder 的 Bean。问题在于,当你同时引入 OpenAI 和 Azure OpenAI 两个 starter 时,容器里会有两个 ChatClient.Builder,Spring 不知道该用哪个,直接抛 NoUniqueBeanDefinitionException。
官方文档给的解法是给每个 Builder 加 @Qualifier,但这套方案在我们这种"运行时动态选供应商"的场景下根本用不上——你不可能在调用前就知道客户要选哪家。我最后的做法是绕开自动配置,自己写了一个 ChatClientConfig,手动为每家供应商构造 ChatClient,按 providerCode 放进一个 Map<String, ChatClient>,要用的时候按 code 取。这样自动配置类虽然还在,但我不依赖它注入的 Bean,等于把 Spring AI 当成了一个"模型调用的 SDK"来用,而不是"Spring Boot 集成套件"。
这个取舍我想了很久,等于放弃了 Spring AI 自动配置的便利,换来了对 Bean 生命周期的完全控制。事后看这个决定是对的,因为我们后面要做数据库驱动配置,所有参数都从数据库读,自动配置那套基于 application.yml 的机制本来就用不上。
三、Spring AI 覆盖不了的供应商怎么办
13家供应商里,Spring AI 有官方 starter 的就那么几家:OpenAI、Azure OpenAI、Ollama、Gemini。剩下的怎么办?
智谱、DeepSeek、通义千问、文心一言、讯飞星火这几家,我都走 OpenAI 兼容接口。这是个取巧的办法——这几家国产模型为了生态兼容,都提供了和 OpenAI 一样的 /v1/chat/completions 接口,请求格式、响应格式几乎一模一样。Spring AI 的 OpenAiChatModel 支持自定义 base-url,把 base-url 指向智谱的 https://open.bigmodel.cn/api/paas/v4,把 api-key 换成智谱的 key,模型名改成 glm-4,就能跑起来。
但这里有个坑:DeepSeek 的兼容接口在流式响应上和 OpenAI 有细微差异,finish_reason 字段在某些情况下返回的是 null 而不是 stop,Spring AI 的 OpenAiChatResponse 解析时会 NPE。我在 spring-ai-openai 的 issue 区翻到了同样的问题,官方说要等 1.2.0 修,我等不及,自己 fork 了一版响应解析逻辑,用 OkHttp 单独包了一个 DeepSeek 的适配器绕过去。
真正没法用 Spring AI 接的是豆包、MiniMax、Moonshot、Claude 这四家。豆包的 API 签名机制是火山引擎那一套,要算 HMAC-SHA256,请求头格式和 OpenAI 完全不一样;MiniMax 的接口要传 group_id,响应结构是自定义的;Moonshot 虽然兼容 OpenAI 协议,但它有个 is_regression 字段处理比较特殊;Claude 我们走的是第三方代理(直连 Anthropic 官方在国内有网络问题),代理的协议和 Spring AI 的 Anthropic starter 对不上。
13家供应商适配方式对比:
| 供应商 | 适配方式 | 协议类型 | 是否流式 | 难度 |
|---|---|---|---|---|
| OpenAI | Spring AI 官方 Starter | OpenAI 原生 | ✅ | ⭐ |
| Azure OpenAI | Spring AI 官方 Starter | OpenAI 兼容 | ✅ | ⭐⭐ |
| Ollama | Spring AI 官方 Starter | Ollama 原生 | ✅ | ⭐ |
| Gemini | Spring AI 官方 Starter | Gemini 原生 | ✅ | ⭐⭐ |
| 智谱 GLM | OpenAI 兼容接口 + base-url | OpenAI 兼容 | ✅ | ⭐ |
| DeepSeek | OpenAI 兼容接口 + base-url | OpenAI 兼容(有差异) | ✅ | ⭐⭐ |
| 通义千问 | OpenAI 兼容接口 + base-url | OpenAI 兼容 | ✅ | ⭐ |
| 文心一言 | OpenAI 兼容接口 + base-url | OpenAI 兼容 | ✅ | ⭐ |
| 讯飞星火 | OpenAI 兼容接口 + base-url | OpenAI 兼容 | ✅ | ⭐ |
| 豆包 | OkHttp 手写适配器 | 火山引擎 HMAC-SHA256 | ✅ | ⭐⭐⭐ |
| MiniMax | OkHttp 手写适配器 | 自定义协议 | ✅ | ⭐⭐⭐ |
| Moonshot | OkHttp 手写适配器 | OpenAI 兼容(有差异) | ✅ | ⭐⭐ |
| Claude | OkHttp 手写适配器(第三方代理) | 代理协议 | ✅ | ⭐⭐⭐ |
这四家我全部用 OkHttp 手写适配器。说实话,手写 HTTP 调用没什么技术含量,就是体力活,但有个好处:你对请求和响应有完全的控制权,遇到协议差异不需要和框架斗智斗勇。每个适配器大概200行代码,构造请求体、发请求、解析响应、异常处理,一个供应商半天搞完。
四、适配器架构:策略模式 + 工厂模式,别用 if-else
13家供应商,如果用 if-else 写,调用入口会变成一个几百行的"开关地狱":if ("openai".equals(provider)) {...} else if ("zhipu".equals(provider)) {...}。这种代码我见过,维护起来是灾难——加一家供应商要改这个方法,改那个方法,漏一个地方就出 bug。
我用的是策略模式 + 工厂模式。定义了一个 AIAdapter 接口,里面就一个核心方法 chat(AIRequest request): AIResponse。所有供应商都实现这个接口:OpenAIAdapter、ZhipuAdapter、DoubaoAdapter、ClaudeAdapter……各管各的。
AIAdapter 接口定义:
public interface AIAdapter {
/**
* 执行 AI 对话
* @param request 包含providerCode、model、messages等
* @return AIResponse 包含回答内容、token用量、耗时等
*/
AIResponse chat(AIRequest request);
/** 获取供应商编码 */
String getProviderCode();
/** 流式对话 */
default Flux<String> chatStream(AIRequest request) {
throw new UnsupportedOperationException("流式对话未实现");
}
}
application.yml 中 AI 相关配置:
ys:
ai:
retry:
max-attempts: 3
backoff-ms: 1000
max-backoff-ms: 8000
timeout:
connect: 10000
read: 60000
openai:
base-url: ${OPENAI_BASE_URL:https://api.openai.com}
zhipu:
base-url: https://open.bigmodel.cn/api/paas/v4
deepseek:
base-url: https://api.deepseek.com
然后抽了一个 AbstractAIAdapter 抽象类做模板方法。这个抽象类把公共逻辑全收拢了:参数校验(AIRequest 里的 providerCode、model、messages 不能为空)、SM4 解密 API Key、3次指数退避重试、调用日志写入。子类只需要实现一个 doChat 方法,专注处理自己供应商的协议差异。
工厂这边有两个类:AIAdapterFactory 负责按 providerCode 创建适配器实例,AIAdapterRegistry 负责缓存。为什么要分两个?因为创建适配器要读数据库配置、要解密 API Key、要构造 OkHttp 客户端,是个重操作,不能每次调用都重建;但又不能在启动时全部初始化,因为有些供应商的客户可能根本没启用。所以用 Registry 做懒加载 + 缓存,第一次调用时创建,之后复用;配置变更时清缓存重建。
这个架构最大的好处是扩展性。上个月产品又提了要加阶跃星辰(Step),我写了一个 StepAdapter 实现 AIAdapter,注册到 Registry,前后不到两小时,没动一行已有代码。这就是设计模式的价值——不是为了装逼,是为了少改代码。
五、API Key 安全:SM4 加密存储,运行时解密
13家供应商,每家都有 API Key,有的客户一个供应商还配了好几把 key 做负载均衡。这些 key 怎么存?
最偷懒的做法是放 application.yml 里,明文。但我们的平台是私有化部署给企业客户的,数据库客户自己管,明文存 key 等于把客户的钱包敞开——谁拿到数据库谁就能拿 key 去刷账单。这种事出一次就是事故,不能干。
我们用的是国密 SM4 加密。数据库里有一张 ai_provider_config 表,api_key 字段存的是 SM4 加密后的密文,加密密钥放在配置中心(Nacos),不在数据库里。运行时 AbstractAIAdapter 调用前会先把密文读出来,用 SM4 解密成明文 key,传给具体的适配器,调用完之后明文 key 所在的局部变量随方法栈一起销毁,不落日志、不落缓存、不落任何持久化介质。
有人可能问为啥不用 AES 而用 SM4。原因是部分客户是政企单位,有等保要求,国密算法是硬性指标。SM4 的性能和 AES 差不多,代码上多引一个 hutool-crypto 依赖就行,SmUtil.sm4Decrypt(key, ciphertext) 一行搞定,没什么额外成本。
还有个细节:调用日志里能不能打请求头?不能。我们在 AbstractAIAdapter 的日志切面里做了脱敏,Authorization 头打出来就是 ***,请求体里的 api_key 字段也过滤掉。这个不做好,日志系统一旦泄露,加密就形同虚设。
六、重试机制:3次指数退避,为什么不用 Spring Retry
AI 供应商的 API 不稳定是常态,限流、网关超时、偶发 500 都会遇到。我们的策略是3次指数退避重试:第一次失败等 1 秒,第二次等 2 秒,第三次等 4 秒,还不行就抛异常给上层。
有人会问,Spring 不是有 @Retryable 注解吗,Spring Retry 一行注解搞定,为什么要自己写?
我用过 Spring Retry,但在这种场景下它有几个不舒服的地方。第一,@Retryable 基于AOP代理,只能作用在 Spring Bean 的公开方法上,而我们的重试逻辑在 AbstractAIAdapter 的模板方法里,子类的 doChat 是被父类调用的,AOP 代理在这种"自调用"场景下不生效,得用 RetryTemplate 编程式调用。第二,Spring Retry 的退避策略配置不够灵活,我想对不同异常做不同处理——429 Too Many Requests 要重试,401 Unauthorized 不用重试(重试也是错),500 要重试,这种细粒度控制用注解表达不了。
自己写一个重试循环也就30行代码,for (int i = 0; i < 3; i++),配合 Thread.sleep 和异常判断,清清楚楚。我还在重试逻辑里加了抖动(jitter),每次退避时间随机加 0~200 毫秒,避免多个实例同时重试造成"惊群"。这种小细节 Spring Retry 也能做,但配置起来比写代码还麻烦。
指数退避重试机制代码:
// AbstractAIAdapter 中的重试机制
protected AIResponse executeWithRetry(AIRequest request) {
int maxAttempts = 3;
long baseBackoff = 1000L;
for (int attempt = 0; attempt < maxAttempts; attempt++) {
try {
return doChat(request);
} catch (Exception e) {
// 401 不重试,重试也是错
if (e instanceof UnauthorizedException) throw e;
if (attempt == maxAttempts - 1) throw new AIException("重试" + maxAttempts + "次后仍失败", e);
// 指数退避 + 抖动: 1s, 2s, 4s + 0~200ms 随机
long delay = baseBackoff * (1L << attempt) + ThreadLocalRandom.current().nextLong(200);
log.warn("AI调用失败,第{}次重试,等待{}ms", attempt + 1, delay);
Thread.sleep(delay);
}
}
throw new AIException("不可达");
}
重试的时候还要注意一点:只有幂等请求才能重试。AI 对话请求本身是幂等的(同样的输入给出同样的输出,不改变服务端状态),所以可以放心重试。但如果哪天要接"创建微调任务"这种非幂等接口,就不能无脑重试了,得在适配器层面禁用。
七、全数据库驱动配置:不用 YAML,支持热更新
最后说一个架构上的决定:所有 AI 配置全部放数据库,不放 application.yml。
这个决定一开始有争议。团队里有同事说"YAML 多清晰,改完重启就行"。我反驳的是"客户不会接受改个模型名就要重启服务"。我们平台是给企业用的,客户可能在白天高峰期想从 GPT-4 切到 DeepSeek 降成本,你不能告诉他"等晚上重启"。
数据库表设计了两张:ai_provider 存供应商级别配置(provider_code、provider_name、base_url、enabled),ai_model 存模型级别配置(model_code、provider_code、max_tokens、temperature、enabled)。API Key 放在 ai_provider_config 表里,按租户隔离。
热更新的实现不复杂。AIAdapterRegistry 里维护了一个 Map<String, AIAdapter> 缓存,配置变更时(通过后台管理界面改的)发一个 AIConfigChangedEvent,Registry 监听到事件后清掉对应 providerCode 的缓存,下次调用时按新配置重建适配器。整个过程不停机、不重启,对上层调用方完全透明。
有个坑要提:缓存重建时要保证线程安全。我们用的是 ConcurrentHashMap 的 computeIfAbsent,但重建过程中如果有请求正在用旧的适配器,会出现"新旧适配器并存"的窗口期。我的处理方式是旧适配器不主动销毁,等它自然 GC,新请求走新适配器,旧请求走完就结束。AI 调用本身是短任务(几秒到几十秒),这个窗口期对业务无感知。
写在最后
三周接完13家供应商,回头看的几个关键决定:选 Spring AI 而不是 LangChain4j,是因为生态契合度;手写 OkHttp 适配器接那4家Spring AI覆盖不了的供应商,是因为协议差异摆在那;策略模式 + 工厂模式,是为了后面加供应商不动老代码;SM4 加密 + 数据库驱动配置,是企业级场景的硬要求,不是锦上添花。
如果你也在做类似的多供应商接入,我的建议是:别迷信框架的"全家桶",Spring AI 解决了70%的问题,剩下30%自己写反而更可控。框架是工具,不是信仰。
更多推荐


所有评论(0)