ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

多模型调度与故障转移:Spring AI Alibaba下的AI Agent高可用方案

多模型调度与故障转移:Spring AI Alibaba下的AI Agent高可用方案

这两年做 Java 侧的 AI Agent 工程,我见过太多团队把整个系统的智能能力押在一个模型上。上线时大家都很兴奋,觉得大模型加持业务所向披靡,但真正跑起来才发现:某个厂商的接口一到高峰期就抖动,所有请求全部超时堆积;某个模型悄无声息调了定价,月初成本账单直接翻倍;某个只适合做分类的小任务,却被丢到最强最贵的模型上跑了一个月。Agent 不是页面上的一个按钮,它是服务链路里的实时决策节点,把 Agent 绑死在一个模型上,等于给所有业务埋了一颗共享的单点故障地雷。

这套"Spring AI Alibaba 多模型调度与故障转移"方案,解决的问题就一个:让 Agent 不再是某个模型的附属品,而是能按任务特性、成本预算、可用状态动态选择模型的自适应系统。文章会从风险拆解、多模型接入、路由策略、熔断降级、可观测性到问题排查,完整走一遍我在多个项目里验证过的架构形态。适合正在用 Spring AI 做 Agent 应用、想把模型从硬编码里解放出来的 Java 开发者,也适合架构师拿去评审方案。

1. 单模型依赖到底有多危险

1.1 三个致命风险:可用性、成本、能力天花板

先说可用性。模型提供方的服务不会永远稳定,不管官网承诺几个 9,实际运行中限流、超时、过载保护、版本升级导致的服务漂移都是常态。你的 Agent 拓扑里所有请求都要穿透这个单一节点,这个节点抖动,整体链路立刻感知。我做过的某个运营分析 Agent,某天上午模型侧开始限流,响应时间从 2 秒涨到 30 秒,页面没有报错,但业务方已经开始投诉。那天没有任何代码变更,纯外部依赖抖动,这恰恰是最难防御的事故类型。

再说成本。不同模型的价格差距不是一倍两倍,而是数量级差异。如果你所有请求都跑在最贵的模型上,那些只需要做关键词抽取、文本分类、简单翻译的请求就全在烧钱。我拆过一个项目的账单,光是按任务粒度做模型分流,月度推理成本直接降了 30% 以上。这个优化不需要换掉任何一位业务同事的使用方式,纯粹是调度层的功劳。

最后是能力天花板。一个模型再强,也不可能在所有任务上都最优。同一个 Agent 内部往往有多个子任务:意图识别、工具调用、代码生成、文本润色、结构化输出,它们对模型的推理深度、上下文长度、指令跟随能力要求完全不同。拿旗舰模型做所有事,典型的杀鸡用牛刀,而且效果不一定比专用小模型好。调度层的存在,就是让每个子任务找到它的最优解。

1.2 多模型调度带来的本质变化

把 Agent 从单模型绑定改造成多模型路由,核心是在 Agent 和模型之间加一层调度逻辑。这层逻辑带来的变化不是"多个模型轮着用",而是三个本质能力的提升:

故障转移。主模型不可用时,自动切换备用模型,业务无感。这是最刚性的需求,因为模型故障是概率事件,只是早晚问题。

成本优化。按任务难度、输入输出规模、客户等级动态选择模型,把预算花在真正需要的地方。成本优化不是让系统变便宜,而是让每一分钱都产生对应价值。

能力组合。不同模型各管一段,比如大模型做规划,小模型做抽取,混合编排后整体效果反而优于单模型。模型的"组合拳"效果,只有脱离单一绑定才能打出来。

这三个能力听起来不复杂,但落地的细节量非常大:多个模型怎么配置、路由规则怎么定义、什么时候重试、什么时候熔断、熔断之后用谁兜底、切换模型后上下文是否要重新组织。这些坑我基本都踩过,下面逐个拆开讲。

2. Spring AI 多模型接入的正确姿势

2.1 统一抽象层是解绑的地基

Spring AI 的核心价值在于统一抽象。所有模型提供方,不管是阿里系的通义系列、OpenAI 兼容接口,还是其他国产模型,在 Spring AI 里都收敛为 ChatModel、ChatClient 这几个核心接口。上层业务完全不感知底层厂商是谁,你只需要面向接口编程。

这带来的第一个好处是代码可迁移。业务代码里没有厂商 SDK 的痕迹,后面要换模型、加模型,业务层基本不用动。第二个好处是工程化能力复用。流式响应、函数调用、结构化输出这些能力,不同模型在 Spring AI 的封装下使用方式趋同,团队的学习成本和维护成本都能降下来。

Spring AI Alibaba 项目对通义系列模型做了默认的自动装配支持,引入依赖、配置 api-key 和模型名,就能直接使用 ChatClient。但注意,自动装配只是给了你一条默认路径,它不妨碍你在同一个应用里手动注册多个厂商的客户端。真正做多模型方案时,我建议把自动装配当作"默认链路"保留,另外手动注册完整的模型路由链路,两条路互不干扰。

2.2 多模型配置管理与 Bean 注册

先说配置。多模型场景下,配置管理是第一道关卡。不同厂商的 key、endpoint、超时时间不能写死在代码里,应该全部外用化。我一般用一个独立前缀隔离多模型配置:

llm: providers: qwen-main: model: qwen-max api-key: ${QWEN_API_KEY} base-url: ${QWEN_BASE_URL} timeout: 60s deepseek-backup: model: deepseek-chat api-key: ${DEEPSEEK_API_KEY} base-url: ${DEEPSEEK_BASE_URL} timeout: 60s qwen-cheap: model: qwen-turbo api-key: ${QWEN_API_KEY} base-url: ${QWEN_BASE_URL} timeout: 30s

观察这个配置你会发现我把兜底模型也作为一个 provider 单独管理了。这个习惯是我吃过亏后养成的:兜底模型不一定和主模型同厂商,而且它承载的是极端场景,超时设置可以更激进。把兜底模型独立管理,后续替换、调整参数都不用碰主链路。

Bean 注册这块是新手最容易踩坑的地方。直接在配置里配了多个 api-key 后,如果自动装配把多个 ChatModel 都建出来,注入的时候 Spring 会报 ambiguous 错误,因为它不知道你要注入哪一个。解决办法是手动声明带限定符的 Bean:

@Configuration public class LlmProviderConfig { @Bean @Qualifier("qwenMainClient") public ChatClient qwenMainClient(ChatModel qwenMainModel) { return ChatClient.builder(qwenMainModel).build(); } @Bean @Qualifier("deepseekBackupClient") public ChatClient deepseekBackupClient(ChatModel deepseekBackupModel) { return ChatClient.builder(deepseekBackupModel).build(); } }

注意上面的写法,每个客户端基于各自独立的 ChatModel 构建,而不是共用一个 ChatClient.Builder。我踩过的坑是:两个 provider 的客户端用同一个 Builder 构建出来,结果底层走的 HttpClient 配置完全一致。某个 provider 要调整连接池参数,另一个也被带着改。改成各自基于自己的 ChatModel 构建后,配置才彻底隔离。这个细节在故障转移时特别关键,如果两个模型共享底层连接池,一个模型把连接池打满,另一个健康的模型也会被拖死。

2.3 路由层选型:别一上来就造规则引擎

模型客户端准备好之后,第二步是设计路由层。这是架构决策,我见过三种常见做法:

硬编码 switch。在调用处手动判断走哪个模型。路由规则散落在业务代码里,一旦要调权重或新增模型,就得改一堆 if 分支。不推荐,哪怕只接两个模型也不推荐,因为这种代码没法测试。

策略模式封装。把路由策略抽象成接口,不同策略独立实现,比如优先级路由、权重路由、降级路由,调用方传入策略类型。优点是逻辑清晰,缺点是策略的调整需要改代码发版。

配置化路由规则。把规则外置到配置中心或数据库,路由层根据规则动态决策。最灵活,但需要额外的规则管理能力。

我的个人建议是:第一阶段先做策略模式,路由规则虽然写在配置里但由策略引擎解析;等确实需要频繁调整规则时再上配置中心。别一上来就设计通用规则引擎,AI 工程里最常见的隐性工程债就是过度设计。需求的确定性还不够时,先用确定性代码解决,比抽象一套华丽但不成熟的引擎靠谱得多。

3. 路由调度策略实战:让请求自动走到该去的模型

3.1 优先级路由与故障转移的核心实现

优先级路由是所有策略里最基础也最重要的一个。语义很简单:给请求定义一个模型候选链,比如 [qwen-main, deepseek-backup, qwen-cheap],第一个可用就用第一个,不可用就往后走。

这里的难点不在"顺序",在于"可用"怎么定义。一个模型当前可用不可用,不是 HTTP 请求不报错就算,而是要路由层做专门判定。我实现的判定分三层:

静态检查:模型是否在禁用名单里。比如某个模型因成本问题被临时摘除,或者正在灰度验证阶段只对特定流量开放。

动态熔断状态:模型是否处在熔断器 open 状态。熔断器的状态机在路由层维护,open 状态下直接跳过,不再发起请求。

请求级失败探测:本轮请求是否已发生连接超时、5xx 错误或限流。这个状态只在单次请求内有效,请求结束即清除。

核心代码大致是这个形态:

@Component public class PriorityModelGateway { private final Map<String, ChatClient> clients; private final Map<String, CircuitBreaker> breakers; public ChatResponse call(List<String> candidates, String prompt) { for (String candidate : candidates) { CircuitBreaker breaker = breakers.get(candidate); if (breaker != null && !breaker.tryAcquirePermission()) { log.warn("模型 {} 熔断开放,跳过", candidate); continue; } try { ChatClient client = clients.get(candidate); ChatResponse resp = breaker == null ? client.call(prompt) : breaker.executeCallable(() -> client.call(prompt)); recordSuccess(candidate); return resp; } catch (Exception e) { recordFailure(candidate, e); } } throw new NoAvailableModelException("候选模型全部不可用"); } }

这段代码的关键点有两个。第一,breakers 的 key 必须和 candidates 完全对应,否则路由会跳过可用模型。第二,不要先手动 tryAcquirePermission 再用 executeCallable,否则一次失败会被计数两次,熔断器提前打开,健康模型被误伤。我第一版实现就踩了这个重复计数的坑,线上熔断频繁误触发,排了半天才找到原因。

3.2 加权轮询:平衡成本与性能

优先级路由解决"谁先上",加权轮询解决"流量怎么分"。

场景举例:你评估过 qwen-plus 和 deepseek-chat 在意图识别任务上效果接近,但成本差 40%。全走贵的,每月多花一大笔;全走便宜的,某些边界输入效果略差。折中方案是按权重分流,比如 7:3。

我用的是随机加权而不是严格轮询。原因是严格轮询在多实例部署时,每个实例各自轮询,全局分布其实不均匀;而基于权重的随机选择,在流量足够大时自然趋近目标比例,而且不需要分布式协调。

public class WeightedRouter { private final List<WeightedTarget> targets; public String route() { double totalWeight = targets.stream() .mapToDouble(WeightedTarget::weight) .sum(); double r = ThreadLocalRandom.current().nextDouble() * totalWeight; for (WeightedTarget target : targets) { r -= target.weight(); if (r <= 0) return target.name(); } return targets.get(targets.size() - 1).name(); } }

权重不建议配成整数百分比,直接用相对权重就行,调整时改一个值就够了。你还可以给某个模型配一个超大权重作为"默认主力",旁边放几个低权重做灰度流量,很自然。带了权重的路由一定配一个监控面板,实时看真实流量分布和预期分布有没有偏差。偏差大的时候大概率是某个模型在罢工被跳过,这本身就是故障信号,比告警来得还早。

3.3 动态路由:按任务类型和上下文决策

如果只按优先级和权重分流,还谈不上"调度"。真正的调度是用任务的属性决定模型。

我常用做法是给每个请求打标,路由层根据标签决策。标签来源可以很丰富:用户显式指定(高级用户要求最强模型)、上游系统传入(客服总结、报表生成、数据分析各有模型偏好)、网关自动识别(根据 prompt 复杂度和上下文长度估算来决策)。

这里分享一个实测比较有效的规则组合:

  1. 上下文长度超过阈值,优先走上下文窗口更大的模型。
  2. 纯抽取或分类任务,走小模型,完全不碰大模型。
  3. 涉及工具调用或多轮规划,走推理能力最强的模型。
  4. 内部测试账号强制走灰度模型。
  5. 线上真实用户按权重分配。

这些规则在代码层面就是一组 Predicate:

public String decideRoute(AgentRequest request) { if (request.isInternalTest()) { return "gray-model"; } if (request.estimatedTokens() > TRIGGER_LARGE_CONTEXT) { return "large-context-model"; } if (TaskType.CLASSIFY == request.taskType() || TaskType.EXTRACT == request.taskType()) { return "cheap-model"; } return "default-model"; }

规则最好配置化,不要写死在代码里。早期我把规则写在 Java 代码里,每次调整都要走发布流程,一个规则调整等一整个迭代周期。后来我把规则改成 JSON 配置,路由引擎加载后按顺序评估命中第一条就返回。规则变更只需要改配置并热加载,效率高了不止一个数量级。当然,配置化之后要配套做规则的版本管理和单元测试,不然改配置也是一样会出错。

4. 故障转移与熔断降级的落地实现

4.1 超时与重试的正确姿势

故障转移的前提是能及时判定"这个模型不行了"。判定的第一步是超时。

我在多模型场景会设置两层超时。连接超时通常 2 到 5 秒,负责探测网络链路是不是通的;读取超时是模型推理的整体等待时间,要根据模型档位区分,旗舰模型思考链路长,60 秒很常见,轻量模型 30 秒以内。如果两层超时都设成一样,出问题时你没法判断到底是连不上还是推理慢。

超时之外必须配合重试,但重试有两个原则要守住。

原则一:不是所有错误都该重试。HTTP 5xx、网络超时、连接重置可以重试;鉴权失败、参数错误这类 4xx 不要重试,重试只是在浪费资源。429 限流可以重试,但必须带退避,否则会加重上游限流。

原则二:重试必须有退避策略。我一般用指数退避加抖动。基础延时 200ms,乘以 2 的失败次数次方,再加上随机抖动,避免多个请求在重试时排成一条整齐的平行线。

Spring Retry 的写法:

@Retryable( retryFor = {RemoteAccessException.class, SocketTimeoutException.class}, noRetryFor = {IllegalArgumentException.class}, maxAttempts = 3, backoff = @Backoff(delay = 200, maxDelay = 2000, multiplier = 2) ) public ChatResponse callWithRetry(String model, String prompt) { return clients.get(model).call(prompt); }

这里必须强调一个我踩过的大坑:多轮对话的重试不能只重发最后一个 prompt。如果第一轮第二轮已经产生过上下文,重试时必须把完整消息历史一次性重新发给模型,否则模型接到的上下文是断裂的,回答质量会明显劣化。很多模型接口不维护服务端会话状态,上下文必须由调用方自己携带。重试场景里,很多人只把最后一个 user 消息放进 payload,结果输出经常文不对题。这个坑排查起来非常隐蔽,日志里看起来调用是成功的,但业务侧反馈答非所问。

4.2 熔断器接入:别让故障流量持续打下去

超时和重试是单请求维度的保护,熔断是系统维度的保护。重试解决的是"这个请求失败了怎么再来一次",熔断解决的是"这个模型已经不行了,别再往它身上打流量"。

我用的是 Resilience4j,比起 Hystrix 更轻,和 Spring 生态整合更顺滑。核心配置如下:

CircuitBreakerConfig config = CircuitBreakerConfig.custom() .failureRateThreshold(50) .waitDurationInOpenState(Duration.ofSeconds(30)) .slidingWindowType(COUNT_BASED) .slidingWindowSize(20) .minimumNumberOfCalls(5) .build(); CircuitBreakerRegistry registry = CircuitBreakerRegistry.of(config);

配置含义:最近 20 次调用中失败率超过 50%,熔断器打开,后续请求直接失败快速返回,不再实际调用模型;30 秒后进入半开状态,放少量请求探测,如果探测成功则关闭熔断器,否则再次打开。

两个参数最值得花时间调:

slidingWindowSize。窗口太小时流量稍微波动就会误触熔断,太大会让故障持续很久才被感知。我一般先定 20 到 50,再根据实际 QPS 调整。注意,窗口大小的本质是"要在多少个样本内做判断",高 QPS 下样本积累很快,低 QPS 下样本积累很慢,需要配套调 minimumNumberOfCalls,避免样本不足时就开始计算。

waitDurationInOpenState。open 状态不能太长,否则模型恢复后流量也不能回流;也不能太短,否则模型还没恢复好就开始反复探测震荡。急性故障场景我一般设 30 秒到 1 分钟。这个参数没有标准答案,要结合模型侧的平均恢复时间来定。

多模型做故障转移时,每个模型单独维护一个熔断器实例,不要共用一个。否则一个模型故障,其他全部连带熔断,故障转移机制就形同虚设了。这个错误我犯过,用一个 Map 按模型名维护熔断器实例就能解决。

4.3 降级兜底:模型全挂时系统还要有最后一口气

当所有候选模型都不可用,你的系统怎么办?这是很多人忽略但必须提前设计的场景。

我落地过三级降级策略,扶级如下:

第一级,降级到更轻量的模型。主链路用 qwen-max,全挂时降到 qwen-turbo,再不行降到本地规则模板。这个思路的本质是"用能力换可用性"——回答质量差一点,但服务不中断。

第二级,降级到语义缓存。把相似请求的答案缓存起来,故障期间命中缓存直接返回。对固定格式的报表类、FAQ 类请求效果非常好。缓存命中的响应可以在返回头上打标记,方便追溯。

第三级,降级到固定话术。给用户明确的提示而不是让它看到 500 或空白页。比如"当前 AI 服务繁忙,请稍后再试",同时把失败信息记录到异常台账,服务恢复后异步补偿。

代码表现就是一个 fallback 金字塔:

try { return gateway.call(candidates, prompt); } catch (NoAvailableModelException e) { Optional<CachedAnswer> cached = semanticCache.get(prompt); if (cached.isPresent()) { return ChatResponse.of(cached.get().content(), META_CACHE_HIT); } return ChatResponse.of(DEFAULT_FALLBACK_TEXT, META_FALLBACK); }

降级逻辑要封装在 gateway 层,不要散落在业务代码里。否则每个业务方向都写一遍降级判断,等真的故障时,拉出一堆人来改各自业务代码,那场面想想就头大。gateway 层统一处理,业务方只需要关心自己的业务逻辑,故障时无感切换是架构层的事。

5. 可观测性:多模型链路必须看得见

5.1 日志与链路追踪

多模型场景和单模型最大的区别是:同一个业务请求,前后可能走了两个不同模型。不追踪链路,出了问题你根本不知道用户那一次对话到底用的谁。

我的做法是在网关层统一生成一个全程唯一的 routeId,塞进日志上下文的 MDC,然后每次模型调用都记录:模型名、prompt 长度、上下文 token 数、响应耗时、返回码、是否走了 fallback。

[routeId=38f2a1] 尝试模型=qwen-main 结果=超时 [routeId=38f2a1] 熔断状态=qwen-main=OPEN 跳过 [routeId=38f2a1] 尝试模型=deepseek-backup 结果=成功 耗时=1800ms tokens=3200

这段日志看起来简单,排查线上问题时价值非常大。模型有没有切换、为什么切换、切换后性能如何,一目了然。而且这些日志天然形成了故障转移的事故报告素材,运营和架构团队都能看懂。

特别提醒一点,MDC 的清理一定要在 finally 里做,或者用过滤器统一清理,否则线程池复用场景下,下一个任务会读到上一个任务的 routeId,日志全乱掉。这个问题我在生产环境遇到过,定位问题的时候发现日志上下文互相污染,排查难度直接翻倍。

5.2 核心指标采集与面板设计

日志之外,指标要能持续可视化。我用 Micrometer 采集五类指标,可以直接抄作业:

调用量。按 route 标签区分每个模型的请求数,用于观察流量分布是否符合预期。

成功率。每个模型成功失败比例,错误类型做细分(超时、5xx、429、参数错误)。

耗时分布。记录 P50、P95、P99,用来评估模型性能波动。模型侧的性能波动往往是业务侧感知不到的,但 P95 会诚实记录。

Token 消耗。每个请求的输入输出 token,用于成本核算。这里要强调,token 计量一定要用模型返回的实际 usage 字段,不要用估算值。有些模型还区分缓存命中 token,成本核算要分开,否则月底对账对不上。

熔断状态。每个模型熔断器的当前状态(CLOSED / OPEN / HALF_OPEN),这个指标能直接展示系统的健康水位。

面板上我会加一个"模型切换事件"统计。正常情况下切换事件是低频的,如果某天切换次数突然暴涨,不用等告警也能猜到某个模型在出问题。该指标比成功率更敏感,因为成功率降低的时候,切换可能已经发生了一轮了。

6. 常见问题与排查技巧实录

6.1 高发问题速查表

把这段时间做多模型调度遇到的高发问题整理成表,方便直接对照。

现象可能原因排查方向
注入时 Spring 报 ambiguous 错误多个 ChatModel Bean 没有限定符每个 Bean 加 @Qualifier,注入点同步指定
模型 A 出故障,其他模型也被拖慢多个模型共用 HttpClient 连接池每个模型独立构建 RestClient/WebClient
熔断器提前打开,误伤健康模型失败计数重复检查 tryAcquirePermission 与 executeCallable 是否混用
重试后回答文不对题重试只传了最后一条消息重试时携带完整的消息历史
模型恢复后流量不回流熔断器 open 时间过长缩短 waitDurationInOpenState,确认半开探测逻辑
切换模型后输出格式不稳定不同模型的 System Prompt 要求未同步每个模型配置相同的结构化输出约束
高 QPS 下切换频繁熔断窗口太小适当调大 slidingWindowSize,同步调整 minimumNumberOfCalls
月底成本对不上账Token 计量只按估算用模型返回的实际 usage 字段拆账

6.2 几条最想分享的实操心得

最后说几条只能靠时间换来的心得,希望帮你少走弯路。

第一条,流式响应别做简单故障转移。一旦开始向客户端输出 token,就不能在模型失败时切换另一个模型了。客户端已经收到部分内容,再切模型会导致上下文不连续或内容重复。流式场景必须换思路:要么在流式输出前做模型可用性探测,要么接受流式场景故障只能中断的现实,给客户端一个明确的错误码,而不是假装可以无缝切换。这块我做过一次自以为完美的重构,压测一跑立刻暴露问题,方案只能推倒重来。

第二条,候选模型列表要有顺序敏感性。很多团队写候选列表是随手顺序,实际上顺序代表成本和质量的预期。把最便宜的模型放第一位并不可取,因为便宜模型更容易在质量上出问题,一旦频繁触发 fallback,整体成本反而更高。建议把质量稳定、成本适中的模型作为主候选,最贵的模型作为能力补位,最便宜的模型作为最后的兜底。

第三条,别在业务代码里到处 new 模型客户端。模型客户端的生命周期、连接池、超时配置都应该是框架层面管理的。业务代码里到处 new,多模型调度基本无从谈起,因为你连"系统里现在到底有几个客户端在跑"都说不清。统一通过 Spring 容器管理和注入,是这套方案的地基。

第四条,故障转移要定期做演练。我们团队每隔一段时间会手动把一个模型的 key 改成错误值,或者切断某个模型的网络出口,观察系统是否真的能无感切换。很多故障转移方案在评审文档里天衣无缝,一演练才发现探活逻辑根本没生效、熔断配置被别的组件覆盖、兜底话术有问题。演练过几次之后,这套机制才真正敢用。

多模型调度这个事,技术上并不算难,难的是把每一种失败路径都想清楚,并且用代码把预案落到实处。这篇笔记给出的方案,是你在多个项目里验证过的形态,直接拿过去改改就能落地。照着这个思路走一遍,至少能把"模型挂了"这种最常见的故障,从事故列表里摘出去。

RELATED READING

延伸阅读

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