2024 年我在产品里做一个 AI 功能——接了某家头部大模型的 API,让它帮用户做内容生成。接大模型这件事,我压根没多想。第一版我做得很省事:接大模型不就是拿到一个 API key、找一个 SDK、在需要的地方调一下 llm.chat(prompt)、把结果拿回来?哪个功能要用,就在哪个文件里 import 那个 SDK、调一下。本地开发时——真不错:我调一下,模型秒回、结果有模有样,几行代码搞定。我心里很踏实:"接大模型嘛,不就是调一个 API?"可等这个功能真正上线、被真实用户用起来,一串问题冒了出来。第一种最先把我打懵:某天那家供应商区域性宕机,接口大面积返回 503——不是我某一个请求失败,是我打过去的每一个请求都失败。我的整个 AI 功能,在那段时间里,完整地、彻底地挂掉了,而我除了等它自己恢复,什么都做不了。第二种最难受:供应商对我的账号限流,流量一上来,大量请求被 429 顶了回来——AI 功能不是挂了,是"一会儿能用、一会儿不能用",时好时坏。第三种最隐蔽:有一阵子供应商没宕机、也没限流,可它的响应延迟从平时的两三秒飙到了三四十秒。功能"还在",可慢到没有任何用户愿意等——"还在但不可用",这种状态比直接挂掉还难处理,因为我的监控只盯着"有没有报错",而它压根没报错。第四种最说不清:出了这些事,我想加一个备用供应商顶上,翻开代码才发现——对那家 SDK 的直接调用,散落在我几十个文件里,想换、想加备份,等于要把整个代码库翻一遍。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"接大模型,就是调一个 API"。这句话把第三方大模型 API,当成了一个像我自己系统里的函数一样稳定、可靠、永远在线的东西。可它不是。第三方大模型 API,不是一个稳定的、永远在线的服务——它是一个你不拥有、不能控制、会整体不可用的外部依赖。它会限流你、会超时、会区域性宕机、会悄悄变慢、会涨价、会改接口,而这每一件事,你都施加不了任何影响,只能被动承受。把你的核心 AI 功能,百分之百地绑死在单一供应商身上,本质上等于:把你这个功能的可用性,完完整整地交给了一家你毫无影响力的公司——它好,你就好;它一旦出事,你就跟着一起出事,而且毫无还手之力。这件事的解法,不是"去找一家更可靠的供应商"——没有这样的供应商,每一家都会宕机。解法是:不再依赖任何单独的一家。你要用一个抽象层,把业务代码和具体是哪家供应商解耦;要用故障转移,让主供应商失效时自动切到备用;要用熔断,别对一个已经挂掉的供应商反复死磕;还要用降级,在所有供应商都不可用时,给用户一个体面的兜底。真正做好大模型接入,核心不是"调一个 API",而是把每一家供应商都看成会整体失效的外部依赖,用抽象层解耦、用故障转移自动切换、用熔断别死磕挂掉的供应商、用降级在全挂时兜住用户。这篇文章就把大模型的多供应商容灾梳理一遍:为什么"把大模型当稳定 API"是错的、抽象层怎么搭、故障转移怎么做、熔断与"慢失败"怎么处理、降级怎么兜底,以及成本分级、配额隔离、灰度切换、可观测这些把容灾真正做扎实要避开的坑。
问题背景
先把那串问题的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一个"把大模型当成稳定 API"、绑死单一供应商的 AI 功能,上线后冒出一串问题:供应商区域性宕机,整个 AI 功能彻底挂掉、束手无策;供应商限流,请求被 429 顶回、功能时好时坏;供应商没报错却变慢,功能"还在但不可用"、监控还发现不了;想加备用供应商,却发现对 SDK 的直接调用散落在几十个文件里、根本换不动。
我当时的错误认知:"接大模型,就是调一个 API。"
真相:这个认知错在它把"调第三方 API"和"调自己系统里的函数"混为一谈了。"调一个 API"——这个说法,听起来就和调用自己代码里的一个函数一样:你调它、它返回、你拿到结果,稳定、可控、确定。可一个第三方大模型 API,根本不是你系统里的函数:它是另一家公司的服务,跑在你够不着的机器上,由你管不到的团队运维。这就意味着,它有一组你自己的函数永远不会有的失效模式:它会因为对方的事故而整体宕机,会因为对方的容量策略而限流你,会因为对方的负载而突然变慢,会因为对方的商业决策而涨价或下线某个模型。一旦你看清这一点,那串问题就都有了解释:它们没有一个是"你的代码写错了"——你的 chat 调用完全正确;它们全都是"你依赖的那个外部东西出事了,而你的代码对此毫无防备"。问题的根子清楚了:这不是"要不要加个 try-except"的小修补,而是要换一个根本视角——你接入的不是一个稳定服务,而是一个会整体失效的外部依赖;你要做的,是为"这个依赖整体不可用"这件迟早会发生的事,提前准备好一整套不依赖它的退路。
要把大模型的多供应商容灾做对,需要几块认知:
- 为什么"把大模型当稳定 API"是错的——它是会整体失效的外部依赖,不是你的函数;
- 供应商抽象层——用统一接口把业务代码和"具体哪家供应商"解耦;
- 故障转移——主供应商失败时,沿一条链自动切到备用供应商;
- 熔断与慢失败——别对挂掉的供应商死磕,把"变慢"也当成一种失败;
- 降级——所有供应商都不可用时,退回缓存或友好提示,不把异常抛给用户;
- 成本分级、配额隔离、灰度切换、可观测这些工程坑怎么处理。
一、为什么"把大模型当成一个稳定的 API"是错的
先把这件最根本的事钉死:"把大模型当成一个稳定的 API",这个想法错在它对"依赖"这个词的理解。在它的世界观里,只有一种依赖——就像依赖自己代码里的一个函数:你写了它、它就在那儿、你调它它就执行,它不会"今天不在"。可第三方大模型 API 是另一种依赖:它是一个外部依赖,它的"在不在、快不快、让不让你用",完全由别人决定。这两种依赖,在本地开发时看起来一模一样——你调 chat,它返回结果,和调一个本地函数没有任何区别,所以你很自然地就把它当成了后者。可它们的失效模式天差地别:本地函数的失效,是"你的逻辑有 bug",改代码就能修;外部依赖的失效,是"对方出事了",你改多少代码都修不了——你只能选择"还依不依赖它"。绑死单一供应商之所以危险,就是因为它在"只依赖这一家"这个选择上,没有给自己留任何余地:这一家好,一切都好;这一家一旦整体出事,你的功能就跟着整体出事,而你手里没有任何别的牌可打。
下面这段代码,就是我那个"把外部依赖当成本地函数"的第一版:
# 反面教材:业务代码里到处直接调某一家供应商的 SDK
from acme_llm import AcmeClient
client = AcmeClient(api_key=API_KEY)
def generate_summary(text):
resp = client.chat(f"请总结这段内容:{text}") # 破绽 1:供应商一宕机,这里整片失败
return resp.content
def generate_reply(question):
resp = client.chat(f"请回答用户:{question}") # 破绽 4:同样的直接调用,散落在几十处
return resp.content
# 破绽 2:被限流 429,这里没有任何应对
# 破绽 3:供应商变慢,这里只会一直干等
这段代码在本地开发时表现不错,因为本地我调的那家供应商一直好好的——没宕机、没限流、也不慢,所以那四个破绽,在本地一个都不会发作。它的问题不在某一行语法上——拿 client、调 chat、取 content,语法都对——而在它对"这个 client 背后是个外部依赖"这件事的彻底无知:client.chat(...) 这一行,它当成了一次稳赚不赔的本地调用,从没想过这一行会因为对方宕机而整片失败、会因为对方限流而被顶回来、会因为对方变慢而干等到天荒地老;而 from acme_llm import AcmeClient 这种直接 import,散落在几十个文件里,又让"换一家"这件事的成本高到做不动。这四个破绽对应的,正是开头那四类问题。问题的根子清楚了:做对这件事,第一步不是给 chat 调用加 try-except,而是先在脑子里把"它是个外部依赖、它会整体失效"这件事认下来,然后为"这一家彻底不可用"准备好一整套不靠它的退路。下面五节,就是这套退路:抽象层、故障转移、熔断、降级,以及它们背后的工程考量。
二、供应商抽象层:把业务代码和具体供应商解耦
第一步,也是后面一切的地基:补的是破绽 4:对某一家 SDK 的直接调用散落在几十处、根本换不动。要能"换供应商""加备用供应商",前提是业务代码不能知道自己用的是哪一家。做法是:定义一个统一的供应商接口,业务代码只认这个接口;每接一家具体供应商,就写一个适配器去实现它:
import time
from abc import ABC, abstractmethod
class LLMResult:
"""统一的返回结构:不管底层是哪家供应商,业务拿到的都是这个。"""
def __init__(self, text, provider, latency_ms):
self.text = text
self.provider = provider
self.latency_ms = latency_ms
class LLMProvider(ABC):
"""供应商抽象:每接一家新供应商,就实现一个这样的适配器。"""
name = "base"
@abstractmethod
def chat(self, prompt, timeout):
"""把 prompt 发给这家供应商,失败就抛异常 —— 由上层决定怎么处理。"""
...
有了这个接口,每一家具体供应商就是一个适配器——它的职责,是把那家供应商五花八门的 SDK,消化成统一的 chat 签名:
class AcmeProvider(LLMProvider):
"""A 家供应商的适配器:把它的 SDK 包成统一接口。"""
name = "acme"
def __init__(self, client):
self._client = client
def chat(self, prompt, timeout):
start = time.monotonic()
resp = self._client.chat(prompt, timeout=timeout)
cost = (time.monotonic() - start) * 1000
return LLMResult(resp.content, self.name, cost)
class BoltProvider(LLMProvider):
"""B 家供应商的适配器:它的接口和 A 家完全不同,差异全消化在这里面。"""
name = "bolt"
def __init__(self, client):
self._client = client
def chat(self, prompt, timeout):
start = time.monotonic()
# B 家的入参名、返回字段都和 A 家不一样,但对外暴露的签名完全一致
out = self._client.complete(text=prompt, deadline_s=timeout)
cost = (time.monotonic() - start) * 1000
return LLMResult(out["message"], self.name, cost)
这里的认知要点是:供应商抽象层的价值,不在"代码更整洁"这种泛泛的好处上,而在它把一个本来"做不到"的事,变成了"做得到"。在第一版里,"切换供应商"这件事之所以做不动,不是因为它有多难,而是因为它要改的地方太多、太散——几十个文件里每一处 import、每一处 chat 调用都要动,这种改动量本身就是一道劝退墙。抽象层做的事,是把"业务代码和具体供应商之间的接触面",从"几十处"收敛成"一处"——业务代码只接触 LLMProvider 这个接口,而"接口背后到底是哪家供应商""怎么从一家切到另一家",全部被关进了适配器和它的上层调度里。一旦接触面收敛成一处,故障转移、熔断、降级这些后面要做的事才有了下手的地方:它们全都可以做在"接口和适配器之间"那一层,而业务代码一行都不用改。所以这一节虽然没解决任何一个具体的可用性问题,它却是后面三节的地基——没有这个把接触面收敛到一处的抽象层,后面的故障转移根本无处安放。地基打好了,接下来就能在这一层上,装上真正的容灾机制——从故障转移开始。
三、故障转移:主供应商失败,自动切到备用
第二步,补的是破绽 1:供应商一宕机,整个功能整片失败。有了抽象层,"多备几家供应商"就成了可能——你可以同时接入 A、B、C 三家,把它们排成一条故障转移链。一次调用进来,先试链上第一家,它失败就试第二家,直到有一家成功:
log = get_logger("llm")
class AllProvidersDown(Exception):
"""所有供应商都失败了 —— 这个异常专门留给降级逻辑去接。"""
def call_with_failover(providers, prompt, timeout=15):
"""故障转移:按顺序试每一家,一家失败就换下一家,直到有一家成功。"""
last_err = None
for provider in providers:
try:
result = provider.chat(prompt, timeout=timeout)
log.info("LLM 调用成功 provider=%s 延迟=%.0fms",
result.provider, result.latency_ms)
return result
except Exception as e:
last_err = e
log.warning("供应商 %s 失败,切换下一家:%s", provider.name, e)
continue
# 整条链都试完了还没成功,抛出去,交给降级逻辑兜底
raise AllProvidersDown(last_err)
下面这张图,把一次调用在故障转移链上是怎么走的画出来:
这里的认知要点是:故障转移能成立,靠的是一个前提——不同供应商的故障,大概率是相互独立的。A 家因为自己机房的事故宕机时,B 家、C 家八成还好好的,因为它们是三家不同的公司、不同的基础设施。正是这个"独立性",让"多备几家"这件事真的有意义:如果三家会同时挂,那备再多也没用;正因为它们通常不会同时挂,所以只要 A 挂了能立刻切到 B,你这个功能的整体可用性,就被几家供应商各自的可用性"撑"了起来——只要还有一家活着,你就活着。但故障转移有一个必须想清楚的前提:被转移的那个调用,得是可以安全重试的。对"内容生成"这类无副作用的纯读调用,换一家重做一遍,没有任何问题。可如果某个 LLM 调用带着副作用——比如它会触发一次扣费、会写一条数据——那你在 A 家失败后切到 B 家重试,就有可能让那个副作用发生两次。所以故障转移和"幂等"是一对:要么你转移的调用本身无副作用,要么你得让那个副作用幂等,否则故障转移就会从"容灾"变成"把一次操作做成两次"。故障转移让"一家挂了能切下一家",可如果那家挂掉的供应商每次都还要先试一下、等它超时再切,代价就太大了——这要靠下一步。
四、熔断与慢失败:别对挂掉或变慢的供应商死磕
第三步,补的是破绽 2 和破绽 3。上一节的故障转移有个隐藏的浪费:供应商 A 已经整体宕机了,可每一次调用进来,链条还是老老实实先去试一遍 A、等它超时失败、再切到 B——这一下白白搭进去的超时等待,每个请求都要付一次。解法是给每家供应商配一个熔断器:它连续失败到一定次数,就在一段冷却时间内,被直接跳过、连试都不试:
class ProviderBreaker:
"""供应商级熔断:一家连续失败到阈值,就在冷却期内直接跳过它。"""
def __init__(self, fail_threshold=5, cooldown=60):
self.fail_threshold = fail_threshold
self.cooldown = cooldown
self._fails = 0
self._open_until = 0.0
def is_open(self):
"""熔断打开 = 这家供应商暂时被判定为不可用,别再去试它。"""
return time.monotonic() < self._open_until
def on_success(self):
self._fails = 0 # 一次成功,失败计数清零
def on_failure(self):
self._fails += 1
if self._fails >= self.fail_threshold:
self._open_until = time.monotonic() + self.cooldown
self._fails = 0 # 进入熔断,计数归零,等冷却后重新观察
把熔断器接进故障转移链——已经熔断的供应商,直接跳过,从还健康的里面挑:
def call_with_breaker(entries, prompt, timeout=15):
"""带熔断的故障转移:跳过已熔断的供应商,只在还健康的里面逐个尝试。"""
last_err = None
for provider, breaker in entries:
if breaker.is_open():
log.info("供应商 %s 处于熔断中,跳过", provider.name)
continue # 已知它挂着,连试都不试,直接下一家
try:
result = provider.chat(prompt, timeout=timeout)
breaker.on_success()
return result
except Exception as e:
last_err = e
breaker.on_failure() # 记一次失败,攒够阈值就熔断这家
log.warning("供应商 %s 失败:%s", provider.name, e)
raise AllProvidersDown(last_err)
还有破绽 3——供应商没报错,但慢得没法用。这种情况,chat 会正常返回,熔断器根本不会记它一次失败。解法是:把"太慢"也当成一种失败——响应虽然回来了,但延迟超过阈值,就主动把它当失败抛出去,让它一样触发熔断和转移:
SLOW_THRESHOLD_MS = 12000
class ProviderTooSlow(Exception):
"""供应商没报错,但慢到不可用 —— 视同一次失败。"""
def chat_or_slow_fail(provider, prompt, timeout):
"""把"慢"也当成失败:响应虽回来了,但慢到没法用,照样触发转移。"""
result = provider.chat(prompt, timeout=timeout)
if result.latency_ms > SLOW_THRESHOLD_MS:
# 没报错,但慢得没法用 —— 主动抛异常,让它和真失败一样被处理
raise ProviderTooSlow(provider.name, result.latency_ms)
return result
这里的认知要点是:这一节其实在纠正两个认知偏差。第一个偏差是"失败了就重试,总没错"。不对——对一个已经整体宕机的供应商,"再试一次"不是容错,是浪费:它这一次会失败,下一次、下下一次也都会失败,你每试一次,就让一个本可以快速失败的请求,多背一次超时等待。熔断的本质,是把"这家供应商现在是死是活"这个信息记下来、复用起来:既然刚刚连着五次都失败了,那就别再假装"这次可能行",直接跳过它,等冷却期过了再重新观察。熔断省下的,是那些注定失败的尝试所白白消耗的时间。第二个偏差更隐蔽:"没抛异常,就等于成功"。也不对——一个响应慢到三四十秒才回来的调用,它在技术上"成功"了,可在用户眼里它和失败没区别,没人会等。如果你只把"抛异常"算作失败,这种"慢性失败"就会永远绕过你所有的容错机制——熔断不会触发、转移不会发生,用户就那么干等着。所以你必须主动地、显式地给"可用"下一个定义:不只是"没报错",而是"没报错,而且快到能用"。把"太慢"显式地转换成一次失败,你的熔断和转移才能真正覆盖到这种最难缠的故障。转移和熔断,处理的都是"还有别的供应商能顶上"的情况;可万一所有供应商都挂了呢——这要靠最后一步。
五、降级:所有供应商都不可用时给用户兜底
第四步,处理那个最坏的情况:故障转移链上每一家供应商都失败了(call_with_breaker 抛出了 AllProvidersDown)。这时候绝不能让这个异常原样冒到用户面前——变成一个 500 错误页、或者一个红色的报错弹窗。要做降级:给用户一个虽然不完美、但体面的兜底:
def call_llm_safe(entries, prompt, cache, timeout=15):
"""全链路兜底:供应商全挂时,退回缓存或一句诚实的降级提示,绝不把异常抛给用户。"""
try:
result = call_with_breaker(entries, prompt, timeout=timeout)
cache.set(prompt, result.text) # 成功的结果顺手缓存,留作将来的兜底
return result.text
except AllProvidersDown:
cached = cache.get(prompt)
if cached is not None:
log.warning("供应商全部不可用,返回缓存里的旧结果")
return cached # 兜底一:有旧结果,先用旧的顶上
log.error("供应商全部不可用,且无缓存,返回降级提示")
return "AI 服务暂时繁忙,请稍后重试。" # 兜底二:连旧的都没有,给一句诚实的提示
这里的认知要点是:降级要做对,关键是想清楚"全挂的时候,到底该给用户什么"。一个很自然但是错的想法是:全挂了,那就把错误如实抛给用户呗——让他看到"服务异常"。这错在它把"系统内部的失败"直接等同于"用户该承受的失败"。用户不关心你接了几家供应商、它们是不是都挂了,用户只关心"我点的这个功能,现在到底能给我什么"。降级要做的,就是在"完美结果"和"赤裸裸的报错"之间,塞进几个层次的退路。这些退路是有优先级的:最好的退路,是返回一个稍旧的、但仍然有用的缓存结果——用户多半根本察觉不到它是旧的;退而求其次,如果连缓存都没有,那就给一句诚实、友好、明确的提示——"AI 服务暂时繁忙,请稍后重试",它至少告诉了用户"这不是你的错,等会儿再来",而不是一个冷冰冰的 500。这里还藏着一个良性循环:你在每次成功时顺手写缓存,其实是在为"将来某次全挂"悄悄地攒退路——平时跑得越多,缓存里能兜底的内容就越丰富。降级的终极目标是:让"所有供应商都挂了"这件本该是灾难的事,对用户而言,最多只是一次"结果不够新"或"稍后再试"的轻微不便。四步退路齐了,最后是几个把多供应商容灾真正用到生产里才会撞见的工程坑。
六、工程坑:成本分级、配额隔离、灰度切换、可观测
四步退路之外,还有几个工程坑,不处理就会让你的容灾在边角上出问题。坑 1:转移链的顺序,要体现成本与质量的取舍。故障转移链不是随便排的——它既是"挂了往哪切"的顺序,也是"平时优先用谁"的顺序。通常把质量最好的放第一位当主力,把便宜或一般的放后面当备胎。这个顺序该集中成一份配置,而不是散在代码里:
# 供应商转移链:顺序既是"故障转移的次序",也体现质量与成本的取舍
PROVIDER_CHAIN = [
{"name": "acme", "tier": "primary", "note": "质量最好,首选主力"},
{"name": "bolt", "tier": "backup", "note": "质量够用,主供应商挂了顶上"},
{"name": "echo", "tier": "last", "note": "便宜但一般,前两家全挂才用"},
]
def build_entries(registry):
"""按链上的顺序,把每家供应商和它各自的熔断器配成一条故障转移链。"""
entries = []
for cfg in PROVIDER_CHAIN:
provider = registry[cfg["name"]]
entries.append((provider, ProviderBreaker())) # 每家一个独立的熔断器
return entries
坑 2:每家供应商一个独立的熔断器。注意上面 build_entries 里,每家供应商配的是各自独立的 ProviderBreaker()。绝不能让几家供应商共用一个熔断器——那样 A 家的失败会污染 B 家的计数,A 一挂、B 也跟着被误判熔断,容灾直接失效。坑 3:配额与限流要按供应商隔离。每家供应商的限流额度是各算各的。你做客户端限流时,要给每家维护一套独立的配额计数,而不是一个全局的——否则 A 家的高频调用,会把 B 家本来还充裕的额度也"算"掉。坑 4:换主供应商要灰度,别一刀切。想把主力从 A 换成 B,别直接改配置全量切过去——B 家在你真实流量下的质量、延迟、稳定性,你其实没底。正确的做法是先切一小部分流量给 B、观察指标,再逐步放大(这正是灰度发布那一套)。坑 5:不同供应商的输出有差异,要做一致性兜底。同一个 prompt,A 家和 B 家给的回答风格、格式可能不一样。如果你的下游依赖某种固定格式(比如要求返回 JSON),那么每家适配器的输出都要做一遍格式校验和规整,别让"换了供应商"导致下游解析崩掉。坑 6:容灾过程必须可观测。故障转移最危险的地方,恰恰是它太安静——A 家挂了、自动切到了 B 家,功能表面上一切正常,你可能好几天都不知道 A 已经挂了。必须给每家供应商单独记账:成功率、失败数、平均延迟、被熔断了几次,分供应商统计:
def record_provider_call(stats, provider_name, ok, latency_ms):
"""给每家供应商单独记账:成败、延迟分开统计,才看得清谁在拖后腿。"""
s = stats.setdefault(provider_name, {"ok": 0, "fail": 0, "latency_sum": 0.0})
if ok:
s["ok"] += 1
s["latency_sum"] += latency_ms
else:
s["fail"] += 1
def provider_health_report(stats):
"""汇总每家的成功率与平均延迟 —— 故障转移有没有在悄悄兜底,一看便知。"""
for name, s in stats.items():
total = s["ok"] + s["fail"]
ok_rate = s["ok"] / total if total else 0.0
avg_lat = s["latency_sum"] / s["ok"] if s["ok"] else 0.0
log.info("供应商 %s 成功率=%.1f%% 平均延迟=%.0fms 总调用=%d",
name, ok_rate * 100, avg_lat, total)
坑 7:别忘了把 prompt 也做成可移植的。有些 prompt 写法是针对某一家模型调优的,换一家可能效果就差了。重要的 prompt,要在每家供应商上都验证一遍效果,别假设"在 A 家好用的 prompt,在 B 家一样好用"。
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 把大模型当稳定 API 的错 | 它是会整体失效的外部依赖,不是你系统里的函数 |
| 外部依赖的失效模式 | 会宕机、限流、变慢、涨价、改接口,你都无法控制 |
| 供应商抽象层 | 统一 LLMProvider 接口,业务代码不知底层是哪家 |
| 适配器 | 每家供应商一个适配器,把 SDK 差异消化在内部 |
| 故障转移链 | 主供应商失败,沿链自动切到下一家备用供应商 |
| 转移依赖独立性 | 不同供应商通常不同时故障,这是多备份的意义 |
| 供应商级熔断 | 一家连续失败就在冷却期内直接跳过,不再死磕 |
| 慢也是一种失败 | 响应虽回但延迟过高,主动当失败,触发熔断转移 |
| 降级兜底 | 全部供应商不可用时,退回缓存或友好提示 |
| 按供应商分别可观测 | 成败延迟分供应商记账,容灾才不会悄无声息 |
避坑清单
- 别把第三方大模型 API 当稳定函数,它是会整体失效的外部依赖。
- 用统一的 LLMProvider 抽象层,把业务代码和具体供应商解耦。
- 每家供应商写一个适配器,把各家 SDK 的差异消化在适配器内部。
- 多接几家供应商排成故障转移链,一家失败自动切到下一家。
- 带副作用的 LLM 调用做故障转移前,必须先保证它是幂等的。
- 给每家供应商配独立熔断器,挂掉的供应商在冷却期内直接跳过。
- 把"响应太慢"也显式当成一次失败,让它一样触发熔断和转移。
- 供应商全挂时要降级,退回缓存或友好提示,绝不把异常抛给用户。
- 转移链顺序集中配置,体现质量与成本取舍,换主供应商要灰度。
- 给每家供应商单独记账,别让故障转移悄无声息地掩盖问题。
总结
回头看那串"供应商宕机整个功能全挂、被限流时好时坏、变慢却无人察觉、想换供应商却换不动"的问题,以及我后来在容灾上接连踩的坑,最该记住的不是某一段转移代码的写法,而是我动手前那个想当然的判断——"接大模型,就是调一个 API"。这句话错在它把一个"外部依赖",当成了一个"本地函数"。我以为调用大模型,和调用我自己代码里的一个函数,是一回事——它永远在那儿,我调它它就执行。可我忽略了一件最要紧的事:第三方大模型 API,跑在我够不着的机器上,由我管不到的团队运维。它的"在不在、快不快、让不让我用",完全是别人说了算。它有一组我自己的函数永远不会有的失效模式:整体宕机、限流、突然变慢、涨价、改接口。而面对这每一种失效,我能做的事只有一件——选择"还要不要继续依赖它"。绑死单一供应商,等于在这个选择上放弃了所有余地:把整个功能的可用性,焊死在了一家我毫无影响力的公司身上。
所以做好大模型接入,真正的工程量不在"调一次 API"那一行上。调 API 本身不难。真正的工程量,在于你要承认"任何一家供应商都迟早会整体不可用",然后为这件迟早会发生的事,准备好一整套不依赖它的退路:你不能让业务代码绑死某一家,就用抽象层把它们解耦、让业务只认统一接口;你不能赌一家永不宕机,就多接几家排成故障转移链、一家挂了自动切下一家;你不能对一个已经挂掉的供应商反复死磕,就给每家配熔断器、还把"变慢"也当成失败;你不能让"全挂"直接砸到用户脸上,就用缓存和友好提示做降级兜底。这篇文章的几节,其实就是顺着这套退路展开的:先想清楚"把大模型当稳定 API"为什么错,再讲抽象层怎么搭、故障转移怎么做、熔断与慢失败怎么处理、降级怎么兜底,最后是成本分级、配额隔离、灰度切换、可观测这几个把容灾守扎实的工程细节。
你会发现,这件事和现实里"一家店铺该怎么安排自己的进货渠道"完全相通。一个没有风险意识的老板会怎么做?他找到一家供货商,价格合适、货也好,于是把店里所有的货,全部、长期地,只从这一家进。平日里这看起来再省心不过——一个电话,货就到了。可天有不测:这家供货商某天工厂失火停产了,他的货架第二天就空了一片,他除了干等对方恢复,毫无办法;供货商旺季供不应求、对他限量供应,他的店就三天有货、两天断货;供货商没断货,但发货慢了、十天半个月才到,他的货名义上"在路上",实际上等于没有;等他终于想换一家供货商,才发现自己连别家的门朝哪开都不知道。而一个有风险意识的老板怎么做?他手里始终攥着三四家供货商的联系方式:平时,他从货最好的那家进(这就是转移链上的主供应商);这家一旦出问题,他立刻打给第二家、第三家顶上(这就是故障转移);他心里清楚哪家最近老是掉链子,那家就先不找了、过阵子再说(这就是熔断);真碰上几家一起出事的极端情况,他也先把仓库里的存货摆上架、再在门口诚实地贴张告示,而不是让顾客对着空货架一脸茫然(这就是降级兜底)。同样是开店进货,没有风险意识的老板把全店的命脉系在一根别人手里的绳上,有风险意识的老板让任何一家出事都动摇不了自己的生意——差别不在"找到的供货商好不好",只在老板有没有把"供货商迟早会出事"当成一件必然会发生、必须提前安排退路的事。
最后想说,大模型接入做没做对,差距永远不会在"本地开发、自己调几下测试"时暴露——本地你调的那家供应商恰好一直好好的:没宕机、没限流、也不慢,你那行直接 client.chat 每次都干净利落地返回结果,你自然觉得"接大模型,不就是调一个 API"一点问题都没有。它只在真实的、长期运行的、要扛真实流量的生产环境里才显形——因为在足够长的时间里,你依赖的那家供应商,一定会有那么一天出事。那时候它会用最难堪的方式给你结账:做不好,你会因为绑死单一供应商,在它宕机的那几个小时里,眼睁睁看着整个 AI 功能彻底瘫痪而无能为力,会因为没有熔断,让每个请求都白白搭上一次超时等待,会因为没有降级,把一个赤裸裸的报错直接糊到用户脸上;而做对了,某一家供应商宕机时,你的流量会在用户毫无察觉之间,平静地切到下一家,某一家变慢时,它会被当成失败、被熔断跳过,哪怕真的全部供应商一起出事,用户得到的也最多是一个稍旧的缓存结果、或一句客气的"稍后再试"。所以别等"一次供应商大宕机把你的 AI 功能打挂一整天"那一刻找上门,在你写下每一行调用大模型的代码时就该想清楚:这次调用绑死单一供应商了吗、主供应商挂了能自动切吗、挂掉的供应商会被熔断跳过吗、全挂了用户能得到一个体面的兜底吗,这一条条退路,我是不是都替它铺好了?这些问题有了答案,你交付的才不只是一个"本地调得通"的 AI 功能,而是一套任凭哪一家供应商出事都照样稳稳运转、让人放心的 AI 系统。
—— 别看了 · 2026