尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

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

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和OracleSpring AI则是用统一的ChatModel、Prompt、ChatResponse去对接各家大模型。你不用再为每家厂商写一套HTTP调用、重试、鉴权和消息组装逻辑这些都被starter给包住了。我在项目里主用的两个实现是OpenAiChatModel和DashScopeChatModel前者接OpenAI兼容协议后者接阿里百炼的通义系列。它们的底层走得完全不是一条链路但在我代码里都是ChatModel调用方法一模一样。这套抽象只要配置好业务代码根本感知不到底层是哪家这是后续所有切换动作的前提。需要注意Spring AI的抽象是“接口统一”不是“效果统一”。同一句Prompt不同模型返回的格式、语气、长度都不一样这个差异后续要靠请求参数层去兜不是接口本身能解决的。1.2 切换的本质是“选择实现”不是替换调用方式一开始我想得复杂以为要做个模型网关或者搞一套策略引擎。后来发现大多数场景根本不需要那么重。所谓多模型切换本质上就是一句话给你一个模型标识找到对应的ChatModel实例然后用它发起调用。最简单的实现就是维护一个MapString, ChatModelkey是业务自定义的模型标识比如“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. 路由实现一套代码接住多个厂商的ChatModel2.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 MapString, ChatModel chatModelMap( OpenAiChatModel openAiChatModel, DashScopeChatModel dashScopeChatModel) { return Map.of( gpt-4o-mini, openAiChatModel, qwen-plus, dashScopeChatModel ); } }这个Map的key就是业务侧统一使用的“模型标识”。Primary注解很关键它告诉Spring当有多个MapString, 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 MapString, ChatModel modelMap; public ModelRouter(MapString, 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 MapString, 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 MapString, String parallelChat(ListString modelNames, String userMessage, Duration timeout) { ExecutorService pool modelExecutor.getThreadPoolExecutor(); ListCompletableFutureModelResult 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 CompletableFutureMapString, String compare(RequestParam String message) { return service.parallelChat(List.of(gpt-4o-mini, qwen-plus), message, Duration.ofSeconds(15)); }这里有个值得细品的点Controller直接返回CompletableFutureSpring 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这些更上层的东西。这个顺序倒过来后面大概率要推倒重来。
返回列表