ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring AI Alibaba多模型调度与故障转移实战指南

Spring AI Alibaba多模型调度与故障转移实战指南

做 Agent 工程的人,迟早会在某一天意识到:我们好像把全部身家都押在了同一个模型上。模型涨一次价,成本跟着涨;模型限流一小时,整个业务跟着慢一小时;模型半夜出故障,值班的人半夜被拉起来。我经历过一次类似的线上事故之后,把“单模型绑定”这条默认原则从架构里删掉了。今天这篇笔记,就围绕 Spring AI Alibaba 里的多模型调度与故障转移,聊清楚怎么把模型当成可编排的资源池,而不是一个不能断的依赖。适合正在做 Agent 应用、希望降低模型依赖风险、又想落地一套可运维方案的后端开发者,尤其是 Java 技术栈的架构师。

1. 为什么要把 Agent 从单一模型上解绑

1.1 一次线上故障:模型说挂就挂之后我的排查过程

先说一个我经历过的真实场景。某智能客服 Agent 上线初期,为了快速迭代,代码里直接写死了模型 A 的客户端,所有对话请求都打到这一个模型上。当时的想法很简单:模型 A 效果好、参数调得熟,先跑起来再说。结果某天晚上,模型 A 的 API 突然开始大面积超时,先是 P99 延迟涨到 10 秒以上,随后直接开始抛 5xx。客服系统的用户消息在一个小时内全部积压,机器人全部“装死”,人工客服电话被打爆。

我当时排查故障时翻代码,发现整个 Agent 的核心链路里到处都是这个模型客户端的直接调用,没有一层可以“换一个模型继续跑”的抽象。想切到备用模型,得改配置、发版本、等重新部署,线上已经炸了,整个过程显得非常被动。后来我在复盘文档里写了一句狠话:模型是外部依赖,不是业务核心。任何外部依赖在架构上都不应该成为单点。从那以后,我把多模型调度和故障转移作为 Agent 架构的一个独立模块来设计,而不是等出事了再补。

1.2 绑定单一模型的三重风险:可用性、成本、能力天花板

绑定单一模型,看起来是省事,实际上是把三类风险一起背在了身上。

第一是可用性风险。第三方模型 API 本质上是外部服务,不是我们自己能控制的东西。限流、网络抖动、模型服务升级、灰度策略变化,任何一个环节出问题,都会直接传导给业务。更麻烦的是,很多模型服务并没有严格的 SLA 承诺,连“降级到哪个版本”都不可控。Agent 一旦绑定,业务可用性就被一个不受自己控制的第三方卡住了。

第二是成本风险。模型的定价是动态的,配额规则也会调整。如果代码里只认一家模型,那么对方涨价或者调整配额,团队基本没有议价空间,只能被动接受。我见过一些项目,为了省成本想换更便宜的模型,结果发现代码里全是某个模型特有的提示词格式和参数,改起来比重新写一个模块还费劲。

第三是能力天花板。Agent 的任务往往不是单一类型:复杂推理、代码生成、长文档总结、工具调用、多轮对话,对模型能力的要求各不相同。一个模型不可能在每个维度都做到最好,强行让一个模型干所有事,效果和效率都会打折扣。比如轻量模型处理简单意图又快又便宜,但让它做复杂推理就容易胡说;强推理模型效果好,但成本和延迟都高,不该为每条简单消息都买单。

1.3 多模型调度的本质:把模型变成可编排的资源池

所以多模型调度的本质,不是“多配几个模型备用”,而是把模型从“业务引擎”重新定位成“可编排的资源节点”。这个思路和基础设施层的负载均衡很像:上游有一组服务节点,每个节点有自己的容量、成本、健康状态,请求过来之后由调度层决定交给谁。只是模型节点有一个普通服务节点没有的特点:能力维度差异很大,而且推理结果存在随机性,所以调度时除了看健康状态,还要看任务类型。

我在架构里把这块拆成三层:模型注册层负责登记“有哪些模型可用、各有什么能力”;路由决策层负责根据请求特征选择模型;故障转移层负责在链路执行过程中兜底。三个层次各管一摊,后面我会逐个展开。这里先说结论:多模型调度的核心价值,是把“某个模型挂了”从一个团队级灾难,降级成“路由表里少了一个节点”这种日常运维事件。

2. 整体架构:模型抽象、注册中心与路由层

2.1 Spring AI Alibaba 的模型抽象:业务代码只面向接口

Spring AI Alibaba 帮我们解决了一个非常基础但关键的问题:模型接入方式的统一。它提供了ChatModel、StreamChatModel、EmbeddingModel等接口,不同模型厂商的实现可以通过配置来切换。业务代码只依赖接口,不依赖具体厂商的 SDK,这是后面所有路由和故障转移能成立的前提。

我见过一些项目没用统一抽象,直接在 service 层 new 出某个厂商的 client,然后封装成自己的工具类。这种做法在只有一个模型时还能凑合,但一旦要多模型调度,就会写出一堆 if-else 来适配不同厂商的参数结构。正确的做法是尽早引入 Spring AI 的ChatModel抽象,让 service 层只面对ChatModel.call(Prompt prompt)这样的统一调用。Spring AI Alibaba 在标准化接口之外还做了 Alibaba 模型相关的适配扩展,同时也支持 OpenAI 兼容接口,方便接入多种模型服务。

注意一点:抽象归抽象,不要让具体厂商的脏细节漏到业务层。比如某个模型特有的temperature范围、max_tokens上限,应该封装在模型适配配置里,而不是散落在提示词构造逻辑中。

2.2 模型注册表:给每个模型打上成本、延迟和能力标签

有了统一接口,接下来就要回答一个问题:系统里到底有哪些模型可以用?每个模型是什么身份?我设计了一个ModelEndpoint结构,每个可用模型对应一个注册项,字段包括模型 id、所属 provider、能力标签、上下文窗口大小、成本单价、默认权重和手动下线标记。

public record ModelEndpoint( String id, String provider, List<String> capabilityTags, int contextWindow, double costPer1KTokens, double priorityWeight, boolean manuallyDisabled ) { }

能力标签我用固定的枚举风格,比如reasoning、chat、tool-calling、code、long-context。这样路由层做能力匹配时,就能直接判断某个模型是否具备处理当前任务的条件。上下文窗口大小在模型切换时很重要,后面第 4 节会专门讲;成本单价则用于成本感知路由,让调度器在“效果差不多”的候选模型里优先选择便宜的那个。

这个注册表可以是静态配置,也可以放在配置中心动态更新。我建议至少把“手动下线”做成动态配置,某个模型出现问题需要紧急摘除时,不需要重启应用,直接改配置中心就能生效。

2.3 路由决策器的职责边界:只选路,不负责重试

路由决策器是整个调度体系的大脑,但我给它定的职责边界非常窄:只负责从可用模型列表里选出一个或多个候选模型,不负责重试、不负责加超时、不负责记录失败次数。重试和熔断这些事,应该由调用链路上的故障转移包装层去处理。为什么这样拆?因为如果路由层和重试层耦合在一起,一个请求失败后,路由层再去选一次路、再发起一次调用,会导致超时预算被反复叠加,整个调用链路的耗时变得不可控。

路由器的输入是一份请求描述,输出是排序后的候选模型列表。请求描述里包含任务类型、估算的输入 token 数、本次请求的预算上限、必须满足的能力标签,以及从故障转移层传来的“当前不可用模型”名单。下面是我在项目里用的简化版本:

public record ModelRequest( String taskType, int approximateInputTokens, double maxCostPerRequest, List<String> requiredCapabilities ) {}

把路由做成纯函数还有一个好处:容易测试。给定同样的请求和模型注册表,应该得到同样的候选顺序,这样可以通过单元测试覆盖各种调度策略。

3. 调度策略:从轮询到成本感知

3.1 基础策略:轮询、加权轮询、优先级

最简单的调度策略是轮询,适合场景:多个模型能力接近,没什么成本差异,纯粹做负载分散。实现上用一个AtomicInteger计数取模就行。加权轮询则给每个模型配一个权重,比如模型 A 权重 3、模型 B 权重 1,那么大概每 4 个请求里有 3 个会落到模型 A 上。

public class WeightedRoundRobinPicker { private final AtomicInteger index = new AtomicInteger(0); private final List<ModelEndpoint> endpoints; public ModelEndpoint pick() { int i = index.getAndIncrement(); return endpoints.get(i % endpoints.size()); } }

加权轮询的权重要和模型容量、成本和健康度联动。我通常会把权重拆成基础权重和动态修正两部分:基础权重表示“我们主观上希望多使用谁”,动态修正来自实时监控数据,比如某个模型最近的失败率升高,动态修正就会压低它的权重,让流量自然切走。这个机制比较温和,适合不想频繁触发硬熔断的场景。

3.2 能力感知路由:按任务类型选模型

轮询解决的是“谁有空”,能力感知路由解决的是“谁干得了”。Agent 任务进入系统后,先做一个任务分类,再根据分类结果选择候选模型。比如复杂数学和逻辑推理任务,要求模型具备reasoning能力;需要调用外部工具并解析返回结果的任务,要求模型具备tool-calling能力;长文档总结,要求模型有long-context标签。

选择过程可以做成打分制。先过滤掉不满足能力标签的模型,再结合上下文窗口大小判断能不能放下输入,最后按基础权重排序。一个比较简洁的实现方式是规则匹配加简单打分:

public List<ModelEndpoint> routeByCapability(ModelRequest request) { return modelRegistry.getAll().stream() .filter(e -> e.capabilityTags().containsAll(request.requiredCapabilities())) .filter(e -> e.contextWindow() > request.approximateInputTokens()) .sorted(Comparator.comparingDouble(ModelEndpoint::priorityWeight).reversed()) .toList(); }

从工程经验上说,能力标签不要设计得太细,控制在 5 到 8 个以内就好。标签太细会导致模型注册表维护成本急剧上升,而且很多模型的能力边界本身是模糊的。粗粒度标签加上适当的灰度观察,比追求精确分类更实用。

3.3 成本与延迟感知:结合实时监控动态调度

当系统里的模型数量变多,就会发现一个规律:能力差异没有想象中那么大,成本和延迟差异倒是很真实。所以我把实时成本与延迟数据引入调度评分,形成“效果优先,预算兜底”的调度模式。

具体做法是为每个模型维护一个滑动窗口统计,记录最近一段时间内的平均延迟和每千 token 成本。路由打分时,在基础权重上减去成本惩罚项和延迟惩罚项,得到最终得分。

double score = endpoint.priorityWeight() - costFactor * endpoint.costPer1KTokens() - latencyFactor * monitor.p95Latency(endpoint.id());

这两个系数的调整需要线上数据支撑。我的经验是先定成本系数,再调延迟系数,因为成本是确定性的,延迟会波动。而且调度策略不能太激进,否则可能出现流量在几个模型之间频繁切换,既影响体验,又让成本统计失去稳定性。宁可让模型 A 稍微贵一点,只要它稳定,就维持一个较高的基础权重;动态修正只用于应对明显异常,而不是追求理论上的最优。

3.4 多租户与隔离路由:别让一个业务线打爆整个模型池

如果你在做一个平台型系统,模型池往往是多个业务线共享的。这时候一定要做租户级别的路由隔离。我的做法是给每个租户配置独立的模型池策略:VIP 租户可以路由到强模型和专用容量,普通租户走共享池,某些成本敏感的流量只能路由到轻量模型。故障转移也按租户边界来做,A 租户的模型配额被打满,不应该拖累 B 租户的流量。

租户隔离的本质是让“资源配额”和“路由策略”形成两个正交的维度。先按租户规则过滤掉不可用的模型池,再走统一的能力、成本和延迟评分。这样即使未来新增业务线,也只是新增一份策略配置,不需要改动路由核心代码。

4. 故障转移与容错体系

4.1 故障检测:超时、异常码与健康检查分层

故障转移的前提是要能快速发现故障。路径上的第一道检测是调用异常分类。我把模型调用异常分成两类:可重试异常和不可重试异常。可重试异常包括超时、5xx、限流 429、瞬时网络错误;不可重试异常包括参数错误、认证失败、请求内容被模型安全策略拦截。可重试异常发生时,故障转移层才有资格去尝试下一个模型;不可重试异常直接抛出,不要浪费候选次数。

第二道检测是主动健康检查。对于流式请求,因为一旦开始输出就很难安全切换,我建议在发起流式调用前先对目标模型做一次轻量探测,比如调用一个 token 数极少的通用请求,探测通过后再真正发起业务请求。这多花了一次小请求的成本,但能显著降低“流开到一半断了”的概率。

4.2 自动切换、降级链与熔断的代码落地

故障转移层的核心是一个包装类,它持有路由器选出来的候选模型列表,逐个尝试调用。下面是我在项目中使用的核心逻辑简化版:

public class FailoverChatModel implements ChatModel { private final ModelRegistry registry; private final ModelRouter router; private final FailureTracker failureTracker; @Override public ChatResponse call(Prompt prompt) { ModelRequest request = buildRequest(prompt); List<String> candidates = router.route(request); for (String modelId : candidates) { if (failureTracker.isOpen(modelId)) { continue; } try { ChatModel model = registry.get(modelId); ChatResponse response = model.call(prompt); failureTracker.recordSuccess(modelId); return response; } catch (Exception e) { if (!failureTracker.isRetryable(e)) { throw e; } failureTracker.recordFailure(modelId); // 记录告警指标,准备切换下一个候选 } } throw new NoAvailableModelException("all candidate models failed"); } }

这里有一个容易被忽略的细节:同一个请求切换到下一个模型时,最好提示词也做一次重新构造。因为不同模型对系统提示词格式的敏感度不一样,一个模型训练出来的指令风格在另一个模型上可能效果很差。我在故障转移层留了一个PromptTransformer扩展点,切换模型后可以对系统提示词做微调。这个扩展点不常触发,但关键时刻能救命。

熔断逻辑我用的是最简单的 status 模式:连续失败次数超过阈值,就把该模型置为 open 状态,短时间内不再路由给它;同时启动一个定时任务,每隔一段时间把它置为 half-open,放少量探活流量验证是否恢复。这个策略足够直观,又不会因为模型服务短暂抖动导致全线不可用。

4.3 上下文窗口适配:模型切换后,消息历史怎么处理

多模型切换最隐蔽的坑是上下文窗口适配。同一个对话在模型 A 上运行得好好的,切到上下文更小的模型 B 时,很可能直接报“超过最大输入长度”。前端用户感知到的就是对话突然丢失记忆,体验非常差。

我的处理方案是把“消息历史管理”和“模型调用”彻底分离。每次调用前,根据候选模型的上下文窗口大小,动态组装要发送的消息列表。如果候选模型窗口足够大,就发送完整历史;如果窗口不够,就先对早期消息做摘要压缩,用一段总结文本替换旧的原始消息。这里有个 token 估算问题,不能靠模型返回的 token 数做精确控制,需要有一个本地快速估算函数,我常用字符数除以 1.6 来估算中文场景的 token 数,误差在可接受范围内。

public static int estimateTokens(String text) { return (int) Math.ceil(text.length() / 1.6); }

注意,这个估算函数只用于路由前的快速决策,不能用来精确计算计费。真正计费还是要以模型服务的返回为准。

4.4 工具调用结果的一致性:切换模型不能丢工具执行上下文

Agent 场景里还有一个特殊问题:多轮工具调用。模型 A 返回了一个tool_calls,Agent 去执行工具,然后把工具执行结果追加到消息历史里,继续让模型生成最终回答。如果此时模型 B 接管下一轮生成,工具调用历史和执行结果必须完整地传递过去。否则模型 B 会一头雾水,不知道前面发生了什么。

我的建议是把工具调用历史也纳入消息历史的“序列化快照”机制。每次 Agent 循环迭代时,把当前完整的消息列表、工具执行结果、输出格式要求做成一个不可变快照,路由和故障转移都基于这个快照工作。这样即使中间切了模型,上下文仍然是连续可解释的。这部分逻辑不要放在模型调用包装层,而应该放在 Agent 执行器层统一管理。

5. 实战落地:Spring AI Alibaba 多模型配置与路由代码

5.1 依赖与配置:注册多个模型端点

先看依赖。如果你用 Maven,核心依赖是spring-ai-alibaba-starter,再加上你需要的模型实现模块。版本号不要手写死,统一用 BOM 管理。

<dependency> <groupId>com.alibaba.cloud.ai</groupId> <artifactId>spring-ai-alibaba-starter</artifactId> <version>${spring-ai-alibaba.version}</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency>

配置层面,我强烈建议把模型定义拆成“逻辑名”和“实际端点”两层。业务代码里只出现逻辑名,比如fast、strong、cheap,实际模型 ID 放配置中心,这样切换模型时不需要改业务代码。

spring: ai: openai: base-url: ${MODEL_A_BASE_URL} api-key: ${MODEL_A_API_KEY} chat: options: model: ${MODEL_A_MODEL_ID}

Spring AI Alibaba 也支持通过配置同时声明多个ChatModelBean。多个 Bean 并存时,用@Qualifier区分注入。不过我更推荐的做法是:不直接注入具体模型 Bean,而是注入一个ModelRegistry,它根据配置动态构建“模型 id 到 ChatModel”的映射。

5.2 路由核心代码实现:策略可选、结果可测

路由层不要直接在 Controller 或者 Agent 执行器里写逻辑,单独抽一个服务。我通常把路由策略做成一个可插拔的接口,默认实现里按“能力过滤 -> 上下文窗口过滤 -> 权重排序 -> 成本延迟修正”的顺序处理。

@Service public class DefaultModelRouter implements ModelRouter { private final ModelRegistry registry; private final MetricsService metrics; @Override public List<String> route(ModelRequest request) { return registry.getAll().stream() .filter(e -> !e.manuallyDisabled()) .filter(e -> e.capabilityTags().containsAll(request.requiredCapabilities())) .filter(e -> e.contextWindow() > request.approximateInputTokens()) .sorted(Comparator .comparingDouble((ModelEndpoint e) -> routeScore(e, request)) .reversed()) .map(ModelEndpoint::id) .toList(); } private double routeScore(ModelEndpoint e, ModelRequest request) { return e.priorityWeight() - 0.0001 * e.costPer1KTokens() - 0.001 * metrics.p95LatencyMs(e.id()); } }

这里的系数我建议做成配置项,而不是硬编码。每个业务的成本敏感度和延迟敏感度不一样,平台型系统和内部工具系统差异很大。上线前先用日志观察一段时间的路由分布,再根据实际情况调整系数。

5.3 故障转移处理器:把异常变成候选列表的推进

故障转移处理器的实现,核心就是把“异常”转化为“换下一个候选”。除了第 4 节展示的基础版本,还可以加入一个开关:候选列表用完后,如果还存在未尝试过的模型,是否忽略能力标签做兜底调用。这个开关平时关闭,但关键时刻能救命。比如主模型和同能力模型全部不可用时,允许用轻量模型先保证服务不中断,哪怕回答质量下降。

流式调用的故障转移要复杂得多。我的建议是流式场景不追求“无缝切换”,因为已经吐出去的部分内容无法撤回。比较务实的做法是:流式请求开始前做一次模型健康探测,失败就不发起;流式过程中如果出现错误,直接终止当前流,并在流的末尾追加一段提示信息,告诉用户“当前服务正在降级,请重新尝试”。这比在中间切模型要简单可靠。

5.4 与 Agent 执行器集成:把路由包装在主流程里

最后把这一切接入 Agent 主流程。我的做法是让 Agent 执行器只依赖一个ChatClient,但这个ChatClient内部已经包装了路由和故障转移。业务代码里看不到任何模型的影子,看到的只是“我用了一个会自己选路、自己切换的 ChatClient”。

在集成 Spring AI Alibaba 的工具调用机制时,要注意循环的退出条件。Agent 的典型循环是“调用模型 -> 模型返回 tool_calls -> 执行工具 -> 把结果追加到消息 -> 继续调用模型”。在这个循环里,我每次都重新走路由,让新的一轮可以由不同模型接管。这要求消息历史的传递不能只传最新一轮,必须传完整上下文快照。

6. 常见问题排查与避坑记录

6.1 问题速查表

问题现象排查思路解决方案
切换模型后回答风格突变提示词格式未适配目标模型使用 PromptTransformer 做切换微调
故障转移反复触发,延迟飙升路由器候选列表排序不合理检查权重与成本系数,缩短候选链
模型 A 恢复后流量没有回切熔断器状态机回置太慢调整 half-open 探活频率
上下文超限错误消息历史未按目标窗口裁剪引入 token 估算和摘要压缩
工具调用结果丢失切换模型时未携带完整消息快照在 Agent 执行器层做上下文快照
成本和预算失控调度策略偏向强模型开启成本感知评分,设置预算上限

6.2 故障转移导致上下文错乱

我最开始实现故障转移时,只处理了响应切换,没有处理上下文适配,结果线上出现了一些很诡异的现象:同一个用户连续问几个问题,前一个话题刚聊完,接管的模型突然不认识前面的内容了。排查下来发现是消息历史里包含了很多已经被截断的旧消息,新模型上下文窗口不够,取到的是残缺的历史。

解决方案就是前面提到的“消息历史快照 + 摘要压缩”。具体来说,我在进入 Agent 循环之前,先把完整上下文做成一个可序列化对象,每次调用模型之前,根据当前候选模型的窗口重新生成要发给模型的消息列表。早期消息如果超长,先用一个独立的小模型生成摘要,用摘要替换原文。这个方案跑了一段时间后,切换模型的上下文连续性问题基本消失了。

6.3 路由器和模型提供方的超时重试叠加

另一个常见坑是超时配置叠加。我在路由层设置了 10 秒超时,模型 SDK 内部也默认重试,两边叠在一起,一个请求可能等 30 秒才失败。排查这类问题要看整条调用链路的所有超时和重试配置,建议路由器只负责选路和故障转移,把超时和重试交给最底层的一次调用完成。

我在实践中把超时配置分成两层:最底层的模型调用超时设为 5 秒,不做重试;故障转移层的整体超时预算设为 15 秒,最多尝试 3 个模型。这样任何一个请求最坏情况也就是 15 秒结束,不会无限拖延。

6.4 成本控制与预算告警

成本感知路由如果只做评分不控制预算,很容易失控。我给每个请求加上maxCostPerRequest限制,路由打分时会把“超出预算”的模型直接过滤掉。同时给每个租户和整个模型池设置每日预算阈值,阈值的 70%、90%、100% 分别触发不同的告警动作:70% 发提醒,90% 自动把强模型的权重压低,100% 直接熔断高成本模型,只保留轻量模型兜底。

6.5 模型切换后的响应格式漂移

不同模型对 JSON 输出格式的遵守程度不一样。有些模型在指定response_format后仍然会偶尔输出解释性的文字,导致下游解析失败。我给故障转移后的响应增加了一层格式校验,如果新模型的输出不符合预期结构,就进行一次消息级重试,在提示词里追加一句“注意:只能输出 JSON,不要解释”。这个方法对多数模型都有效果,但如果连续两次仍失败,说明该模型不适合当前任务,应该继续切换。

6.6 测试与灰度策略

多模型调度最怕的是上线后才发现问题。我的做法是把路由器做成可以指定固定模型 id 的调试模式,测试环境和灰度环境都强制路由到指定模型,先把单模型链路验证完,再放开动态路由。灰度期间观察两个指标:候选模型转移次数和故障转移触发率。这两个指标异常升高,大概率是评分系数或健康检查配置出了问题。先把路由策略调稳,再考虑优化成本和延迟,顺序不能反。

结尾:一点个人实践体会

最后分享一个我自己踩过几次坑之后总结的小建议:故障转移不是越聪明越好,而是越可预期越好。复杂的状态机虽然看起来很高级,但在生产环境里维护成本很高。我用的是简单的失败计数熔断加候选列表推进,配合手动下线开关,已经能满足大多数场景。多模型调度是一套运维体系,不是一个花哨的算法,稳定性和可观测性远比理论最优重要。如果你也在做 Agent 架构,不妨先把“绑定单一模型”这条默认假设删掉,哪怕只是增加一个可切换的模型端点,也会在未来的某个深夜帮你保住一次线上稳定。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进