1. SpringAI 多模型动态路由到底解决什么问题
在 SpringAI 里做多模型接入,最容易踩的坑不是「怎么调通一个模型」,而是「怎么让同一个接口按请求参数切到不同模型」。我见过不少项目一开始只接了 OpenAI,后来业务方要求灰度通义千问、本地 Ollama 兜底、成本高的请求走便宜模型,结果代码里到处是 if-else,每加一个模型就要改一遍 Controller,测试和上线都提心吊胆。
SpringAI 多模型切换的核心诉求其实很朴素:前端传一个模型名称,后端根据这个名称找到对应的 ChatClient,然后正常发起对话。听起来简单,但真正落地时会遇到几个具体问题。第一,不同模型的 ChatClient 配置差异很大,系统提示词、temperature、工具注册、记忆 Advisor 都不一样,不能简单共用一个 Builder。第二,模型名称和 Bean 名称的映射关系需要可维护,不能硬编码在业务代码里。第三,流式返回、工具调用、对话记忆这些能力在不同模型上要尽量保持一致的行为,否则前端拿到的响应格式会乱。
适合谁看这篇内容?如果你正在用 Spring Boot + SpringAI 做后端服务,需要支持多模型灰度、成本分流、或者给不同租户分配不同模型,那这套按模型名称动态路由的方案可以直接拿去改。如果你只是本地跑个 demo,那可能用不上这么重的结构,但了解一下注册表和分发的思路也没坏处。
我试过的场景是这样的:一个客服问答接口,VIP 用户走 GPT-4o,普通用户走 qwen-max,离线环境走本地 Ollama 的 gemma3:1b。三个模型的 ChatClient 各自独立配置,前端只需要在请求里带上modelName参数,后端通过 ApplicationContext 按名称取 Bean,整个切换过程对业务代码零侵入。下面把配置、注册表、分发逻辑和验证步骤完整拆开讲。
2. TaoToken 前置准备:模型接入的 Base URL 与 Key 管理
在写路由代码之前,得先把模型接入的凭证和地址准备好。SpringAI 的 OpenAI Starter 默认指向官方地址,但实际项目里我们通常需要一个统一的接入层来管理多个模型的 Key 和 Base URL。TaoToken 在这里的角色是提供一个兼容 OpenAI 协议的接入地址,让你可以用同一套 SpringAI 配置去访问不同来源的模型。
先明确三个必须准备好的东西:Base URL、API Key、Model ID。这三个要素在 SpringAI 的配置文件里对应spring.ai.openai.base-url、spring.ai.openai.api-key和具体请求时的model参数。如果你用的是通义千问的 DashScope Starter,配置项名称会不同,但逻辑一样。
TaoToken 的 API 地址是https://taotoken.net/api,这个地址兼容 OpenAI 的/v1/chat/completions路径。你需要在 TaoToken 的控制台创建一个 API Key,然后把它写进 Spring Boot 的配置文件。注意,API Key 不要硬编码在 Java 代码里,用环境变量或者配置中心注入。
# application.yml spring: ai: openai: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} chat: options: model: gpt-4o temperature: 0.5这里有个细节要注意:SpringAI 的 OpenAI Starter 在拼接请求路径时,会在 base-url 后面加上/v1/chat/completions。所以 base-url 写https://taotoken.net/api就够了,不要自己再加/v1,否则会变成/api/v1/v1/chat/completions,直接 404。
如果你要同时接入 DashScope 和 Ollama,那需要引入对应的 Starter 依赖,并且各自配置。DashScope 的配置项是spring.ai.dashscope.api-key,Ollama 的配置项是spring.ai.ollama.base-url。这些配置和 TaoToken 的 OpenAI 兼容配置可以共存,互不影响。
提示:TaoToken 的 API Key 可以在控制台的 API Keys 页面创建,创建后立即复制保存,页面刷新后不会再显示完整 Key。如果你需要查看接入文档,可以访问 TaoToken 的文档页面了解各模型的 Model ID 和参数说明。
模型注册表的设计思路是这样的:每个模型对应一个 Spring Bean,Bean 名称就是前端传过来的modelName。这样applicationContext.getBean(modelName)就能直接拿到对应的 ChatClient。但 Bean 名称不能有特殊字符,所以前端传的名称要规范化,比如gpt-4o要映射成openAiChatClient,不能直接用gpt-4o当 Bean 名。这一点在后面的注册表章节会详细讲。
3. 可复制的 ChatClient 路由配置与模型注册表
这一节是整篇的核心,直接给可复制的配置和代码。先看自动配置类,它负责把每个模型的 ChatClient 注册成独立的 Bean。
@Configuration public class ChatClientConfig { @Bean public ChatClient openAiChatClient(OpenAiChatModel openAiChatModel, ChatMemory chatMemory, ToolService toolService) { return ChatClient.builder(openAiChatModel) .defaultSystem("你是一位专业的客服专员") .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build()) .defaultOptions(ChatOptions.builder() .temperature(0.5) .model("gpt-4o") .build()) .defaultTools(toolService) .build(); } @Bean public ChatClient dashScopeChatClient(DashScopeChatModel dashScopeChatModel, ToolService toolService) { return ChatClient.builder(dashScopeChatModel) .defaultSystem("你是一位女仆助手") .defaultOptions(ChatOptions.builder() .temperature(0.5) .model("qwen-max-latest") .build()) .defaultTools(toolService) .build(); } @Bean public ChatClient ollamaChatClient(OllamaChatModel ollamaChatModel, ToolService toolService) { return ChatClient.builder(ollamaChatModel) .defaultSystem("你是一位客服专员") .defaultAdvisors(new SimpleLoggerAdvisor(0)) .defaultOptions(ChatOptions.builder() .temperature(0.5) .model("gemma3:1b") .build()) .defaultTools(toolService) .build(); } @Bean public ChatMemory chatMemory(JdbcTemplate jdbcTemplate) { return MessageWindowChatMemory.builder() .chatMemoryRepository(JdbcChatMemoryRepository.builder() .jdbcTemplate(jdbcTemplate) .build()) .build(); } }这段配置里,三个 ChatClient 的 Bean 名称分别是openAiChatClient、dashScopeChatClient、ollamaChatClient。前端传的modelName需要映射到这三个名称之一。直接让前端传 Bean 名不太优雅,所以加一层注册表。
@Component public class ModelRegistry { private final Map<String, String> modelToBean = new HashMap<>(); public ModelRegistry() { modelToBean.put("gpt-4o", "openAiChatClient"); modelToBean.put("qwen-max", "dashScopeChatClient"); modelToBean.put("gemma3", "ollamaChatClient"); } public String resolveBeanName(String modelName) { String beanName = modelToBean.get(modelName); if (beanName == null) { throw new IllegalArgumentException("不支持的模型名称: " + modelName); } return beanName; } public Set<String> supportedModels() { return modelToBean.keySet(); } }注册表的好处是,前端传的模型名称和 Bean 名称解耦。以后要加新模型,只需要在注册表里加一行映射,再在配置类里加一个 Bean,业务代码完全不用动。
然后是 Controller 的分发逻辑:
@RestController @RequestMapping("/api/chat") public class ChatController { private final ApplicationContext applicationContext; private final ModelRegistry modelRegistry; public ChatController(ApplicationContext applicationContext, ModelRegistry modelRegistry) { this.applicationContext = applicationContext; this.modelRegistry = modelRegistry; } @GetMapping("/switch") public Flux<String> chat(@RequestParam String message, @RequestParam String modelName) { String beanName = modelRegistry.resolveBeanName(modelName); ChatClient chatClient = applicationContext.getBean(beanName, ChatClient.class); return chatClient.prompt() .user(message) .stream() .content(); } @GetMapping("/models") public Set<String> listModels() { return modelRegistry.supportedModels(); } }这里用applicationContext.getBean(beanName, ChatClient.class)而不是强制类型转换,更安全。/models接口方便前端查询当前支持哪些模型名称。
如果你用的是 Spring Boot 3.x + SpringAI 1.0.0-M6 以上版本,ChatClient.builder()的 API 可能有细微差异,比如defaultOptions的参数类型。建议对照你实际使用的 SpringAI 版本调整。另外,DashScope 的 Starter 在 Maven 中央仓库的坐标是spring-ai-dashscope-spring-boot-starter,Ollama 的是spring-ai-ollama-spring-boot-starter,OpenAI 的是spring-ai-openai-spring-boot-starter。
注意:如果你在配置类里同时注入了多个 ChatModel(比如 OpenAiChatModel 和 DashScopeChatModel),Spring 可能会因为类型匹配问题报
NoUniqueBeanDefinitionException。解决办法是在注入参数上加@Qualifier,或者确保每个 ChatModel 的 Bean 名称唯一。SpringAI 的 Starter 默认会为每个模型创建独立的 ChatModel Bean,一般不会冲突,但如果你手动定义了多个同类型 Bean,就要注意。
4. 验证请求与日志断言:切换是否真的生效
配置写完了,怎么确认切换真的生效?不能只看接口返回 200 就完事,得从日志和响应内容两个维度验证。
先启动应用,确认三个 ChatClient Bean 都注册成功。可以在启动类里加一段日志:
@SpringBootApplication public class Application implements CommandLineRunner { private final ApplicationContext ctx; public Application(ApplicationContext ctx) { this.ctx = ctx; } public static void main(String[] args) { SpringApplication.run(Application.class, args); } @Override public void run(String... args) { String[] beans = ctx.getBeanNamesForType(ChatClient.class); System.out.println("已注册的 ChatClient Bean: " + Arrays.toString(beans)); } }启动后控制台应该输出类似已注册的 ChatClient Bean: [openAiChatClient, dashScopeChatClient, ollamaChatClient]。如果少了某个,说明对应的 Starter 依赖没引入或者配置有误。
然后发三个请求,分别指定不同的 modelName:
curl "http://localhost:8080/api/chat/switch?message=你好&modelName=gpt-4o" curl "http://localhost:8080/api/chat/switch?message=你好&modelName=qwen-max" curl "http://localhost:8080/api/chat/switch?message=你好&modelName=gemma3"流式返回会逐字输出。要确认走的是哪个模型,最直接的办法是看日志。SpringAI 的 OpenAI Starter 在 DEBUG 级别会打印请求的 model 参数。在application.yml里加上:
logging: level: org.springframework.ai: DEBUG然后观察日志里有没有model=gpt-4o、model=qwen-max-latest、model=gemma3:1b这样的字段。如果三个请求的日志里 model 字段各不相同,说明路由生效了。
另一个验证角度是响应内容的风格差异。因为三个 ChatClient 的defaultSystem不同,gpt-4o 走的是「专业客服专员」,qwen-max 走的是「女仆助手」,gemma3 走的是「客服专员」。你可以问同一个问题,看回复的语气是否不同。比如问「你是谁」,女仆助手可能会用比较活泼的语气,专业客服会更正式。这能侧面证明不同 ChatClient 的配置确实被应用了。
如果日志里 model 字段始终是同一个值,那说明路由没生效,所有请求都走了默认的 ChatClient。这时候要检查ModelRegistry的映射是否正确,以及applicationContext.getBean拿到的 Bean 是不是预期的那个。可以在 Controller 里加一行日志打印实际拿到的 Bean 名称:
System.out.println("实际使用的 Bean: " + beanName);还有一个容易忽略的点:SpringAI 的ChatOptions里设置的 model 参数会覆盖配置文件里的默认 model。如果你在defaultOptions里写了.model("gpt-4o"),那即使配置文件里写的是别的模型,实际请求也会用gpt-4o。所以验证时要确认defaultOptions里的 model 和预期一致。
5. 本篇常见错误排查:401、local proxy failed 与 reading choices
多模型路由跑起来之后,最常见的报错集中在认证和网络层面。下面按真实报错逐个排查。
401 Unauthorized:这个最直接,API Key 不对或者没传。检查spring.ai.openai.api-key是否配置正确,环境变量TAOTOKEN_API_KEY是否在启动时注入。如果你用的是 TaoToken 的 Key,确认 Key 没有过期,并且请求的 Base URL 是https://taotoken.net/api。有时候 Key 复制时带了空格,也会导致 401,建议用echo $TAOTOKEN_API_KEY | tr -d ' '检查一下。
local proxy failed / Connection refused:这个报错通常出现在 Ollama 场景。Ollama 默认监听http://localhost:11434,如果应用跑在容器里,localhost 指向的是容器本身,不是宿主机。解决办法是把spring.ai.ollama.base-url改成宿主机的实际 IP,或者用 Docker 的host.docker.internal。另外,Ollama 服务没启动也会报这个错,先确认ollama serve在跑。
Error reading choices / JsonParseException:这个报错说明请求发出去了,但返回的 JSON 格式不符合 SpringAI 的预期。常见原因是 Base URL 配错了,比如多加了/v1导致返回的是 HTML 错误页而不是 JSON。检查base-url是否只写到https://taotoken.net/api,不要带/v1。另一个原因是模型名称写错了,比如把qwen-max-latest写成了qwen-max,有些接入层会返回错误信息而不是标准响应。
NoUniqueBeanDefinitionException:前面提过,多个同类型 ChatModel Bean 导致注入歧义。解决办法是在ChatClientConfig的方法参数上加@Qualifier("openAiChatModel")之类的限定符,或者用@Primary标记一个默认的。
OAuth 相关报错:如果你用的是需要 OAuth 认证的模型接入方式,可能会遇到 token 过期的问题。SpringAI 本身不管理 OAuth token 刷新,需要你在接入层或者自定义的ChatModel里处理。如果报错信息里有invalid_token或token expired,检查你的认证配置。
流式返回中断:有时候前端收到一半就断了,日志里没有明显错误。这可能是Flux的超时设置问题。SpringAI 的流式请求默认超时时间可能偏短,可以在配置文件里调整spring.ai.openai.chat.options.timeout或者用WebClient的自定义配置延长超时。
排查顺序建议是:先看 HTTP 状态码,401 查 Key,404 查 Base URL,500 查模型名称和参数。然后看日志里的请求 URL 和请求体,确认 model 字段是否正确。最后看响应体,如果是 HTML 或者非 JSON,基本就是地址配错了。
提示:如果你在排查过程中需要确认某个模型是否可用,可以先用 curl 直接请求 TaoToken 的 API,排除 SpringAI 配置的干扰。命令是
curl https://taotoken.net/api/v1/chat/completions -H "Authorization: Bearer $TAOTOKEN_API_KEY" -H "Content-Type: application/json" -d '{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}'。如果 curl 能通但 SpringAI 不通,那就是配置问题;如果 curl 也不通,那就是 Key 或地址问题。
6. 从路由到生产:多模型灰度与成本分流的落地建议
动态路由跑通之后,下一步就是把它用到实际业务里。多模型灰度最常见的做法是按用户维度分流,比如 VIP 用户走 gpt-4o,普通用户走 qwen-max。这个逻辑可以放在ModelRegistry里,根据用户 ID 或者租户 ID 返回不同的 Bean 名称。
public String resolveBeanName(String modelName, String userId) { if (isVip(userId)) { return modelToBean.get("gpt-4o"); } return modelToBean.getOrDefault(modelName, "dashScopeChatClient"); }成本分流的思路类似,但更关注 token 消耗。你可以在请求前预估 token 数,超过阈值的走便宜模型,低于阈值的走贵模型。SpringAI 的ChatOptions里可以设置maxTokens,但预估 token 需要额外的 tokenizer,这个可以根据业务需求决定是否引入。
长期来看,如果你需要频繁切换模型做 A/B 测试,建议把模型注册表放到配置中心(比如 Nacos 或 Apollo),这样改映射关系不用重启应用。SpringAI 的 ChatClient Bean 还是静态注册的,但映射关系可以动态刷新。
对于需要长期编码和 Agent 场景的团队,可以考虑用 Coding Plan 来管理多个模型的接入和额度,避免每个模型单独申请 Key 的麻烦。如果你只是想快速验证某个模型的效果,可以直接在模型对话页面测试,确认没问题再写进代码。
最后说一个实际踩过的坑:不同模型的流式返回速度差异很大,gpt-4o 通常很快,本地 Ollama 的 gemma3:1b 在低配机器上可能几秒才吐一个字。如果你的前端没有做加载状态和超时处理,用户体验会很差。建议在 Controller 层加一个统一的超时控制,比如Flux.timeout(Duration.ofSeconds(30)),超时后返回兜底话术。
整套方案的核心就是「注册表 + ApplicationContext 按名取 Bean」,没有复杂的反射或动态代理,小白也能看懂。你可以先从两个模型开始,跑通之后再逐步加。配置类里的defaultSystem和defaultOptions按需调整,不用照搬我的参数。