API 超支怎么防:从预警到熔断的三层防线怎么设
我见过的 API 超支,几乎没有一次是「慢慢涨上去的」。真实的形状是阶跃:某天下午上线了一个版本,账单曲线从那一刻起抬高一个台阶,然后稳稳地保持在新高度,直到有人发现。原因通常特别朴素——有人把提示词里的一段例子从两条扩到十条;有个重试逻辑在特定错误码下没有退出条件,转起来了;某个对外接口没做鉴权,被人拿去当免费代理刷。
这类问题的共同点是:**它们在发生的那一刻就已经定型了,剩下的只是时间在计费。**所以防超支这件事,真正决定损失大小的不是你的预算估得多准,而是你多久能发现。一个估得很粗但两小时内就报警的体系,比一个估得很精却要等月底对账的体系强一个数量级。
下面讲的是一套分级防线:硬上限兜底、速率告警提前、结构性告警再提前一层。三层不是替代关系,缺哪层都会在某个场景漏掉。
第一层:硬上限,最后那根保险丝
硬上限的作用不是「控制成本」,是限制单次事故的最大损失。它一定会以业务中断的方式生效,所以设计它的时候脑子里要装的问题是「万一炸了,我最多能接受赔多少」,而不是「我这个月大概要花多少」。
以 LiteLLM Proxy 为例,虚拟 key 上有三个直接相关的字段(字段名逐字来自官方文档,具体行为与版本差异以官方文档为准):
| 字段 | 类型 | 拦的是什么 |
|---|---|---|
max_budget | 浮点 | 这把 key 的累计花费上限,单位 USD。触到就是花完了 |
rpm_limit | 整数 | 每分钟请求数上限,拦的是「调用次数暴涨」 |
tpm_limit | 整数 | 每分钟 token 数上限,拦的是「次数没涨但每次变得巨大」 |
这三个要一起设,因为它们拦的失败模式不同。死循环会先撞 rpm_limit;提示词膨胀或者有人往上下文里塞了整本文档,请求数可能一点没变,但 tpm_limit 会先响。只设其中一个,另一类事故就是裸奔。
max_budget 则是终点线。它的问题在于:等它触发,钱已经花完了——它保护的是「不再继续花」,不是「没花」。所以它的数值该按「正常业务绝不可能达到」的水平来设,比如预期用量的两到三倍这种量级(具体倍数看你的业务波动有多大,促销季和平峰期显然不是一个数)。设得太贴近预期用量,结果就是业务高峰期被自己的保险丝掐断,运维半夜起来调额度,几次之后大家就开始把额度设得很松,防线名存实亡。
配额还有个容易忽略的维度是有效期。生成 key 的时候可以带 duration(官方文档里出现的示例值形如 "30min"、"30d"),给临时用途的 key 一个到期时间。很多超支的源头是「三个月前给外包同学开的一把测试 key,项目结束了没人收回」。有到期时间的话,这类僵尸 key 会自己死掉。
创建 key 时把这些字段一次配齐的调用形式,可以参考 LiteLLM 虚拟 key 那篇里的完整参数说明。这里要强调的只有一句:不要用 master key 直接跑业务。master key 是用来签发别的 key 的,它自己没有额度概念,一旦泄漏或者被某段代码直接引用,上面这些防线全部作废。
第二层:速率告警,盯的是斜率不是总数
绝大多数团队的预算告警长这样:花到预算的 80% 报警。这个设计的问题是,80% 这个信号来得太晚,而且它到达的时间取决于事故发生在月初还是月末。
同样一个把消耗翻三倍的 bug:月初第三天上线,你会在第十天左右收到 80% 告警,还有机会止损;月末第二十五天上线,等 80% 响的时候你可能已经超了。同一个 bug,同一个阈值,损失差好几倍——这说明阈值挂在累计值上,本身就不是个稳定的信号。
更早的信号是日均消耗速率。它的好处是当天就能看出来:昨天花了 X,今天花了 3X,这个比值第一天就成立,不需要等累计值爬到任何位置。
具体做法不复杂:
- 每天固定时间(比如凌晨)拉一次各 key 的累计花费,存下来
- 相邻两天做差,得到当日消耗
- 用最近七天的当日消耗算一个基线(中位数比平均值稳,能扛住单日毛刺)
- 当日消耗 ÷ 基线,超过设定的倍数就报警
外推也很直接:当前速率 × 本周期剩余天数 + 已花费 = 预计周期结束时的花费。这个数超过预算就该报,哪怕现在累计值才用了三成。它把「还有多久会出事」这个真正有用的信息直接摆在了告警里,而不是让值班的人自己去算。
拉花费数据可以用 /key/info,它返回指定 key 的花费情况。官方的花费追踪是通过数据库在 key / user / team 三个层级自动进行的,所以你既可以按单个 key 采,也可以直接按团队维度看聚合,取决于你的告警要落到哪一层。
两个实践上的坑:
- **别只盯总量。**总花费平稳不代表没事,很可能是一个业务降了、另一个涨了,两边抵消。速率告警一定要按 key(或 team)分别算,聚合层只做二次确认。
- **要区分「涨了」和「换了」。**换模型、改批处理窗口这类计划内变更也会让速率跳变。所以告警渠道里最好能顺手看到最近的发布记录,否则每次上线都要人工去 mute 一遍,几轮之后没人再看这个告警了。
更完整的采集口径和面板设计,调用量监控这篇讲得更细,这里不重复。
第三层:结构性告警,比花费更早的那批信号
前两层盯的都是钱。但钱是结果,结果永远比原因晚。真正最早暴露问题的是几个结构指标,它们在花费还没明显变化的时候就已经动了:
**平均输出 token 数。**这是最灵敏的一个。提示词改动、模型换代、格式约束失效,都会先反映在这里。一个「简要回答」改成「详细说明」的编辑,在花费曲线上要好几天才看得出来,在平均输出 token 上第一批请求就体现了。
**重试率与重试后成功率。**重试是隐性成本的重灾区:失败的那次调用产生的 token 一样要算钱,而且用户完全感知不到。重试率从 1% 涨到 8%,账单会跟着涨,但请求成功率可能依然漂亮——因为重试最终成功了。这块的成本结构在重试的隐性成本那篇里拆得比较细。更麻烦的情况是重试率高但重试后成功率也低,那说明你在为一批注定失败的请求反复付费,这时候该做的是熔断而不是加大重试次数。
**缓存命中率。**缓存失效是最安静的成本上涨。命中率从 60% 掉到 10%,请求数、错误率、延迟都可能只有轻微变化,但花费直接翻倍。掉命中率的原因往往是无意的——有人在提示词前缀里加了个时间戳,或者把用户 ID 拼进了系统提示的开头。
这三个指标加上按维度切分的成本归属,构成了一套比错误率更早的先行指标,网关可观测性那篇里有更系统的说明。这一层的价值在于:当它响的时候,钱通常还没花多少,你有时间从容处理,而不是一边止血一边排查。
告警要能定位到人,否则等于没有
这一条我想单独讲,因为它比阈值怎么设重要得多。
一条查不到归属的告警,等价于没有这条告警。「本月 API 花费超预算 30%」发到群里,会发生的事情是:所有人看一眼,觉得可能是别的组,然后继续干自己的活。没有人认领,也没有人有足够的上下文去查。这样的告警发几次之后,大家会自动把它当背景噪音。
归属能力必须在发 key 的那一刻就建好,事后是补不回来的。生成 key 时把 user_id、team_id 填上,把项目名、环境、负责人这类信息放进 metadata;用 models 把每把 key 能调的模型限死,这样一把 key 的花费上限在结构上就是可推理的。做到这一步之后,告警内容才可能长成这样:
key-xxxx(团队:推荐算法组 / 环境:prod / 负责人:某某)近 24 小时消耗为七日中位数的 4.2 倍,按当前速率外推,本周期预计超支。
这条告警有人认领,因为它点名了。而且收到它的人可以立刻用 /key/info 查这把 key 的花费明细,用三层追踪往上看是团队层面的问题还是单个应用的问题。
一个很常见的反模式是「一个应用一把 key,全公司共用一套配置模板」。这样做省事,但事故来的时候你只能知道「某个应用超了」,不知道是哪个环境、哪条链路。key 的粒度决定了你排查的最小单位——粒度粗,排查就得靠翻日志;粒度细,看告警就够了。粒度也不是越细越好,细到每个请求一把 key 就变成运维负担了,一般按「团队 × 环境 × 关键业务线」切是比较能落地的档位。
超支之后:分级预案要提前写好
告警响了,接下来做什么?这个问题必须在平静的时候想清楚,因为真出事的时候没有人能冷静地做取舍——现场的默认反应是「先别停,再观察观察」,而观察的每一分钟都在计费。
按业务代价从小到大,四级预案:
**一级:降档。**把非关键路径切到更小的模型。分类、摘要、意图识别这类任务,小模型往往够用。代价是质量小幅下降,用户大概率感知不到。这一级应该做成配置开关,能在几分钟内切换,而不是需要发版。
**二级:降频。**收紧非关键路径的 rpm_limit 和 tpm_limit。比如把批量处理任务、后台预计算、数据清洗这类不需要实时性的东西压下来,把额度让给在线请求。代价是这些任务变慢或积压。这一级的前提是你的 key 拆得够细,否则一压就把在线业务一起压了——这又绕回上面那句,粒度是提前决定的。
**三级:停非核心功能。**关掉那些「有更好、没有也能用」的 AI 功能:智能推荐、自动标签、内容润色。代价是功能降级,用户可见,最好配一句明确的降级提示,别让页面变成静默失败。
**四级:停 key。**用 /key/block 停用具体的 key,确认没问题之后用 /key/unblock 恢复。这是最后手段,代价是这把 key 对应的业务直接中断。
关于第四级有一点要强调:**block 优于删。**删掉一把 key,它的配置、归属、花费记录关联可能一起没了,事后复盘时你会发现自己毁掉了最关键的证据——到底是哪个 team_id、配了哪些 models、什么时候开始异常的。block 只是让它不能用,所有信息都还在,确认误报之后还能 unblock 回来。真正确定不再需要了,等复盘写完再清理。
还有一点:预案要写成一页纸,标明每一级由谁决定。「降档」这种低代价动作应该授权给值班的人直接执行,不需要等审批;「停 key」这种会中断业务的,要写清楚谁有权拍板、通知哪些人。没有授权边界的预案,实际执行的时候会卡在「我不敢做这个决定」上,那几十分钟的犹豫比事故本身贵。
开发和 CI 的额度必须独立
这块单拎出来,因为它是超支来源里占比很高、又最容易被漏掉的一类。
开发环境和 CI 的消耗有几个特点:波动极大(今天写测试可能一整天不调,明天调一次就跑几千条)、无人值守(跑在半夜的流水线,出问题没人看见)、失败模式特别脏(一个写错的循环边界能跑一整晚)。我听过不止一次的故事是:某个同学在测试脚本里循环调用,忘了 break,晚上下班回家,第二天早上账单已经出来了。
隔离做法很直接:
- **CI 单独一把 key,
max_budget设成一个很小的数。**它是一次性的、可以随时重发的,额度花完就花完了,重新生成一把就是——但生产环境的额度一分没动。 - **
rpm_limit卡死。**CI 不需要高并发。把速率卡在一个明显偏低的水平,能把「一夜跑失控」这种事故的损失锁在一个可接受的范围内。 - **
duration设短。**给开发环境的 key 一个短有效期,到期自动失效。想继续用就重新申请一次,顺便清掉那些「项目早结束了但 key 还活着」的历史遗留。 - **
models白名单收窄。**开发和测试不该有权限调用最贵的那档模型,除非在专门验证它。用models直接限死,比在代码里写规范可靠得多。 - **
metadata里标上环境。**这样成本报表里可以一眼分出研发消耗和生产消耗。这两个数混在一起的时候,你既算不准生产的单位成本,也看不出研发消耗是不是失控了。
一个附带好处是:把研发消耗单独统计出来之后,你会得到一个相当有说服力的数字,用来判断某个 AI 功能的开发过程本身值不值这个投入。这个数在混算的账单里是永远看不到的。
定期对账:网关的账和厂商的账要能对上
最后一层保险是对账:把网关侧统计的花费,和厂商账单上的实际金额,定期比一次。频率按业务规模定,量大的话每周一次不算多。
对得上,说明你的观测口径是可信的,前面所有告警才有意义。对不上,差异本身就是最有价值的信息,常见的几种原因:
- **有绕过网关的直连调用。**某个老服务里还硬编码着厂商的原始 API key,从来没接网关。这是最常见的一种,也是最该优先处理的——这部分消耗完全在你的所有防线之外,
max_budget、rpm_limit一个都拦不到。 - **有厂商侧的计费项没进你的统计。**比如某些附加能力、某些不走标准调用路径的功能。
- **统计口径本身有差。**失败请求算不算、重试算几次、缓存命中的请求怎么记,网关和厂商的口径未必一致。这类差异是稳定的,摸清楚一次就行,之后按固定系数校正。
前两种要修,第三种要记录下来当作已知偏差。真正危险的是差异比例在变化——上个月差 3%,这个月差 12%,那多半是有新的直连调用冒出来了。所以对账要记的不只是这次差多少,而是差异比例的历史曲线。
另外,网关侧的花费换算依赖你配置的价格表,厂商调价、你换了模型档位、或者用了不同的计费模式,都会让换算失真。这块的口径问题在月度预算怎么估那篇里有更细的讨论。总之一句:网关的花费数字是拿来做趋势判断和告警的,最终以厂商账单为准。
自查清单
- 每把业务 key 是否同时设了
max_budget、rpm_limit、tpm_limit?只设一个的话,另外两类事故没有防线。 max_budget的数值是按「绝不该达到」设的,还是按预期用量设的?后者会在业务高峰期误伤,几次之后防线就废了。- 告警是挂在累计值上还是消耗速率上?只有 80% 阈值的话,月末发生的事故基本来不及止损。
- 告警内容里有没有
team_id、环境、负责人?没有归属的告警不会有人认领。 - 平均输出 token、重试率、缓存命中率这三个先行指标,有没有单独的异常告警?它们比花费早。
- 分级预案(降档 → 降频 → 停非核心 →
/key/block)写下来了吗?每一级由谁拍板,写清楚了吗? - 开发和 CI 是否用独立的 key,配了小额度、低速率、短
duration、窄models白名单? - 网关统计与厂商账单多久对一次?差异比例是在收敛还是在扩大?
以上涉及的接口和字段名以官方文档为准,不同版本的行为可能有差异,落地前请以你实际部署的版本的官方文档核对一遍。