摘要:面向负责 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 路由风格的示例配置,数值均为示例值:
yaml
# 示例配置:声明式降级链(数值为示例,需按实际 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-chat
litellm_params:
model: provider-a/large
api_key: os.environ/PROVIDER_A_KEY
- model_name: secondary-chat
litellm_params:
model: provider-b/large
api_key: os.environ/PROVIDER_B_KEY
- model_name: tertiary-chat
litellm_params:
model: provider-a/medium
api_key: os.environ/PROVIDER_A_BACKUP_KEY
熔断状态机通常独立于配置、在网关运行时维护。下面是一个简化实现,阈值同样是示例值:
python
# 示例:提供商级熔断器(状态机简化版,阈值为示例)
import time, random
class Breaker:
def __init__(self, fail_max: int = 5, cooldown: int = 60):
self.fail_max, self.cooldown = fail_max, cooldown
self.fails, self.opened_at, self.state = 0, 0.0, "closed"
def allow(self) -> bool:
if self.state == "open":
if time.time() - self.opened_at >= self.cooldown:
self.state = "half_open" # 放一个探测请求
return True
return False # 冷却中,直接跳过该提供商
return True
def on_success(self) -> None:
self.fails, self.state = 0, "closed"
def on_failure(self) -> None:
self.fails += 1
if self.fails >= self.fail_max or self.state == "half_open":
self.state, self.opened_at = "open", time.time()
def backoff(attempt: int) -> float:
# 指数退避 + 抖动,封顶 30 秒
return min(2 ** attempt + random.uniform(0, 1), 30)
调用侧的循环就变得很直白:遍历降级链上的每个目标,先问熔断器 allow() 是否放行;放行则带 num_retries 次数与 backoff 退避去打,成功调 on_success() 并返回,失败调 on_failure() 后转下一个目标;整条链走完仍无果,才向上抛错。熔断状态和降级链解耦,是这套结构能长期维护的关键。
熔断器的状态迁移可以画成一个状态机:
#mermaid-svg-ux5tovtuNZI1ZE7J{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ux5tovtuNZI1ZE7J .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ux5tovtuNZI1ZE7J .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ux5tovtuNZI1ZE7J .error-icon{fill:#552222;}#mermaid-svg-ux5tovtuNZI1ZE7J .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ux5tovtuNZI1ZE7J .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ux5tovtuNZI1ZE7J .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ux5tovtuNZI1ZE7J .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ux5tovtuNZI1ZE7J .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ux5tovtuNZI1ZE7J .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ux5tovtuNZI1ZE7J .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ux5tovtuNZI1ZE7J .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ux5tovtuNZI1ZE7J .marker.cross{stroke:#333333;}#mermaid-svg-ux5tovtuNZI1ZE7J svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ux5tovtuNZI1ZE7J p{margin:0;}#mermaid-svg-ux5tovtuNZI1ZE7J defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-ux5tovtuNZI1ZE7J g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-ux5tovtuNZI1ZE7J g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-ux5tovtuNZI1ZE7J g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-ux5tovtuNZI1ZE7J g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-ux5tovtuNZI1ZE7J g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-ux5tovtuNZI1ZE7J .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-ux5tovtuNZI1ZE7J .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-ux5tovtuNZI1ZE7J .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-ux5tovtuNZI1ZE7J .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ux5tovtuNZI1ZE7J .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-ux5tovtuNZI1ZE7J .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-ux5tovtuNZI1ZE7J .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-ux5tovtuNZI1ZE7J .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ux5tovtuNZI1ZE7J .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ux5tovtuNZI1ZE7J .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ux5tovtuNZI1ZE7J .edgeLabel .label text{fill:#333;}#mermaid-svg-ux5tovtuNZI1ZE7J .label div .edgeLabel{color:#333;}#mermaid-svg-ux5tovtuNZI1ZE7J .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-ux5tovtuNZI1ZE7J .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-ux5tovtuNZI1ZE7J .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-ux5tovtuNZI1ZE7J .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-ux5tovtuNZI1ZE7J .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-ux5tovtuNZI1ZE7J .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ux5tovtuNZI1ZE7J .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ux5tovtuNZI1ZE7J #statediagram-barbEnd{fill:#333333;}#mermaid-svg-ux5tovtuNZI1ZE7J .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ux5tovtuNZI1ZE7J .cluster-label,#mermaid-svg-ux5tovtuNZI1ZE7J .nodeLabel{color:#131300;}#mermaid-svg-ux5tovtuNZI1ZE7J .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-ux5tovtuNZI1ZE7J .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-ux5tovtuNZI1ZE7J .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-ux5tovtuNZI1ZE7J .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-ux5tovtuNZI1ZE7J .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-ux5tovtuNZI1ZE7J .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-ux5tovtuNZI1ZE7J .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-ux5tovtuNZI1ZE7J .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-ux5tovtuNZI1ZE7J .note-edge{stroke-dasharray:5;}#mermaid-svg-ux5tovtuNZI1ZE7J .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-ux5tovtuNZI1ZE7J .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-ux5tovtuNZI1ZE7J .statediagram-note text{fill:black;}#mermaid-svg-ux5tovtuNZI1ZE7J .statediagram-note .nodeLabel{color:black;}#mermaid-svg-ux5tovtuNZI1ZE7J .statediagram .edgeLabel{color:red;}#mermaid-svg-ux5tovtuNZI1ZE7J #dependencyStart,#mermaid-svg-ux5tovtuNZI1ZE7J #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-ux5tovtuNZI1ZE7J .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ux5tovtuNZI1ZE7J :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 失败数达到阈值
冷却窗口结束
探测请求成功
探测请求失败
Closed
Open
HalfOpen
边界与取舍:流式与幂等才是真正难啃的部分
前面这套在非流式请求上很干净,但流式(streaming)会把它撕开一道口子。真正棘手的是流式中断:如果一个提供商已经开始吐 token、又在中途挂掉,你没法无缝切到另一家------客户端已经收到了半截数据。此时通常只有两种不完美的选择:一是网关缓冲主目标的输出、失败就整段丢弃再从头打备用目标(代价是感知延迟上升),二是让调用方看到一段被截断的主响应,后面再跟一段完整的备用响应。哪种对,取决于业务:交互式对话通常选缓冲,批量生成通常选从头重试。发生在首个 token 之前的失败可以透明切换,一旦 token 已经发出,应用层往往就得自己处理残缺输出或直接报错。
幂等是第二个硬约束。故障转移的本质是"把同一请求打给下一个提供商",而只要这个请求带副作用,重复投递就可能造成双花------消息发两遍、卡扣两次、库写两条。带副作用的调用必须携带幂等键,并由下游按键去重;否则投机式重试和降级都可能重复执行。
第三个取舍是语义漂移。不同提供商、甚至同家不同规格的模型,输出风格、JSON 结构化能力、拒答边界都不同。降级到备用模型能保住"有响应",但不保证"响应质量一致"。对强结构化输出或工具调用的场景,备用目标最好用同一档能力的模型,或在降级后加一层 schema 校验兜底,否则可用性上去了、正确性掉下来。
最后是成本与延迟。缓冲会抬高首字延迟,重试和降级会在故障期放大调用量和账单,熔断的冷却窗口太长又会在提供商已恢复时白白拒服务。这些参数没有标准答案,只能用真实流量的观测数据反复校准。
技术结论
多提供商故障转移不是"再加一个 API key"就完事,它是重试、降级链、熔断三层机制的组合,外加抖动退避防惊群。声明式配置让降级链可维护,熔断让系统在提供商长时间故障时快速失败而非集体挂起。真正决定这套东西成败的,是流式中断如何处理、有副作用的调用是否幂等、以及降级后输出语义是否还够用------这三点做不好,故障转移换来的不是可用性,而是更隐蔽的重复副作用和质量塌陷。落地顺序应当是:先把错误分类和幂等边界理清,再声明降级链,最后叠熔断与观测,而不是反过来。