
我去年年底接了一个内部 AI 平台的治理需求背景很直接公司把 DeepSeek、MiniMax 这类大模型 API 统一封装成内部网关开放给几个业务团队用。结果第一个月账单出来额度直接超了 4 倍。仔细查日志发现原因并不复杂——某个业务方写了个定时任务每五分钟跑一批长文本总结并发一开单 Key 的配额就像漏水的桶几百个请求同时在途每笔都认为自己能拿到额度最终把月额度在三天内烧完了。这个问题的本质不是“钱不够”而是“分配机制失效”。大模型 API 的配额治理需要的不只是计费而是一套能够承受并发、支持多租户、且能精确表达“预扣”语义的额度控制层。我最终选了 Redis Lua 脚本的方案落地了一套原子预扣加多租户配额治理体系。这篇文章就把这套思路和完整实现拆开来讲包括为什么是非 Lua 不可、脚本怎么设计、多租户模型怎么搭以及我在实际对接中踩过的坑。1. 配额透支的真相与治理目标1.1 大模型 API 额度为什么会被“静默”消耗很多人以为超额只是并发高导致的其实远不止。我梳理了我们这边的三个典型案例。第一种是应用层重试风暴。调用大模型 API 时遇到 429、5xx 或者网络超时是很正常的。业务方图省事直接给 HTTP Client 配置了重试 3 次指数退避。平时没事一旦服务端抖动所有请求同时进入退避-重试循环每一个重试都会重新经过配额模块。原本 100 个请求在配额层看到的可能是 300 甚至更多次调用意图。如果配额扣减不涉及“在途预扣”这 100 个请求都能轻松绕过额度限制。第二种是长上下文场景的估算偏差。现在的模型上下文窗口越来越大动辄 128K token。一次请求的输入可能就有 3 万 token输出可能 5 千。按次计费相对好治理按 Token 计费就需要估算。配额模块必须在请求发出前预估“这一笔大概会消耗多少”不能等服务返回后再算账——因为额度可能在同一瞬间被其他请求耗光后到的请求就已经不可能被服务了。第三种是共享 Key 被无限放大。没有多租户概念时所有业务共用同一个账号下的 Key。A 团队做数据分析B 团队做会话机器人C 团队做批量翻译。三个团队的流量模型完全不同但共享同一个额度池。结果是 B 团队的夜间定时任务直接把第二天的广告分析流量挤掉业务不可用时大家互相甩锅。这三种情况共同指向一个事实配额治理不能做成“事后记账”必须做成“事前拦截”。也就是说每个请求在真正发往大模型厂商之前就必须完成额度的校验与预占。1.2 防透支设计的三个核心锚点做这套系统时我给自己定了三个原则。第一原子性。检查额度是否充足和扣减额度必须发生在同一个不可分割的操作内。中间不能有“先读出余额再在应用层判断再写回”这样的流程因为并发环境下这个窗口期就是透支的温床。第二实时性。配额模块必须能在毫秒级完成预扣操作。不能因为治理配额引入超过 10ms 的延迟否则业务方会抱怨“调用大模型本身就慢你还给我加个瓶颈”。第三可追溯性。每一笔预扣都要有迹可循——哪个租户、哪个业务、哪个时间点、预扣了多少、最终实际消耗了多少、退还了多少。没有流水出了问题就只能对着账单发呆。1.3 配额控制在哪个环节生效从我的实践看配额控制在统一 API 网关层做是最省力的。业务方不感知也不需要每个团队在代码里集成配额 SDK。网关收到请求后先做身份识别拿到租户信息再走配额预扣预扣成功才把请求转发给真实的大模型服务商。如果你没有独立的网关层也可以做一个轻量级的LocalProxy或者装饰器模式把配额逻辑嵌入到现有 API 封装库中。不管怎么做原则一致配额判断必须发生在真实请求前且后置的额度确认必须能修正预扣误差。2. 为什么选择 Redis Lua 原子预扣2.1 数据库扣减为什么扛不住一开始团队里有同事主张用 MySQL 做额度扣减。逻辑很简单在account_quota表上用SELECT ... FOR UPDATE锁住行判断余额再UPDATE。这套方案数据一致性没问题坏处是性能撑不住。大模型 API 的调用 QPS 虽然不像电商秒杀那样夸张但单网关入口汇聚多个业务方峰值也能到几百 QPS。每一笔都走行锁连接池和数据库压力都很明显而且事务提交耗时长直接把“实时性”原则碾碎了。数据库方案的另一个隐性问题它会放大故障半径。数据库一抖动额度校验就卡住所有 API 请求都会被堵在网关上业务全挂。2.2 纯 Redis 操作为什么也不安全如果不走数据库直接用 Redis 的GET和DECR行不行我做过实验不行。关键原因在于GET和DECR是两步操作无法保证原子性。假设租户可用余额是 100两个并发请求同时把余额读成 100都判断“够”然后都去扣最终余额变成 -99 之类的负数。更麻烦的是如果 Redis 端开启了事务管道之类的优化应用层读到的数据可能是过期快照判断逻辑更加失真。有些方案会退而求其次用 Redis 的DECRBY直接扣减允许余额变成负数然后在事后扫描负数订单去重。这是能用的兜底但不够优雅——超额请求已经发出去钱已经产生再去补救就处于被动。2.3 Lua 脚本解决原子性与边界条件Redis 从 2.6 版本开始内置 Lua 解释器可以把多个 Redis 命令封装在一个脚本里由服务端保证整个脚本的原子执行。这个特性是真的为这类问题量身定做的。脚本执行期间其他命令不会插入天然规避了并发下“检查-扣减”之间的竞态窗口。我的判断很简单不用 LuaRedis 在这里顶多算一个计数器用了 Lua它才真正成为一个可控的配额引擎。而且 Lua 脚本很短性能损失极小压测时单脚本耗时基本在 0.1ms 级别。2.4 多租户配额模型怎么设计在做具体脚本前还有个前置设计问题多租户的度在哪里我这边最终采用了四级模型平台级所有租户共享总配额池防止某个租户异常把整个账号额度耗尽。租户级每个租户有独立额度比如 A 部门每月 1000 万 Token。应用级同一租户下不同项目或应用有独立限额方便成本分摊。调用级单次请求的最大预扣额避免一次异常超长输出把余额打穿。这个模型并不是要建四张表而是结合 Redis Key 设计来实现具体到脚本里就是校验多级余额是否都充足然后逐级预扣。多租户治理的核心不是技术而是配额表达的一致性。3. 配额预扣的 Lua 脚本设计与完整实现3.1 Redis Key 的结构设计存储层我用了 Redis Hash 和 String 的组合Key 的命名遵循了{业务}:{域}:{标识}的规范。配额余额我用 Hash 存储quota:balance:{tenant_id}:{app_id}这个 Hash 维护两个字段total总额度例如 1000000单位 Tokenused已用额度初始化按 0。每次预扣都往used方向加available total - used调用流水我用 ZSet 或者 String 存储quota:ledger:{tenant_id}:{app_id}:{yyyyMM}每个元素记录request_idScore 用时间戳Value 保存预扣量|确认量|状态。这串流水是为了给对账和排障留数据。另外限流窗口我单独用了一个 Keyrate:window:{tenant_id}:{app_id}:{minute}用INCREXPIRE计数。配额防透支解决的是“总量超卖”限流解决的是“瞬时突发”两者配合使用。3.2 Lua 脚本逐行拆解直接上脚本这个是我实际在环境里验证过的版本-- KEYS[1]: 配额余额 Hash 对应的 key -- KEYS[2]: 流水记录 Zset 对应的 key -- ARGV[1]: 本次预扣 token 数 -- ARGV[2]: 请求唯一 ID -- ARGV[3]: 当前时间戳 local balance_key KEYS[1] local estimated tonumber(ARGV[1]) local total_str redis.call(HGET, balance_key, total) local used_str redis.call(HGET, balance_key, used) if total_str false or used_str false then return -1 end local total tonumber(total_str) local used tonumber(used_str) local available total - used if available estimated then return 0 end redis.call(HINCRBY, balance_key, used, estimated) redis.call(ZADD, KEYS[2], ARGV[3], ARGV[2]) return 1这个脚本的精髓在于HGET读取和HINCRBY写入在同一个 Lua 执行上下文里Redis 保证中间不会有其他写操作插队。并发来了 100 个预扣请求最终实际扣减的总量一定不会超过total值因为每个请求在检查余额时看到的used都是前一个脚本执行完成后的结果。return -1表示 Key 不存在或配置缺失return 0表示配额不足被拦截return 1表示预扣成功。3.3 预扣、确认、回滚三阶段流程光有脚本还不够整个请求生命周期要把预扣、确认、回滚串起来。阶段一预扣请求前应用网关收到请求解析出租户和应用信息估算本次调用预计消耗的 Token 数。估算是关键环节。我这边用了两层估算输入部分按字符数除以 3 或 4中英文混合场景按 2.5 到 3 之间调节输出部分按最大输出 Token 数加一个缓冲值。基准公式是estimated_tokens input_chars / 3 max_output_tokens * 1.1阶段二确认请求完成后大模型 API 返回结果时响应里通常带usage字段包含prompt_tokens、completion_tokens和total_tokens。用实际值校正预扣值actual usage.total_tokens diff actual - estimated_tokens if diff 0: # 实际消耗比预扣多补扣 redis.eval(ADD_USED_SCRIPT, [balance_key], [diff]) elif diff 0: # 预扣多了退还 redis.eval(RELEASE_SCRIPT, [balance_key], [-diff])阶段三回滚请求异常时请求因为网络超时、鉴权失败、上游 5xx 被中断时预扣的额度要原路退回。注意不是全额退如果上游已经处理了一部分上下文就需要酌情按 token 估算回退。3.4 补扣与退还的 Lua 脚本补扣脚本我单独写了一个-- KEYS[1]: 余额 key -- ARGV[1]: 补扣 token 数 local total tonumber(redis.call(HGET, KEYS[1], total) or 0) local used tonumber(redis.call(HGET, KEYS[1], used) or 0) if (total - used) tonumber(ARGV[1]) then return 0 end redis.call(HINCRBY, KEYS[1], used, ARGV[1]) return 1退还脚本更简单只做HINCRBY负向扣减-- KEYS[1]: 余额 key -- ARGV[1]: 退还 token 数 redis.call(HINCRBY, KEYS[1], used, -tonumber(ARGV[1])) local used tonumber(redis.call(HGET, KEYS[1], used) or 0) if used 0 then redis.call(HSET, KEYS[1], used, 0) end return 1这里做了个防负保护避免因为退还额多于已用额把 used 打成负数导致可用余额虚高。3.5 网关集成时序整个调用链路由网关统一编排。我用 Python 写网关中间件流程如下def handle_request(tenant_id, app_id, estimated_tokens): balance_key fquota:balance:{tenant_id}:{app_id} ledger_key fquota:ledger:{tenant_id}:{app_id}:{month} req_id uuid.uuid4().hex # 预扣 result redis.eval(PRE_DEDUCT_SCRIPT, 2, balance_key, ledger_key, estimated_tokens, req_id, int(time.time())) if result 0: raise QuotaExceededError(配额不足请升级或等待额度重置) if result -1: raise ConfigError(配额未配置) # 调用真实大模型 API try: resp llm_client.chat(..., max_tokens...) actual resp.usage.total_tokens if actual estimated_tokens: redis.eval(ADD_USED_SCRIPT, 1, balance_key, actual - estimated_tokens) elif actual estimated_tokens: redis.eval(RELEASE_SCRIPT, 1, balance_key, estimated_tokens - actual) return resp except Exception as e: # 异常退回预扣额度 redis.eval(RELEASE_SCRIPT, 1, balance_key, estimated_tokens) raise这套流程把账期从“事后结算”变成了“事前占用、事后校准”从根本上杜绝了并发穿透。压测数据也验证了200 并发同时预扣总额度 10 万的场景没有一笔请求实际扣减量越过总量所有超额请求都被精确拦截。3.6 配额告警与超限处理预扣失败之后怎么通知业务方也是治理的一部分。我做了一个三级告警机制Info 级租户可用额度低于 30%定时巡检触发推送到 IM 群。Warning 级低于 10%追加通知业务负责人建议评估是否扩容。Critical 级可用额度不足但有缓存中的长时间未确认预扣触发主动释放逻辑同时暂停该租户的新请求。这里有个容易忽略的点配额不足不应该直接让上游请求失败到底比较稳的做法是返回 429Too Many Requests让业务方可以在客户端做退避重试。如果直接返回 500业务方会认为服务端故障反而触发不可控重试。4. 多租户治理与配额动态调整实战4.1 租户隔离方案多租户治理的核心不是“一个 Key 一个额度”而是“一个租户一套维度”。我在设计时把数据存储拆成了两层。第一层是租户专属配额。每个租户有独立的 Redis Hash互不干扰。这样 A 租户即使流量爆炸也不影响 B 租户。第二层是共享池兜底。给整个平台设一个总账户。租户在自己的专属额度耗尽之后可以尝试从共享池中借用借用逻辑用另一段 Lua 脚本实现顺序是先租户后平台逐级判断。两级共享池的设计要遵循一个原则借用必须有上限和审批。无限制借用等于没有租户隔离。我在共享池借用脚本里会校验单次借用额不能超过总池的 5%且租户借用的累计未还额度不能超过一定比例防止单个租户把平台池掏空。4.2 动态调整配额的实现额度不是一成不变的。业务团队要做活动、接入新模型时额度需要动态调整。我实现了一个管理接口走配置下发流程POST /internal/quota/adjust Request: { tenant_id: auth_service, app_id: qa_robot, field: total, delta: 200000 // 正数表示增加负数表示减少 }调整脚本也是原子的-- KEYS[1]: 余额 key -- ARGV[1]: 调整后的 total 值 local used tonumber(redis.call(HGET, KEYS[1], used) or 0) local new_total tonumber(ARGV[1]) if new_total used then return 0 -- 新总额小于已用不允许 end redis.call(HSET, KEYS[1], total, new_total) return 1这个脚本表面是调整总额实际上隐含了业务规则的约束不能把总额调到比已用还低。否则会导致 available 为负数后续所有请求全部失败。这种逻辑写在 Lua 里比写在应用层更可靠因为应用层无法保证读到的 used 是最新的。4.3 配额的审计与对账大模型 API 提供商返回的账单通常有 T1 延迟。我们内部预扣的流水是实时数据这就天然存在时间差。所以我在流水表里增加了“待确认”状态。每个request_id从预扣成功开始就带一个待确认标记。请求完成后确认脚本把状态改为“已确认”并记录实际 token 差值。每天凌晨跑一个对账任务把所有“待确认”超过 24 小时的记录拉出来用估算值强制确认并补上说明。这样做有两个好处避免大量“僵尸预扣”长期占用余额导致可用额度虚低。给财务和成本分析提供准确的结算口径。审计维度上按租户、应用、模型、日期四个维度聚合用 Redis 的INCRBY维护日级别的消耗计数再同步到 ClickHouse 做历史分析。4.4 配额与模型调用失败的联动一个容易被忽略的治理场景是模型调用失败时的配额恢复。大模型服务商偶尔返回401 unauthorized或400 context length exceeded这类错误。前者是密钥问题不是配额问题后者是请求参数问题也不是配额问题。但这两类错误都会消耗“预扣”的额度吗不一定。关键看预扣发生在哪个环节。我建议只在请求被网关成功转发后开始预扣鉴权失败和参数校验失败的请求不应该走预扣逻辑。这样可以让配额池更真实地反映“实际意图消耗”而不是被错误请求灌水。5. 常见问题与踩坑实录5.1 Lua 脚本的动态 Key 问题这个是新手最容易踩的坑。Redis 在集群模式下要求 Lua 脚本里的所有 Key 必须通过KEYS数组传入不能在脚本内部通过字符串拼接动态生成。我一开始写脚本时图省事在 Lua 里面直接拼接 Key单机模式跑得好好的切到 Redis Cluster 直接报错。正确写法是调用时显式传入redis.eval(script, 2, balance_key, ledger_key, estimated, req_id, ts)脚本内部只引用KEYS[1]、KEYS[2]。这样 Cluster 才能正确路由。5.2 数字与字符串类型的隐式转换Lua 脚本里redis.call返回的HGET结果是字符串直接做算术前必须用tonumber()转换。我早期一个版本漏了转换比较total used时变成了字符串比较导致“10” “9” 返回 false余额充足却被拦截排查了半天才定位到。如果你也写 Lua记住一个习惯凡是做数值运算的变量一律显式tonumber。不要相信 Redis 返回类型的“看起来像数字”。5.3 已用额度不能轻松清零有人会想月初把 used 重置为 0 不就行了单纯重置 used 没问题但要注意历史流水不能删。配额平衡需要冻结和结转概念。我的做法是月度切换时把上月 used 存档到另一个 Key 里然后新建当前月的余额 Hashtotal 保持配置值used 初始化为 0。这样历史账还在新账从零开始。5.4 预扣估算偏差导致“饿死”估算值设得太大会导致大量额度被“在途占住”实际请求被误杀。比如一个输入只有 500 token 的请求估算成了 5000并发一高配额瞬间耗尽。我优化过一版估算逻辑不固定用最大输出而是允许客户端传max_tokens预扣额设为estimated input_tokens_estimate min(max_tokens, 4096) 256同时提供参数开关对实时性要求高的短交互场景可以设置更小的缓冲系数对离线批量推理场景则用更保守的估算系数。5.5 异常回滚时机过早导致配额黑洞还有一个诡异的坑请求发出后上游模型可能已经消耗了 token但由于响应流中断网关没有拿到实际 usage。如果此时全额退回预扣就会导致额度被重复使用看起来余额够实际上钱已经花了。解决办法是流式响应的场景下不要立刻回滚全部预扣。可以折中按输出流已接收的 token 数估算一个“最小已消耗值”退回剩余部分等待对账任务兜底释放。这个经验我们用在了流式输出场景里显著降低了月底账单与内部流水的偏差。5.6 配额模块本身的性能监控最后提醒一点配额模块现在是链路里的关键路径必须纳入可观测体系。我接入 Prometheus 监控指标包括预扣成功率预扣拒绝率单次 Lua 脚本执行耗时Redis 连接池等待时长租户维度配额耗尽次数这些指标不仅是排查故障的依据也直接反哺配额模型的规划。比如某个租户的拒绝率持续偏高就可以提前沟通扩容而不是等业务方抱怨不可用。6. 多租户大模型网关的后续演进方向这套配额治理体系上线后我们把内部大模型网关的可用性提升了几个档次。后续我还在规划两个方向这里一并分享给你作为扩展思考。第一个是用量预测与自动充值。基于租户历史消费曲线用简单的周期分解算法预测下一天的 Token 消耗如果预测值接近租户额度上限就提前触发扩容流程或通知管理员。不需要做得很复杂移动平均加短期趋势基本够用。第二个是更细粒度的模型路由。不同租户的请求在不同模型之间的分布可以直接影响账单。比如简单的摘要任务用便宜的小模型复杂推理用贵的大模型。配额模块与模型路由联动可以让“严格遵守模型级别配额”和“总预算控制”同时生效。这些方向本质上都是把配额从“静态防透支”升级成“动态成本治理”希望在系统上线稳定后能够让成本在更精细的粒度上受控。回到你最关心的问题上如果你的业务也接了多个大模型 API并且存在多业务方共用额度的情况强烈建议不要再沿用“一把 Key 大家刷”的老路试试 Redis Lua 原子预扣这套打法。代码量不大逻辑也不复杂但它能精准地解决并发下额度的分配公平问题是性价比极高的治理投入。