Spring AI多模型路由实战:企业级智能路由+自动降本指南

Spring AI多模型路由实战:企业级智能路由+自动降本指南

同一个 AI 应用里,所有请求都丢给最贵的模型,其实很像让高级架构师每天只处理"怎么重置密码"。

企业 AI 应用上线后,常见问题通常不是"模型不够强",而是:

  • 简单 FAQ 也走高价模型,调用量一涨,账单跟着飞
  • 只接一个供应商,模型服务波动时业务一起抖
  • 想接便宜模型或本地模型,但不知道哪些请求可以放心交给它

这篇文章用 Spring AI 实现一个可落地的多模型智能路由:根据任务复杂度自动选模型,主模型失败时逐级 fallback,预算快超限时自动降级到低成本模型。

示例基于 Spring AI 1.1.x 的 ChatClient 思路,以 OpenAI 云端模型 + Ollama 本地模型为例。其他 OpenAI-compatible 服务也可以按同样方式扩展。


一、为什么企业 AI 不该只接一个模型

先看一个典型客服场景:

text 复制代码
你的 AI 客服上线 3 个月,用户量从每天 1000 次请求涨到 5000 次。

问题随之出现:
- 用户问"怎么重置密码",仍然走最贵的强模型
- 某个云模型服务波动时,客服入口直接不可用
- 想用本地模型分担流量,又担心回答质量不稳定

单模型方案的问题可以拆成三类:

  1. 成本浪费:简单任务不需要复杂推理,却消耗高价模型预算。
  2. 风险集中:单一模型、单一供应商、单一路径,任何一层异常都会影响用户。
  3. 资源错配:强模型应该留给代码生成、复杂分析、长文推理这类高价值任务。

多模型路由的核心原则很简单:让对的模型做对的事


二、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 模型故障时可降级
简单任务延迟 网络调用延迟 本地推理更快 取决于本地模型和硬件
复杂任务质量 强模型保证 强模型仍保留 复杂任务不降配

真正上线前,需要至少压测三类数据:

  1. 路由准确率:SIMPLE 是否误判为 COMPLEX,COMPLEX 是否被错误降级。
  2. 模型质量:每类任务至少准备 50 - 100 条样本做人工评分。
  3. 失败恢复:模拟 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();
    }
}

这样路由器负责"选哪类能力",注册表负责"当前有哪些模型可用"。职责拆开后,后续接入新模型会轻很多。


七、上线前检查清单

  1. 先离线评测:准备 SIMPLE / MEDIUM / COMPLEX 三类样本,确认路由准确率。
  2. 再灰度放量:从 5% 流量开始,观察错误率、延迟、人工投诉。
  3. 记录完整链路:每次请求记录复杂度、命中模型、fallback 次数、token 成本。
  4. 加人工兜底:连续 fallback 失败时,不要静默失败,要转人工或返回明确提示。
  5. 定期复盘阈值:业务变化后,复杂度规则也要跟着更新。

多模型路由不是为了"把所有请求都变便宜",而是把预算花在真正需要强模型的地方。只要你已经有稳定的 AI 调用量,这个架构就值得尽早做。


相关资源

相关推荐
Komorebi_99991 小时前
OCR + 大模型融合方案
大模型·ocr
程序员三明治1 小时前
【AI】RAG 数据分块(Chunk)策略与实践
java·人工智能·后端·ai·大模型·llm·rag
「維他檸檬茶」1 小时前
大模型算法学习2026.6.1
学习·算法·大模型·nlp
RemainderTime2 小时前
Spring Boot脚手架集成Sa-Token实现生产级RBAC权限管理
java·spring boot·后端·系统架构
世界尽头与你2 小时前
Spring Boot Watcher 未授权访问漏洞
spring boot·安全·网络安全·渗透测试
lpd_lt2 小时前
AI生成Spring Boot + Vue 3 + MySQL + MyBatis-Plus的项目实战
java·spring boot·vue·ai编程
绝知此事2 小时前
RabbitMQ 从入门到精通:Spring Boot 实战三部曲(三)—— 高级应用与性能优化
spring boot·rabbitmq·java-rabbitmq
绝知此事2 小时前
RabbitMQ 从入门到精通:Spring Boot 实战三部曲(一)—— 基础核心与快速上手
spring boot·rabbitmq·java-rabbitmq
刘大猫.10 小时前
智造短剧新引擎:火山引擎上线「火山剧创 1.0」,制作效率提升 80%
人工智能·ai·chatgpt·机器人·大模型·火山引擎·短剧新引擎