给大模型调用加一个熔断器:60 行代码,三个阈值

0 阅读6分钟

去年冬天一个晚上,我们的写作助手挂了 62 秒。

不是崩了,是"一直转圈"。用户点了发送,屏幕上一个光标闪了一分钟,然后弹出一句"生成失败,请重试"。

那晚服务端 5xx 高峰持续了 11 分钟。我们发了 4,200 次请求,其中 2,100 次以超时结束,平均每个用户等了 62 秒。事后看日志,前 300 次超时的时候我们就该停下来——但我们没有停,因为代码里写的是 max_retries=3,每一次都老老实实等满 60 秒超时。

重试本身不是问题,"在已知对方挂了的时候还重试"才是问题。

这篇文章讲我加的那个熔断器:三个阈值、60 行代码、上线半年拦住两次事故。

一、为什么大模型调用特别需要熔断

传统 HTTP 服务加熔断,通常是为了防雪崩。大模型调用有三个不一样的地方:

第一,单次请求特别慢。  首字延迟正常 800ms,但一旦服务端排队,能拖到 40 秒以上。超时设 60 秒是常规做法,这意味着一次失败要占用一个连接 60 秒。

第二,重试特别贵。  一次调用可能几千 token,重试三次就是三倍的钱。服务端挂的时候,你不但没拿到结果,还把钱花出去了。

第三,失败是成片出现的。  服务端抖动不是随机的,是一段一段的。你要是能识别出"这段是坏的",跳过这一段,收益极大。

那次事故的数字很直观:

没熔断加了熔断(事后复盘模拟)
发出去的请求4,2004,200
真正打到服务端4,2002,140
用户平均等待62 秒1.2 秒
失败前浪费的调用费约 210 元约 96 元

image.png

多出来的 2,060 次,全是在服务端已经明确故障之后发出去的。

二、三个阈值,缺一不可

我的实现只有一个类,三个参数:

class Breaker:
    def __init__(self, window=20, fail_ratio=0.5, cooldown=30.0):
        self.window = window          # 只看最近 20 次的结果
        self.fail_ratio = fail_ratio  # 失败率超过 50% 就打开
        self.cooldown = cooldown      # 打开后 30 秒进半开

阈值一:窗口 = 20 次。  不建议用"连续失败 N 次",因为大模型调用里混着用户取消、内容审核拦截这类非服务端失败,连续计数容易被个别请求带偏。用滑动窗口看比例,抗噪。

窗口太小(比如 5)会误判——一次批量任务是并发发的,5 个里有 3 个超时很正常;窗口太大(比如 200)反应太慢,200 次 × 60 秒的损失已经发生了。20 是我试出来的平衡点。

阈值二:失败率 = 50%。  这个不能设太高。日常我们的失败率在 0.3%–0.8%,50% 已经是明确异常。设 30% 会在早晚高峰误触发,设 80% 等于没设。

阈值三:冷却 = 30 秒。  打开后不再放行,30 秒后进"半开",放 3 个探测请求过去。3 个里 2 个成功就关闭,否则重新打开。30 秒的依据是:服务端抖动通常以分钟为单位的块状出现,探测太频繁只是给已经挂掉的服务端添乱。

三、状态机就三个状态

import time
from collections import deque

class Breaker:
    def __init__(self, window=20, fail_ratio=0.5, cooldown=30.0, probes=3):
        self.window, self.fail_ratio = window, fail_ratio
        self.cooldown, self.probes = cooldown, probes
        self.results = deque(maxlen=window)
        self.state = "closed"
        self.opened_at = 0.0
        self.probe_ok = 0
        self.probe_total = 0

    def allow(self) -> bool:
        if self.state == "closed":
            return True
        if self.state == "open":
            if time.monotonic() - self.opened_at >= self.cooldown:
                self.state, self.probe_ok, self.probe_total = "half", 0, 0
                return True
            return False
        # half-open:只放行有限个探测
        return self.probe_total < self.probes

    def record(self, ok: bool):
        if self.state == "half":
            self.probe_total += 1
            self.probe_ok += int(ok)
            if self.probe_total >= self.probes:
                if self.probe_ok >= 2:
                    self.state, self.results = "closed", deque(maxlen=self.window)
                else:
                    self.state, self.opened_at = "open", time.monotonic()
            return
        self.results.append(ok)
        if len(self.results) == self.window:
            ratio = 1 - sum(self.results) / len(self.results)
            if ratio >= self.fail_ratio:
                self.state, self.opened_at = "open", time.monotonic()

调用处的改动很小:

from openai import OpenAI
import os

client = OpenAI(
    api_key=os.environ["API_KEY"],
    base_url="https://api.tokenfreeai.com/v1",   # OpenAI 兼容
    timeout=60, max_retries=0,               # 重试交给熔断器判断
)
breaker = Breaker()

def ask(prompt: str) -> str | None:
    if not breaker.allow():
        return None                          # 快速失败,别让用户干等
    try:
        r = client.chat.completions.create(
            model="glm-5",
            messages=[{"role": "user", "content": prompt}],
        )
    except Exception:
        breaker.record(False)
        raise
    breaker.record(True)
    return r.choices[0].message.content

注意两件事:max_retries=0 必须写,否则 SDK 自带的退避重试会绕过熔断器;返回 None 的时候上层要能接住,这一步比熔断逻辑本身更容易漏。

四、返回 None 之后干什么

这是很多人漏掉的一半。熔断的价值不在"拦",在"拦了之后给什么"。

我们的三级降级:

  1. 换模型。  主力模型熔断后,切到同厂的轻量版,输出质量掉一档但能用。实测切换后用户投诉量没有明显上升,因为大部分请求是润色、起标题这类任务。
  2. 排队。  非实时任务(夜间批处理、报告生成)进延迟队列,5 分钟后重试,用户端显示"处理中,完成后通知"。
  3. 明说。  实时对话类如果两样都不行,直接告诉用户"当前服务繁忙",而不是让他等一分钟再看失败。

第三条最难做,但最重要。  那个 62 秒的晚上,用户真正愤怒的不是失败,是"你不知道它会不会成功"。

五、上线半年的两次拦截

第一次是四月份,服务端区域性故障,熔断在 38 秒后打开,拦掉了 1,800 多次请求。用户平均等待从预估的 50 秒降到 1.1 秒。

第二次更隐蔽:我们自己的一个脚本死循环在重试,一小时打了 9,000 次调用。熔断器本身没拦(因为服务端是好的),但同一套状态机加了个"每小时调用量超阈值就打开"的旁路,把它挡住了——那次省了大约 380 元。

六、一个坑:别把熔断器做成分区无关的

我们刚开始只有一个全局熔断器。后来发现一个问题:主力模型熔断后,所有请求都被挡在外面,包括本来可以走备用模型的那部分。

后来改成按"模型 + 区域"分桶。每个桶一个熔断器,桶之间互不影响。这个改动只多了三行(用 dict 键存实例),但可用性提升比熔断器本身还大。

如果你的调用是走 OpenAI 兼容协议的,这件事做起来很省——同一份客户端代码,换个 model 字符串就是一个新桶,不用为每个厂商写一套适配。我们这边用的是词元云,一个密钥管着几个模型,横评和切桶都在同一个客户端里;不过你直接用各家的官方接口也完全能跑,熔断逻辑是一样的。

七、要不要上现成的

有现成的库,pybreaker、tenacity 都行。我最后自己写,原因是三个阈值的语义要按大模型调用的特点调(窗口用次数不用时间、失败判据要能排除内容审核拦截),现成库把这层封得比较死。

如果你只是想要个能跑的,用现成的完全不丢人。这 60 行真正值钱的不是代码,是那三个数字背后的判断。