06 — 按用户请求数限流(user_request_limiter)¶
第五类限流器:基于 Redis ZSET 滑动窗口的纯请求数限流,不依赖 token 数、spend、模型价格。
1. 与其他限流器的区别¶
现有的四类限流器(01-limiter-types.md)全部依赖 response_cost 或 token 计数——必须实际调上游 LLM 才能累加。user_request_limiter 不同:
| 维度 | 现有四类 | user_request_limiter |
|---|---|---|
| 限流指标 | spend(USD)/ token 数 / 并发数 | 请求次数 |
| 计数时机 | post-call(LLM 响应后) | pre-call(网关收到请求即计数) |
| 依赖上游 LLM | 是(需获取 usage 或 response_cost) | 否——不需要任何模型 |
| 失效条件 | cost 图缺失、cache 价格漏配 | 仅 Redis 不可用(fail-open) |
| 计数方式 | Redis counter / DB spend 列 | Redis ZSET 滑动窗口 |
核心差异:即使没有配置任何模型、模型不存在、或请求最终返回 400/401,
user_request_limiter仍然计数。只要请求到达网关并被认证识别出user_id,就算一次。
2. 架构¶
HTTP 请求到达
│
├─ user_api_key_auth → 识别 user_id
│
└─ async_pre_call_hook (user_request_limiter)
│
├─ ENABLED=false? → return(跳过)
├─ user_id 为空? → return(跳过)
├─ max_requests ≤ 0? → return(跳过)
│
└─ Redis Lua 脚本(原子操作):
ZCOUNT key (now-window) +inf → current
if current >= max → BLOCKED 429
ZADD key now_ts now_ts
EXPIRE key window+60
ZREMRANGEBYSCORE 清理过期条目
return OK
Redis 数据结构¶
| 字段 | 值 |
|---|---|
| Key | user_request_counter:{user_id} |
| 类型 | ZSET(sorted set) |
| Member | Unix timestamp(请求时刻) |
| Score | Unix timestamp(同 member) |
| TTL | window_seconds + 60 秒 |
每次请求在 Lua 脚本中完成三件事:ZCOUNT 判断 → ZADD 记录 → ZREMRANGEBYSCORE 清理。整个操作在单次 Redis 往返中原子完成,无竞态。
3. 配置¶
环境变量¶
| 变量 | 默认值 | 说明 |
|---|---|---|
USER_REQUEST_LIMIT_ENABLED |
true |
开关,设为 false 完全关闭限流 |
USER_REQUEST_LIMIT_WINDOW_MINUTES |
300 |
滑动窗口长度,单位分钟 |
USER_REQUEST_LIMIT_MAX_REQUESTS |
500 |
窗口内允许的最大请求数 |
关闭方式(两种等价)¶
USER_REQUEST_LIMIT_ENABLED=false— 整个 hook 跳过USER_REQUEST_LIMIT_MAX_REQUESTS=0— 计数逻辑跳过
生效方式¶
Hook 通过 PROXY_HOOKS 字典自动加载(hooks/init.py),不需要在 proxy_server_config.yaml 中配置。仅需设置环境变量后重启。
示例¶
# 压测用:5 分钟窗口,最多 5 次
USER_REQUEST_LIMIT_ENABLED=true
USER_REQUEST_LIMIT_WINDOW_MINUTES=5
USER_REQUEST_LIMIT_MAX_REQUESTS=5
# 生产默认:300 分钟(5 小时),500 次
USER_REQUEST_LIMIT_ENABLED=true
USER_REQUEST_LIMIT_WINDOW_MINUTES=300
USER_REQUEST_LIMIT_MAX_REQUESTS=500
# 关闭
USER_REQUEST_LIMIT_ENABLED=false
4. 限流响应¶
超过限制时返回 HTTP 429:
{
"error": {
"message": "User request limit reached: 5 requests per 5.0min. Current count: 5.",
"type": "None",
"param": "None",
"code": "429"
}
}
消息中包含当前配置参数,方便排查。
5. 解封¶
方式一:清 Redis key(立即生效)¶
下一个请求立即可通过,重新开始计数。
方式二:等待窗口自然过期¶
窗口从最后一次请求起算。ZSET TTL 为 window_seconds + 60,过期后 Redis 自动删除 key。但由于 ZCOUNT 只看窗口内的条目,实际上最早条目滑出窗口后,新请求即可通过——不需要等整个 key 过期。
6. 与 auth_checks 内 budget 检查的关系¶
user_request_limiter 在 ProxyLogging.pre_call_hook 链中执行,位于 user_api_key_auth 之后、实际 LLM 调用之前。与 auth_checks.py 内的 _virtual_key_max_budget_check 等 budget 检查完全独立——两者互不影响,一个请求需要同时通过两类检查。
关键区别:auth_checks 内的 budget 检查受 _is_model_cost_zero 旁路影响(见 05-skip-budget-checks-bug.md),user_request_limiter 不受该旁路影响。
7. 设计决策¶
为什么 pre-call 计数¶
计数所有到达网关的请求(包括最终失败的),而非仅成功的 LLM 调用。理由:
- 更难绕过——攻击者发起的无效请求也会消耗配额
- 实现简单——不需要 post-call hook,不用等 LLM 响应
- 避免"请求发出但上游超时导致计数丢失"的问题
为什么 fail-open¶
Redis 不可用、Lua 脚本注册失败、user_id 缺失等异常场景下静默放行,不阻塞正常流量。限流是防护手段,不能成为故障源。
为什么用 ZSET 而非 simple counter¶
滑动窗口需要区分"窗口内的请求"和"窗口外的请求"。ZSET 天然支持按 score(timestamp)范围查询和清理,配合 Lua 脚本实现原子 check-and-add。
8. 验证方法¶
# 1. 查看 Redis 中某用户的计数
redis-cli ZCARD "user_request_counter:{user_id}"
redis-cli ZRANGE "user_request_counter:{user_id}" 0 -1 WITHSCORES
redis-cli TTL "user_request_counter:{user_id}"
# 2. 快速压测(窗口 5min / 上限 5次)
for i in {1..8}; do
curl -s -w "\n%{http_code}\n" -X POST \
http://127.0.0.1:4000/ai-gateway/v1/chat/completions \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"hi"}]}'
done
# 预期:前 5 次正常(或 400),第 6 次起返回 429
# 3. 手动解封
redis-cli DEL "user_request_counter:{user_id}"
9. 相关文件¶
| 文件 | 说明 |
|---|---|
| litellm/proxy/hooks/user_request_limiter.py | Hook 实现 |
| litellm/proxy/hooks/init.py | 注册 user_request_limiter 到 PROXY_HOOKS |
| .env.local.example | 本地开发环境变量模板 |
| references/custom-rate-limit-hook.md | 自定义限流 Hook 模板(含 Redis ZSET + Lua 模式) |