大多数「限额」只是通知,钱已经花出去了才告诉你;真正的限额会在请求路径中检查并直接返回错误。Agent 死循环、webhook 重放等场景可在几分钟内烧掉数千美元。
大多数"消费限制"其实是通知。它们告诉人类钱已经花出去了,这固然有用,但它不是限制。真正的限制会拒绝请求。
告警不是上限
两者的区别在于机制是否位于请求路径上。告警在事后读取消费数据并通知相关人员。上限是在调用之前进行检查,可以返回错误而不是答案。只有后者才能真正限制你的损失,两者之间的差距取决于一个人醒来、理解问题并部署修复方案所需的时间。
这种机制要防护的故障很少是渐进式超支。它是一种循环:重试无限的 Agent、Webhook 重复处理同一份文档、bug 反复提交队列、爬虫发现了未认证的端点。这些不会慢慢爬升,而是以你的并发允许的速度运行,通常是你正常速度的数千倍,且在每个仪表盘上看起来都和健康流量无异——除了成本仪表盘。
max_loss = burn_rate * detection_lag
burn_rate 事件期间每分钟消耗的美元
detection_lag 告警延迟 + 通知 + 诊断 + 部署
用自己的最坏情况计算 burn_rate,而不是靠猜:它是 并发数 × 每秒每个 worker 的请求数 × 每个请求的费用 × 60。假设 50 个并发 worker,每个 worker 每秒处理 2 个请求,每个请求 $0.004,那就是 50 × 2 × 0.004 × 60 = $24/分钟。
burn = $24/min
用量仪表盘每小时刷新一次 .......... 60 分钟
告警触发,工程师注意到 ............ 15 分钟
诊断、决策 ....................... 20 分钟
发布修复 ......................... 15 分钟
总计 ... 110 分钟
max_loss = 24 * 110 = $2,640 一次循环 bug 就能造成,
即使告警系统完美运作。
主导项是第一项。如果你的消费数据延迟了一小时,无论告警机制多完善,损失都不会低于一小时的消耗——这就是在请求路径中设置上限的理由,因为这样延迟从构造上就是零。
上限的核心:竞态问题
这就是它比限流更难的原因。一个请求的费用直到它完成后才能知道,因为输出长度是由模型决定的。所以,在调用之前检查消费情况,其实是在检查一个不包含所有正在处理请求的数字。
解决方案是借鉴普通会计的预占机制:先预扣一个保守的估计值,然后用实际数字进行结算。
ceiling = ( T_in * P_in + max_tokens * P_out ) / 1e6
这个请求可能产生的最大费用,可以在发送前计算
——这就是为什么必须设置 max_tokens
on_request:
reserved = atomic_add(key_spend, ceiling)
if reserved > cap:
atomic_add(key_spend, -ceiling)
reject(402)
on_response:
atomic_add(key_spend, actual_cost - ceiling) # 退还差额
on_error_or_timeout:
atomic_add(key_spend, -ceiling) # 释放预占
三个特性使这个机制生效。预占是原子的,所以并发请求不能同时看到只有其中一个才有的空间。上限是过度估计,所以上限会倾向于提前拒绝而不是超调。而结算会退回差额,所以设为 $100 的上限不会表现得像 $40——因为每个请求预占的都是实际用量的 2.5 倍。
即便如此,超调仍然是有界的而不是零。当达到上限时有 k 个请求在飞,最大超调量是 k × ceiling——即已授予的预占。在设置上限时要考虑到这个缓冲空间,还要注意,这是不要让 max_tokens 变得巨大的又一个原因:它直接影响超调上限。
四层防护,由浅入深
每一层拦截不同的故障,且彼此不可替代。
每请求上限。每个调用都设置 max_tokens,永远如此。它是唯一能限制单个失控生成的成本的东西,不需要任何代价,而且使上面的预占计算成为可能。
每个逻辑操作的预算。一次用户操作可能涉及 Agent 循环、重试和降级。为整个操作设置一个以微美元为单位的预算和步数上限,当其中一个耗尽时就中止。这能捕获单个请求限制看不到的循环,而且它就是重试成本部分描述的同一个累加器。
每个 key 或每个租户的每日上限。爆炸半径控制。一个被破解的 key、一个滥用服务的客户、一个配置错误的集成,其影响被限制在自己的上限内,而不是你的账户余额。用每日而不是每月,因为月度上限会让糟糕的一天吃掉一整个月的额度。
提供商层面的账户级上限。最后的防线,而且也是当 bug 出在自己的限流器中时唯一仍然有效的层。它是故障关闭的,会让整个产品宕机,所以它应该设置在预期消费之上很远的位置——作为断路器使用,而不是作为预算控制。
测试各层是否真实有效的方法:选择每一层,问自己它阻止了什么具体事件而下一层阻止不了。如果你答不上来,那就是重复的。
当达到上限时怎么办
对所有人返回 402 是一个决策,而不是默认,而且通常它是错误的。在完全服务和完全拒绝之间有一把梯子,每一级都比上一级更便宜:
路由到更便宜的模型。通常低一个数量级,对很多功能来说用户不会注意到用了更小的模型。
缩短输出。收紧 max_tokens,删除可选的生成部分。切掉成本高昂的那一半。
先关闭昂贵功能。关闭推理、减少检索片段、禁用摘要器。提前按优先级排序好,这样在事件中就不用做决策。
排队而不是拒绝。如果工作不是交互式的,把它推迟到下一个预算周期而不是丢弃它。
然后再拒绝,但要给出具体的错误。402 错误说明触发了哪个上限以及何时重置,这是一个可操作的错误。通用的 500 会把一个运作良好的成本控制变成一张工单。
无论你实现了哪几级,都要测试它们。一个从未被执行过的上限是一个在关键时刻会失效的上限,最便宜的发掘方式是在预发环境把它设为一个极小的值,然后观察产品的行为。
当上限存在于网关而不是每个调用模型的服务中时,第三层会容易得多。Multigrid 在中心强制执行每个 key 的消费限制,并以微美元返回每个响应的确切成本,这样上面的累加器就可以针对真实数字而不是估计值进行结算——key 和限制行为在文档中有描述。
成本回归:在 10 倍账单落地之前捕获它
重试、故障转移和超时的成本
随着增长预测 LLM 消费