一次死循环烧掉 340 块:LLM 网关的限流与熔断该怎么做

事情的起因

某个周一早上,我照常打开后台看昨天的账单,数字是 412 块。

前一周同一天是 72 块。

我以为是自己周末测了什么大任务,翻日志才发现:凌晨三点到七点这四小时里,网关发出了 12 万次请求。

四小时,12 万次。平均每秒 8.3 次。

而那个项目当时的日活用户不到 200 人。

原因很蠢:前端有个"重试"逻辑,写成了 while (!success) 循环,而某个 bug 导致 success 永远不会被置为 true。就这么一个循环,跑了四个小时。

更尴尬的是:我的网关完全没有任何限流。后端来多少请求就转多少,转多少就计多少费。

这篇文章讲我后来怎么补的,以及每层踩过的坑。


一、为什么"加个限流"没那么简单

我一开始以为限流就是中间件的事,套个现成的库就行。

实际做下来发现,LLM 场景和普通 Web API 完全不同,有三个特殊点:

1. 单次成本高几个数量级

普通查询 API 的请求成本约等于零,但一次 LLM 调用可能花掉几分到几毛。按"每秒千次"这种常规限流来做,一次就能烧掉几百块。

2. 不同请求的成本差异巨大

100 token 的翻译请求和 5 万 token 的文档分析,成本差 500 倍。用"请求数"做限流维度,等于没限。

3. 上游自己也有限流

你限得再严,上游该给你 429 还是会给。所以不能只做入口限流,还得处理上游反馈。

所以限流必须分层做。我最后落了三层。


二、第一层:请求级限流

目标很简单:不让单个用户或单个来源把系统占满。

先按请求数限

python 复制代码
import time
from collections import defaultdict

class RateLimiter:
    def __init__(self, max_per_minute: int = 20):
        self.max = max_per_minute
        self.windows = defaultdict(list)

    def allow(self, key: str) -> bool:
        now = time.time()
        window = self.windows[key]
        # 清理一分钟前的记录
        while window and now - window[0] > 60:
            window.pop(0)
        if len(window) >= self.max:
            return False
        window.append(now)
        return True

处理请求前检查一下:

kotlin 复制代码
user_key = request.headers.get("x-user-id", "anonymous")
if not limiter.allow(user_key):
    return JSONResponse({"error": "请求过于频繁"}, status_code=429)

坑一:只按次数限根本不够

上线之后我发现,有个用户虽然每分钟只发 20 次,但每次都是 3 万字的长文档。20 次 × 3 万 token,一分钟就能烧掉好几块。

光限次数不行,还得限 token。

再按 token 量限

python 复制代码
class TokenLimiter:
    def __init__(self, max_tokens_per_minute: int = 30000):
        self.max = max_tokens_per_minute
        self.windows = defaultdict(list)   # key -> [(ts, tokens)]

    def allow(self, key: str, estimated_tokens: int) -> bool:
        now = time.time()
        self.windows[key] = [
            (t, n) for t, n in self.windows[key] if now - t <= 60
        ]
        window = self.windows[key]
        used = sum(n for _, n in window)
        if used + estimated_tokens > self.max:
            return False
        window.append((now, estimated_tokens))
        return True

这里有个细节:要估算请求的 token 数,因为这时还没调用上游。

python 复制代码
def estimate_request_tokens(body: dict) -> int:
    text = json.dumps(body.get("messages", []), ensure_ascii=False)
    return max(1, len(text) // 2)   # 中文场景约 2 字符/token

估不准没关系。限流不需要精确,只要有一个数量级的保护就够。

坑二:内存存储多实例会失效

上面这段跑在单机上没问题,但一上多实例,每个实例维护自己的计数器,实际额度就翻倍了。

生产环境得用 Redis:

python 复制代码
import redis
r = redis.Redis()

def allow_by_redis(key: str, limit: int, window: int = 60) -> bool:
    """滑动窗口:用 zset 存时间戳"""
    now = time.time()
    pipe = r.pipeline()
    pipe.zremrangebyscore(key, 0, now - window)
    pipe.zadd(key, {str(now): now})
    pipe.zcard(key)
    pipe.expire(key, window)
    _, _, count, _ = pipe.execute()
    if count > limit:
        r.zrem(key, str(now))
        return False
    return True

三、第二层:消费级熔断

这一层最容易被忽略,但也是最有效的一层。

思路很朴素:不管请求数多少,只要今天花超了预算,就先停下来。

日消费上限

python 复制代码
from datetime import datetime

class CostGuard:
    def __init__(self, daily_limit: float):
        self.daily_limit = daily_limit
        self.spent = 0.0
        self.reset_date = None
        self._alerted = False

    def _check_reset(self):
        today = datetime.now().date()
        if self.reset_date != today:
            self.spent = 0.0
            self.reset_date = today
            self._alerted = False

    def check(self) -> tuple[bool, str]:
        self._check_reset()
        if self.spent >= self.daily_limit:
            return False, f"今日预算已用完({self.spent:.2f}/{self.daily_limit})"
        if self.spent >= self.daily_limit * 0.8 and not self._alerted:
            self._alerted = True
            alert(f"今日消费已达 {self.spent / self.daily_limit * 100:.0f}%")
        return True, ""

    def add(self, cost: float):
        self.spent += cost

用法:

ini 复制代码
ok, msg = cost_guard.check()
if not ok:
    return JSONResponse({"error": msg}, status_code=503)

坑三:上限不能一刀切

一开始我设了个全局日上限 300 块。结果某个正常的大客户在月中用超了,整个站被停。

后来改成三级预算:

层级 举例 上限
全局 整个网关 500 元/日
业务线 文档分析功能 200 元/日
用户 单个 API 用户 20 元/日

任意一级超限,就拦截对应粒度的请求,不影响其他部分。这样即使某个用户跑飞了,也只影响他自己。

坑四:拦了之后不能直接报错

直接 503 用户体验很差。更好的做法是:超预算后降级到便宜模型,而不是直接拒绝。

python 复制代码
def choose_model(requested_model: str) -> str:
    ok, _ = cost_guard.check()
    if ok:
        return requested_model
    return "cheap-model"    # 超预算,降级到最便宜的

对很多非核心场景,降级后用户几乎感知不到,但成本能降一个数量级。


四、第三层:上游级降级

前两层是"限制自己",这一层是"应对上游"。

处理 429 和 5xx

上游也会给你限流,返回 429。这时候不能傻等,也不能无脑重试。

先记录健康度:

python 复制代码
from collections import deque

class UpstreamHealth:
    def __init__(self, name: str, window: int = 60):
        self.name = name
        self.events = deque()

    def record(self, status_code: int):
        now = time.time()
        is_err = status_code >= 500 or status_code == 429
        self.events.append((now, is_err))
        while self.events and now - self.events[0][0] > 60:
            self.events.popleft()

    def error_rate(self) -> float:
        if not self.events:
            return 0.0
        return sum(1 for _, e in self.events if e) / len(self.events)

坑五:切流要按比例,不能一把全切

这一点我吃过大亏。第一版实现是"一超阈值就全切到备用",结果备用上游瞬间被压垮,比原来的故障还严重。

正确做法是渐进切换:

python 复制代码
import random

def should_use_backup(health: UpstreamHealth) -> bool:
    rate = health.error_rate()
    if rate < 0.3:
        return False
    ratio = min((rate - 0.3) / 0.7, 0.8)   # 最多切 80%
    return random.random() < ratio

坑六:降级后要补语义兜底

备用上游的返回格式可能和主上游不一致,前端解析会失败。用户看到的不是"降级响应",而是"报错"。

所以降级后一定要加输出校验:

python 复制代码
def safe_parse(response: dict) -> str:
    try:
        content = response["choices"][0]["message"]["content"]
        assert isinstance(content, str) and content.strip()
        return content
    except (KeyError, IndexError, AssertionError):
        return "服务暂时繁忙,请稍后再试"

五、三层是怎么协作的

请求进来后的完整流程:

css 复制代码
请求
 ├─ [1] 用户级限流:请求数 / token 数超了?→ 429
 ├─ [2] 消费级熔断:日预算超了?→ 降级或 503
 ├─ [3] 选上游:主上游错误率高?→ 按比例切备用
 ├─ 转发请求
 └─ 记录用量、更新计数器、检查告警

三层各有分工:

层 防的是什么 触发条件
请求级限流 单用户跑飞 次数或 token 超限
消费级熔断 整体成本失控 日预算耗尽
上游级降级 上游故障或限流 错误率过高

缺任何一层都有漏洞:

  • 只有限流没有熔断 → 正常用户高频使用也能烧穿预算
  • 只有熔断没有限流 → 一天开头就跑飞,预算瞬间耗尽
  • 只有前两层没有降级 → 上游一挂,整个服务不可用

六、上线效果

补完这三层之后,那次 340 块的事故变成了可预防的:

  • 死循环跑到第 20 秒就会被用户级限流拦住
  • 就算绕过了,也会在 5 分钟内触发用户预算熔断
  • 全程有告警,不用等到早上看账单才发现

一个月的实际运行数据:

指标 改之前 改之后
单次异常最大损失 340 元 4 元
异常发现时间 次日早上 5 分钟内
上游故障影响面 全站不可用 单业务线降级
429 对用户可见 是 否(内部消化)

七、四条设计原则

1. 限流要分级,不能一刀切

全局、业务线、用户三级,各管各的。用单层限流,一定会出"一个用户拖垮整个系统"的事。

2. 熔断要能降级,而不是拒绝

能降级到便宜模型就别直接 503。用户体验差别巨大,成本却可能降一个数量级。

3. 所有限制都必须配告警

限制本身不会发现问题,告警才会。我现在的设置是:消费达 80% 提醒、触发限流提醒、上游错误率异常提醒。

4. 能自动化的防护,绝不依赖人工检查

我最初的想法是"上线前我人工看一眼日账单",事实证明完全不靠谱------人不会每天看,但循环不会停止。能自动化的防护必须自动化,人工检查只能作为补充。


最后

那次事故之后我最大的感受是:成本问题往往不是"用得太贵",而是"用得失控"。

12 万次请求里,绝大部分都是垃圾调用,一次有效输出都没有。如果我一开始就做了限流,这 340 块根本不会发生。

对一个 AI 应用来说,限流熔断不是可选优化,是上线前必须做的事。它可能平时完全不显眼,但一旦出事,它就是唯一能救你的东西。

相关推荐
泯泷17 小时前
单个 AI 再强,为什么也干不过一支 AI 团队?
人工智能·llm·agent
ndz18 小时前
Claude Fable 5.2 今日上线 ModelFold:百万上下文,挑战复杂难题!
llm·aigc·claude
泯泷1 天前
同样是多 Agent,为什么有的稳,有的却天天翻车?
人工智能·llm·agent
用户956256254291 天前
五分钟用 E2B SDK 打通沙箱:环境变量只改三行
python·api
用户298698530141 天前
用 Python 自动化把 JavaScript 代码转换为 Word 文档
javascript·python·api
泯泷2 天前
像项目经理一样指挥 AI 团队:管理者模式
人工智能·llm·agent
用户956256254292 天前
跟做:Firecrawl 单页转 Markdown 再丢进沙箱总结
python·api
2601_967980112 天前
Python行情API量化 02:顺序拉 20 要 2 秒?并发批量把时间压到 0.5 秒
python·api·限流·并发·量化
DigitalOcean2 天前
Kimi K3.1 Pro 现已上线 DigitalOcean 无服务器推理
llm