news 2026/10/6 12:50:50

Spring AI多模型路由与CompletableFuture并行调用实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring AI多模型路由与CompletableFuture并行调用实战

去年年中我们接内部AI能力的时候,最折磨人的不是写Prompt,而是换模型。今天老板说成本太高,把主力模型换成便宜的,明天算法同学说某个中文场景下Qwen效果更好,后天客户要私有化部署,又得接一套本地模型。每换一家厂商,业务代码里就要改一遍调用逻辑,再重新跑一轮回归。后来我把Spring AI的路由能力和CompletableFuture的并行编排结合起来,用一套代码同时支撑多厂商模型切换和并发调用,这个折腾了我小半年的问题才算彻底理顺。

这套方案的核心其实不复杂:对外暴露统一的模型路由入口,内部维护一个“模型标识到ChatModel实现”的映射,切换模型只是改传入的标识;并发场景则用CompletableFuture把多个模型调用投到一个独立线程池里并行执行,最后统一汇聚结果。Spring AI把各家大模型的底层差异收敛成了同一个ChatModel接口,这一步是整件事的地基。适合正被多模型接入困扰、或者想让一次请求同时问多个模型做效果对比的人参考。

1. 先想清楚:多模型切换解决的是“改配置”而不是“改代码”

1.1 Spring AI到底帮你抽象了什么

很多人第一次看到Spring AI会觉得它只是个“AI请求工具包”,但它的价值更像当年JDBC之于数据库。JDBC让你用同一套Connection、Statement去操作MySQL和Oracle,Spring AI则是用统一的ChatModel、Prompt、ChatResponse去对接各家大模型。你不用再为每家厂商写一套HTTP调用、重试、鉴权和消息组装逻辑,这些都被starter给包住了。

我在项目里主用的两个实现是OpenAiChatModel和DashScopeChatModel,前者接OpenAI兼容协议,后者接阿里百炼的通义系列。它们的底层走得完全不是一条链路,但在我代码里都是ChatModel,调用方法一模一样。这套抽象只要配置好,业务代码根本感知不到底层是哪家,这是后续所有切换动作的前提。

需要注意,Spring AI的抽象是“接口统一”,不是“效果统一”。同一句Prompt,不同模型返回的格式、语气、长度都不一样,这个差异后续要靠请求参数层去兜,不是接口本身能解决的。

1.2 切换的本质是“选择实现”,不是替换调用方式

一开始我想得复杂,以为要做个模型网关或者搞一套策略引擎。后来发现大多数场景根本不需要那么重。所谓多模型切换,本质上就是一句话:给你一个模型标识,找到对应的ChatModel实例,然后用它发起调用。

最简单的实现就是维护一个Map<String, ChatModel>,key是业务自定义的模型标识,比如“qwen-max”“gpt-4o-mini”,value是对应的模型实现。调方传“qwen-max”,路由层就返回对应的ChatModel,业务代码继续走统一的prompt调用。这套设计比if-else优雅得多,新增一个模型只需要注册一个新的映射,不用改任何调用逻辑。

我后来还在这层加了两个小功能:一个是按权重分发,把一部分流量切到新模型上做灰度;另一个是故障降级,主模型调用抛异常时自动路由到备选模型。这些逻辑都在路由层统一处理,调用方完全无感。

1.3 换模型时最容易遗漏的其实是请求参数

多模型切换有个隐蔽的坑:模型接口统一了,但各家模型默认参数并不统一。有的模型temperature默认0.7,有的默认1.0;有的max_tokens默认4096,有的默认2048。直接把同一个Prompt切过去,效果经常莫名其妙地变差,排查半天发现是参数默认值不一样。

我的做法是给每个模型准备一套独立的请求选项模板,放在配置里或者注册的时候一并带上。切换模型时同时切换的是“模型实例+请求选项模板”这一整组配置,而不是只换模型名。这个细节在后面章节会展开讲,但它决定了路由层是不是真的能用起来。

2. 路由实现:一套代码接住多个厂商的ChatModel

2.1 依赖坐标与版本选择

Spring AI的依赖体系这些年变化很快,我一开始被坐标坑过一次。1.x时代和2.0时代的starter命名、模块划分都有差异,如果你直接跟网上的老教程抄,经常会遇到类找不到或者配置项不生效。

以我手头项目为例,用Spring Boot 3.3.x配Spring AI 2.0.1,接OpenAI兼容协议用的是spring-ai-openai-spring-boot-starter,接阿里百炼用的则是spring-ai-alibaba-starter这类阿里适配包。不同厂商的starter各自封装了自己的自动配置和属性绑定,但最终都实现了ChatModel接口。

这里要提醒一句:别追最新版本。Spring AI还在快速演进,组件的坐标、配置前缀都有可能变。我的习惯是锁版本,升级前先看变更记录。关于阿里那个适配包的更新节奏,社区确实有讨论,但我的态度是别赌某个组件是否会长期维护,而是把路由层和适配层隔离开,哪天适配层变了,改依赖和配置类就行,业务代码不要受影响。

2.2 多厂商配置模板

配置这块的核心是利用Spring Boot的属性绑定,把密钥、模型名、参数模板都放到配置文件里,而不是散落在代码中。以下是我常用的配置结构:

spring: ai: openai: base-url: ${OPENAI_BASE_URL} api-key: ${OPENAI_API_KEY} chat: options: model: gpt-4o-mini temperature: 0.7 max-tokens: 1024 dashscope: api-key: ${DASHSCOPE_API_KEY} chat: options: model: qwen-plus temperature: 0.8

密钥一律从环境变量里取,不写进配置文件,更别提交到代码仓库。base-url改成你实际使用的网关地址,如果你用的是兼容OpenAI协议的其他网关,这一项就能覆盖。

配置类会自动生成对应的ChatModel Bean。这里有个Spring的小知识:容器里注册了多个ChatModel类型的Bean之后,按类型注入会报错,你必须显式指定Bean名称。我在配置类里做了两件事:一是给每个模型Bean起清晰的名字,二是聚合出一个带业务含义的模型映射。

2.3 把路由写成配置而不是if-else

下面是路由层的核心代码,先看完整的配置类:

import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.openai.OpenAiChatModel; import org.springframework.ai.dashscope.DashScopeChatModel; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; import java.util.Map; @Configuration public class AiModelRoutingConfig { @Bean @Primary public Map<String, ChatModel> chatModelMap( OpenAiChatModel openAiChatModel, DashScopeChatModel dashScopeChatModel) { return Map.of( "gpt-4o-mini", openAiChatModel, "qwen-plus", dashScopeChatModel ); } }

这个Map的key就是业务侧统一使用的“模型标识”。@Primary注解很关键,它告诉Spring当有多个Map<String, ChatModel>类型的Bean时优先注入我定义的这个。毕竟Spring容器本身也会按类型生成一个包含所有ChatModel的Map,不加@Primary很容易在注入时歧义报错。

然后是路由组件:

import org.springframework.ai.chat.model.ChatModel; import org.springframework.stereotype.Component; import java.util.Map; @Component public class ModelRouter { private final Map<String, ChatModel> modelMap; public ModelRouter(Map<String, ChatModel> modelMap) { this.modelMap = modelMap; } public ChatModel resolve(String modelName) { ChatModel model = modelMap.get(modelName); if (model == null) { throw new IllegalArgumentException("未知的模型标识: " + modelName); } return model; } public Map<String, ChatModel> allModels() { return modelMap; } }

这里直接用小写短横线风格的标识作为key。新增模型时,只需要在配置类里加一行映射,业务代码不动。这就是“一套代码支撑多厂商”的真正含义——不是框架帮你做了什么,而是你的抽象层让变化收敛在一个点。

2.4 业务代码中的使用示例

路由层写好后,业务侧调用就非常简单了:

import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.model.ChatModel; import org.springframework.stereotype.Service; @Service public class AiChatService { private final ModelRouter modelRouter; public AiChatService(ModelRouter modelRouter) { this.modelRouter = modelRouter; } public String chat(String modelName, String userMessage) { ChatModel model = modelRouter.resolve(modelName); return ChatClient.builder(model) .build() .prompt() .user(userMessage) .call() .content(); } }

ChatClient是Spring AI里比ChatModel更上一层的外观,它帮你封装了消息构造、参数合并和结果提取。调用方只传“模型标识+消息内容”,剩下的事全部交给路由层。

我还在ChatClient基础上做过一层封装,把每个模型对应的temperature、max_tokens等参数在构建时固化进去,这样业务代码连参数都不用关心。切换模型从此变成改一个配置key或者请求里的一个字符串,排查问题、做灰度都轻松很多。

3. CompletableFuture并行调用:线程池、超时与最终代码

3.1 什么场景真正需要并发调模型

模型切换解决了“选谁”的问题,等真正跑起来又发现一个新问题:如果只想做一次效果对比,串行调三个模型,最慢的那个会拖累整个流程。更典型的场景是聚合问答,比如用户问一个问题,你同时让三个模型各自回答,再结合结果做一个综合摘要。串行调用延迟累加,体验完全不能接受。

这个时候就用得上CompletableFuture。它能把多个模型调用投到线程池里并行执行,多个请求的时间叠加变成时间重叠。对模型API这种典型IO密集场景,收益非常明显。

但并发不是白来的。并发量上去之后,厂商侧的限流、线程池的容量、超时的兜底这些之前串行时不太敏感的问题都会浮出来。所以并发代码一定要“带鞘”,也就是提前规划好线程池、超时和失败降级。

3.2 线程池的选型和容量估算

很多新手图省事,直接用CompletableFuture.supplyAsync(() -> call()),这其实底层用的是ForkJoinPool.commonPool,一个Tomcat进程里所有模块共享的公共池,生产环境用它等于把命运交给别人。我在项目里是单独定义一个线程池,专门给模型调用用。

线程池大小到底开多少,我一般按这个公式估算:线程数 ≈ 目标QPS × 模型平均延迟(秒)。假设你的接口目标是20 QPS,模型平均一次调用耗时1.5秒,那么至少需要20乘以1.5等于30个线程。如果p95延迟到了3秒,保守一点就按60准备。模型调用是IO等待为主,线程池可以比CPU密集型任务开得大些,但也别无限大,因为每个线程后面的HTTP连接、内存占用都是成本。

在Spring Boot里我推荐直接用@Bean注册一个ThreadPoolTaskExecutor,设置核心线程数、最大线程数、队列容量和拒绝策略。拒绝策略建议用CallerRunsPolicy或者自定义降级,直接抛RejectedExecutionException会把异常打到接口层,体验很差。

3.3 并行调用与多模型切换的组合代码

把路由层和并行编排组合起来的代码是这个项目的核心,直接给出我在生产环境使用的简化版:

import java.time.Duration; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; public Map<String, String> parallelChat(List<String> modelNames, String userMessage, Duration timeout) { ExecutorService pool = modelExecutor.getThreadPoolExecutor(); List<CompletableFuture<ModelResult>> futures = modelNames.stream() .map(name -> CompletableFuture .supplyAsync(() -> { ChatModel model = modelRouter.resolve(name); String reply = ChatClient.builder(model) .build() .prompt() .user(userMessage) .call() .content(); return new ModelResult(name, reply); }, pool) .orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS) .exceptionally(e -> new ModelResult(name, "[调用失败] " + e.getClass().getSimpleName() + ": " + e.getMessage()))) .collect(Collectors.toList()); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); return futures.stream() .map(CompletableFuture::join) .collect(Collectors.toMap(r -> r.modelName, r -> r.reply)); }

这段代码里有两个细节必须说明。第一个是orTimeout和exceptionally的顺序。orTimeout让单个future到点后主动完成并抛TimeoutException,后面的exceptionally捕获它并返回兜底内容,这样整个并行流程不会因为一个模型卡住而无限等待。第二个是先exceptionally再allOf,这样allOf等待的所有future都保证正常完成,不会因为某个future异常导致主线程抛ExecutionException,拿不到其他正常返回的结果。

ModelResult只是个简单的内部类,有modelName和reply两个字段,实际项目里我还会带上耗时、token使用量这些信息,方便后面分析。

3.4 超时与部分失败的兜底策略

超时设置很讲究。别设太短,模型在长文本生成时跑到十几二十秒很常见;也别设太长,用户体验受不了。我的经验是普通问答5秒,需要复杂推理的任务放到15秒左右。给每个模型单独的future设置超时,比整体设置更可控,因为多个模型里就算一个超时了,其他正常返回的还能用。

并行调用的上游HTTP接口本身也要有超时,我通常在Controller层用Spring MVC对CompletableFuture返回值的支持做异步处理:

@GetMapping("/compare") public CompletableFuture<Map<String, String>> compare(@RequestParam String message) { return service.parallelChat(List.of("gpt-4o-mini", "qwen-plus"), message, Duration.ofSeconds(15)); }

这里有个值得细品的点:Controller直接返回CompletableFuture,Spring MVC会把整个请求切换到异步模式,Tomcat线程先被释放回去,等未来结果完成后再由回调线程写响应。这样就不会出现“每个请求占一个Tomcat线程干等模型响应”的情况,服务整体吞吐能高一大截。这个改变很小,但对并发性能的影响是决定性的。

4. 并发+切换上线后,我遇到的四个真问题

4.1 并发一上来,厂商侧开始429

第一次把并行调用放量的时候,我清楚地记得控制台刷满了429限流错误。单看某个模型平均延迟也就两秒,但只要瞬时并发超过调用配额,厂商就会直接拒掉。这个问题不是代码逻辑错,是你的消费速度超过了上游允许的速率。

处理上有两层:一是应用入口做信号量限流,比如用Resilience4j的RateLimiter,把每秒请求数压到配额以内;二是利用Spring AI的RetryTemplate机制对429做退避重试,注意必须退避,瞬间重试只会加剧限流。有些厂商会在响应头里返回Retry-After,但多数时候拿不到,所以指数退避加抖动是更通用的方案。

4.2 线程池耗尽和服务雪崩

另一个印象深刻的事故是线程池被排队任务堆满,新请求直接抛RejectedExecutionException。排查发现原因很典型:模型服务偶发变慢,原来两秒的响应变成十秒,线程池里所有线程都被长期占住,后面进来的任务只能在队列里排队,队列满就开始拒绝。

解决思路是分层隔离:一是给线程池设置一个合理的最大线程数和有界队列,宁可拒绝也不要无限积压;二是结合3.4节说的返回CompletableFuture,让HTTP请求线程不被模型调用阻塞;三是给并行的整体结果做降级,比如三个模型里失败两个,只要有一个成功就先把成功的内容返回给用户。降级策略听起来有损,但比整个请求挂掉强太多了。

4.3 切换模型后效果变差,根因不在模型

上线后经常收到反馈说“同样的Prompt,换了个模型结果明显变差”,一开始怀疑是模型能力问题,后来开始对比请求参数才发现,根本不是模型不行,是请求参数没跟着切。有的厂商对同样一个temperature参数的定义范围不一样,有的模型对system prompt里的特殊指令处理方式不同。

这个问题的解法就是前面说的“模型标识+参数模板”绑定机制。我在注册每个模型时,把它最合适的temperature、max_tokens、top_p都固化在配置里,切换模型的同时切换整套参数。也提醒一句:Prompt本身最好也按模型做微调。现在很多模型提供商都有自己的最佳实践文档,照着调整后再跑效果对比,才相对公平。

4.4 并行日志难排查

并行最大的运维痛点是日志乱。三个模型的调用同时发生,日志交错在一起,光靠时间戳根本看不出哪条日志属于哪个请求,出问题的时候定位非常费劲。

我的做法是两步:第一步在进入并行编排前生成一个requestId,扔到MDC里,这样同一请求的所有日志自动带上同一个标识;第二步在ModelResult里带上模型名和耗时,最后统一打到一条汇总日志里,“requestId+模型名+耗时+内容摘要”一应俱全,排查问题直接从日志里捞。这套方法极大节省了我的排查时间,建议你一开始就做,别等日志乱到没法看才加。

下面是我整理的排查速查表,方便你对照:

现象根因最直接的解法
大量429限流瞬时并发超过配额入口限流+指数退避重试
线程池抛RejectedExecutionException线程被慢请求占满,队列堆积有界队列+拒绝策略降级+接口异步化
切模型后效果变化大模型参数默认值不一致每模型一套请求参数模板
并行日志混乱缺少链路标识MDC注入requestId,结果汇总打点

5. 顺着这套组合还能延伸到哪里

5.1 百炼/Qwen等国产模型的接入路径

国内环境下的模型接入,很大比例会落到阿里百炼的Qwen系列上。Spring AI Alibaba适配层把DashScope的调用封成了ChatModel,所以你前面写的路由和并行代码完全不用改,只需要增加配置类里的一个映射项,把“qwen-max”这样的标识指向对应的DashScopeChatModel Bean就行。这点是整套设计的红利,模型从OpenAI换到百炼,业务侧改动可以控制在配置类一个文件内。

不过我还是要多提醒一句:适配包的坐标和配置前缀可能会随着Spring AI版本变化,网上信息比较杂,最靠谱的方式是去你锁定的版本对应的官方文档里查属性绑定说明。我见过不少人在版本升级后因为配置前缀变了排错排了一下午。

5.2 Dify工作流迁移到Java的思考

社区里经常有人问能不能把Dify工作流直接生成Spring AI Java代码,GitHub上也有不少demo。我的结论是:不存在开箱即用的一键转换工具,但Dify工作流的编排思路完全可以映射到Java代码上。Dify的一个节点就是一个处理步骤,LLM节点、HTTP请求节点、条件分支节点,对应到Java里就是一个个方法或函数;节点之间的连线就是调用关系。CompletableFuture在表达并行分支时非常合适,多个节点无依赖关系需要同时执行时,用supplyAsync并行,再用thenCombine汇聚结果。

我实际迁移过一个小型工作流,方法是把它先画成一张节点依赖图,然后按图把每个节点实现为一个类方法,编排部分用3.3节的并行模式。过程比想象中顺畅,但要提前接受一个事实:工作流里的人工设定、界面交互、内置数据表这些能力,在Java里都得自己重新实现,转换成本不在“生成代码”,而在业务逻辑的重新梳理。

5.3 为Agent场景做规划和执行分离

多模型切换和并行编排在后面还有一个更大的用武之地:Agent。一个Agent应用往往有多个模型诉求,规划阶段用强推理模型做主控,工具调用后的结果总结可以用便宜的小模型,两者在完整链路里完全可以并行。Spring AI对工具调用的支持也已经成熟,你可以把工具方法注册成可调用函数,Agent在循环里动态决定调用哪些工具。如果一轮决策要同时获取天气、库存、物流三个信息,这三个工具调用放到CompletableFuture里并行执行,Agent的响应速度会有明显提升。

这套延伸我没有完全跑完,但实测下来路径是通的。核心还是那句话,底层模型可以换来换去,但你自己的编排抽象保持稳定,整个系统才扛得住快速变化。

最后分享一点个人的实际操作体会:多模型切换和异步并发这两件事,千万不要做成两套割裂的功能。只做切换不做并发,每次换模型你都得重新串行验证效果;只做并发不铺路由,那并行的也只是固定几个写死的模型,成本优化和灰度都无从谈起。我的建议是先固定一个抽象层,让切换和并发建立在同一个“模型标识”之上,再用CompletableFuture把并行编排做起来,最后才是考虑Agent这些更上层的东西。这个顺序倒过来,后面大概率要推倒重来。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 12:50:34

AI PPT制作工具如何辅助学术汇报:原理、实操与避坑指南

1. 为什么学术汇报总变成“熬夜项目”&#xff0c;以及 AI 到底能在哪里帮上忙 如果你经历过“接到汇报任务 → 先查三天文献 → 再排两天版式 → 最后对着空白的过渡页发呆”这条流水线&#xff0c;大概能理解我说的痛点。学术汇报的 PPT 和商业路演完全不是一回事&#xff1a…

作者头像 李华
网站建设 2026/10/6 12:50:26

Shell+Curl 调用短信 API:运维告警零依赖短信发送实战

凌晨两点被电话吵醒&#xff0c;原因是某台服务器的磁盘写满了&#xff0c;但监控平台设置的告警规则漏掉了这个分区&#xff0c;日志直接在系统盘里涨到 100%&#xff0c;网站白屏了半小时才被值班同事发现。那台机器上装的是精简版 Linux&#xff0c;没有 Python3&#xff0c…

作者头像 李华
网站建设 2026/10/6 12:50:24

汽车4S店客户管理系统源码实战指南

简介&#xff1a;这是一套面向计算机、数学及电子信息类专业学生的毕业设计级客户管理实战源码&#xff0c;聚焦汽车4S店业务场景&#xff0c;完整实现客户档案管理、车辆信息登记、预约服务跟踪、工单处理与系统权限控制等核心功能&#xff0c;适合作为课程设计、期末大作业或…

作者头像 李华
网站建设 2026/10/6 12:49:36

智能体开发实战:从求婚策划到工作流与API批量任务

这次我们来看一个不太一样的智能体项目&#xff1a;一个为了解决“求婚到底怎么搞”而诞生的求婚智能体。它不是那种演示用的问答机器人&#xff0c;而是把“策划一场求婚”这件事拆成了需求理解、方案生成、流程编排、文案输出、避坑检查几个环节&#xff0c;最终能直接给出一…

作者头像 李华
网站建设 2026/10/6 12:48:30

Go商城后端架构实战:MySQL读写分离与分布式日志链路全解析

简介&#xff1a;一份基于 gingormredismysql 读写分离架构的电子商城项目源码&#xff0c;面向 Go 后端学习者、毕业设计与课程设计开发者。项目实现了 JWT 鉴权、CORS 跨域、AES 对称加密&#xff0c;并引入 ELK 日志平台、jaeger 链路追踪与 skywalk 监控&#xff1b;其中 J…

作者头像 李华
网站建设 2026/10/6 12:47:42

随机森林信贷违约预测实战:从特征工程到模型解释

简介&#xff1a;这份资源面向计算机相关专业的本科生与研究生&#xff0c;提供一套可直接用于毕业设计、期末大作业或课程设计的贷款违约预测完整方案&#xff0c;帮助解决建模流程不清晰、代码难以复现的问题。压缩包共22个文件&#xff0c;约6.16MB&#xff0c;包含7个Pytho…

作者头像 李华