LLM 网关多提供商故障转移:重试、降级链与熔断

发布时间:2026/7/22 20:08:51
LLM 网关多提供商故障转移:重试、降级链与熔断 摘要面向负责 LLM 网关与生产可靠性的工程团队本文拆解重试、降级链和熔断的职责边界并给出声明式配置、熔断状态机以及流式中断、幂等和语义漂移三个落地约束。生产环境里的 LLM 调用会遇到上游限流、服务端错误、连接重置和超时。服务如果只接一个提供商又把 429 或 5xx 几乎原样透传给终端用户一次上游抖动就会变成整条业务链路的不可用。多提供商故障转移failover的目标不是承诺“模型永远可用”而是让系统在单个出口失效时仍有受控的退路。现象单提供商依赖下的故障面比想象中大把 LLM 调用写死到单一 SDK 里暴露的故障面并不小。常见的可重试信号有几类限流(HTTP 429)、服务端错误(500/502/503)、连接重置以及网络超时。路由层通常会按错误类型决定是否重试或降级上下文超长和内容过滤则需要单独处理不能和瞬时网络故障混在一起。麻烦在于应用代码通常只有一条 try/except要么把错误抛给用户要么原地重试同一个端点——而如果是提供商区域性故障重试同一个端点只是在放大失败。再叠一层粒度问题同一家提供商的不同部署(不同 region、不同 API key、不同额度池)其实是相互独立的容量单元但单一 SDK 调用感知不到这种区别。于是那些换个 key 就能过的请求也一起堵在了同一个出口上。单提供商依赖的本质是把可用性押在了一个你完全无法控制的黑盒上。原理重试、降级链与熔断是三件不同的事这三个概念经常被混着说但它们解决的问题并不一样。重试(retry)针对瞬时抖动同一个目标退避若干毫秒后再打一次它假设失败是暂时的、下一次大概率会好。降级链(fallback)针对这个目标本身不行主目标用尽重试仍失败就把同一请求转交给链条里的下一个目标——可能是另一家提供商也可能是同家的另一个部署。降级既可以按部署单独配置也可以设置全局 fallback路由器先试主目标失败后再沿列表依次切换。熔断(circuit breaker)针对这个目标短期内反复失败别再浪费预算和延迟去试它。它按提供商统计失败情况超过阈值就跳闸(open)在冷却窗口内让新请求直接跳过这个提供商冷却结束后进入半开(half-open)放一两个探测请求成功才重新闭合。阈值既可以按连续失败次数设置也可以按滑动窗口内的失败率设置。下面代码里的 5 次失败和 60 秒冷却只是演示值生产参数要按真实流量、延迟预算和 SLA 校准。三者叠起来才构成完整的韧性重试吸收毛刺降级链换出口熔断避免对着一口枯井反复取水。少了熔断降级链会在提供商大面积故障时把每个请求都拖满整条链的超时少了降级链熔断只能快速失败而没有任何退路。退避这一层还有个容易被忽略的细节重试间隔必须带抖动(jitter)。没有抖动所有客户端会在同一时刻同步重试恰好在服务端试图恢复时制造一波突发流量也就是惊群效应。常见做法是指数退避(1s、2s、4s……封顶约 30s)再叠一个随机偏移让客户端错峰。在把这套机制接进有副作用的调用(比如工具调用会写库、发消息、扣费)之前建议先照着一份覆盖幂等键与补偿动作的回滚清单把重试会不会让副作用执行两次这件事过一遍再决定哪些请求可以安全地做投机式重试。这一步没做故障转移带来的往往不是可用性而是重复扣费和脏数据。落地用配置声明式地表达降级链工程上更可维护的做法是把降级链和重试策略声明在配置里而不是散落在业务代码的 if/else 中。下面是一份 LiteLLM 路由风格的示例配置数值均为示例值# 示例配置:声明式降级链(数值为示例,需按实际 SLA 调整)router_settings:num_retries:2# 每个目标的重试次数timeout:30# 整次调用的超时上限(秒)retry_after:true# 遵循 429 返回的 Retry-After 头fallbacks:-primary-chat:[secondary-chat,tertiary-chat]# 触发降级的状态码通常取 429/500/502/503 这一常见集合model_list:-model_name:primary-chatlitellm_params:model:provider-a/largeapi_key:os.environ/PROVIDER_A_KEY-model_name:secondary-chatlitellm_params:model:provider-b/largeapi_key:os.environ/PROVIDER_B_KEY-model_name:tertiary-chatlitellm_params:model:provider-a/mediumapi_key:os.environ/PROVIDER_A_BACKUP_KEY熔断状态机通常独立于配置、在网关运行时维护。下面是一个简化实现阈值同样是示例值# 示例:提供商级熔断器(状态机简化版,阈值为示例)importtime,randomclassBreaker:def__init__(self,fail_max:int5,cooldown:int60):self.fail_max,self.cooldownfail_max,cooldown self.fails,self.opened_at,self.state0,0.0,closeddefallow(self)-bool:ifself.stateopen:iftime.time()-self.opened_atself.cooldown:self.statehalf_open# 放一个探测请求returnTruereturnFalse# 冷却中,直接跳过该提供商returnTruedefon_success(self)-None:self.fails,self.state0,closeddefon_failure(self)-None:self.fails1ifself.failsself.fail_maxorself.statehalf_open:self.state,self.opened_atopen,time.time()defbackoff(attempt:int)-float:# 指数退避 抖动,封顶 30 秒returnmin(2**attemptrandom.uniform(0,1),30)调用侧的循环就变得很直白遍历降级链上的每个目标先问熔断器 allow() 是否放行放行则带 num_retries 次数与 backoff 退避去打成功调 on_success() 并返回失败调 on_failure() 后转下一个目标整条链走完仍无果才向上抛错。熔断状态和降级链解耦是这套结构能长期维护的关键。熔断器的状态迁移可以画成一个状态机失败数达到阈值冷却窗口结束探测请求成功探测请求失败ClosedOpenHalfOpen边界与取舍流式与幂等才是真正难啃的部分前面这套在非流式请求上很干净但流式(streaming)会把它撕开一道口子。真正棘手的是流式中断如果一个提供商已经开始吐 token、又在中途挂掉你没法无缝切到另一家——客户端已经收到了半截数据。此时通常只有两种不完美的选择一是网关缓冲主目标的输出、失败就整段丢弃再从头打备用目标(代价是感知延迟上升)二是让调用方看到一段被截断的主响应后面再跟一段完整的备用响应。哪种对取决于业务交互式对话通常选缓冲批量生成通常选从头重试。发生在首个 token 之前的失败可以透明切换一旦 token 已经发出应用层往往就得自己处理残缺输出或直接报错。幂等是第二个硬约束。故障转移的本质是把同一请求打给下一个提供商而只要这个请求带副作用重复投递就可能造成双花——消息发两遍、卡扣两次、库写两条。带副作用的调用必须携带幂等键并由下游按键去重否则投机式重试和降级都可能重复执行。第三个取舍是语义漂移。不同提供商、甚至同家不同规格的模型输出风格、JSON 结构化能力、拒答边界都不同。降级到备用模型能保住有响应但不保证响应质量一致。对强结构化输出或工具调用的场景备用目标最好用同一档能力的模型或在降级后加一层 schema 校验兜底否则可用性上去了、正确性掉下来。最后是成本与延迟。缓冲会抬高首字延迟重试和降级会在故障期放大调用量和账单熔断的冷却窗口太长又会在提供商已恢复时白白拒服务。这些参数没有标准答案只能用真实流量的观测数据反复校准。技术结论多提供商故障转移不是再加一个 API key就完事它是重试、降级链、熔断三层机制的组合外加抖动退避防惊群。声明式配置让降级链可维护熔断让系统在提供商长时间故障时快速失败而非集体挂起。真正决定这套东西成败的是流式中断如何处理、有副作用的调用是否幂等、以及降级后输出语义是否还够用——这三点做不好故障转移换来的不是可用性而是更隐蔽的重复副作用和质量塌陷。落地顺序应当是先把错误分类和幂等边界理清再声明降级链最后叠熔断与观测而不是反过来。