最近社区里讨论最热的,不是哪家模型又发新版,而是 Spring AI Alibaba 的停更传闻。虽然官方还没给出明确说法,但这一波讨论已经让不少人意识到一个扎心的事实:如果你把整个项目的 AI 能力都绑在一个框架上,框架一停更,你的业务就像被卡住了脖子。与其每天打听“某框架还活着吗”,不如从一开始就把 AI 能力设计成可替换依赖。这篇文章围绕这个思路,聊聊我自己的设计实践和踩坑总结,希望能给还在纠结框架选型的人一些参考。
1. 传闻背后的技术生态变化
1.1 停更传闻折射出的现实
Spring AI Alibaba 的停更传闻传出后,社区里最明显的反应是“要不要迁移”的焦虑。这种焦虑我见过很多次,不只是 AI 框架,任何热门开源组件出现维护停滞的苗头,都会引发一轮连锁讨论。但放到 AI 这个领域,停更的冲击比传统中间件要猛烈得多,因为底层模型和技术协议还在高速迭代。
举个例子,某团队为了赶项目进度,直接依赖某个 AI 框架内置的对话客户端。一开始很顺利,后来底层大模型厂商升级了 API 协议,框架没有及时跟进,团队被迫自己写一层兼容代码。这层代码越写越复杂,最后等于自己在维护框架,代价远超当初选型时节省的那几天。Spring AI Alibaba 的情况类似,它把多厂商接入做得非常方便,但正因为方便,很多人忽略了“方便”背后其实是一种强耦合。
这类事件的本质不是“某个项目停更了”,而是“你的核心业务能力是否绑在别人的维护节奏上”。AI 能力不像普通工具函数,它涉及模型选择、计量计费、数据隐私、调用频率,每一个都跟具体的供应商策略挂钩。框架停更只是表象,更深层的问题是在技术选型阶段,我们有没有为变化预留空间。
1.2 为什么“押注框架”会成为一个普遍误区
很多人选择押注 Spring AI Alibaba,理由其实很充分:官方示例多、集成体验好、社区活跃。这种“框架红利”在项目早期非常诱人,尤其是当业务需要快速验证 AI 场景时,谁都不愿意花时间自己封装一层调用逻辑。
但押注框架的代价往往在半年后显现。首先是升级困难。框架为了兼容多厂商,抽象层级很深,类名、方法签名、配置项可能在小版本之间就发生变化。升级一个版本,业务代码要跟着改好几处。其次是概念侵入。框架特有的一些抽象,比如“模板方法”“消息消息结构”,会慢慢渗透到业务代码里,你原本只是想调一个聊天接口,最后却被迫理解框架内部的设计哲学。
可以用数据库连接打个比方:JDBC 之所以存在,就是为了让 Java 应用切换数据库时只换驱动不改业务代码。AI 调用本质上也是一样的需求,只是很多人还没有形成“数据访问层”那样的自觉。数据库驱动停更、数据库厂商变化,都不会让业务代码重写;但 AI 框架一旦停更,业务代码可能真的要重写。这个反差说明,问题出在架构设计,而不是运气。
2. 把AI能力做成可替换依赖的核心设计思路
2.1 依赖倒置与接口隔离:用契约替代实现
解决押注框架问题的方式并不复杂,核心就是一句老话:面向接口编程,而不是面向实现编程。放到 AI 场景里,就是让业务代码依赖你定义的抽象接口,而不是依赖具体某个 AI 框架或厂商 SDK 的类。
以最基础的对话能力为例。业务侧可能只需要一句话:我发一段文本,你给我一段响应。至于这个响应是来自模型 A、模型 B,还是走某个框架的封装,业务代码完全不需要关心。所以我通常会先定义这样一个接口:
public interface ChatClient { String chat(String message); }这个接口非常小,但它是整个可替换架构的基石。后续如果要支持多轮对话,可以加历史记录参数;如果要支持流式返回,可以定义 StreamChatClient;如果要支持工具调用,可以在接口里增加 methodCalls 相关字段。关键是,接口的抽象程度要从业务真实需求出发,而不是照着某个厂商的 API 去抄一遍。
实际设计时有一个很容易犯的错:接口里直接暴露厂商 SDK 的类型。比如在 ChatClient 的返回值里使用某个大模型平台的响应类,一旦切换厂商,这个响应类就变成硬伤。所以接口的入参和出参一定要使用自己定义的简单对象,或者干脆使用 String / Map 这类通用结构。这样教育团队的成本最低,后续扩展也最自由。
2.2 适配器模式:让不同AI厂商无缝接入
有了统一接口,接下来就是用适配器去实现不同厂商的接入。每个 AI 厂商都有自己的 API 地址、鉴权方式、请求头、请求体、响应结构、限流策略和计费逻辑。适配器的职责是把这些差异统统“消化”掉,让上层只看到 ChatClient 的同一套行为。
一个典型的适配器实现大概包含四块逻辑:认证与参数填充、请求发送、响应解析、错误映射。认证可能是简单的 API Key,也可能是签名机制;请求发送可能需要走官方 SDK,也可能直接用 HTTP 客户端;响应解析要做格式标准化,把不同厂商返回的 JSON 换成统一的业务结果;错误映射则是把厂商的限流、超时、参数错误等异常转成自己定义的异常类型。
我在实践中的做法是:每个厂商适配器独立为一个类,类内部可以自由依赖该厂商的 SDK,但这些依赖绝不能扩散到上层。厂商 SDK 的版本、API 细节被封装在适配器内部,即使 SDK 发生破坏性升级,影响范围也只是单个适配器。
这里有一个很实用的设计细节:适配器之间尽量不要共享请求体构造逻辑。不同厂商的模型参数名称差得离谱,比如温度参数有人叫 temperature,有人叫 temp,强制抽象通用参数容易造成语义混乱。不如让每个适配器自己处理参数映射,只在接口层保持统一。
2.3 配置驱动:用配置文件管理供应商切换
实现可替换的最后一块拼图是配置驱动。系统运行哪家 AI 服务,不应该通过修改代码来决定,而应该通过配置来决定。这样切换供应商时,只需要改配置 + 重启,甚至可以实现热切换,成本非常低。
配置内容一般包括:当前启用的供应商标识、各供应商的 API 地址、模型名、API Key 对应的环境变量名、超时时间、重试次数等。把这些内容放在统一的配置文件里,既方便本地调试,也方便在不同环境之间切换,比如开发环境用低成本的测试模型,生产环境用正式模型。
配置驱动和工厂模式经常搭配使用。根据 ai.provider 的值,创建一个对应的 ChatClient 实现。这样业务代码在 Spring 容器里通过依赖注入拿到 ChatClient,但它不关心绑定的是哪个厂商。
在团队协作中,配置驱动还有一个额外好处:可以让非技术人员参与供应商切换。比如产品经理想对比不同模型的效果,只需要在测试环境改一行配置,不用等你重新发版。这一点在很多项目里意外地受欢迎。
3. 实操:构建最小可替换的AI调用层
3.1 定义统一接口
折腾了这么多理论,下面给一套可以直接抄作业的最小实现。先再强调一下,这个实现的核心目标不是把架构做得花里胡哨,而是让 AI 调用在“厂商替换”这件事上成本接近零。
第一步是定义一个更实际的接口。除了最简单的 chat,我通常还会加一个带历史记录的调用方法,因为大部分业务场景不是“问一句答一句”,而是需要上下文。
public record ChatMessage(String role, String content) {} public record ChatResponse(String content, String model, long tokensUsed) {} public interface ChatClient { ChatResponse chat(List<ChatMessage> messages); }这里用 record 定义数据类,是为了避免引入额外的 VO 层,记录天生不可变,适合做接口传输。ChatResponse 里的 model 和 tokensUsed 是运营需要的数据,别省。
3.2 实现不同厂商适配器
第二步是实现至少两个适配器,否则“可替换”没有说服力。为了安全,我不使用任何真实厂商的 SDK,只模拟 HTTP 调用的流程。假设有两个厂商:厂商A 和 厂商B,它们的 API 风格完全不同。
厂商A 的适配器可能长这样:
public class VendorAChatClient implements ChatClient { private final RestClient restClient; private final String model; private final String apiKey; public VendorAChatClient(String baseUrl, String model, String apiKey) { this.restClient = RestClient.builder().baseUrl(baseUrl).build(); this.model = model; this.apiKey = apiKey; } @Override public ChatResponse chat(List<ChatMessage> messages) { Map<String, Object> requestBody = Map.of( "model", model, "messages", messages.stream().map(m -> Map.of("role", m.role(), "content", m.content())).toList() ); Map response = restClient.post() .uri("/v1/completions") .header("Authorization", "Bearer " + apiKey) .contentType(MediaType.APPLICATION_JSON) .body(requestBody) .retrieve() .body(Map.class); String content = (String) ((Map) ((List) response.get("choices")).get(0)).get("text"); String modelName = (String) response.get("model"); Long tokensUsed = ((Number) response.get("usage")).longValue(); return new ChatResponse(content, modelName, tokensUsed); } }厂商B 的适配器则可能请求路径不同、响应字段不同。关键点在于,ChatResponse 的统一结构把差异隐藏了。业务层拿到的是同样的对象,消费方式完全一样。
3.3 配置与装配
第三步是配置文件。我用 Spring 的 @ConfigurationProperties 来绑定,比较规范。
ai: provider: vendorA vendors: vendorA: base-url: https://api.vendor-a.example.com model: demo-chat api-key: ${VENDOR_A_KEY} vendorB: base-url: https://api.vendor-b.example.com model: chat-v2 api-key: ${VENDOR_B_KEY}配置类:
@Component @ConfigurationProperties(prefix = "ai") public class AiProperties { private String provider; private Map<String, VendorProperties> vendors = new HashMap<>(); // getters and setters ... public static class VendorProperties { private String baseUrl; private String model; private String apiKey; // getters and setters ... } }然后创建 Bean,根据配置动态生成对应的 ChatClient:
@Configuration public class AiConfig { @Bean public ChatClient chatClient(AiProperties props) { AiProperties.VendorProperties vendor = props.getVendors().get(props.getProvider()); if (vendor == null) { throw new IllegalStateException("Unknown AI provider: " + props.getProvider()); } return switch (props.getProvider()) { case "vendorA" -> new VendorAChatClient(vendor.getBaseUrl(), vendor.getModel(), vendor.getApiKey()); case "vendorB" -> new VendorBChatClient(vendor.getBaseUrl(), vendor.getModel(), vendor.getApiKey()); default -> throw new IllegalArgumentException("Unsupported provider"); }; } }这里有一个小技巧:switch 表达式的可读性比一长串 if-else 好很多。如果后面加入的厂商超过三家,建议把适配器也注册成 Spring Bean,通过 provider 名称做条件装配,避免这个工厂类无限膨胀。
3.4 切换验证
最后一步是验证可替换性。把 ai.provider 从 vendorA 改成 vendorB,再重启服务,调用同一个业务接口。如果返回的 ChatResponse 结构一致,说明你的业务代码没有绑定任何单一厂商。
我在演示时还会故意让厂商B 返回结构跟厂商A 不同,以此校验适配器是否真正兜住了差异。实际操作中,很多适配器只覆盖了“正常响应”的解析,对错误响应、空结果、异常状态码的处理却各不相同,这往往是切换时爆雷的重点。
另一个验证维度是回归测试。建议针对每个适配器都写少量单元测试,重点不是厂商 SDK 怎么调,而是响应解析逻辑是否正确。用一个伪造的 HTTP 服务器(比如 WireMock 或 MockServer)模拟不同厂商的响应,这样即使厂商 API 变化,你也能第一时间发现适配器哪里要修。
4. 常见问题与排查实录
4.1 模型响应格式不统一的处理
可替换架构落地时,最常遇到的坑就是响应格式解析。不同厂商不仅返回字段名不同,嵌套结构也可能完全不同。有的把内容放在 data.choices[0].message.content,有的放在 output.text,还有的返回 Markdown 文本需要额外处理。
解决思路只有一个:适配器内部完成“格式归一化”,上层绝不处理厂商特有的字段。我见过一些团队图省事,在业务代码里写类似map.get("choices").get(0)的逻辑,一旦切换厂商就全面报错,还要靠人肉搜索哪里有硬编码。
我的建议是给每个适配器配一组解析工具方法,或者在适配器的构造阶段就把响应转换成自己的 VO。测试时重点对比每种厂商的“正常响应”“空内容”“错误响应”三类数据,确保解析逻辑稳健。
4.2 超时与重试的差异
AI 服务的性能波动比普通 REST API 更明显。模型推理耗时长,遇到高峰期甚至可能出现十几秒的延迟。不同厂商对超时和重试的建议完全不一样:有的厂商建议超时设置 30 秒以上,有的则对长时间连接有限流;有的遇到 429 限流会建议指数退避,有的则建议直接失败。
如果你在抽象层统一设置一个超时时间,结果必然是某些厂商频繁超时,另一些厂商容易触发限流。正确做法是让超时、重试、退避策略都进入配置,每个厂商单独设置一组参数。然后在适配器内部使用独立的 RestClient 或 HTTP 客户端,不要共享连接池和超时配置,避免一个厂商的慢响应拖垮整个服务。
我踩过一次很典型的坑:当时把超时统一设成了 10 秒,厂商A 的模型响应经常需要 15 秒,导致业务一直报错,厂商B 却一切正常。排查了半天才发现是超时设置不匹配。改成配置驱动后,每个厂商按推荐值设置,问题立刻消失。
4.3 依赖冲突与版本管理
接入多个 AI 厂商 SDK 时,依赖冲突几乎是必然的。不同厂商的 SDK 可能依赖了不同版本的 Jackson、OkHttp、SLF4J,Maven 的依赖仲裁机制有时候会把运行时错误偷偷带到线上。
应对方式有两种,我都实践过。第一种是把适配器拆成独立模块,每个厂商一个 jar,业务模块只依赖统一定义的 API 模块和当前激活的适配器模块。这样可以利用 Maven 的依赖隔离机制,减少冲突。第二种是使用 Maven Shade 插件对适配器模块中的 SDK 依赖重新定位包名,或者将冲突依赖设为 runtime,避免编译期受到干扰。
比较推荐的做法是至少拆一个独立的 AI 适配层模块。这个模块内部可以自由依赖各种 SDK,对外暴露的只有自定义的 ChatClient 接口和简单的数据对象。这样业务模块几乎不会感受到底层 SDK 的存在。
4.4 可替换性设计中的过度设计风险
写完上面这些,必须泼一盆冷水:可替换性设计要适度,不要一开始就追求完美抽象。如果你的项目现在只接一家 AI 厂商,并且没有任何切换预期,那就直接用最简单的方式调用,别为了“未来可能换”而引入一堆接口和适配器。
过度设计的典型症状包括:接口参数无所不包、适配器数量超过实际需求、每个方法都要考虑泛化、配置项远超维护者理解范围。这种抽象会让新成员一头雾水,也会让你在改动功能时多绕好几层。
我的建议是“两次规则”:当同一个 AI 厂商接入方式出现第二个业务场景,或者确实出现了第二个厂商的接入需求,再考虑做抽象。过早抽象是浪费时间,过晚抽象是浪费更多时间,必须把握时机。
5. 我的实操体会与建议
围绕 Spring AI Alibaba 停更传闻的讨论还会持续一阵,但比起关注某个框架的去留,我更建议大家把注意力放在自己的架构上。我做过一个项目,早期把所有 AI 调用都写在一个框架的模板类里,后来框架版本升级,接口变更,团队花了整整一周处理兼容问题。重构为可替换依赖之后,再遇到类似传言,大家心态完全不同,因为知道就算底层框架真停了,也只需要换个适配器,业务代码不会大动。
实际操作中,我最推荐的落地方式是三步走。第一步,先把单一的 AI 调用封装成一个独立的方法或类,哪怕没有接口也可以。第二步,当需要引入第二个厂商时,再把公共方法抽取成接口,确保至少有两套实现能平替。第三步,把供应商选择驱动到配置层,配上切换验证脚本。这三步不需要一次性完成,但每一步都能显著降低未来踩坑的概率。
最后分享一个细节:在记录日志和监控时,最好把厂商名、模型名、token 消耗都沉淀下来。很多 AI 应用上线后才发现成本不可控,如果这些指标从第一天就记录,后面做模型选型对比会容易很多。AI 技术还在快速演变,今天的主流框架可能明年就被替代,我们的系统设计必须具备“随时换掉它也不心疼”的底气。