ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

多模型切换与并发聚合:Spring AI + CompletableFuture实战方案

多模型切换与并发聚合:Spring AI + CompletableFuture实战方案 直接上结论这套方案的落地价值核心是用Spring AI把不同厂商的模型API差异消化掉再用CompletableFuture把多个模型的调用从串行变成并行让“多模型路由”和“并发聚合”这两件看似独立的事在同一个应用里可以干净地组合起来。我先讲一个真实的业务背景不然很多人理解不了为什么非要搞“多模型切换”。我有个项目一开始只接了一家模型服务商业务跑得挺好。结果上线两个月客户开始提需求有的客户指定要用某厂商的模型做文案生成有的客户内部合规要求只能用另一家的模型处理敏感数据还有几个场景需要同时问两三个模型再把结果做对比。如果每个厂商都写一套独立调用代码那Controller层会膨胀到没法看光是鉴权、超时、重试、日志这些横切逻辑就要复制好几份。更麻烦的是产品侧随时可能换供应商代码里哪怕只改一个SDK版本都可能牵连十几个接口。所以核心诉求就一句话业务代码里不出现任何厂商特有的类型只依赖Spring AI的抽象接口通过配置和路由决定请求发给谁。至于多厂商带来的性能开销就用CompletableFuture把互相独立的调用并发出去。这篇内容会分成几个部分来拆先从设计层面讲Spring AI的抽象机制为什么适合干这个活再给出一套基于配置和工厂模式的路由方案然后是CompletableFuture并行调用的完整代码和线程池配置最后把我在生产环境踩过的坑整理成排查表。这套东西做完之后加一个新厂商的成本大概就是引入依赖、写一个Bean配置类、加一段yml配置完事。1. 为什么需要“一套代码支撑多厂商”——从业务场景看多模型切换1.1 多模型接入背后真正的痛点很多人觉得多模型切换不就是“if else判断一下用哪个”其实真到生产环境完全不是这个逻辑。模型切换有四个层面的问题必须一起解决第一个问题是接口协议差异。OpenAI风格接口和通义百炼风格的接口虽然都是HTTP JSON但鉴权方式不一样有的是Bearer Token有的是自定义Header请求体结构不一样messages字段、model字段、temperature参数命名都有细微差异响应结构也不一样有的返回choices[0].message.content有的返回output.text。这些差异如果散落在业务代码里后续每换一次SDK就是一次全局改动。第二个问题是依赖冲突和SDK管理。每个厂商的Java SDK依赖一堆不同的HTTP客户端、JSON库版本多个SDK同时引入到同一个工程里经常会冒出ClassNotFoundException或者方法签名不兼容的鬼问题。Spring AI的做法是把这些SDK都封装在统一的spring-ai-xxx-starter里底层依赖由框架帮你协调应用层不需要直接碰厂商SDK的类。第三个问题是切换的动态性。配置写在配置文件里只在启动时生效但实际业务需要的是运行时切换——比如按租户切换、按请求头切换、按模型能力标签切换。这就要求模型实例不能是写死的单例而是能被路由层动态取出来。第四个问题是故障隔离。某家模型服务商限流了、超时了、返回500了不能拖垮整个请求链路。异步并行本身就能起到一定的隔离效果配合超时控制和降级策略才能做到单点故障不影响主流程。1.2 Spring AI 2.0在模型接入层面的改进Spring AI 2.0这个版本在模型接入上比1.0要成熟不少尤其是对通义百炼这类国内平台的支持。1.0时代要连百炼得自己写一堆配置2.0里spring-ai-alibaba的starter直接提供了对应平台的ChatModel实现配置项也规范了。社区里关于“spring ai 2.0 连接百炼 qwen3.7”这类方案已经跑得比较通这也是为什么我建议新项目直接上2.x。“spring ai alibaba停更了吗”这个问题我也关注过实际去看仓库状态就知道还在维护只是发版节奏没之前那么密集。对这种框架性组件我的原则是只要核心API稳定、官方文档还在更新就值得用。技术选型最怕的不是功能少而是方向选错后面推倒重来。1.3 方案要解决的核心矛盾把这几个痛点汇总成一张表就明白设计目标是什么了痛点常见错误做法正确姿势厂商API差异在Service层写if/switch分发依赖ChatModel统一接口路由层决定注入哪个BeanSDK版本冲突直接在pom里堆厂商SDK用spring-ai-starter统一管理依赖切换灵活性只靠配置文件改完要重启工厂模式上下文标识运行时路由性能和故障风险串行调用多个模型CompletableFuture并发超时控制快速失败这套方案做完后业务Service层长什么样大概就是先拿到一个ChatModel实例然后调用它的call方法。至于这个ChatModel背后是哪个厂商、走的是什么协议、有没有做重试业务代码完全不用关心。2. Spring AI的抽象机制与设计思路2.1 ChatModel接口厂商差异是怎么被抹平的Spring AI的设计灵感其实来自Spring对DataSource、PlatformTransactionManager这类资源的抽象。它的核心接口是ChatModel所有厂商适配器都实现这个接口public interface ChatModelP extends Prompt { ChatResponse call(P prompt); default ChatResponse call(P prompt, ChatOptions options) { // 默认实现合并options后调用call } default String call(String prompt) { // 快捷方法 } default FluxChatResponse stream(P prompt) { throw new UnsupportedOperationException(Streaming is not supported); } }也就是说不管底层是百炼、DeepSeek还是OpenAI只要拿到一个ChatModel实例调call方法就能得到模型回复。ChatResponse里封装了文本内容、Token用量、模型名等统一结构。这里要强调一个容易被忽略的点统一接口只是第一步真正难的是让各种模型都能用同一套Prompt结构去描述。Spring AI统一采用OpenAI风格的message结构system/user/assistant不同厂商适配器在内部做消息格式转换。这意味着你写Prompt提示词的时候不用管底层模型的system prompt写法有什么差异。2.2 ChatOptions与模型参数的差异化适配不同模型对参数的支持程度不一样。比如OpenAI支持temperature、top_p、max_tokensGoogle的Gemini还支持top_k而有些国产模型对seed参数的支持并不保证。Spring AI用ChatOptions作为一个可选项集合每种模型的适配器只读取自己认识的那部分参数不认识的自动忽略。所以设计上要记住在路由层不要试图把某个厂商特有的参数透传给所有模型。参数应该在工厂创建模型实例的时候就绑定好放到配置里或者Bean定义里。2.3 为什么自研封装比不过Spring AI有人会说我自己写一个AIClient接口不也一样吗区别在于自研封装只解决接口统一问题解决不了适配器生态。Spring AI社区已经在维护各个厂商的适配器新的模型出来很快就有对应的Starter。而且Spring AI对流式SSE、函数调用、向量数据库、Agent这些扩展都有统一支持等到业务需要做流式输出或者Agent的时候自研封装的改造量就会大得惊人。再说一个香的点Spring AI对spring-ai-alibaba的支持让国内模型的接入成本大幅降低百炼平台上的qwen系列模型在2.0版本里基本是一键接入。3. 多模型切换的实现落地3.1 配置驱动的多厂商接入先写Spring Boot的配置文件把多个模型服务商的连接信息都放进去。这里以三个为例通义百炼qwen、DeepSeek、一个OpenAI兼容的服务。实际项目里再加其他厂商就是复制粘贴的活spring: ai: dashscope: api-key: ${QWEN_API_KEY} chat: options: model: qwen-plus temperature: 0.7 openai: api-key: ${DEEPSEEK_API_KEY} base-url: https://api.deepseek.com chat: options: model: deepseek-chat temperature: 0.7 custom-openai: api-key: ${CUSTOM_OPENAI_KEY} base-url: ${CUSTOM_OPENAI_BASE_URL} chat: options: model: custom-chat-model注意Spring AI里spring.ai.openai.base-url是可以自定义成任何OpenAI兼容服务的地址的。很多第三方模型服务商都提供OpenAI兼容接口这意味着你甚至不需要等官方适配器只要是OpenAI兼容协议就能接进来。但只靠yml配置还不行因为Spring Boot的自动配置只能生成固定的一套Bean多个模型同时接入时需要手动定义命名Bean。在配置类里显式声明Configuration public class MultiModelConfig { Bean Primary // 默认使用百炼 public ChatModel qwenChatModel() { // 从spring.ai.dashscope配置读取 return new DashScopeChatModel( new DashScopeApi(apiKey), DashScopeChatOptions.builder() .withModel(qwen-plus) .withTemperature(0.7) .build() ); } Bean public ChatModel deepSeekChatModel() { // 手动构建DeepSeek的ChatModel return OpenAiChatModel.builder() .openAiApi(new OpenAiApi(apiKey, https://api.deepseek.com)) .defaultOptions(OpenAiChatOptions.builder() .withModel(deepseek-chat) .withTemperature(0.7) .build()) .build(); } }这里最关键的是给默认的Bean加Primary避免其他模块拿到多个ChatModel时不知道该注入哪个。3.2 工厂模式模型路由按上下文动态选择配置好了多个Bean接下来就是路由。我设计了一个ModelRouter组件它不直接依赖任何厂商类型只操作Spring容器里的ChatModel列表Component public class ModelRouter { private static final Logger log LoggerFactory.getLogger(ModelRouter.class); private final MapString, ChatModel modelMap; public ModelRouter(ListChatModel chatModels, ListModelProviderProperties providers) { this.modelMap new ConcurrentHashMap(); for (ModelProviderProperties provider : providers) { for (ChatModel model : chatModels) { if (matchesProvider(model, provider)) { modelMap.put(provider.name(), model); } } } } public ChatModel route(String providerName) { ChatModel model modelMap.get(providerName); if (model null) { throw new IllegalArgumentException(未注册的模型提供方: providerName); } return model; } }路由标识从哪里来常见的做法有三种请求头传递前端或者网关在Header里放X-Model-Provider: qwen后端用一个过滤器解析后放到RequestContext里。租户维度配置每个租户在数据库里配置默认的模型厂商登录后加载到缓存路由时直接查当前租户的设置。业务类型映射比如“合同审查”固定走某一个模型“摘要生成”固定走另一个。这是规则路由最稳定。实际业务里通常是三种组合用。我见过最实用的做法是ThreadLocal 过滤器 规则兜底。过滤器里先看Header有没有显式指定没有就看租户配置再没有就用默认的qwen模型。Component public class ModelContextFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest (HttpServletRequest) request; String provider httpRequest.getHeader(X-Model-Provider); ModelContext.setProvider(provider ! null ? provider : qwen); try { chain.doFilter(request, response); } finally { ModelContext.clear(); } } }注意ThreadLocal一定在finally块里清理不然线程池复用的时候上下文会串掉。这个坑我踩过症状就是莫名其妙的某个请求走错了模型、返回的内容风格不对排查了半天发现是ThreadLocal泄漏。3.3 切换策略的取舍请求级切换与全局默认值模型路由的粒度直接决定系统复杂度。最省钱的做法是只有全局默认值所有请求都走同一个模型什么时候换模型就改配置重启。但真实业务往往需要更细的粒度这里给一个我在项目里验证过的分档方案切换粒度实现方式适用场景复杂度全局默认yml配置所有请求无差别低请求头/参数过滤器解析 ThreadLocal接口级别的A/B测试、人工指定模型中租户维度租户上下文查询 缓存SaaS多租户每个租户签约不同模型中高业务类型规则引擎映射不同的业务域固定用不同模型中用户维度用户表扩展字段个别大客户定制高有一个容易被忽略的问题路由的决策点应该在入口而不是在Service层中间。如果路由逻辑散落在各业务方法里将来要做审计和配置后台改动就会想骂人。把路由收敛到过滤器里统一决策配合日志记录每次请求的route结果出了事故能快速定位。另外一个经验是不要只支持一个维度的切换。至少要把“请求级显式指定”和“全局默认值”这条链路打通后续加租户维度才有基础。我在项目里第一个版本只有全局默认后来客户提租户需求因为Filter本来就留了口子改造成本很低。4. CompletableFuture并行调用多个模型4.1 并行调用的典型场景多模型路由解决了“这次请求该用哪个模型”但很多场景是需要“同时问多个模型然后把结果拼起来用”。举几个最典型的模型对比评测同一个Prompt发给3个模型收集各自的输出放到评测页面里人工打分。串行调用的话最慢的那个模型决定了整个页面的等待时间。路由决策前收集信息某些Agent场景需要多个模型独立分析不同维度一个看情感一个看实体一个看合规最后综合结果。这三个分析之间毫无依赖关系天然适合并行。主备降级主模型稳定但偶尔抽风备用模型质量稍差但便宜。为了兼顾质量和成本可以让主备模型同时跑谁先返回就用谁或者主模型超时就用备用模型的结果兜底。内容聚合比如生成营销文案时分别用三个模型生成三个版本最后做去重和混合。这些场景里串行的响应时间等于所有模型耗时的和并行的响应时间约等于最慢那个模型的耗时。如果每个模型平均响应时间在3秒左右三个模型串行就是9秒并行就是3秒多一点。硬件资源没有额外增加效果立竿见影。4.2 核心代码CompletableFuture 自定义线程池CompletableFuture的默认并行池是ForkJoinPool.commonPool()它被整个JVM共享不建议在IO密集型的模型调用里使用。因为模型调用几乎全部时间都在等待网络IO如果多个业务同时触发并行调用commonPool很容易被打满反而拖慢其他用ForkJoinPool的逻辑。所以我的原则是每个业务场景单独定义线程池方便隔离和监控Configuration public class AiThreadPoolConfig { Bean(aiCallThreadPool) public ExecutorService aiCallThreadPool() { int coreSize 20; int maxSize 80; long keepAlive 60L; BlockingQueueRunnable queue new ArrayBlockingQueue(200); ThreadFactory threadFactory new ThreadFactoryBuilder() .setNameFormat(ai-call-%d) .build(); RejectedExecutionHandler handler new ThreadPoolExecutor.CallerRunsPolicy(); return new ThreadPoolExecutor( coreSize, maxSize, keepAlive, TimeUnit.SECONDS, queue, threadFactory, handler); } }线程池参数不是拍脑袋定的要依据业务的QPS和单个模型调用耗时来算。假设单次模型调用平均耗时2秒每秒大概有15个请求触发并行三模型调用每个请求拉起来3个任务那么峰值并发任务数就是45个。核心线程数20能扛住常规流量缓冲队列200的任务积压能力意味着即使突然流量翻倍也不会立刻触发拒绝策略。CallerRunsPolicy是兜底策略线程池满的时候让提交任务的线程自己执行这样不会丢弃任务但调用线程比如Tomcat线程会阻塞相当于天然限流。然后写并行调用的核心代码public class ParallelModelInvoker { private final ModelRouter router; private final ExecutorService aiCallThreadPool; public ListModelResult invokeModels(String prompt, ListString providers) { ListCompletableFutureModelResult futures providers.stream() .map(provider - CompletableFuture .supplyAsync(() - callSingleModel(provider, prompt), aiCallThreadPool) .exceptionally(ex - ModelResult.failure(provider, ex.getMessage()))) .collect(Collectors.toList()); CompletableFutureVoid allDone CompletableFuture.allOf( futures.toArray(new CompletableFuture[0])); // 阻塞等待所有任务完成最多5秒 try { allDone.get(5, TimeUnit.SECONDS); } catch (TimeoutException e) { // 超时保底哪些future没完成就记录超时 futures.forEach(f - { if (!f.isDone()) { // 注意真正取消异步任务需要future.cancel但正在执行的任务不一定能被中断 f.cancel(true); } }); } catch (Exception e) { Thread.currentThread().interrupt(); } return futures.stream() .map(CompletableFuture::join) .collect(Collectors.toList()); } private ModelResult callSingleModel(String provider, String prompt) { long start System.currentTimeMillis(); ChatModel model router.route(provider); try { ChatResponse response model.call(prompt); return ModelResult.success(provider, response.getResult().getOutput().getContent(), System.currentTimeMillis() - start); } catch (Exception e) { return ModelResult.failure(provider, e.getMessage()); } } }这段代码有几个细节值得展开。第一supplyAsync传入自定义线程池是必须的否则默认用ForkJoinPool.commonPool前面说过了不推荐。第二exceptionally处理单任务异常这样单个模型报错不会影响其他模型的执行最后返回的结果里包含失败信息由调用方决定是降级还是报错。这里比join()时统一抛异常要优雅得多。第三allOf等待所有任务完成get(5, TimeUnit.SECONDS)设置整体超时。为什么外面还要再套一层超时因为即使每个单模型调用内部都有HTTP超时设置多个任务并行时的整体耗时上限还取决于线程池排队情况。假如线程池队列积压严重单个任务可能等很久才开始执行所以在外面加一个总闸。第四超时后调用cancel(true)并不保证能中断正在运行的任务它只是标记取消。真正网络IO的阻塞点是很难被中断打断的。所以这里的超时保护主要价值是让调用方快速返回而不是真正回收底层线程。4.3 结果聚合与降级策略并行调用拿到各个模型的结果后怎么写聚合逻辑我的建议是把聚合策略和业务解耦做成可配置的Component public class ModelResultAggregator { private static final ListString STRATEGIES List.of(first_success, merge, vote); public String aggregate(ListModelResult results, String strategy) { switch (strategy) { case first_success: return results.stream() .filter(ModelResult::isSuccess) .findFirst() .map(ModelResult::content) .orElseThrow(() - new IllegalStateException(所有模型均失败)); case merge: return results.stream() .filter(ModelResult::isSuccess) .map(ModelResult::content) .collect(Collectors.joining(\n\n---\n\n)); case vote: // 简单投票相同内容出现次数最多者胜出 return results.stream() .filter(ModelResult::isSuccess) .collect(Collectors.groupingBy(ModelResult::content, Collectors.counting())) .entrySet().stream() .max(Map.Entry.comparingByValue()) .map(Map.Entry::getKey) .orElseThrow(() - new IllegalStateException(全部失败)); default: throw new IllegalArgumentException(未知聚合策略: strategy); } } }first_success适用于主备降级场景——取最先成功的那个模型的结果直接返回。merge适用于需要多角度输出的场景。vote适用于有标准答案倾向的任务比如分类、打分不过模型输出通常有细微差异简单的字符串投票可能不够用实际生产里可以做语义相似度后再投票。我特别想强调一个最佳实践不要等所有结果都出来了才做第一个响应。某些场景里可以改成“谁先成功就用谁其他任务立刻取消”的竞速模式。这个用CompletableFuture的anyOf实现public ModelResult raceInvokeModels(String prompt, ListString providers) { ListCompletableFutureModelResult futures providers.stream() .map(provider - CompletableFuture .supplyAsync(() - callSingleModel(provider, prompt), aiCallThreadPool)) .collect(Collectors.toList()); CompletableFutureModelResult firstSuccess new CompletableFuture(); for (CompletableFutureModelResult future : futures) { future.whenComplete((result, ex) - { if (result ! null result.isSuccess()) { firstSuccess.complete(result); } }); } return firstSuccess.get(5, TimeUnit.SECONDS); }这里有个细节completedFuture如果在complete之前就被get等待那一旦某个future成功applyToEither就会立即触发如果调用返回前已经有结果那就是直接取到那个结果。注意handle里面只complete第一次后续结果会被忽略。真正生效的时候还要把其他future cancel掉释放资源虽然cancel不一定能中断但至少能清掉排队中未执行的任务。5. 实操过程与踩坑记录5.1 线程池配置的意外发现别让业务被IO拖死我在生产环境遇到过这个问题某天流量高峰模型调用的TP99从2秒飙到11秒。查了半天发现我的线程池参数在流量高峰被打满队列积压的任务排队等待执行模型实际调用还没开始就已经等了6秒。这里需要对线程池参数做一次更严格的核算。假设业务QPS是50每次用户请求需要并行调用3个模型每个模型耗时2.5s。那么每秒新增的任务数是150每个任务耗时2.5s系统需要的并发能力大约是375个并发线程/槽位。我原来配的核心20、队列200最大80理论上极限吞吐只有 (80200)/2.5 ≈ 112 任务/秒低于所需的150任务/秒所以高峰期必然排队。调整思路是缩小任务粒度。不要在一次请求内并行调用三个模型而是把三个调用分散到三次请求里。这个改动看起来很小但把任务并发量从一次突发的3N降低成平稳的N线程池压力直接减半。对于真正需要并行的场景我用ShedLock做了任务分片不集中在同一个时间点发起。另一个经验是把线程池参数放到配置中心这样流量模型变化时可以动态调整不需要重启服务。Spring Boot支持通过ConfigurationProperties动态刷新线程池的核心线程数配合Nacos或Apollo用得很顺手。5.2 流式响应与异步并发是天然的敌人Spring AI的stream()方法返回的是Flux也就是响应式流。刚开始我想做“多个模型流式输出前端像ChatGPT那样一个字一个字蹦出来”。结果发现Flux配合CompletableFuture非常别扭Flux本身就是异步的你再套一层线程池去subscribe等于两层异步叠加调试起来特别痛苦。这里我的建议是如果业务需要并发调用多个模型优先使用同步的call()方法别碰stream()。流式响应场景就老老实实单个模型输出或者用WebFlux的Mono组合来实现真正意义上的响应式并发而不是把CompletableFuture往Flux上面硬套。以Reactor的方式做多模型流式并发其实也有解public FluxModelResult streamParallel(String prompt, ListString providers) { ListFluxModelResult streams providers.stream() .map(provider - Flux.defer(() - Mono.fromCallable(() - callSingleModel(provider, prompt)))) .collect(Collectors.toList()); return Flux.merge(streams); }但这条线我建议是等业务真正需要的时候再仔细做不要从项目第一天就把“流式并发”当成默认组合。很多场景下流式只是给用户一个“它在思考”的动效实际用同步call然后前端做打字机效果也一样能接受。5.3 生产环境典型问题排查表把这一路踩过的坑整理成一张速查表遇到问题先对着查一遍现象可能原因排查方法解决方案启动报NoUniqueBeanDefinitionException容器里注册了多个ChatModel被自动注入时冲突检查是否有Primary标注的默认Bean给默认模型Bean加Primary模型切换不生效一直走默认ThreadLocal上下文在异步线程丢失打印线程ID和ModelContext值用包装Runnable的方式传递上下文或改用请求参数透传并行调用偶发超时线程池队列积压查看jstack确认线程状态、活跃线程数调整核心线程数、增加队列容量、增加负载保护一个模型报错导致全部失败exceptionally没有单独处理看异常堆栈中哪个future抛错每个future单独加exceptionally内存中模型Result对象堆积高并发下同时产生大量结果对象查看堆内存分析GC日志流式处理结果、及时释放引用新接入的模型参数不生效ChatOptions配置名不对或者绑定错误打印ChatModel实例的options值确认Spring AI版本的配置项还有一个我特别想提的坑配置文件的优先级和覆盖关系。Spring Boot的项目里经常有application.yml、application-dev.yml、Nacos配置等多层配置某个模型服务商的api-key在dev覆盖了prod结果生产环境走了测试配置。排查方式很简单启动时增加--debug参数能看到每个配置项最终绑定到哪个值一步定位。5.4 异步链路下的上下文传递问题使用自定义线程池后原本在Tomcat线程里的ThreadLocal上下文比如租户ID、TraceId、模型路由标识不会自动传到异步任务里。直接读取会得到null或者更糟糕的是拿到上一个请求的脏数据。解决这个问题有几种方案在提交任务前把需要的上下文字段复制成局部变量或参数传给异步方法。这是最可控的做法。使用TransmittableThreadLocalTTL它能在线程池切换时自动传递上下文。对Spring AI这种涉及过滤器和异步任务的场景非常有用。包装Runnable和Callable在线程提交时快照上下文任务执行前恢复。我个人更推荐第一种尽量把上下文变成显式参数。比如callSingleModel(provider, prompt, tenantId)虽然参数多了一点但逻辑透明不会出现玄学丢数据的问题。使用TTL确实方便但它引入了额外依赖而且一旦忘记在任务里清理还是可能泄漏。6. 从“能用”到“好用”可观测性与生产落地建议6.1 模型调用的可观测性设计多模型并行调用之后出问题时最难的是定位到底是哪个模型慢、哪个模型报错。所以我给每次模型调用都加了结构化日志和指标采集。用法非常简单在callSingleModel方法里无论成功还是失败都记录一条日志字段包括provider、prompt摘要、token数量、耗时、错误信息。线上排查的时候按TraceId搜索就能看到一次请求分支出哪几个模型任务每个模型耗时多少[2025-06-15 10:23:45.123] [ai-call-12] [traceIdabc123] providerqwen statussuccess modelqwen-plus tokens_in120 tokens_out45 cost_ms2350 [2025-06-15 10:23:45.567] [ai-call-13] [traceIdabc123] providerdeepseek statussuccess modeldeepseek-chat tokens_in120 tokens_out52 cost_ms2780 [2025-06-15 10:23:46.002] [ai-call-14] [traceIdabc123] providercustom statustimeout cost_ms5000 errortimeout看这个日志能立刻知道qwen和deepseek正常返回custom超时了整体请求的等待时间被custom拖到了5秒。如果用了前面介绍的allOf超时策略主请求会在5秒后返回custom那条future被标记成取消状态。指标层面可以接入Micrometer把“模型调用次数”和“模型调用耗时直方图”打点出来按provider做tag。这样在Grafana上直接能看到每个模型的健康度设置了告警之后某家服务商连续超时会立刻触发值班告警。6.2 生产环境落地的检查清单最后整理一份清单照着做能少走很多弯路路由优先级要有明确约定Header显式指定 租户配置 业务规则 全局默认。别让多种规则互相打架。每个模型要有独立的超时设置不同服务商响应速度不一样统一用5秒会导致快的模型被拖累。建议按模型历史TP99动态设置。Token用量必须做统计多模型切换后成本会失控每次调用记录token按租户/业务线做分账统计。Spring AI的ChatResponse里本身就包含usage信息把它落到监控里就行。配置中心管理模型参数模型的temperature、max_tokens这些参数经常要调放配置中心可以随时调不用发布版本。回滚预案是刚需每次切换模型之后至少要观察10分钟再放开全量流量写一个按Header指定模型的开关紧急时候可以强制某个流量全部走老模型。降级文案要提前备好所有模型都失败的时候怎么给前端返回直接抛500肯定不行准备一个兜底提示信息或者降级到缓存答案。6.3 关于Spring AI的版本选择现在网上关于Spring AI版本的讨论非常多有说1.0好用的有说2.0变化大不敢升的。我的实际体验是新项目直接上2.x老项目如果只是用了简单的模型调用API升级成本也不大。2.0重新组织了一些配置项和包名但核心的ChatModel/StreamableChatModel接口没变业务代码改造量很小。“spring ai alibaba停更了吗”这个问题背后其实有个担忧选了一个不维护的框架怎么办我的看法是Spring AI本身的架构是厂商无关的alibaba只是其中一个适配器即使真的停更了目前看没有也只是影响那一个厂商的接入方式底层的ChatModel抽象不会崩。做技术选型要盯着核心抽象是否稳定而不是某个适配器的更新频率。结尾这套“Spring AI多模型切换CompletableFuture并行调用”的方案我自己在项目里已经跑了大半年最大的体会是技术方案的价值不在于用到了多新潮的框架而在于把容易脏乱的横切逻辑收敛在一起。多厂商接入如果没有统一抽象配再多线程池也只是在一个乱糟糟的代码结构上加性能补丁。如果你正准备做类似的事情听我一句劝先把路由和并行拆成两个独立模块来设计让路由负责“选对模型”让异步并发只负责“跑得快”两者通过一个ModelResult对象做解耦。千万不要在业务Service里又做if/else路由又做并行编排那将是未来三个月里你最想删掉的重构对象。最后再分享一个实用的小技巧并行调用多个模型做结果聚合的时候建议记录——不是简单拼接字符串而是把每个模型的结果和其对应的provider名一起结构化输出这样后续做模型替换的时候能清晰地看出“哪个模型在哪个环节贡献了什么”。顺便说一句聚合策略用“first_success”的时候千万注意如果第一个成功的模型恰好是最贵的那个长此以往成本报表会很难看所以竞速场景下最好还是在代码里预留一个“按优先级”的路由参数。
返回列表