更新于

LLM 用户级配额管理实现


如果你的产品把 AI 功能作为付费点卖给终端用户——比如”免费版每月 50 次生成,Pro 版每月 1000 次”——单纯控制公司整体的 API 账单是不够的,你还需要一套精确到每个用户的配额系统:知道每个用户还剩多少额度、什么时候重置、超额了怎么处理。这和公司层面的成本预警(参考 AI API 预算上限自动化设计)是两个不同层次的问题——预算上限管的是”公司这个月总共花了多少钱”,用户级配额管的是”这个用户这个周期还能用几次”。

这篇文章聚焦后者,给出一套可以直接落地的用户级配额系统设计。

一、配额模型该按什么计量

先想清楚配额的计量单位,这直接决定后面所有的实现细节。常见的三种模型:

计量方式适用场景优点缺点
按调用次数功能边界清晰、单次调用成本相近的场景(如”生成一次头像”用户容易理解长短输出成本差异大时不公平
按 Token 数成本敏感、输入输出长度差异大的场景(如长文写作)和实际成本强绑定用户对”token”没有直觉,体验差
按积分/额度多功能混合计费(不同功能消耗不同权重)灵活,能反映真实成本差异需要额外设计积分换算规则,实现复杂度最高

经验建议:如果产品功能单一(只有一种 AI 能力),优先用调用次数,用户理解成本最低;如果产品有多种消耗差异大的功能(文本生成、图片生成、长文档处理混用),用积分模型更公平,但要把积分消耗规则在界面上讲清楚,否则用户会觉得”莫名其妙就用完了”。

二、数据模型设计

配额系统的核心是两张表:配额定义(这个用户/套餐允许多少)和用量流水(实际消耗了多少)。

-- 套餐配额定义
CREATE TABLE plan_quota (
    plan_id       VARCHAR(32) PRIMARY KEY,
    quota_amount  INT NOT NULL,        -- 周期内总配额
    reset_cycle   VARCHAR(16) NOT NULL, -- 'monthly' / 'daily' / 'never'
    overage_policy VARCHAR(16) NOT NULL -- 'block' / 'allow_overage' / 'auto_topup'
);

-- 用户当前周期用量(高频读写,建议放 Redis,定期落库)
CREATE TABLE user_quota_usage (
    user_id       BIGINT NOT NULL,
    cycle_start   DATE NOT NULL,
    used_amount   INT NOT NULL DEFAULT 0,
    PRIMARY KEY (user_id, cycle_start)
);

user_quota_usage 是高频更新的表,如果直接用 MySQL/PostgreSQL 承接每次请求的扣减,在高并发下很容易出现锁竞争。实际生产中建议把实时扣减放在 Redis,数据库只做定期落库和历史归档。

三、用 Redis 做原子扣减,避免并发超卖

配额扣减的核心难点是并发安全——如果用户同时发起多个请求,简单的”查询剩余额度 → 判断是否够用 → 扣减”这套逻辑在高并发下会出现竞态条件,导致实际消耗超过配额(俗称”超卖”)。

解决方法是用 Redis 的原子操作,把”检查+扣减”合并成一步:

import redis

r = redis.Redis()

QUOTA_CHECK_SCRIPT = """
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local cost = tonumber(ARGV[2])
local current = tonumber(redis.call('GET', key) or '0')
if current + cost > limit then
    return -1
end
redis.call('INCRBY', key, cost)
return limit - current - cost
"""

check_and_deduct = r.register_script(QUOTA_CHECK_SCRIPT)

def consume_quota(user_id, cycle_key, limit, cost=1):
    key = f"quota:{user_id}:{cycle_key}"
    remaining = check_and_deduct(keys=[key], args=[limit, cost])
    if remaining == -1:
        raise QuotaExceededError(f"user {user_id} quota exceeded")
    return remaining

用 Lua 脚本保证”读取当前用量 + 判断 + 扣减”在 Redis 内是原子执行的,杜绝了两个并发请求同时读到”还有余量”然后都扣减成功导致超卖的问题。这比在应用层加分布式锁更轻量,性能也更好。

四、配额重置:用周期 key 而不是定时清零任务

很多人第一反应是写一个定时任务,每月 1 号把所有用户的用量清零。这个做法有个隐患:如果定时任务失败或漏跑,会导致某些用户的配额没有正确重置。

更稳健的做法是用周期本身作为 key 的一部分,不需要主动清零:

from datetime import date

def get_cycle_key(reset_cycle):
    today = date.today()
    if reset_cycle == "monthly":
        return f"{today.year}-{today.month:02d}"
    if reset_cycle == "daily":
        return today.isoformat()
    return "lifetime"

quota:{user_id}:2026-07 这样的 key 到了 8 月自动变成 quota:{user_id}:2026-08,全新的计数从 0 开始,完全不需要清零操作。旧周期的 key 设置一个稍长于周期的 TTL(比如月度配额设置 35 天过期),让 Redis 自动回收,同时留出足够时间做用量归档到数据库。

五、超额处理策略:不要只有”硬拒绝”一种选项

配额用完之后怎么办,直接影响用户体验和产品的商业化转化率。三种常见策略:

  1. 硬拒绝(block):直接返回配额已用尽的错误,引导用户升级套餐。适合免费版这种明确的功能边界。
  2. 允许超额计费(allow_overage):超出部分按用量额外计费,常见于按 Token 计费的场景,配合月底账单结算。
  3. 自动加油包(auto_topup):配额快用完时,用户可以一键购买”加油包”临时提额,不用等到下个周期。这种模式在游戏化、高频使用的产品里转化效果通常更好。

实现上,overage_policy 字段决定了 consume_quota 抛出 QuotaExceededError 之后系统的下一步动作,而不是把这个判断逻辑硬编码在业务代码里——策略变化时只需要改配置,不用改调用逻辑。

六、给用户看得懂的配额展示

后端设计得再精确,如果前端没有把配额状态清楚地展示出来,用户体验依然很差。几个关键点:

  • 接近上限时主动提醒:用量达到 80%、95% 时触发提示,而不是等到用尽才告知
  • 响应头暴露剩余额度:在 API 响应中加入 X-Quota-RemainingX-Quota-Reset-At 这类自定义头,方便前端或第三方开发者实时展示,这个思路可以参考 LLM 429 响应中 Retry-After 头的正确处理 里对标准限速头的处理方式
  • 区分”配额用尽”和”临时限速”两种错误:这是两个完全不同的问题,配额用尽需要引导升级/购买,临时限速只需要让客户端重试,返回的错误码和文案不应该混用

七、和上游 API 限速的联动

用户级配额是你自己产品加的一层限制,但别忘了上游模型 API 本身也有限速(如 Anthropic Tier 限制与升级路径详解 里提到的 RPM/TPM 限额)。这两层限制需要分开处理:

  • 用户级配额超限:业务逻辑层面的拒绝,返回明确的”额度已用尽”提示
  • 上游 API 限速触发:基础设施层面的临时问题,应该做重试或降级,不应该直接暴露给终端用户一个”配额用尽”的错误信息,容易造成误导

如果你的用户量较大,建议在配额检查通过之后,还要有一层针对上游 API 的整体限流保护,防止某个时间点大量用户同时请求导致触发上游账户的整体限速,拖累所有用户的体验,而不只是当前这一个请求。

八、小结

用户级配额系统的核心是三件事:选对计量单位、用 Redis 原子操作杜绝并发超卖、用周期 key 代替定时清零任务简化重置逻辑。在此基础上,超额策略和前端展示决定了用户体验的好坏,值得投入设计而不是简单粗暴地”用完就拒绝”。这套系统和公司层面的整体成本控制是互补关系,两者都要有,不能互相替代。

九、相关阅读

如果你的 SaaS 产品需要同时对接多家模型 API 并统一管理成本,用 YoTradeApi 中转可以把上游账单和用户配额分离管理,减少多平台对接的工程量。