Spring Boot集成通义千问:多模型切换的三步实现
发布时间:2026/10/2 7:21:32 作者:尧图编辑部 阅读量:1,286

先说个场景你辛辛苦苦在一个 Spring Boot 项目里接好了通义千问的 API跑通了单模型调用结果产品经理第二天就过来说“再加三个模型还要能做到线上切着用”。你打开 Spring AI 的官方文档发现版本号一堆 M 系列类名换了又换网上教程各写各的半天下来整个人是懵的。我大概有一小段时间天天都在跟这套东西打交道搭过一个给多个业务线共用的大模型网关里面接了好几个通义千问系列的模型最终沉淀出来的方案其实就是三步引依赖、写配置、用 Map 管理模型实例。这篇文章就把这三步完整展开附上能直接跑起来的代码再把那些文档里不会写、但你会真实撞见的坑按我踩过的顺序给你捋一遍。这套东西适合谁适合那些在 Spring Boot 里做 AI 功能集成、想快速接入通义千问并且业务上有多模型切换、多环境隔离需求的 Java 开发者。你不需要对大模型底层原理有多深的理解但你需要有一点 Spring Boot 自动配置和依赖管理的基础。看完之后你会得到一个相当稳的框架切换模型不重启、不写死代码、加新模型只改配置不碰业务逻辑。1. 整体设计与思路拆解1.1 Spring AI 在 2025 年的真实处境任何搞 Java AI 集成的人都绕不开一个困惑Spring AI 到底该用哪个版本哪个依赖是官方主推的这个困惑非常合理因为 Spring AI 的版本演进速度极快。0.8.x 时期叫spring-ai-openai它里面有个OpenAiChatClient到 1.0.0-M 系列ChatClient的理念开始取代原来的*AiClient再到现在以 starter 方式提供spring-ai-starter-model-qwen、spring-ai-starter-model-dashscope这类依赖。你在搜资料的时候会看到大量过时内容但它们往往发布时间并不久远这一点相当迷惑人。我的建议是以 1.0.0 正式版及之后的版本为主路线不要去依赖那些 M 系列的旧教程。因为 M 系列是里程碑版本API 变动幅度很大你照着写出来的代码可能等正式版发布之后就编译不过了。在实际项目中我用的是“BOM 统一管理 starter 依赖”这种组合Spring AI 官方提供spring-ai-bom它把当前推荐版本的各路模块统一管理好了你不需要自己去核对每个子模块的版本号这跟 Spring Cloud Alibaba 的 BOM 管理思路一模一样。还有一个很重要的判断通义千问相关的依赖在 Spring AI 生态里有两条路线。一条是 Spring AI 官方仓库里的spring-ai-starter-model-qwen另一条是阿里维护的spring-ai-alibaba项目后来它以spring-ai-starter-model-dashscope等命名方式融入。网上总有人说“spring-ai-alibaba 停更了”我实际用下来的观察是它并非停更而是整合进 Spring AI 的生态里starter 的命名和自动配置逻辑已经和官方统一。你如果还在用老坐标的 spring-ai-alibaba确实会发现它在仓库里不再活跃所以不要在旧坐标上继续纠结尽快迁到新 starter 上就行。1.2 “多模型切换”到底切的是什么很多人把多模型切换理解成“改一行配置切换 base-url”实际上一旦你真的在代码里接入多个模型你切换的对象不是 URL而是“一个封装好的、有状态的客户端实例”。通义千问系列有 qwen-turbo、qwen-plus、qwen-max甚至还有开源版本如 qwen3-7b 部署在百炼上它们走的是同一个 DashScope 网关但模型名、上下文长度、计费模式完全不同。更微妙的是你在一个项目里可能还要同时接“百炼上部署的模型”和“本地私有化部署的模型”两者的 base-url 和 api-key 也不同。所以我设计的核心思路是把每一个模型定义成一个ChatModelBean 的候选再用一个带 Key 的 Map 把它们收集起来业务代码通过 Key 来决定当前使用哪一个。这样做的好处是切换的过程实质上是“换 Map 的 Key”Spring 容器本身没有变化Bean 也没有重建开销几乎为零。对比起来如果你每次切换都去 new 一个 ChatModel那每次都要重新初始化 HTTP 连接池、超时配置、重试策略并发高的时候很容易把网关打满这不叫切换这叫折腾。整个架构的组织方式其实更像一个“模型路由表”你有一个模型名比如plus路由表告诉你这个模型在哪个网关、用什么模型 ID、超时是多少。业务层完全不感知这些细节它只需要拿到一个ChatModel然后调用call()就好。这个思路还能天然复用ChatClient.Builder来做 prompt 模板、System 消息注入。2. 核心配置与实操要点2.1 一个干净且可复现的 Maven 依赖先上依赖下面的写法我认为是当前阶段最稳妥的方案。用 BOM 引入版本管理再用 starter 引入通义千问支持dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-qwen/artifactId /dependency /dependencies其中spring-ai-starter-model-qwen会帮你配好 DashScope 相关的自动配置你只需要提供 api-key 和 base-url。如果你用的模型不在 OpenAI 兼容协议范围内或者你需要自定义网关地址那就用spring-ai-starter-model-dashscope它更贴合阿里云的网关语义。我的经验是能用一个 starter 解决的事情绝不要同时引入两个。因为你只要在 classpath 里同时有两个模型 starterSpring Boot 的自动配置就可能会尝试创建两个不同类型的ChatModelBean导致注入时发生歧义。后面我会专门讲这个坑。还有一个细节容易被忽略Spring AI 的 starter 默认依赖 Spring Boot 3.x我不想看到还有人卡在 Spring Boot 2.7 里问怎么接。如果项目确实还是 Spring Boot 2.x那请先升级这不是可选项。Spring AI 的自动配置大量使用了 Spring Boot 3 的注解和配置处理机制硬降版本的话各种报错会让你欲哭无泪。2.2 配置文件这样写切换开关才真正可控配置文件是整个多模型切换方案里最容易写乱的部分。我的设计分两层第一层是给 Spring AI 自动配置用的固定参数第二层是自己定义的应用级参数。不要嫌麻烦直接把所有模型参数塞到同一个前缀下面以后维护起来会很痛苦。看这个application.yml的片段spring: ai: dashscope: api-key: ${DASHSCOPE_API_KEY} base-url: ${DASHSCOPE_BASE_URL:https://dashscope.aliyuncs.com/api/v1} chat: client: enabled: true app: llm: default-model: plus models: plus: model: qwen-plus temperature: 0.8 max-tokens: 2048 max: model: qwen-max temperature: 0.5 max-tokens: 2048 local: model: qwen3-7b base-url: http://localhost:8000/v1 temperature: 0.2 max-tokens: 4096第一行的api-key建议一定用环境变量注入不要提交到 git 里。base-url用默认的百炼网关即可但如果你有私有化部署的模型可以在第二层里给不同的模型配置不同的 base-url。默认模型我要说明一下这不是指 Spring AI 自动配置里的默认模型而是你自己后来在 Map 路由里决定“分不到明确模型时兜底用哪个”的那个选择项。第二层app.llm.models是我的自定义配置它不是 Spring AI 标准配置但它是整套方案的地基。它的结构是“模型别名 - 具体参数”在 Java 侧只需要一个配置类就能拿住。这里用到了ConfigurationProperties(prefix app.llm)Spring Boot 会按宽松绑定的规则把所有models里的内容解析成一个MapString, ModelProperties人工再核对一遍字段名别拼错就行。2.3 关键组件选择ChatModel、ChatClient 还是 StreamingChatClientSpring AI 的 API 换代是初学者最容易踩坑的地方。1.0.0 正式版之后ChatModel是底层的对话模型接口ChatClient是封装好的高级客户端它把 prompt 组装、消息历史、结构化输出都集成进去了。你看到很多旧博客写OpenAiChatClient或者OpenAiChatModel这基本都可以直接划走因为新版已经把这些类迁移到了org.springframework.ai.chat.model.ChatModel体系下。我的建议是用ChatClient作为业务入口用ChatModel作为底层路由单位。这样你的业务代码不直接依赖某个模型的细节而是依赖ChatClient的 builder 来构建消息。切换模型时只需要把不同的ChatModel塞给ChatClient.Builder去重建一个ChatClient这个过程中所有的 prompt 模板、System 消息、内存记忆策略都是复用同一套配置。你可以理解为ChatModel是发动机ChatClient是整车。你要换发动机但整车其他部分保持不变。3. 实操过程与核心功能实现3.1 第一步写好配置类把模型参数变成代码里的对象我把这个配置类放在config包里它承担两件事读取自定义配置以及把每个模型的参数转换成对应的ChatModelBean。先看 ModelPropertiesData public class ModelProperties { private String model; private String baseUrl; private Double temperature; private Integer maxTokens; }然后在主配置类里用ConfigurationProperties读取Component ConfigurationProperties(prefix app.llm) Data public class LlmProperties { private String defaultModel; private MapString, ModelProperties models new HashMap(); }这个LlmProperties表面上看只是承接配置但你注意一下它的默认值new HashMap()这样做的好处是即使 yml 里没有写任何模型配置启动也不会直接 NPE。宁可启动后因为模型列表为空而报一个业务可感知的错误也不要在配置缺失时抛一个让人摸不着头脑的 NullPointerException。3.2 第二步构建模型路由表接下来是核心把“配置对象”变成“可运行的模型客户端”。逻辑其实不复杂遍历LlmProperties.models根据baseUrl是否存在决定创建标准 DashScope 客户端还是自定义网关客户端。我写了一个工厂方法Service public class ChatModelRouter { private final MapString, ChatModel modelMap new ConcurrentHashMap(); private final LlmProperties props; private final DashscopeApi defaultApi; private final ChatMemory chatMemory; // 可选用于多轮 public ChatModelRouter(LlmProperties props, DashscopeApi defaultApi, ChatMemory chatMemory) { this.props props; this.defaultApi defaultApi; this.chatMemory chatMemory; init(); } private void init() { for (Map.EntryString, ModelProperties entry : props.getModels().entrySet()) { String key entry.getKey(); ModelProperties mp entry.getValue(); String baseUrl StringUtils.hasText(mp.getBaseUrl()) ? mp.getBaseUrl() : null; OpenAiConnectionProperties connectionProps new OpenAiConnectionProperties(); if (baseUrl ! null) { connectionProps.setBaseUrl(baseUrl); connectionProps.setApiKey(props.getApiKey()); // 如果private模型需要独立key可以扩展 } QwenChatModel model new QwenChatModel(connectionProps, apiKey, mp.getModel()); // 如果DashScope系列可以直接用自动配置中的DashscopeApi if (baseUrl null) { model new QwenChatModel(defaultApi, mp.getModel()); } // 处理temperature/max-tokens if (mp.getTemperature() ! null) { model.setTemperature(mp.getTemperature().floatValue()); } if (mp.getMaxTokens() ! null) { model.setMaxTokens(mp.getMaxTokens()); } modelMap.put(key, model); } } public ChatModel get(String key) { return modelMap.getOrDefault(key, modelMap.get(props.getDefaultModel())); } }写这段代码的时候我特意做了几个防御性处理。一个是 ConcurrentHashMap因为路由表一旦初始化完成就是只读的但并发场景下 get 操作要保证安全所以用并发容器最稳妥。另一个是getOrDefault兜底逻辑核心业务传了不存在的模型 Key 时不会直接抛 NPE而是落到默认模型上避免一个参数写错导致整条链路崩溃。如果你的业务要求参数写错必须报错那就把兜底改成抛自定义异常这个看你团队怎么约定两种我都实践过。这里还有一个细节就是QwenChatModel的构造。Spring AI 1.0 里QwenChatModel有几个构造函数直接传DashscopeApi、传 connectionProperties、传 apiKey 和 modelName 等。我代码中通过判断 baseUrl 是否为 null 来二选一是因为 Dashscope 自动配置已经把默认的DashscopeApi注入进来了我们优先复用只有私有化部署模型或需要自定义网关的模型才需要绕过自动配置新建连接。如果你想在同一个路由表里既保留百炼官方模型又挂一个本地 vLLM 或 Ollama 的 OpenAI 兼容接口这个模式可以直接照搬。3.3 第三步业务层用一个 Service 完成向外提供模型切换能力模型路由表建好之后业务调用层就非常简单了。我不建议把ChatModelRouter直接暴露给 Controller因为路由表是底层细节Controller 应该只知道“我传一个模型别名拿回一个模型响应”。所以我加了一个LlmServiceService public class LlmService { private final ChatModelRouter router; private final ChatClient.Builder chatClientBuilder; public LlmService(ChatModelRouter router, ChatClient.Builder chatClientBuilder) { this.router router; this.chatClientBuilder chatClientBuilder; } public String chat(String modelKey, String userMessage) { ChatModel model router.get(modelKey); ChatClient chatClient chatClientBuilder.build() .mutate() .defaultOptions(ChatOptions.builder() .model(model.getModel()) .temperature(0.7) .build()); return chatClient.prompt() .user(userMessage) .call() .content(); } }这段代码的关键点在于mutate()的使用。ChatClient.Builder构建出来的客户端在 Spring AI 里是一个不可变对象你没法直接改它已经设置好的 default options所以要用mutate()生成一个新的ChatClient。每次切换模型其实都是一次mutate()开销极小。如果你只是临时切换一次不改默认配置那直接用chatClient.prompt()并在 prompt 里指定模型参数也可以但那样做会让“切换模型”的逻辑散落在业务代码里不好维护。Controller 层的写法RestController RequestMapping(/api/chat) public class ChatController { private final LlmService llmService; public ChatController(LlmService llmService) { this.llmService llmService; } GetMapping(/switch/{modelKey}) public String chat(PathVariable String modelKey, RequestParam(defaultValue 你好) String msg) { return llmService.chat(modelKey, msg); } }到这里“多模型切换只需 3 步”已经全部贯穿起来了第一步是引依赖第二步是写配置包括自定义配置类和 Bean 装配第三步是建路由表和 Service。切换模型就是调接口传一个modelKey不需要改代码、不需要重启。测试的时候你可以这样验证调用/api/chat/switch/plus和/api/chat/switch/max观察返回内容的风格和 token 消耗差异如果没有报错说明路由表生效了。3.4 多模型切换的两种扩展方案基于线程变量和基于策略模式上面展示的是基础的路由表方案但实际业务里你会遇到更花的需求。比如同一时刻不同线程要使用不同模型或一个长流程要按阶段动态切换模型。这时候 Map 路由表还不够得再加一层上下文变量。我做得比较顺手的方式是ThreadLocalString持有当前模型的 Key在请求开始时由拦截器设置在调用结束后清理。这个思路是从数据库的多数据源切换方案里搬过来的也确实管用。不过要注意一点ThreadLocal 必须配过滤器或拦截器清理否则线程池复用的场景下模型 Key 会串到下一个请求那种 bug 排查起来非常痛苦定位半天才看到是残留变量导致的。另一种扩展是策略模式。把每一个模型包成一个小策略类比如PlusStrategy、MaxStrategy它们各自实现同一个ModelCallStrategy接口切换模型等于替换整个策略对象。这个方案的好处是如果不同模型有完全不同的参数初始化流程、不同的 prompt 处理逻辑策略模式会很清晰代价是要写的类也比较多。我建议你的项目里模型数少于五个时直接用 Map 路由表就好别过度设计。等到每个模型确实有独立逻辑了再演进成策略模式也不晚。4. 避坑指南与实战问题排查4.1 最常见的几个配置与依赖错误第一个坑同时引入多个 starter 导致 Bean 歧义。假设你在 pom.xml 里为了保险同时加了spring-ai-starter-model-qwen和spring-ai-starter-model-dashscope启动时多半会遇到NoUniqueBeanDefinitionException。原因很容易理解两个 starter 都会自动配置ChatModel相关的 BeanSpring 不知道该注入哪一个。解决方式是明确只保留一个官方 starter再把另一个模型接进自定义路由表中而不是让自动配置一起来掺和。第二个坑模型名称写错。DashScope 的模型 ID 是精确的字符串比如qwen-plus、qwen-max、qwen-turbo大小写和下划线都不能乱来。我见过有人把qwen-plus写成qwen结果调用时返回 404。这类错误的提示还不一定是模型不存在有时是InvalidParameter: model not found有时直接报 HTTP 400让人一开始还以为是网络问题。如果你在用私有化部署的 qwen3-7b模型名也不一定是qwen3-7b要看你在 vLLM 或 Ollama 启动时注册的名称这两个环境里模型名不一致是很常见的事。第三个坑temperature 参数不生效。Spring AI 1.0 里ChatOptions的设置和模型的默认设置有一个合并逻辑。你如果在 yml 里通过spring.ai.chat.options配置了一个全局 temperature又在代码里手动设置defaultOptions后者的优先级更高。但如果你在QwenChatModel构造后直接setTemperature()然后又在ChatClient.Builder里设置了另一个 temperature可能会看到最终调用时 options 覆盖了 model 里的设置具体结果取决于代码执行顺序。我的习惯是把多变参数的配置集中放在一个地方——要么全在代码里要么全在配置里不要在两层都配同一种参数否则排查问题时你会怀疑人生。4.2 与流式响应和并发相关的性能坑很多接入通义千问的项目早晚都会遇到流式输出的需求。Spring AI 的StreamingChatClient接口返回FluxString看起来很好用但要注意流式响应和普通响应的配置并不完全互通。如果你在ChatModel配好了重试和超时那对流式同样生效但流式响应里一旦出现网络中断或服务端主动断开错误提示往往是泛泛的IO Exception你没法轻易判断是模型崩溃还是网络抖动。我给的建议是强制开启 HTTP 连接池的健康检查并把 readTimeout 设置得比普通请求更长一些因为流式场景下模型边生成边返回耗时天然比一次性返回的接口要长如果你的 readTimeout 只有 10 秒那长文本生成的连接大概率会被客户端自己掐断。并发这块Spring AI 底层的ChatModel本身是线程安全的你不需要为每个线程创建新实例。但你要小心的是不要在ChatModel里保存用户相关的对话状态。通义千问的 API 是无状态的多轮对话的上下文需要你自己在业务层维护你如果把用户Session 存在ChatModel里前面提到的线程安全问题立刻就会浮现。我见过有团队把当前对话的 message history 存在一个单例 Map 里并发用户一多A 用户的上下文跑到了 B 用户的请求里这种 bug 定位难度极大排查了一晚上最后发现是共享 Map 惹的祸。4.3 从 Dify 工作流迁移到 Spring AI 或迁移到 Agent 时的一个关键认知热词里提到 “dify 工作流转成 spring ai java 代码” 很多人也想搞清楚这件事。我的认知是Dify 的工作流更看重“流程编排”而 Spring AI 更看重“模型调用和工具编排”把 Dify 的工作流直接翻译成 Spring AI 的 Java 代码本质上不是一行行翻译而是把工作流里的每个节点拆成两个部分一部分是“调大模型的节点”它对应ChatClient的一次prompt().call()另一部分是“工具节点或条件分支”它对应 Spring AI 里的Tool方法或编程式路由。如果你试图把整个工作流图完整地搬到 Spring AI 里会非常痛苦因为两边对状态管理和分支的判断逻辑完全不同。同样的道理适用于 Spring AI Agent。从 1.0 开始Spring AI 强化了ChatClient的工具调用能力和可观测性接口Agent 编排越来越像一个可执行的链路。但多模型切换这件事在 Agent 场景下依然是同一个核心不要硬编码模型要把模型名作为一种路由参数注入到ToolContext或ChatOptions里。我之前就摔过一次在 Agent 工具里直接写死了qwen-max导致后续模型替换要改方法签名很不优雅。你现在写代码时就多问自己一句如果明天我把默认模型改成别的名字我的代码需要动吗如果答案是需要动那设计还不够好。4.4 避坑速查表我总结的一张自查清单pom.xml 里只保留一个模型 starter 依赖模型接入路由表统一管理如果确实需要多个网络通道优先用spring-ai-starter-model-dashscope加手动配置而不是堆多个 starter。api-key 一定通过环境变量或配置中心注入不要落在本地文件里尤其是多人协作的项目.gitignore 里要把 application-local.yml 排除掉。模型名严格参照百炼平台模型管理页面的名称不要自行拼接私有化部署环境模型名需单独确认。不要在代码里同时用“全局配置 方法内 options 模型对象 setter”设置同一个参数选一种方式并坚持到底。ThreadLocal 切换模型时一定要在 finally 或拦截器 afterCompletion 中清理。流式接口的 readTimeout 要单独评估不能和普通接口共用一个时间。共用ChatModel没问题但绝不能在ChatModel或其外部包装对象里保存用户级状态。这张表贴在项目文档里新同学接手时看一眼基本就能规避大部分低级问题。很多看似妖孽的 bug查到最后都是在这些基础节点上出了问题。5. 别忘了把日志和可观测性加上否则排查时要崩溃如果你只做多模型切换不做日志分级那你一定会后悔。我在实际项目里是给每个模型调用都加了 traceId 和 modelKey 的日志输出效果非常显著。你想象一下线上出问题的时候你排查“为什么这条回答质量这么差”如果没有日志你连当时用的哪个模型、temperature 是多少都不知道只能靠用户描述猜测效率低到令人沮丧。在 Spring AI 里添加这些信息的方式并不复杂。你可以在 Controller 或 Service 层打日志也可以在底层为每个 ChatModel 单独定制一个带日志包装的代理模型。我偏向于前者因为在 Service 层打日志能同时记录业务侧的用户意图和模型侧的选择信息更完整。比如这样log.info([llm][traceId{}][model{}] prompt: {} - response: {}, traceId, selectedModelKey, userMessage, responseContent);不过要注意response 全文打到日志里可能会非常大生产环境记得做截断我一般只记录前 200 个字符。另外建议把 modelKey 加进 HTTP 响应头里比如X-Model-Key: plus这样可以大大方便联调和排查尤其是当你从网关层透传的时候一个简单的响应头能帮你快速确认是不是模型路由选错了。这个做法实现成本几乎为零收益却很大我把它当作一个强制要求来执行。如果你想更系统一些可以引入 Spring Boot Actuator 和 Micrometer 的指标体系把每次模型调用的耗时、成功数、失败数、token 消耗做成 Counter 和 Timer。虽然这一套不是必须的但多模型切换后你必然要回答“哪个模型最贵、哪个模型最慢”这种问题。没有指标数据你只能拍脑袋回答有了指标数据产品经理拿着你的报表开会你的工作成果会非常直观。我的实操体会这套方案我用了很久前前后后迭代过几轮。最初我也试图用 Spring AI 的spring.ai.model开关去做切换后来发现那套机制更适合“应用级启动时切换”但线上跑起来之后想在不重启的前提下做灰度对比就比较吃力了。于是才沉淀出 Map 路由表这套东西。经过几年的生产实践验证我的真实感觉是多模型切换的本质不是“调用哪个 API”而是“如何把模型作为资源管理起来”。当你把模型当成数据库连接一样管理——有连接池、有路由表、有超时控制你面对新模型接入就不会慌。通义千问系列演进很快今天写死的工具有可能就是明天淘汰的配置但你自己搭好的这套“路由表 配置类 统一模板”框架不会过时。如果以后你还想接新的模型只需要在 yml 里多写一组配置路由表自动识别业务零改动如果想做本地模型也只要在 base-url 上指一下即可。这个扩展性是整个方案最值钱的地方。