跳转至

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(立即生效)

redis-cli DEL "user_request_counter:{user_id}"

下一个请求立即可通过,重新开始计数。

方式二:等待窗口自然过期

窗口从最后一次请求起算。ZSET TTL 为 window_seconds + 60,过期后 Redis 自动删除 key。但由于 ZCOUNT 只看窗口内的条目,实际上最早条目滑出窗口后,新请求即可通过——不需要等整个 key 过期。


6. 与 auth_checks 内 budget 检查的关系

user_request_limiterProxyLogging.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 模式)