Spring AI多模型路由实战:企业级智能路由+自动降本指南
同一个 AI 应用里,所有请求都丢给最贵的模型,其实很像让高级架构师每天只处理"怎么重置密码"。
企业 AI 应用上线后,常见问题通常不是"模型不够强",而是:
- 简单 FAQ 也走高价模型,调用量一涨,账单跟着飞
- 只接一个供应商,模型服务波动时业务一起抖
- 想接便宜模型或本地模型,但不知道哪些请求可以放心交给它
这篇文章用 Spring AI 实现一个可落地的多模型智能路由:根据任务复杂度自动选模型,主模型失败时逐级 fallback,预算快超限时自动降级到低成本模型。
示例基于 Spring AI 1.1.x 的 ChatClient 思路,以 OpenAI 云端模型 + Ollama 本地模型为例。其他 OpenAI-compatible 服务也可以按同样方式扩展。
一、为什么企业 AI 不该只接一个模型
先看一个典型客服场景:
text
你的 AI 客服上线 3 个月,用户量从每天 1000 次请求涨到 5000 次。
问题随之出现:
- 用户问"怎么重置密码",仍然走最贵的强模型
- 某个云模型服务波动时,客服入口直接不可用
- 想用本地模型分担流量,又担心回答质量不稳定
单模型方案的问题可以拆成三类:
- 成本浪费:简单任务不需要复杂推理,却消耗高价模型预算。
- 风险集中:单一模型、单一供应商、单一路径,任何一层异常都会影响用户。
- 资源错配:强模型应该留给代码生成、复杂分析、长文推理这类高价值任务。
多模型路由的核心原则很简单:让对的模型做对的事。
二、Spring AI 多模型路由架构
Spring AI 的 ChatClient 文档明确提到,多 ChatModel / 多 ChatClient 场景适合:
- 不同任务使用不同模型
- 主模型不可用时 fallback
- A/B 测试不同模型配置
- 为用户提供模型选择
本文的路由策略如下:
text
User Request
|
v
Complexity Assessor
|
+-- SIMPLE -> Ollama 本地模型 -> 低成本 / 低延迟
+-- MEDIUM -> GPT-4o-mini -> 性价比优先
+-- COMPLEX -> GPT-4o -> 强推理 / 复杂生成
异常时 fallback:
COMPLEX -> MEDIUM -> SIMPLE
MEDIUM -> SIMPLE
SIMPLE -> 返回服务暂不可用
预算超限时:
任意任务 -> SIMPLE
这个设计比"按模型名称写 if else"更稳定,因为路由依据是业务任务,而不是供应商名称。后续你把 SIMPLE 从 Ollama 换成 DeepSeek、本地 Qwen,或把 COMPLEX 换成别的强模型,Controller 层都不用改。
三、实战代码
3.1 定义任务复杂度
java
public enum TaskComplexity {
SIMPLE, // FAQ、短问答、知识查询
MEDIUM, // 摘要、改写、普通文案
COMPLEX // 代码生成、长文分析、多步骤推理
}
生产环境里,复杂度不建议只靠"模型自己判断"。更稳的做法是先用规则兜底,再逐步接入轻量分类模型或人工标注数据。
3.2 配置多个 ChatClient
先关闭默认 ChatClient 自动创建,然后手动创建多个不同用途的 ChatClient。
yaml
spring:
ai:
chat:
client:
enabled: false
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o-mini
ollama:
base-url: http://localhost:11434
chat:
options:
model: qwen3:4b
ai:
budget:
monthly: 1000
Ollama 需要提前准备本地模型:
bash
ollama pull qwen3:4b
配置类:
java
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.ai.ollama.api.OllamaChatOptions;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MultiModelConfig {
@Bean("simpleClient")
public ChatClient simpleClient(OllamaChatModel ollamaChatModel) {
return ChatClient.builder(ollamaChatModel)
.defaultOptions(OllamaChatOptions.builder()
.model("qwen3:4b")
.temperature(0.2)
.build())
.build();
}
@Bean("mediumClient")
public ChatClient mediumClient(OpenAiChatModel openAiChatModel) {
return ChatClient.builder(openAiChatModel)
.defaultOptions(OpenAiChatOptions.builder()
.model("gpt-4o-mini")
.temperature(0.3)
.build())
.build();
}
@Bean("complexClient")
public ChatClient complexClient(OpenAiChatModel openAiChatModel) {
return ChatClient.builder(openAiChatModel)
.defaultOptions(OpenAiChatOptions.builder()
.model("gpt-4o")
.temperature(0.2)
.build())
.build();
}
}
这里用 ChatClient.builder(chatModel).defaultOptions(...) 为同一个模型类型派生不同配置,符合 Spring AI 官方多 ChatClient 的推荐写法。
3.3 实现路由器
java
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
@Service
@Slf4j
public class ModelRouter {
private final ChatClient simpleClient;
private final ChatClient mediumClient;
private final ChatClient complexClient;
public ModelRouter(
@Qualifier("simpleClient") ChatClient simpleClient,
@Qualifier("mediumClient") ChatClient mediumClient,
@Qualifier("complexClient") ChatClient complexClient) {
this.simpleClient = simpleClient;
this.mediumClient = mediumClient;
this.complexClient = complexClient;
}
public String route(String userInput) {
TaskComplexity complexity = assessComplexity(userInput);
return routeTo(complexity, userInput);
}
public String routeTo(TaskComplexity complexity, String userInput) {
return executeWithFallback(fallbackChain(complexity), userInput);
}
public String routeToSimple(String userInput) {
return executeWithFallback(List.of(simpleClient), userInput);
}
public TaskComplexity assessComplexity(String input) {
int length = input.length();
String lower = input.toLowerCase();
if (length < 50 &&
(lower.contains("怎么") || lower.contains("什么是") || lower.contains("请问"))) {
return TaskComplexity.SIMPLE;
}
if (length > 200 ||
lower.contains("实现") || lower.contains("编写") ||
lower.contains("分析") || lower.contains("比较") ||
lower.contains("架构") || lower.contains("性能优化")) {
return TaskComplexity.COMPLEX;
}
return TaskComplexity.MEDIUM;
}
private List<ChatClient> fallbackChain(TaskComplexity complexity) {
return switch (complexity) {
case COMPLEX -> List.of(complexClient, mediumClient, simpleClient);
case MEDIUM -> List.of(mediumClient, simpleClient);
case SIMPLE -> List.of(simpleClient);
};
}
private String executeWithFallback(List<ChatClient> clients, String input) {
for (int i = 0; i < clients.size(); i++) {
try {
return clients.get(i)
.prompt()
.system("你是企业级 AI 助手。回答要准确、简洁;不确定时说明不确定。")
.user(input)
.call()
.content();
} catch (Exception e) {
log.warn("模型调用失败,index={}, reason={}", i, e.getMessage());
}
}
throw new IllegalStateException("所有模型调用失败,AI 服务暂时不可用");
}
}
这段代码刻意做了两件事:
- fallback 链是单向的,复杂任务只会
complex -> medium -> simple,不会来回跳。 - 预算降级可以直接调用
routeToSimple(),不会出现"预算超了但只返回一段假提示"的空实现。
3.4 成本监控与自动降级
真实成本最好来自模型响应里的 token usage、网关账单或可观测系统。为了让示例完整,这里先用"单次请求估算成本"做预算判断。
java
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
@Slf4j
public class CostMonitoredRouter {
private static final Map<TaskComplexity, Double> ESTIMATED_COST = Map.of(
TaskComplexity.SIMPLE, 0.0,
TaskComplexity.MEDIUM, 0.0003,
TaskComplexity.COMPLEX, 0.003
);
private final ModelRouter router;
private final double monthlyBudget;
private final Map<TaskComplexity, AtomicLong> callCounts = new ConcurrentHashMap<>();
public CostMonitoredRouter(ModelRouter router,
@Value("${ai.budget.monthly:1000}") double monthlyBudget) {
this.router = router;
this.monthlyBudget = monthlyBudget;
}
public String routeWithBudgetCheck(String userInput) {
TaskComplexity complexity = router.assessComplexity(userInput);
double nextCost = ESTIMATED_COST.getOrDefault(complexity, 0.001);
if (currentMonthlySpend() + nextCost > monthlyBudget) {
log.warn("预算即将超限,降级到 simpleClient");
record(TaskComplexity.SIMPLE);
return router.routeToSimple(userInput);
}
String response = router.routeTo(complexity, userInput);
record(complexity);
return response;
}
private void record(TaskComplexity complexity) {
callCounts.computeIfAbsent(complexity, key -> new AtomicLong()).incrementAndGet();
}
private double currentMonthlySpend() {
return callCounts.entrySet().stream()
.mapToDouble(entry -> entry.getValue().get()
* ESTIMATED_COST.getOrDefault(entry.getKey(), 0.001))
.sum();
}
}
实际项目中,我建议把 callCounts 换成 Micrometer 指标、Redis 计数或数据库账单表。否则应用重启后预算状态会丢。
3.5 Controller 接入
java
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/ai")
public class AiController {
private final ModelRouter router;
private final CostMonitoredRouter costRouter;
public AiController(ModelRouter router, CostMonitoredRouter costRouter) {
this.router = router;
this.costRouter = costRouter;
}
@GetMapping("/chat")
public ResponseEntity<String> chat(@RequestParam String message) {
return ResponseEntity.ok(router.route(message));
}
@GetMapping("/chat-with-budget")
public ResponseEntity<String> chatWithBudget(@RequestParam String message) {
return ResponseEntity.ok(costRouter.routeWithBudgetCheck(message));
}
}
注意这里不要把 @Autowired 写在 @GetMapping 方法参数上。依赖注入放在构造器里,Controller 方法只接收 HTTP 参数。
四、效果怎么估算
下面这张表不是"承诺值",而是一个典型任务分布下的估算口径:
- 日均 5000 次请求
- SIMPLE / MEDIUM / COMPLEX 比例约为 60% / 30% / 10%
- SIMPLE 走本地 Ollama,MEDIUM 走 GPT-4o-mini,COMPLEX 走 GPT-4o
| 指标 | 单模型全走强模型 | 多模型路由 | 说明 |
|---|---|---|---|
| 月度成本 | 高 | 可下降约 50% - 70% | 取决于简单任务占比 |
| 可用性 | 单点依赖 | 多级 fallback | 模型故障时可降级 |
| 简单任务延迟 | 网络调用延迟 | 本地推理更快 | 取决于本地模型和硬件 |
| 复杂任务质量 | 强模型保证 | 强模型仍保留 | 复杂任务不降配 |
真正上线前,需要至少压测三类数据:
- 路由准确率:SIMPLE 是否误判为 COMPLEX,COMPLEX 是否被错误降级。
- 模型质量:每类任务至少准备 50 - 100 条样本做人工评分。
- 失败恢复:模拟 OpenAI / Ollama 任一服务不可用,看 fallback 是否真的生效。
五、踩坑总结
坑 1:便宜模型不是强模型的平替
本地模型适合 FAQ、检索后摘要、固定格式回复,不适合承担所有复杂推理。
建议给 SIMPLE 模型更明确的系统提示:
java
.system("""
你负责回答企业客服中的简单问题。
如果问题涉及代码、架构设计、合同条款或不确定事实,请明确建议转人工或升级模型。
""")
坑 2:fallback 不要写成循环跳转
不要让 A 失败后跳到 B,B 失败后又跳回 A。fallback 链应该是单向、有限、可观测的:
text
COMPLEX -> MEDIUM -> SIMPLE -> fail
每次降级都要打日志,并在指标里记录,否则线上只会看到"回复质量忽高忽低",却不知道是哪一级模型在回答。
坑 3:预算监控不能只靠内存 Map
示例里的 ConcurrentHashMap 只是演示。生产环境建议:
- 调用成功后记录 token usage、模型名、用户 ID、业务线
- 按天 / 月聚合成本
- 为不同租户设置不同预算阈值
- 预算超限时走降级模型或要求用户确认
坑 4:版本和依赖要对齐
Spring AI 版本更新较快,建议使用 BOM 管理依赖,并按当前官方文档确认 starter 名称和配置项:
xml
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
六、进阶:动态模型注册
如果你要做 A/B 测试、租户自选模型,或者临时接入新的 LLM 服务,可以把模型注册表抽出来。
java
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Component;
@Component
public class DynamicModelRegistry {
private final Map<String, ChatClient> models = new ConcurrentHashMap<>();
public void register(String name, ChatClient client) {
models.put(name, client);
}
public String routeTo(String modelName, String input) {
ChatClient client = models.get(modelName);
if (client == null) {
throw new IllegalArgumentException("未知模型: " + modelName);
}
return client.prompt().user(input).call().content();
}
}
这样路由器负责"选哪类能力",注册表负责"当前有哪些模型可用"。职责拆开后,后续接入新模型会轻很多。
七、上线前检查清单
- 先离线评测:准备 SIMPLE / MEDIUM / COMPLEX 三类样本,确认路由准确率。
- 再灰度放量:从 5% 流量开始,观察错误率、延迟、人工投诉。
- 记录完整链路:每次请求记录复杂度、命中模型、fallback 次数、token 成本。
- 加人工兜底:连续 fallback 失败时,不要静默失败,要转人工或返回明确提示。
- 定期复盘阈值:业务变化后,复杂度规则也要跟着更新。
多模型路由不是为了"把所有请求都变便宜",而是把预算花在真正需要强模型的地方。只要你已经有稳定的 AI 调用量,这个架构就值得尽早做。