事情的起因
某个周一早上,我照常打开后台看昨天的账单,数字是 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 应用来说,限流熔断不是可选优化,是上线前必须做的事。它可能平时完全不显眼,但一旦出事,它就是唯一能救你的东西。