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 自动回收,同时留出足够时间做用量归档到数据库。
五、超额处理策略:不要只有”硬拒绝”一种选项
配额用完之后怎么办,直接影响用户体验和产品的商业化转化率。三种常见策略:
- 硬拒绝(block):直接返回配额已用尽的错误,引导用户升级套餐。适合免费版这种明确的功能边界。
- 允许超额计费(allow_overage):超出部分按用量额外计费,常见于按 Token 计费的场景,配合月底账单结算。
- 自动加油包(auto_topup):配额快用完时,用户可以一键购买”加油包”临时提额,不用等到下个周期。这种模式在游戏化、高频使用的产品里转化效果通常更好。
实现上,overage_policy 字段决定了 consume_quota 抛出 QuotaExceededError 之后系统的下一步动作,而不是把这个判断逻辑硬编码在业务代码里——策略变化时只需要改配置,不用改调用逻辑。
六、给用户看得懂的配额展示
后端设计得再精确,如果前端没有把配额状态清楚地展示出来,用户体验依然很差。几个关键点:
- 接近上限时主动提醒:用量达到 80%、95% 时触发提示,而不是等到用尽才告知
- 响应头暴露剩余额度:在 API 响应中加入
X-Quota-Remaining、X-Quota-Reset-At这类自定义头,方便前端或第三方开发者实时展示,这个思路可以参考 LLM 429 响应中 Retry-After 头的正确处理 里对标准限速头的处理方式 - 区分”配额用尽”和”临时限速”两种错误:这是两个完全不同的问题,配额用尽需要引导升级/购买,临时限速只需要让客户端重试,返回的错误码和文案不应该混用
七、和上游 API 限速的联动
用户级配额是你自己产品加的一层限制,但别忘了上游模型 API 本身也有限速(如 Anthropic Tier 限制与升级路径详解 里提到的 RPM/TPM 限额)。这两层限制需要分开处理:
- 用户级配额超限:业务逻辑层面的拒绝,返回明确的”额度已用尽”提示
- 上游 API 限速触发:基础设施层面的临时问题,应该做重试或降级,不应该直接暴露给终端用户一个”配额用尽”的错误信息,容易造成误导
如果你的用户量较大,建议在配额检查通过之后,还要有一层针对上游 API 的整体限流保护,防止某个时间点大量用户同时请求导致触发上游账户的整体限速,拖累所有用户的体验,而不只是当前这一个请求。
八、小结
用户级配额系统的核心是三件事:选对计量单位、用 Redis 原子操作杜绝并发超卖、用周期 key 代替定时清零任务简化重置逻辑。在此基础上,超额策略和前端展示决定了用户体验的好坏,值得投入设计而不是简单粗暴地”用完就拒绝”。这套系统和公司层面的整体成本控制是互补关系,两者都要有,不能互相替代。
九、相关阅读
- AI API 预算上限自动化设计:防止账单爆炸的工程实践
- LLM 429 响应中 Retry-After 头的正确处理
- Anthropic Tier 限制与升级路径详解
- OpenAI Batch 与 Streaming 的选择决策
如果你的 SaaS 产品需要同时对接多家模型 API 并统一管理成本,用 YoTradeApi 中转可以把上游账单和用户配额分离管理,减少多平台对接的工程量。