AI服务依赖治理:模型供应商故障时的自动降级与静态规则切换

AI服务依赖治理:模型供应商故障时的自动降级与静态规则切换

2025 年某头部 AI 应用在 OpenAI 宕机的 4 小时内损失了 40% 的活跃用户——不是因为他们的产品不行,而是因为他们的系统没有「供应商故障自动切换」能力。对模型供应商的依赖治理,已经从「锦上添花」变成了「生存必需」。

一、模型供应商依赖的脆弱性全景

一个典型的大模型应用通常不会只接一家供应商。生产环境中常见的供应商矩阵是:

  • GPT-4o(OpenAI):主力推理,质量最高但延迟不稳定
  • Claude 4(Anthropic):复杂推理首选,但并发限制严格
  • Gemini 2.5(Google):多模态能力突出,亚洲延迟低
  • GLM-5 / Qwen3(国产):合规场景必备,中文理解力强

每多接一个供应商,系统的健壮性就增加一分,但治理复杂度也指数级上升。核心挑战包括:

  1. SLA 不统一:OpenAI 承诺 99.9%,但某国产模型的 SLA 只有 99.5%
  2. 限流策略各异:有的按 RPM(每分钟请求数),有的按 TPM(每分钟 Token 数)
  3. 错误语义混乱:同样的「超时」,有的返回 504,有的返回 503,有的直接 TCP Reset
  4. 成本差异巨大:GPT-4o 的价格可能是 GLM-5 的 5~10 倍
flowchart LR App[大模型应用] --> Router[智能路由层] Router --> HealthCheck{健康检查 & SLA 监控} HealthCheck --> ProviderA["OpenAI (GPT-4o)\nSLA: 99.9% | 优先级: 1\n权重: 50% | 成本: $$$"] HealthCheck --> ProviderB["Anthropic (Claude 4)\nSLA: 99.5% | 优先级: 2\n权重: 30% | 成本: $$$"] HealthCheck --> ProviderC["Google (Gemini 2.5)\nSLA: 99.5% | 优先级: 3\n权重: 15% | 成本: $$"] HealthCheck --> ProviderD["国产模型 (GLM-5)\nSLA: 99.0% | 优先级: 4\n权重: 5% | 成本: $"] ProviderA -->|故障| Fallback{故障检测触发} Fallback -->|自动切换| ProviderB Fallback -->|自动切换| ProviderC style ProviderA fill:#f96,stroke:#333,stroke-width:2px style ProviderB fill:#fc6,stroke:#333,stroke-width:2px style ProviderC fill:#9cf,stroke:#333 style ProviderD fill:#cfc,stroke:#333

二、供应商健康度监控与故障检测

故障检测的核心难点在于:如何区分「模型慢了」和「模型挂了」?误判会导致不必要的切换,漏判会拖慢整个系统的响应。

我们的方案是三层检测机制:

package com.example.router.health; import java.time.Duration; import java.util.Map; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; /** * 模型供应商健康检查器 —— 三层检测:心跳/错误率/延迟分位数 */ public class ProviderHealthChecker { // 滑动窗口参数 private static final int WINDOW_SIZE = 60; // 60 秒窗口 private static final int HEARTBEAT_INTERVAL = 10; // 每 10 秒心跳检测 // 故障判定阈值 private static final double ERROR_RATE_THRESHOLD = 0.10; // 错误率 > 10% private static final double P99_LATENCY_THRESHOLD_MS = 8_000; // P99 > 8s private static final int CONSECUTIVE_FAILURES = 5; // 连续失败 5 次 private final ScheduledExecutorService scheduler; private final Map<String, SlidingWindowStats> providerStats; public ProviderHealthChecker() { this.scheduler = Executors.newScheduledThreadPool(2, r -> new Thread(r, "provider-health-checker")); this.providerStats = new ConcurrentHashMap<>(); // 启动心跳检测定时任务 scheduler.scheduleAtFixedRate( this::heartbeatAll, HEARTBEAT_INTERVAL, HEARTBEAT_INTERVAL, TimeUnit.SECONDS ); } /** * 对所有供应商执行心跳检测。 */ private void heartbeatAll() { for (Map.Entry<String, SlidingWindowStats> entry : providerStats.entrySet()) { String providerId = entry.getKey(); SlidingWindowStats stats = entry.getValue(); evaluateHealth(providerId, stats); } } /** * 三层检测 —— 任一命中即标记为 UNHEALTHY。 */ private void evaluateHealth(String providerId, SlidingWindowStats stats) { try { // 第一层:连续失败检测 —— 最快发现硬故障 if (stats.consecutiveFailures.get() >= CONSECUTIVE_FAILURES) { markUnhealthy(providerId, "连续失败 " + CONSECUTIVE_FAILURES + " 次"); return; } // 第二层:错误率检测 —— 软故障(间歇性失败) if (stats.getErrorRate() > ERROR_RATE_THRESHOLD) { markUnhealthy(providerId, "错误率 " + String.format("%.2f%%", stats.getErrorRate() * 100)); return; } // 第三层:延迟检测 —— 性能退化 if (stats.getP99LatencyMs() > P99_LATENCY_THRESHOLD_MS) { markUnhealthy(providerId, "P99 延迟 " + stats.getP99LatencyMs() + "ms 超过阈值"); return; } // 全部通过,标记为健康 markHealthy(providerId); } catch (Exception e) { // 统计过程异常不影响主流程,记录告警 // logger.warn("Health evaluation failed for provider: {}", providerId, e); } } private void markUnhealthy(String providerId, String reason) { // 写入 Redis 通知路由层:该供应商不可用 // redisTemplate.opsForValue().set("provider:" + providerId + ":status", "UNHEALTHY", 60, TimeUnit.SECONDS); // 触发告警通知 // alertService.send(providerId + " marked UNHEALTHY: " + reason); } private void markHealthy(String providerId) { // redisTemplate.opsForValue().set("provider:" + providerId + ":status", "HEALTHY"); } // --- 内部类:滑动窗口统计 --- private static class SlidingWindowStats { final AtomicInteger consecutiveFailures = new AtomicInteger(0); // 实际应使用 HdrHistogram 或 t-digest 做精确分位数, // 此处以简化实现示意 double getErrorRate() { // 伪代码:最近 60 秒内 errorCount / totalCount return 0.0; } double getP99LatencyMs() { // 伪代码:HdrHistogram.getValueAtPercentile(99.0) return 0.0; } } }

三、多供应商路由:优先级、权重与成本感知

故障检测只是第一步,更关键的是当多个供应商都健康时,流量如何分配?

我们采用「优先级 + 权重 + 成本」三维路由策略:

package com.example.router.strategy; import java.util.*; import java.util.concurrent.ThreadLocalRandom; /** * 多供应商智能路由器。 * 路由策略:优先选择健康的高优先级供应商,按权重分配流量。 * 当高优先级供应商不可用时自动 Fallback 到下一级。 */ public class MultiProviderRouter { // 供应商路由配置 —— 生产环境从 Nacos 动态加载 private final List<ProviderRoute> routes = Arrays.asList( new ProviderRoute("openai-gpt4o", 1, 50, 12.0, "https://api.openai.com"), new ProviderRoute("anthropic-claude",2, 30, 15.0, "https://api.anthropic.com"), new ProviderRoute("google-gemini", 3, 15, 6.0, "https://generativelanguage.googleapis.com"), new ProviderRoute("glm5", 4, 5, 1.5, "https://open.bigmodel.cn") ); /** * 选择供应商。核心逻辑: * 1. 过滤掉不健康的供应商 * 2. 按优先级排序,选择最高优先级的一组 * 3. 在选中组内按权重随机选择 * * @param strategy 路由策略:PRIORITY / COST_AWARE / PERFORMANCE_FIRST * @return 被选中的供应商路由配置 * @throws NoAvailableProviderException 当所有供应商都不可用时抛出 */ public ProviderRoute select(String strategy) { // 按健康状态过滤 List<ProviderRoute> healthy = routes.stream() .filter(r -> isHealthy(r.providerId)) .sorted(Comparator.comparingInt(r -> r.priority)) .toList(); if (healthy.isEmpty()) { throw new NoAvailableProviderException("所有模型供应商均不可用"); } // 只保留最高优先级的一组(同优先级多个供应商做负载均衡) int topPriority = healthy.get(0).priority; List<ProviderRoute> candidates = healthy.stream() .filter(r -> r.priority == topPriority) .toList(); return switch (strategy) { case "COST_AWARE" -> selectByCostWeight(candidates); case "PRIMARY_BACKUP" -> selectPrimaryBackup(candidates); default -> selectByWeight(candidates); }; } /** * 按权重随机选择(加权随机算法)。 * 选择了高优先级组中权重最大的供应商。 */ private ProviderRoute selectByWeight(List<ProviderRoute> candidates) { int totalWeight = candidates.stream() .mapToInt(r -> r.weight).sum(); int random = ThreadLocalRandom.current().nextInt(totalWeight); int cumulative = 0; for (ProviderRoute r : candidates) { cumulative += r.weight; if (random < cumulative) { return r; } } // 理论上不会到这里,兜底返回第一个 return candidates.get(0); } /** * 成本感知选择:在高优先级组内选择成本最低的。 */ private ProviderRoute selectByCostWeight(List<ProviderRoute> candidates) { return candidates.stream() .min(Comparator.comparingDouble(r -> r.costPer1kTokens)) .orElse(candidates.get(0)); } /** * 主备模式:只选择第一个(权重最高的) */ private ProviderRoute selectPrimaryBackup(List<ProviderRoute> candidates) { return candidates.stream() .max(Comparator.comparingInt(r -> r.weight)) .orElse(candidates.get(0)); } private boolean isHealthy(String providerId) { // 从 Redis 读取健康状态 // String status = redisTemplate.opsForValue() // .get("provider:" + providerId + ":status"); // return "HEALTHY".equals(status); return true; // 简化实现 } // --- 路由配置数据结构 --- public record ProviderRoute( String providerId, // 供应商唯一标识 int priority, // 优先级:1=最高 int weight, // 权重:同优先级内分配比例 double costPer1kTokens, // 每千 Token 成本(美元) String baseUrl // API 地址 ) {} }

四、切换时的请求重放与幂等保证

供应商切换中最容易被忽视的问题是:切换过程中正在飞行的请求怎么办?

假设请求发给了 OpenAI,但 OpenAI 在请求发送后、响应返回前宕机了。此时如果直接将请求重放给 Anthropic,可能会导致重复消费——如果这个请求是「生成一封邮件并发送给用户」,用户就会收到两封。

解决方案是请求级别的幂等 Key

/** * 带幂等保证的模型请求客户端。 * 核心设计:每个请求携带唯一的 idempotencyKey, * 供应商切换时的重试不会导致重复副作用。 */ public class IdempotentModelClient { private final MultiProviderRouter router; private final Cache<String, String> responseCache; // 幂等响应缓存 public IdempotentModelClient(MultiProviderRouter router) { this.router = router; // Caffeine 本地缓存:10 万条,5 分钟过期 this.responseCache = Caffeine.newBuilder() .maximumSize(100_000) .expireAfterWrite(5, TimeUnit.MINUTES) .build(); } public String callWithFallback(ModelRequest request) { String idempotencyKey = request.getIdempotencyKey(); // 检查缓存:如果这是一个重试请求且之前已成功,直接返回缓存结果 String cached = responseCache.getIfPresent(idempotencyKey); if (cached != null) { return cached; // 幂等返回,不重复调用模型 } // 获取当前可用的健康供应商列表 List<String> attemptedProviders = new ArrayList<>(); for (int attempt = 0; attempt < 3; attempt++) { try { ProviderRoute provider = router.select("PRIORITY"); // 跳过已经尝试过的供应商 if (attemptedProviders.contains(provider.providerId())) { continue; } attemptedProviders.add(provider.providerId()); String response = callProvider(provider, request); // 成功:缓存响应(用于后续可能的重复请求) responseCache.put(idempotencyKey, response); return response; } catch (ProviderTimeoutException e) { // 超时:可能供应商已收到请求正在处理,使用幂等 Key 安全重试 // logger.warn("Provider timeout, retrying with next. key={}", idempotencyKey); } catch (ProviderRateLimitException e) { // 限流:等待退避后重试同一供应商 attempt--; sleepWithBackoff(attempt + 1); } catch (Exception e) { // 其他异常:记录并尝试下一个供应商 // logger.error("Provider call failed, key={}", idempotencyKey, e); } } throw new AllProvidersExhaustedException( "所有供应商均调用失败,已尝试: " + attemptedProviders); } private String callProvider(ProviderRoute provider, ModelRequest request) { // 实际 HTTP 调用逻辑 return ""; } private void sleepWithBackoff(int attempt) { try { long delay = Math.min(1000L * (1L << attempt), 10_000L); Thread.sleep(delay); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } } }

五、总结

模型供应商依赖治理的四个核心原则:

  • 健康检测要三层叠加:连续失败(硬故障)+ 错误率(软故障)+ 延迟(性能退化),单一指标误判率太高。
  • 路由策略要三维一体:优先级保障核心体验,权重做流量分配,成本做智能优化。三者不是互斥的,是叠加的。
  • 切换时必须幂等:超时不等于失败,重试不等于安全。每个请求携带唯一的幂等 Key 是切换安全的最后一道防线。
  • 兜底必须是静态规则:当所有供应商都不可用时,系统需要一组硬编码的静态降级规则(如返回预设文案、使用本地小模型、排队等待恢复),而不是静默失败。

供应商治理的终极目标是:用户永远感知不到底层换了多少个模型。这件事说起来简单,做起来是一整套工程体系。