《AI Agent 工程实战》6.3 限流与配额:按租户 token 预算的令牌桶

本节用 Redis 给 ResearchCopilot 加两道闸:按租户的令牌桶限流(Lua 脚本保证读-算-写原子),以及按消耗 token 计费的配额——不是限制请求数,而是限制烧掉的 token。真连本机 Redis 跑出连续请求直到 429 的每一步剩余令牌与剩余额度,并给出限流粒度表与「被限流时 Agent 该怎么办」的策略。

6.3 限流与配额:按租户 token 预算的令牌桶

6.1 和 6.2 解决了「重复计算」——把该省的省下来。可 Agent 服务还有第二个烧钱源,缓存救不了它:滥用。一个脚本狂刷接口、一个租户把配额吃光、一个失控的 Agent 循环在几分钟内跑掉几百万 token——这些不是「重复」,是「过量」。缓存对过量无能为力,得靠限流和配额来兜。

本节把 ResearchCopilot 推进到 researchcopilot/limits.py:用 Redis 实现按租户的令牌桶限流与按 token 计费的月度配额,真连本机 Redis 跑出连续请求到 429 的过程,并定下粒度与降级策略。

6.3.1 限流限的是什么

先说清楚「限流」和「配额」是两件事,虽然常被混为一谈:

机制限的对象时间尺度超限后果目的
限流(rate limit)请求的速率秒 / 分钟当场 429保护服务不被瞬时打垮
配额(quota)资源的总量天 / 月拒绝或降级控制成本、公平分配

限流管「快不快」,配额管「多不多」。一个租户可以速率合规(每秒 1 个请求)却总量超标(一个月烧光了预算),也可以总量富裕却突然并发打爆服务——两者必须同时有,缺一个都留窟窿。

那为什么 Agent 服务的配额不能简单地「按请求数」计?因为一次请求的成本差几十倍。用户问「你好」是一次请求,问「把这份 200 页的文档总结成报告」也是一次请求,后者可能要几十轮模型调用、烧掉几万 token。按请求数限,等于把这两种请求同等对待——真正的成本黑洞恰恰是少数几次「重请求」。所以 Agent 的配额必须按消耗的 token 计,这正是本节下半场的重点。

6.3.2 令牌桶:为什么不用固定窗口

最简单的限流是固定窗口计数:用 Redis INCR 记当前秒(或分钟)的请求数,超过阈值就拒。它实现极简,但有个著名的边界缺陷:窗口临界处会放行两倍的请求。假设限「每分钟 100 次」,客户端在第 59 秒发 100 次、第 61 秒再发 100 次,两个窗口各自合规,可实际是两秒内 200 次——后端瞬间挨了双倍。

令牌桶(token bucket)没有这个问题。它的模型是:桶里最多装 capacity 个令牌,按固定速率 refill 每秒补充,每个请求取走一个令牌,桶空则拒绝。三个好处:一是允许突发(桶里攒的令牌可以一次性用掉,应对正常的流量尖峰);二是平滑限速(长期平均速率不超过 refill);三是没有边界双倍问题。代价是要存「当前令牌数 + 上次补充时间」两个状态。

固定窗口的实现确实简单,值得先看一眼再对比:

def fixed_window(r: redis.Redis, workspace_id: str, limit: int = 100, window: int = 60):
    key = f"fw:{workspace_id}"
    n = r.incr(key)                 # 原子自增
    if n == 1:
        r.expire(key, window)       # 第一次访问时设过期,窗口到点自动清零
    return n <= limit, n

实测输出(Python 3.14.6,redis 8.1.0):

请求 1: 1  200
请求 2: 2  200
请求 3: 3  200
ttl: 60

它只有十几行,INCR 保证原子,EXPIRE 让窗口自动滚动。问题不在实现,在语义——它把时间切成一段段硬边界的窗口,跨边界时计数各自归零,于是有了前面说的「临界双倍」。令牌桶用连续补充的令牌代替离散窗口,从根本上绕开了这个问题,代价是状态从「一个计数」变成「令牌数 + 时间戳」。

工程上的关键是读-算-写必须原子。令牌桶要「读出当前令牌 → 按流逝时间补令牌 → 判断够不够 → 扣减 → 写回」,这五步如果在客户端做,并发请求会互相覆盖——两个请求同时读到「5 个令牌」,各自扣 1 写回「4」,实际用掉 2 个却只扣了 1 个。必须把这五步放进 Redis 用 Lua 原子执行。

6.3.3 令牌桶实现

Redis 的 EVAL 可以原子执行一段 Lua。用 redis-py 的 register_script 注册后,每次调用就是一个原子操作:

from __future__ import annotations
import time
from dataclasses import dataclass
import redis

R = redis.from_url("redis://localhost:16380/0", decode_responses=True)

TOKEN_BUCKET = R.register_script("""
local cap  = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now  = tonumber(ARGV[3])
local need = tonumber(ARGV[4])
local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(b[1])
local ts = tonumber(b[2])
if tokens == nil then tokens = cap; ts = now end          -- 首次访问:桶满
tokens = math.min(cap, tokens + math.max(0, now - ts) * rate)  -- 按流逝时间补
local allowed = 0
if tokens >= need then tokens = tokens - need; allowed = 1 end
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(cap / rate) + 1)  -- 空闲桶自动回收
return {allowed, tostring(tokens)}
""")

这段 Lua 里有四个要点。第一,桶状态存在一个 Hash 里(tokens 和 ts 两个字段),比存两个独立 key 少一次往返。第二,补令牌用 math.min(cap, ...) 封顶,桶永远不会超容量——这正是「允许突发但有限度」的实现。第三,EXPIRE 让空闲租户的桶自动消失,否则每个租户一个 key,十万租户就是十万个永不释放的 key。第四,返回 tostring(tokens) 是因为 Redis 的 Lua 返回值会被截断成整数,浮点令牌数必须转字符串才能原样带回。

包一层 Python 类:

@dataclass
class RateLimiter:
    r: redis.Redis
    capacity: int = 5
    refill: float = 1.0

    def allow_key(self, key: str, cost: int = 1) -> tuple[bool, float]:
        allowed, tokens = TOKEN_BUCKET(
            keys=[key], args=[self.capacity, self.refill, time.time(), cost])
        return bool(int(allowed)), float(tokens)

    def allow(self, workspace_id: str, cost: int = 1) -> tuple[bool, float]:
        return self.allow_key(f"rl:{workspace_id}", cost)   # 默认按租户

注意 key 又是 rl:<workspace_id>——和 6.1 的缓存一样,限流也必须按租户隔离。限流是「谁的配额」,不区分租户就变成了全局限流,一个租户的滥用会误伤所有人。

6.3.4 真跑:连续请求到 429

容量 5、每秒补 1 个令牌,连发 6 个请求:

limiter = RateLimiter(R, capacity=5, refill=1.0)
WS = "ws-acme"
print("令牌桶: capacity=5, refill=1/s")
for i in range(1, 7):
    ok, tokens = limiter.allow(WS)
    print(f"  请求 {i}: {'200' if ok else '429'}  剩余令牌 {tokens:.2f}")
time.sleep(2.0)
print("  ... 等待 2 秒补充令牌 ...")
for i in range(7, 9):
    ok, tokens = limiter.allow(WS)
    print(f"  请求 {i}: {'200' if ok else '429'}  剩余令牌 {tokens:.2f}")

实测输出(Python 3.14.6,redis 8.1.0,服务端 7.4.11):

令牌桶: capacity=5, refill=1/s
  请求 1: 200  剩余令牌 4.00
  请求 2: 200  剩余令牌 3.01
  请求 3: 200  剩余令牌 2.02
  请求 4: 200  剩余令牌 1.02
  请求 5: 200  剩余令牌 0.02
  请求 6: 429  剩余令牌 0.03
  ... 等待 2 秒补充令牌 ...
  请求 7: 200  剩余令牌 1.03
  请求 8: 200  剩余令牌 0.04

逐步看这段输出。请求 1 到 5 依次拿走令牌,剩余从 4.00 递减到 0.02,都是 200;请求 6 是第一个 429——桶里只剩 0.03 个令牌,不够一个请求用,被拒。之后 sleep(2.0) 让桶按 1/s 补进约 2 个令牌,请求 7 立刻被放行(剩余 1.03),请求 8 又拿走一个(剩余 0.04)。

有两个细节值得注意。第一,剩余令牌不是整数(3.01、0.02),因为补令牌是连续的:每次循环之间真实流逝了几毫秒,就补进了零点零几个令牌——这正是令牌桶「平滑」的体现,固定窗口计数只会给出整数。第二,请求 6 被拒后剩余令牌是 0.03 而不是 0,因为它被拒时又过了一瞬、补进了一点点。这些小数是真实计时的痕迹,不是 bug。

6.3.5 token 配额:按消耗计费

限流解决了「快」,配额解决「多」。前面说过,Agent 的配额不能按请求数,得按 token 消耗。实现同样用 Lua 保证「检查 + 扣减」原子:

QUOTA = R.register_script("""
local budget = tonumber(ARGV[1])
local cost   = tonumber(ARGV[2])
local used = tonumber(redis.call('GET', KEYS[1]) or '0')
if used + cost > budget then
  return {0, tostring(budget - used)}      -- 超预算:拒绝,返回剩余额度
end
local new = redis.call('INCRBY', KEYS[1], cost)
return {1, tostring(budget - new)}
""")

@dataclass
class TokenQuota:
    r: redis.Redis
    monthly_budget: int = 1000

    def charge(self, workspace_id: str, tokens: int) -> tuple[bool, int]:
        key = f"quota:{workspace_id}:2026-09"      # 按月分桶,天然滚动
        allowed, remaining = QUOTA(keys=[key], args=[self.monthly_budget, tokens])
        return bool(int(allowed)), int(remaining)

三个设计点。第一,先检查后扣减必须原子:如果分两步(先 GET 判断、再 INCRBY 扣减),并发请求会一起看到「还有额度」然后一起扣,把预算冲破——Lua 把两步焊成一步,杜绝超扣。第二,key 里带月份(2026-09),这样配额按月天然滚动:到 10 月换 key,计数自动从零开始,不需要定时任务清零。第三,拒绝时返回剩余额度,让调用方能告诉用户「你还剩 100 token,这次要 300,不够」——比干巴巴一个 429 友好得多。

配额和限流的关系是两道独立的闸:请求先过令牌桶(速率闸),再过配额(总量闸),两个都通过才真正执行。任一道拒绝就返回 429,但返回的原因不同——限流是「你太快了,稍后再试」,配额是「你这个月额度用完了,下月再来或升级套餐」。区分这两者,用户才知道该怎么办。

6.3.6 真跑:配额耗尽

月度预算 1000 token,连续消耗:

quota = TokenQuota(R, monthly_budget=1000)
print("token 配额: 月度预算 1000 token")
for cost in (300, 300, 300, 300, 100, 1):
    ok, remaining = quota.charge(WS, cost)
    print(f"  消耗 {cost:4d}: {'200' if ok else '429'}  剩余额度 {remaining:4d}")

实测输出:

token 配额: 月度预算 1000 token
  消耗  300: 200  剩余额度  700
  消耗  300: 200  剩余额度  400
  消耗  300: 200  剩余额度  100
  消耗  300: 429  剩余额度  100
  消耗  100: 200  剩余额度    0
  消耗    1: 429  剩余额度    0

这段输出讲清了「按量计费」和「按次数」的区别。前三次各消耗 300,剩余额度 700 → 400 → 100,都放行。第四次消耗 300 被拒——因为 100 + 300 > 1000,但注意它没有把额度扣成负数,剩余仍是 100(拒绝的操作不留痕迹,这正是 Lua 里「先判断再扣」的价值)。第五次消耗 100 恰好用完,放行;第六次消耗 1 就超了,拒绝。

对比一下:如果按请求数限「每月 4 次」,前四次都放行,可实际消耗是 300×4=1200,已经超了预算;而按 token 计的方案在第四次就拦住了。这就是为什么 Agent 的配额必须按 token——请求数不是成本,token 才是。

6.3.7 限流粒度与被限流时怎么办

限流可以加在不同的维度上,粒度选错会误伤或漏放:

粒度key 形态拦什么注意
租户rl:<workspace_id>单个租户的滥用最常用,与配额对齐
用户rl:<workspace_id>:<user_id>租户内某个用户的刷量防止内部一人拖垮全租户
工具rl:tool:<tool_name>某个高成本工具的调用频率保护外部依赖(如付费 API)

三者不互斥,而是叠加:一次请求要同时过租户闸、用户闸、工具闸。租户闸保公平,用户闸防内鬼,工具闸保外部依赖。真实系统通常三层都上,每层是一个独立的限流器、各配自己的容量与速率——租户闸宽松(总量大)、工具闸最紧(每次调用都贵)。叠加的逻辑很短:

GATES = {
    "tenant": RateLimiter(R, capacity=20, refill=5.0),   # 租户:宽松
    "user":   RateLimiter(R, capacity=6,  refill=2.0),   # 用户:防内鬼
    "tool":   RateLimiter(R, capacity=2,  refill=0.5),   # 工具:最紧
}

def admit(workspace_id: str, user_id: str, tool: str):
    checks = [
        ("tenant", f"rl:{workspace_id}"),
        ("user",   f"rl:{workspace_id}:{user_id}"),
        ("tool",   f"rl:tool:{tool}"),
    ]
    for name, key in checks:
        ok, tokens = GATES[name].allow_key(key, 1)   # 直接吃完整 key
        if not ok:
            return False, name, tokens         # 告诉调用方是哪道闸拦的
    return True, None, 0.0

这里的 allow_key 就是 6.3.3 里 allow 的底层形态(allow 只是把 f"rl:{workspace_id}" 拼好再调它)。连发 5 次,工具闸最先顶不住:

请求 1: (True, None, 0.0)
请求 2: (True, None, 0.0)
请求 3: (False, 'tool', 0.0132)
请求 4: (False, 'tool', 0.0216)
请求 5: (False, 'tool', 0.0317)

工具闸容量只有 2,第 3 次就被它拦住,返回 'tool' 明确指出是哪道闸——这就是「能定位到闸门」的价值。返回「是哪道闸拦的」很关键:被租户闸拦和被工具闸拦,用户的应对完全不同,前者是自己太快,后者是某个工具在限流。限流结果必须能定位到闸门,否则排障时只能干瞪眼。

最后,也是最容易被忽略的:被限流时 Agent 应该怎么办。直接抛 429 给用户是最差的做法,正确姿势分情况:

  • 限流(速率):请求本身没问题,只是太快。应退避重试——Retry-After 头告诉客户端等多久,Agent 侧用指数退避重试,而不是立刻失败。第 7 章的任务队列天然适合承接这种「稍后重试」。
  • 配额(总量):重试没用,额度就是没了。应明确告知并降级——要么拒绝并说明原因,要么切换到更省的路径(用便宜模型、缩小检索范围、返回缓存结果),让用户在额度耗尽时仍能拿到降级服务而不是彻底不可用。
  • 工具闸:某个工具被限,Agent 应换工具或跳过——比如付费搜索被限流,就回退到本地语料检索,并在报告里标注「本次未使用外部搜索」。

一个设计原则:限流的目的是保护服务、不是惩罚用户。所以能在服务内部消化的(退避、降级、换工具)就内部消化,实在不行才把 429 抛出去。抛出去时也一定要带上可操作的信息(等多久 / 缺多少额度 / 怎么升级),否则用户只会困惑。

真实环境会怎样:本节的阈值(5 个令牌、1000 token)是为了让演示在几秒内跑完而刻意调小的,真实阈值按租户套餐定,且通常从配置中心下发、可动态调整。真实系统还有三个本机没覆盖的点:一是限流要分布式一致——多实例部署时,令牌桶必须放在 Redis 这种共享存储上(本节正是这么做的),不能各实例自己内存计数,否则限流会被实例数放大;二是要有全局熔断,当整个服务过载时,租户级限流不够,还得有服务级的紧急降级开关;三是限流指标要上报(13 章),429 的比例是容量规划最直接的信号。本节把令牌桶和配额都放进 Redis、都用 Lua 保证原子,正是因为这两条是分布式限流的正确底座——把状态集中、把判断原子,剩下的只是调阈值。

阅读导航:上一节:6.2 语义缓存:相似问题复用与错配检测 · 下一节:7.1 任务队列:run 丢给 worker 异步执行 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距