别把 Agent 绑死在一个模型上:Spring AI Alibaba 多模型调度与故障转移实战(Java 架构师的 AI 工程笔记 08)

29 阅读18分钟

Spring AI Alibaba 多模型调度实战:DashScope、DeepSeek、Ollama 的路由、降级与本地兜底

系列目标:从零构建一个机票比价 Agent 本篇目标:把第 7 章的单模型 Agent,升级成可路由、可降级、可本地兜底的多模型系统 前置知识:建议先读完第七章《完整 Agent 集成实战》

本篇速览:第 7 章把“票小蜜”组装成了一辆能开的整车,但模型层仍然是单点:默认只有一个主模型,出了故障整条链路就会跟着抖;复杂请求和简单请求混在一起,成本也压不住;一旦遇到敏感数据,还要重新考虑“推理链路到底能不能出域”。这一章继续往前走一步:把 DashScope、DeepSeek、Ollama 收敛到统一的 ChatModel 抽象,在 Agent 前面补上一层多模型治理能力,让系统第一次具备模型路由、主备降级和本地兜底。这里最重要的工程判断只有一句:降级保证可用,不保证质量无损。

最终效果预览

# 1. 默认走主模型
curl "http://localhost:8086/api/v6/agent/chat?q=北京到上海明天的机票&sessionId=s1"
# → 默认命中 qwen-plus,返回航班信息

# 2. 复杂分析走推理模型
curl "http://localhost:8086/api/v6/agent/chat?q=帮我比较这三个航班的性价比&model=deepseek-chat&sessionId=s1"
# → 命中 DeepSeek,输出对比结论

# 3. 主模型故障自动降级
curl "http://localhost:8086/api/v6/agent/chat?q=退改签政策&sessionId=s1"
# → DashScope 失败后自动切到备用模型,请求不中断

# 4. 敏感请求走本地模型
curl "http://localhost:8086/api/v6/agent/chat?q=查询我的订单&model=qwen2.5:7b&sessionId=s1"
# → Ollama 本地处理,推理链路不出域

在这里插入图片描述


理论篇

一、为什么第 7 章之后必须补一层多模型调度治理层

1.1 单模型 Agent 一旦接近线上,就会暴露 5 个问题

第 7 章已经把 Agent 主链路跑通了:ChatClient + Advisor + Tool + Memory + RAG 都在工作。但只要开始往真实业务靠,这套单模型结构很快就会碰到几个非常现实的问题。

风险表现结果
可用性风险模型提供商超时、503、限流整条 Agent 链路跟着失败
成本风险简单问答也走高成本模型Token 开销失控
能力错配所有任务都压给同一个模型该省的时候没省,该强的时候不够强
供应商锁定强绑定某一家 API 形态后续切换与议价都被动
合规压力敏感数据统一送云端推理无法满足部分业务边界

所以这一章真正要解决的,不是“再接几个模型”,而是把“单模型调用”升级成“多模型治理”。

1.2 先别把“多模型”理解成“多配几个 API Key”

很多人第一次做多模型,会把注意力都放在这些动作上:

  • 再引一个 starter
  • 再配一个 api-key
  • 在 Controller 上多加一个 model 参数

这些事情当然都要做,但它们都不是本质。

本质是:你要不要在 Agent 前面加一层治理能力。

这层治理层至少要回答 3 个问题:

  1. 这个请求应该去哪个模型?
  2. 主模型失败以后,应该切到谁?
  3. 降级以后,哪些能力要一起收缩?

只有这 3 个问题想清楚了,“多模型”才不是一堆散落的接入代码,而是一个可以持续演进的系统能力。

1.3 模型角色分工:别让选型停留在参数表

与其背一堆排行榜、价格表、上下文窗口,我更建议先把模型理解成不同的“系统角色”。

模型角色推荐代表主要职责
默认主模型qwen-plus一般对话、工具调用、成本与效果平衡
强推理模型deepseek-chat / deepseek-reasoner复杂分析、对比决策、多步推理
低成本模型qwen-turboFAQ、轻问答、高并发场景
本地兜底模型qwen2.5:7b on Ollama本地调试、离线场景、敏感数据链路

真正需要建立的,不是“哪个模型最强”,而是:什么请求更适合交给谁。

1.4 什么场景下先别急着上多模型治理

这套方案不是越早上越好。下面 3 种场景,我会建议先稳住:

  1. 单模型 Agent 还没跑顺 先把 Tool、Memory、RAG、Guardrails 跑稳,比一上来接多模型更重要。

  2. 业务还没有成本、可用性、合规压力 如果只是 Demo 或 PoC,多模型治理增加的复杂度,通常大于它带来的收益。

  3. 没有日志、告警、回归验证 没有观测能力时,自动降级很容易把问题从“请求失败”变成“质量变差但你不知道”。


二、先统一接入,才能统一治理

2.1 三类模型接入路径,各自解决什么问题

多模型系统里,接入方式可以不同,但治理方式不能分裂。

在这里插入图片描述

接入路径代表对象适合场景
原生 StarterDashScope框架集成顺滑,适合做默认主模型
OpenAI 兼容接口DeepSeek、Moonshot、GLM兼容范围广,适合接入备用模型和第三方模型
本地运行时Ollama / vLLM / Xinference适合本地调试、离线环境、敏感数据链路

这三条路径的共同目标不是“把模型连上”,而是“把模型收敛到统一抽象”。

2.2 最关键的统一抽象:ChatModel

Spring AI 适合做这一章,一个很重要的原因就是它在模型层给了足够稳定的统一抽象:

// 治理层只关心 ChatModel,不关心底层厂商实现
public interface ChatModel {
    ChatResponse call(Prompt prompt);
}

@Qualifier("qwenPlusModel") ChatModel qwenPlus;
@Qualifier("deepSeekModel") ChatModel deepSeek;
@Qualifier("ollamaModel") ChatModel ollama;

一旦 DashScope、DeepSeek、Ollama 都被收敛到这一层,后面的治理组件就能复用:

  • ModelRegistry
  • SmartModelRouter
  • ResilientChatModel

这就是为什么我更建议你先统一模型抽象,再谈路由、降级和扩展。

2.3 一个实用判断:什么时候用 Starter,什么时候走兼容接口

如果目标是“先把主链路跑稳”,我的建议顺序很简单:

  1. 默认主模型:优先用原生 Starter
  2. 备用模型:优先用 OpenAI 兼容接口
  3. 本地兜底模型:优先用 Ollama 这类本地运行时

原因也很直接:

  • 主模型看重稳定和集成顺滑
  • 备用模型看重兼容性和切换成本
  • 本地模型看重可控与不出域

这不是唯一方案,但对大多数 Spring Boot 团队来说,这是第一版最稳的落地顺序。

实战篇

三、模型路由:请求到底该去哪个模型

3.1 第一版最容易落地:先做简单路由

多模型不是“随机切换模型”,而是“根据任务特征做分流”。

在这里插入图片描述

第一版不要一上来就做得太复杂。最稳的做法,是先按任务类型做静态映射。

// 先按任务类型做简单路由
public ChatModel route(TaskType taskType) {
    return switch (taskType) {
        case COMPLEX_REASONING -> deepSeek;
        case GENERAL_CHAT      -> qwenPlus;
        case SIMPLE_QA         -> qwenTurbo;
        case SENSITIVE_DATA    -> ollamaLocal;
    };
}

这个版本的优点很明显:

  • 好理解
  • 好调试
  • 好做回归测试

缺点也一样明显:

  • 依赖任务类型划分是否准确
  • 场景一复杂,静态规则就容易硬编码化
3.2 再往前一步:让轻量模型先做“复杂度分类”

如果你已经把简单路由跑稳了,就可以再往前走一步:先让一个更便宜的模型判断请求复杂度,再决定真正调用哪个模型。

// 用轻量模型做复杂度分类,再决定路由结果
public ChatModel route(String userMessage) {
    String complexity = classifier.prompt("""
        判断以下用户消息的复杂度,只回答一个词:
        SIMPLE / MEDIUM / COMPLEX

        用户消息:%s
        """.formatted(userMessage))
        .call()
        .content()
        .trim()
        .toUpperCase();

    return switch (complexity) {
        case "SIMPLE"  -> models.get("qwen-turbo");
        case "COMPLEX" -> models.get("deepseek-chat");
        default         -> models.get("qwen-plus");
    };
}

这类智能路由的 trade-off 必须说清楚:

  • 好处:高成本模型只留给更值得的请求
  • 代价:多了一次分类调用,链路变长、复杂度上升

所以它更适合第二阶段优化,不适合第一版就做成黑盒。

3.3 我更推荐你优先落地的 4 条路由规则

如果让我给一套第一版就能上线的规则,我会先用这 4 条:

规则去向原因
一般对话 / 工具调用qwen-plus默认主模型,平衡最好
复杂分析 / 对比决策deepseek-chat推理能力更强
高并发 FAQ / 简单问答qwen-turbo成本更低,吞吐更高
敏感数据 / 本地调试qwen2.5:7b推理链路可本地化

生产提醒:把模型切到本地,不等于系统天然满足合规。真正决定“是否出域”的,不只是模型调用本身,还包括日志、检索、工具调用和监控链路是不是也在本地闭环。


四、模型降级:主模型挂了怎么办

4.1 主备降级的目标,不是优雅,而是保可用

路由解决的是“平时该走谁”,降级解决的是“出问题时别一起挂”。

在这里插入图片描述

第一版其实不用想得太复杂。只要先把模型链拉出来,就已经比“只绑一个模型”稳很多了。

我更建议把模型链理解成下面这个顺序:

  1. Primary:默认主模型,承担日常主要流量
  2. Secondary:主模型异常时的备用模型
  3. Fallback:本地模型或能力较弱但可用的兜底模型

这条链路的核心目标只有一个:先保请求不中断。

4.2 熔断器不是为了“酷”,而是为了别反复打坏链路

如果主模型已经在持续失败,你还每次都打过去,本质上是在把故障放大。

所以熔断器通常要做 3 件事:

  • CLOSED:正常放行
  • OPEN:连续失败达到阈值后直接跳过
  • HALF_OPEN:冷却一段时间后,放一个请求去试探是否恢复

这也是为什么“自动降级”和“熔断状态”最好放在一起理解:前者负责保服务,后者负责保系统别继续自伤。

4.3 一个足够实用的 ResilientChatModel
// 按优先级尝试主模型、备用模型、本地兜底
public class ResilientChatModel {

    private final List<ChatModel> modelChain;
    private final Map<ChatModel, CircuitBreaker> breakers;

    public ChatResponse call(Prompt prompt) {
        for (ChatModel model : modelChain) {
            CircuitBreaker breaker = breakers.get(model);
            if (breaker.isOpen()) {
                continue;
            }

            try {
                ChatResponse response = model.call(prompt);
                breaker.recordSuccess();
                return response;
            } catch (Exception e) {
                breaker.recordFailure();
            }
        }

        throw new RuntimeException("所有模型均不可用,请稍后重试");
    }
}

这段代码的重点不是类名,而是职责拆分:

  • 路由器负责选主模型
  • 降级链负责主模型失败后的接棒顺序
  • 熔断器负责暂时跳过不稳定模型
4.4 一个必须写清楚的工程结论:降级保证可用,不保证质量无损

工程上必须说清楚的一句话:降级保证可用,不保证质量无损。

主模型故障后,系统可以通过备用模型或本地模型继续返回结果,但这并不代表:

  • 回答质量完全不变
  • 工具调用准确率完全不变
  • RAG 召回效果完全不变

所以多模型降级设计,保的是“服务不断”,不是“体验零损失”。

真实工程里,降级之后通常还要同步考虑:

  • 写日志
  • 打告警
  • 按模型能力收缩工具集
  • 必要时给用户明确提示“当前正在使用备用服务”

五、把治理层接回第 7 章的票小蜜

5.1 本章新增的是治理层,不是重写 Agent 内核

第 7 章已经有一条能跑通的链路:

  • ChatClient + Advisor
  • searchFlights / compareFlights / searchKnowledge
  • Memory / Checkpoint
  • Guardrails

第 8 章不是推翻它,而是在它前面补上 3 个治理组件:

  • ModelRegistry
  • SmartModelRouter
  • ResilientChatModel

也就是:

  • 第 7 章解决“Agent 能不能工作”
  • 第 8 章解决“Agent 上线后能不能稳、能不能省、能不能守住边界”
5.2 当前仓库的真实入口在哪里

当前仓库里,票小蜜已经存在一个真实入口:

@RestController
@RequestMapping("/api/agent")
public class AgentController {

    private final ChatClient flightAgent;

    @GetMapping("/chat")
    public String chat(@RequestParam String q,
                       @RequestParam(defaultValue = "default") String sessionId) {
        return flightAgent.prompt(q)
            .toolNames("searchFlights", "compareFlights", "searchKnowledge")
            .advisors(advisor -> advisor.param(ChatMemory.CONVERSATION_ID, sessionId))
            .call()
            .content();
    }
}

这段代码的价值在于:它告诉我们治理层应该接在哪里。多模型系统不是从零再造一个 Agent,而是接在已经存在的 flight-agent 主链路前面。

5.3 演进后的接入方式:让 Controller 只做入口,让治理层负责决策

演进到这一层后,Controller 的职责会明显收窄:它只保留 HTTP 入口,模型选择、降级和兜底都交给治理层服务处理。

@RestController
@RequestMapping("/api/v6/agent")
public class MultiModelAgentController {

    private final MultiModelChatService multiModelChatService;

    public MultiModelAgentController(MultiModelChatService multiModelChatService) {
        this.multiModelChatService = multiModelChatService;
    }

    @GetMapping("/chat")
    public String chat(@RequestParam String q,
                       @RequestParam(defaultValue = "auto") String model,
                       @RequestParam String sessionId) {
        return multiModelChatService.chat(q, model, sessionId);
    }
}
@Service
public class MultiModelChatService {

    private final SmartModelRouter router;
    private final ModelRegistry registry;
    private final ResilientChatModel resilientChatModel;

    public String chat(String q, String model, String sessionId) {
        ChatModel primary = "auto".equals(model)
            ? router.route(q)
            : registry.get(model);

        ChatModel effectiveModel = resilientChatModel.withFallback(primary);

        ChatClient client = ChatClient.builder(effectiveModel)
            .defaultSystem("你是机票分析师『票小蜜』")
            .build();

        return client.prompt(q)
            .toolNames("searchFlights", "compareFlights", "searchKnowledge")
            .advisors(advisor -> advisor.param(ChatMemory.CONVERSATION_ID, sessionId))
            .call()
            .content();
    }
}

这一步的关键变化只有两个:

  1. Controller 不再承担模型决策
  2. 治理逻辑下沉到 service,再接回第 7 章的 ChatClient 主链

这也是第 8 章最重要的架构演进价值。


六、验证一下:4 组必须跑通的测试

6.1 前置条件

先把验证边界说清楚:

  • 当前仓库可直接跑的是 flight-agent 模块
  • 当前真实端口是 8086
  • 当前真实入口是 /api/agent/chat
  • AI_DASHSCOPE_API_KEY 已配置在环境变量中
  • 如果要验证本地模型,需要先启动 Ollama
# 进入 flight-agent 模块后启动应用
mvn spring-boot:run
6.2 先回归第 7 章已经跑通的底盘

这一组不是多模型验证,而是确认第 7 章底盘还在。

# 1. Function Calling:查航班
curl "http://localhost:8086/api/agent/chat?q=明天北京到上海的航班&sessionId=test1"

# 2. Memory:基于上文继续追问
curl "http://localhost:8086/api/agent/chat?q=帮我对比前两个&sessionId=test1"

# 3. Agentic RAG:政策问答
curl "http://localhost:8086/api/agent/chat?q=经济舱能带多大行李&sessionId=test1"

如果这 3 类能力还没稳定,就不要急着往上接治理层。否则后面出了问题,你根本分不清是底盘问题还是治理层问题。

6.3 完成本章改造后,验证 3 类模型能否独立工作

这一组是演进后的目标验证:

# 主模型:默认请求
curl "http://localhost:8086/api/v6/agent/chat?q=北京到上海明天的机票&sessionId=s1"

# 备用模型:手动指定 DeepSeek
curl "http://localhost:8086/api/v6/agent/chat?q=帮我比较这几个航班的性价比&model=deepseek-chat&sessionId=s1"

# 本地模型:手动指定 Ollama
curl "http://localhost:8086/api/v6/agent/chat?q=查询我的订单&model=qwen2.5:7b&sessionId=s1"

这一步要确认的,不只是“接口有响应”,还包括:

  • 路由是否符合预期
  • Tool 是否还能正常调用
  • Memory 是否仍然保持上下文
6.4 验证自动降级,而不是只看 happy path

降级不验证,等于没做。

我更建议用最笨但最直接的方式验证:主动让主模型失败一次。

比如:

  • 临时把主模型 api-key 改错
  • 或把主模型 base-url 指向错误地址
  • 或在测试环境里手工断掉外部网络

然后再发请求:

curl "http://localhost:8086/api/v6/agent/chat?q=退改签政策&sessionId=s1"

至少要观察到两件事:

  1. 应用日志里出现主模型失败记录
  2. 请求没有直接报错,而是切到备用模型继续返回
6.5 回归验证:降级后能力有没有塌

这是很多文章会省略的一步,但上线时最容易出事。

降级后我会至少回归下面 3 个点:

  1. Tool 调用是否还能成功
  2. Memory 是否还保留上下文
  3. RAG 回答质量是否明显变差

如果备用模型或本地模型能力弱很多,就应该同步考虑:

  • 减少可用工具数量
  • 收缩 System Prompt
  • 视情况给用户一个“当前使用备用服务”的提示

七、FAQ 与踩坑记录

Q1:DeepSeek 的 Function Calling 返回格式和 DashScope 不一致,工具调用失败

症状:同一套工具定义,DashScope 正常调用,切换到 DeepSeek 后 Function Calling 解析异常,报 JSON 格式错误。

原因:虽然 DeepSeek 兼容 OpenAI 格式,但不同模型对 Function Calling 的细节支持仍然有差异,尤其是 required 字段和复杂嵌套对象。

建议

  1. 工具参数尽量使用基本类型(String / int / boolean
  2. 切换模型后,务必回归测试所有工具调用场景
  3. 兼容性差的模型,必要时在工具层做参数适配
Q2:Ollama 本地模型容易不调工具,或者选错工具

症状:使用 qwen2.5:7b 时,Agent 容易直接回答,不调用工具,或者选择了错误的工具。

原因:7B 级别模型在 Function Calling 能力上本来就弱,尤其当工具数量多于 3~5 个时,准确率会明显下降。

建议

  1. 本地模型场景下收缩工具数量
  2. 工具描述写得更短、更明确
  3. 本地验证时优先测流程,不要过度期待质量
  4. 真正上线时,把本地模型更多当兜底或调试工具,而不是主力生产模型
Q3:降级后服务没挂,但回答质量明显变差

症状:主模型降级到本地模型后,服务还活着,但对比分析和工具选择明显不如之前。

这通常不是 bug,而是多模型治理最现实的 trade-off。

建议

  1. 降级事件一定要写日志
  2. 有条件的话加告警
  3. 降级后按模型能力收缩功能边界
  4. 必要时对用户明确提示“当前正在使用备用服务”

八、架构演进——本章给系统新增了什么

第 7 章解决的是:票小蜜能不能作为一个完整 Agent 跑起来。 第 8 章解决的是:当它接近生产环境时,模型层还缺什么。

这一章新增的不是几个配置项,而是一层独立的多模型治理能力。它让系统第一次具备 4 个能力:

  1. 模型注册:把 DashScope、DeepSeek、Ollama 收敛到统一的 ChatModel
  2. 模型路由:根据任务复杂度、成本和数据边界选择模型
  3. 故障降级:主模型失败时,自动切到备用模型或本地模型
  4. 能力收缩意识:明确“降级保可用,不保质量无损”

下面这张图就是第 8 章的架构演进重点:

在这里插入图片描述

这章的核心不是重写第 7 章,而是在它前面补上一层治理层。 第 7 章回答“Agent 能不能工作”,第 8 章回答“Agent 上线以后能不能稳、能不能省、能不能守住边界”。


本章小结

这一章真正新增的,不是“又接了几个模型”,而是系统第一次有了模型治理层。

核心收获有 4 个:

  1. 统一抽象:不同接入方式最终都要收敛到 ChatModel
  2. 模型路由:根据任务复杂度、成本和边界做分流
  3. 故障降级:主模型失败后,用主备链和熔断机制保住可用性
  4. 工程边界:降级保证可用,不保证质量无损,必须配合日志、告警和能力收缩一起设计

如果把第 7 章看成“把票小蜜装成一辆能开的车”,那第 8 章做的,就是给这辆车补上一套模型调度系统:

  • 平时按规则分流
  • 出问题时自动切换
  • 极端情况下还能本地兜底
  • 同时明确知道什么时候该收缩功能边界

下一章预告

下一篇,我会继续往 Agent 架构更深的地方走:从“能调用工具的 Agent”,升级到“能做更强自主决策的 Agent”。


评论区聊聊

  1. 你的线上 Agent 现在是单模型硬扛,还是已经做了主备 / 降级设计?踩过什么坑?
  2. 如果一个请求既涉及敏感数据,又需要复杂推理,你在项目里会优先保合规还是保质量?为什么?
  3. 你遇到过“服务没挂,但降级后质量明显变差”的情况吗?当时日志、告警和用户提示是怎么做的?
  4. 如果让你给 Agent 补一层模型治理,你会先上静态路由,还是先上智能分类路由?为什么?

本文代码说明:当前仓库中可直接对照的底盘代码在 flight-agent/ 模块;第 8 章的重点是把多模型治理层接回这条主链,让系统从“单模型可运行”演进到“多模型可治理”。

如果这篇文章对你有帮助,欢迎点赞收藏。你在项目里做过多模型路由、模型降级或本地模型接入的话,也欢迎在评论区留下你的方案和踩坑记录。