📑 本页目录(点开跳转)
12 · 限流、配额与成本护栏
⏱ 64 分钟 | ⭐⭐ AI 应用和普通 Web 应用最大的差别:每个请求真的花钱
🎯 一句话
普通接口被刷爆,代价是服务器变慢;AI 接口被刷爆,代价是账单。 所以护栏必须有三层 —— 限流管「多快」、配额管「多少」、全局熔断管「今天最多亏多少」。只做第一层的人非常多,而能一夜烧掉几万块的,恰恰是第三层要防的那种事故。
🧩 一、三层护栏,缺一不可
| 层 | 管什么 | 单位 | 不做的后果 |
|---|---|---|---|
| ① 限流 | 每个用户多快 | 次 / 分钟 | 一个脚本把你的并发和上游速率打满,其他人全排队 |
| ② ⭐ 配额 | 每个用户多少 | token / 钱 / 月 | 单个用户一个月慢慢悠悠花掉几千块,限流永远不会触发 |
| ③ ⭐⭐ 全局熔断 | 整个应用今天最多花多少 | 钱 / 天 | 一个 bug 循环调 API,一夜烧穿全年预算 |
⭐ 为什么限流挡不住配额要挡的事:限流是「每分钟 10 次」,一天 1440 分钟 = 14400 次上限。 一个用户老老实实地每分钟 9 次跑一整天,从头到尾没触发过任何限流 —— 但账单是真的。
🧩 二、限流:令牌桶
令牌桶的形状是:一个桶按固定速率往里滴令牌,桶有容量上限;来一个请求就拿走一个,没得拿就拒绝。 两个参数分别对应两件事:滴速 = 长期允许的速率,桶容量 = 允许多大的突发。
import time
class TokenBucket:
def __init__(self, rate, burst, clock=time.monotonic):
self.rate, self.burst = rate, burst # 每秒补几个 / 桶最多装几个
self.tokens, self.clock = float(burst), clock
self.ts = clock()
def take(self, n=1):
now = self.clock()
# ⭐ 不开线程定时补令牌:用「距上次过了多久」现算,少一个线程也不会漂
self.tokens = min(self.burst, self.tokens + (now - self.ts) * self.rate)
self.ts = now
if self.tokens >= n:
self.tokens -= n
return True, 0.0
# ⭐ 拒绝时顺手算出「等多久才够」——这个数直接写进 Retry-After 响应头
return False, round((n - self.tokens) / self.rate, 2)
now = [0.0] # 假时钟,让结果可复现
b = TokenBucket(rate=2, burst=5, clock=lambda: now[0])
print("桶满时连发 7 次:", [b.take()[0] for _ in range(7)])
print("被拒时 Retry-After:", b.take()[1], "秒")
now[0] += 3.0 # 等 3 秒补 6 个,但桶只装得下 5 个
print("等 3 秒后再连发 7 次:", [b.take()[0] for _ in range(7)])
跑出来:桶满时前 5 次通过、后 2 次被拒,Retry-After 是 0.5 秒;等 3 秒后又是 5 通过 2 拒(补了 6 个但桶只装 5 个)。⭐ 令牌桶最值钱的一点就是它天然算得出 Retry-After ——「等多久才够」= 缺的令牌数 ÷ 滴速。
⚠️ 固定窗口的边界漏洞
另一种常见实现是固定窗口计数:按整分钟分桶,每桶限 N 次。省内存,但跨桶那一瞬间能放进两倍流量。
实测(把 59.0–59.9 秒的 10 次和 60.0–60.9 秒的 10 次喂进去,限额是「每 60 秒 10 次」):固定窗口放过 20 次,滑动窗口只放过 10 次。这 20 次落在 2 秒之内。
| 固定窗口 | 滑动窗口(记录每次时刻) | 令牌桶 | |
|---|---|---|---|
| 内存 | 一个计数器 | ⚠️ 跟着 QPS 走 | 两个数 |
| 边界 | ⚠️ 能放进两倍 | 精确 | 精确 |
| 突发 | 说不清 | 不允许 | ⭐ 参数可调 |
⚠️ 多实例部署时,桶必须是共享的。每个进程各持一个桶 = 实际限额乘以进程数。放 Redis 里,并且用一个 Lua 脚本把「读 + 算 + 写」做成一次原子操作 —— 分成三次命令,中间那道缝就是超发的来源。
🧩 三、配额与全局熔断
这两件事的形状是一样的:一个账本 + 一条带条件的扣减语句。关键是判断和扣减必须在同一条语句里 —— 「先 SELECT 看看够不够、再 UPDATE 扣掉」中间那道缝,会让两个并发请求都看到「还够」。
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE quota(user TEXT PRIMARY KEY, used INTEGER, cap INTEGER)")
db.execute("CREATE TABLE budget(day TEXT PRIMARY KEY, spent INTEGER, cap INTEGER)")
db.executemany("INSERT INTO quota VALUES(?,0,100)", [("u1",), ("u2",)]) # 单位:分
db.execute("INSERT INTO budget VALUES('2026-08-07',0,150)")
db.commit()
class Blocked(Exception):
pass
def reserve(user, day, cents):
"""调模型【之前】先预扣。返回 ok / quota / breaker。"""
try:
with db: # ⭐ 抛异常自动回滚:不会留下「全局扣了、个人没过」的脏账
# ⭐ 条件写在 WHERE 里,不是先 SELECT 再 if —— 那道缝就是超发的来源
if db.execute("UPDATE quota SET used=used+? WHERE user=? AND used+?<=cap",
(cents, user, cents)).rowcount == 0:
raise Blocked("quota")
if db.execute("UPDATE budget SET spent=spent+? WHERE day=? AND spent+?<=cap",
(cents, day, cents)).rowcount == 0:
raise Blocked("breaker")
except Blocked as e:
return str(e)
return "ok"
for u in ["u1", "u1", "u1", "u2", "u2"]:
print(u, reserve(u, "2026-08-07", 40))
print(db.execute("SELECT user, used FROM quota").fetchall(),
db.execute("SELECT spent FROM budget").fetchone())
输出是 ok / ok / quota / ok / breaker,最后 [('u1', 80), ('u2', 40)] (120,)。
⭐ 注意最后一次:u2 的个人配额还够(40 + 40 ≤ 100),是全局日预算拦下的(120 + 40 > 150);而且因为异常触发了回滚,u2 的 used 停在 40 而不是脏成 80。这就是第三层护栏存在的意义。
⚠️ 预扣要在调用之前,结算要在调用之后。调用前你只有一个估的 token 数(输出多少还不知道),所以流程是:按上限预扣 → 调用 → 拿到真实用量后补差价。只在调用后记账,等于在超支之后才知道超支了。
⭐ 熔断触发时要报警到人,而不是静默返回 503。它是「今天出事了」的信号,不是一个正常状态。
🧩 四、在哪一层拦
⭐ 三处都要拦,各拦不同的东西。 只在一处拦,另外两类攻击面全裸着。
| 位置 | 拦什么 | 为什么必须在这一层 |
|---|---|---|
| 网关 / 反向代理 | 按 IP 的粗粒度洪水、超大请求体 | ⭐ 这一层的请求还没进你的进程,最便宜。但它不知道「谁是谁」 |
| 应用中间件 | 按用户/租户限流、检查配额 | 认证之后才知道用户是谁。⚠️ 要放在认证之后、业务逻辑之前 |
| ⭐ 调用层 | 每次调模型前预扣、之后记账 | 只有这里知道用了哪个模型、多少 token、有没有命中缓存。一次请求可能调 5 次模型,中间件那层看不见 |
⚠️ 只在中间件拦的典型漏洞:一个 Agent 请求内部循环调了 40 次模型。 中间件看到的是「1 次请求」,全都放行 —— 钱是在调用层花的,护栏也得在调用层。
⭐ 中间件这一层怎么接线
第 3 章第四节埋了两个空壳依赖,其中 quota 就是留给这一章的挂载点(另一个 current_user 留给第 8 章)。现在把它填上:
from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi.testclient import TestClient
class Bucket:
"""第二节那个令牌桶,缩到最短:⚠️ 省掉了按时间补令牌那半截(完整版在第二节)。
本段的重点不是桶怎么写,是【它挂在哪】。"""
def __init__(self, rate=2.0, burst=3):
self.rate, self.tokens = rate, float(burst)
def take(self):
if self.tokens >= 1:
self.tokens -= 1
return True, 0.0
return False, round((1 - self.tokens) / self.rate, 2) # 差多少 ÷ 滴速
app = FastAPI()
BUCKETS = {} # ⚠️ 单进程演示用;多实例部署必须放 Redis(第二节)
def current_user(authorization: Annotated[str, Header()] = ""):
if not authorization.startswith("Bearer "):
raise HTTPException(401, "unauthorized")
return {"uid": authorization[7:]} # 第 8 章换成真的 token 校验
def guard(user: Annotated[dict, Depends(current_user)]):
# ⭐ 依赖套依赖:顺序天然就是「认证之后、业务之前」,不用在每个路由开头手写
ok, wait = BUCKETS.setdefault(user["uid"], Bucket()).take()
if not ok:
raise HTTPException(429, "rate limited", headers={"Retry-After": str(wait)})
return user # 配额扣减(第三节的 reserve)也挂在这里
@app.post("/chat")
def chat(user: Annotated[dict, Depends(guard)]):
return {"uid": user["uid"]} # ⭐ 业务函数里一行限流代码都没有
c = TestClient(app)
h = {"Authorization": "Bearer u1"}
print("u1 连打 5 次:", [c.post("/chat", headers=h).status_code for _ in range(5)])
print("被拒时 Retry-After:", c.post("/chat", headers=h).headers["Retry-After"], "秒")
print("换成 u2:", c.post("/chat", headers={"Authorization": "Bearer u2"}).status_code)
print("不带 token:", c.post("/chat").status_code)
实跑输出:[200, 200, 200, 429, 429],被拒时 Retry-After: 0.5 秒;换成 u2 是 200(⭐ 桶是按用户分的,一个人打爆不该影响别人),不带 token 是 401(还没进到限流就被上一层依赖拦了)。
⭐ 三件事值得注意:① 业务函数 chat() 里一行限流代码都没有——这就是第 3 章说「依赖注入不是花架子」的兑现;② ⚠️ 顺序是靠依赖链保证的,不是靠你记得把中间件注册在正确的位置(第 16 章那条「限流中间件挂在认证之前所以永远拿不到用户」就是手写顺序才会犯的错);③ 换到 Node / Go 就是中间件,Depends 这个名字会过期,「每请求要做的事得有统一的地方挂」不会。
🛑 读到这里可以停 —— 前半章讲完了(约 28 分钟)。 后半章还有:超限之后怎么办 · 成本归因:别只知道总账单涨了 · 事故复盘:前端重试写错,一夜烧穿预算 · 换个栈怎么对应 回来的时候不用重读,直接从下一节接着看就行。
🧩 五、超限之后怎么办
| 策略 | 怎么做 | 什么时候用 | ⚠️ 代价 |
|---|---|---|---|
| 拒绝 | 429 + Retry-After: 12 |
默认。⭐ 必须给 Retry-After,否则客户端只会立刻重试,把你打得更狠 |
用户看到报错 |
| 降级 | 换成小模型 / 缩短上下文 / 关掉重排 | 功能允许质量打折时最好用 | ⚠️ 必须告诉用户,否则就是偷偷变差 |
| 排队 | 转成异步 job,做完再通知 | 任务本来就不需要即时结果 | 队列会积压;⚠️ 排队本身要有上限,否则只是把爆炸推迟 |
⚠️ 429 一定要带 Retry-After,而客户端一定要尊重它并加抖动。这两件事有一件没做,限流就从「保护」变成「放大器」—— 详见下面那场事故。
🧩 六、成本归因:别只知道总账单涨了
⭐ 每次调用至少记三个维度:谁(user_id)、哪个功能(feature)、哪个模型(model)。少记一列,就有一个维度永远查不出来。月底账单涨了 40%,没有这三列你只能猜;有了,一条 GROUP BY 就知道是谁。
⭐ 这张表不是新的:它就是第 4 章第四节 record() 那个最小字段集落进库的样子。那一章的原话是「有了这张表,12 章的配额扣减、15 章的成本看板都只是拿它做聚合」—— 本节就是那个聚合的第一个用处,第 15 章的成本看板是第二个。所以下面这张表的列和那边一一对应,不要在项目里建第二张。
⚠️ 单价表也全项目只该有一份(第 4 章那个 cost_of 就是它)。这里为了让金额能当场心算,示例价换成了「分 / 千 token」,而第 4 章那张写的是「元 / 百万 token」—— ⭐ 两边单位不一样这件事本身就是个坑:真项目里挑一个单位钉死在一处,混用会让你的账和厂商账单差一个数量级,而且极难查。
import sqlite3
PRICE = {"big": (0.30, 1.50), "small": (0.02, 0.08)} # 🗓️ 示例价:分/千token,会变
CACHE_RATE = 0.1 # 🗓️ 示例:命中缓存的输入按几折算,各家不同
db = sqlite3.connect(":memory:")
# ⭐ 就是第 4 章 record() 的字段集,一列不多一列不少
db.execute("""CREATE TABLE calls(trace_id TEXT, user_id TEXT, feature TEXT, model TEXT,
tok_in INT, tok_out INT, ms INT, cached INT, retries INT, cents REAL)""")
def log(trace_id, user_id, feature, model, tok_in, tok_out, ms=0, cached=0, retries=0):
pin, pout = PRICE[model]
# ⭐ 缓存命中的输入必须单独算,否则你的账和厂商账单永远对不上
c = round((tok_in - cached) / 1000 * pin + cached / 1000 * pin * CACHE_RATE
+ tok_out / 1000 * pout, 4)
# ⭐ user_id / feature / model 少记一列,就有一个维度永远查不出来
db.execute("INSERT INTO calls VALUES(?,?,?,?,?,?,?,?,?,?)",
(trace_id, user_id, feature, model, tok_in, tok_out, ms, cached, retries, c))
db.commit()
return c
log("t-1", "u1", "chat", "small", 1200, 400, ms=900)
log("t-2", "u1", "summary", "big", 30000, 800, ms=9400, cached=28000)
log("t-3", "u2", "summary", "big", 30000, 800, ms=9600) # 同样的活,没命中缓存
for r in db.execute("SELECT user_id, feature, model, cents FROM calls"):
print(r)
⚠️ 别只看 token 数
上面跑出来的三行是 0.056 / 2.64 / 10.2 分(用示例价表算的)。两个对比:同样的 30000 + 800 token,命中缓存的那次 2.64 分、没命中的 10.2 分,差 3.9 倍;而一次大模型长上下文 10.2 分 vs 一次小模型短对话 0.056 分,差 182 倍。
⭐ 所以「本月 token 数涨了 20%」这句话没有信息量 —— 同样的 token 数,落在不同模型、不同缓存命中率上,账单可以差两个数量级。记账要记钱,不要记 token(token 数也留着,出问题时拿它对账)。
💀 事故复盘:前端重试写错,一夜烧穿预算
⚠️ 这是一个构造的教学案例,不是真实统计。下面每个数字都是用本章那张示例价格表算出来的,你可以自己验算。
发生了什么。 一个「长文档总结」功能,每次调用约 30000 输入 + 800 输出 token,按示例价表 = 10.2 分($0.102)。前端给这个流式接口设了一个 5 秒的「首字超时」:5 秒没收到第一帧就判定这次失败、断开重发 —— 没有次数上限,也没有退避。可这个功能光把 30000 token 的输入送进去,首字本来就要六到八秒,于是每一次请求都在第一帧到达之前被前端掐掉。而后端没做断连感知(第 5 章),被掐掉的那条流照样把整段回答生成完,钱一分不少。11 个用户的标签整夜挂在那儿,从 22:00 到次日 07:00 共 9 小时。
- 每个标签:9 × 3600 ÷ 5 = 6480 次
- 合计:11 × 6480 = 71280 次 × $0.102 = 7270 美元
- 当月预算 $5000,在凌晨 4 点多(22:00 起第 6.2 小时)就穿了;早上 7 点有人上班时已经是 $7270
⭐ 这个事故的形状要看清楚:后端每一次都成功了。 前端认定失败、后端认定成功,两边的判断从头到尾没对上过 —— 这正是下面三条监控全说「正常」的根源。
为什么没被发现。 三条监控全都显示「正常」:
1. 成本告警是按账单发的,而账单数据 T+1 才到 —— 事故当晚根本没有数据
2. 服务健康指标看 5xx 率和 p99 延迟。⚠️ 流式响应的 200 在第一个字之前就已经发出去了(第 5 章),这些请求在服务端的记录里全是 200,耗时也在这个功能平时的区间里 —— 曲线上完全正常
3. QPS 确实涨了,但 71280 ÷ 32400 秒 = 2.2 QPS,绝对值太小,淹没在正常波动里
⭐ 最刺眼的一点:花掉 $7270 的过程中,没有任何一个系统认为出了问题。
该补什么(四条,都在本章里):
1. ⭐ 全局日预算熔断 —— 第三节那条 UPDATE ... WHERE spent+?<=cap。$5000 的月预算折成日上限 ≈ 167 美元;烧钱速度是 2.2 QPS × $0.102 ≈ 0.22 美元 / 秒,所以它在重试风暴开始后约 12 分钟(22:12 前后)就会把开关拉掉,当天损失止步在日上限那 167 美元。⚠️ 别忘了跨零点会重置:新的一天再烧十来分钟才第二次熔断,两天合计约 334 美元 —— 是 7270 的 1/22
2. 按用户限流 —— 令牌桶。一个标签打不出 6480 次
3. ⭐ 成本告警看自己记的账,不看账单 —— 第六节那张 calls 表是秒级可查的,T+1 的账单只配用来对账。⚠️ 但要分清分工:拉闸的是熔断,告警只负责让人看见;只有告警没有熔断,你得到的是一条「你已经烧掉了很多钱」的通知
4. 客户端重试必须有上限 + 退避 + 抖动 —— 见上一章的 backoff()。⭐ 这次还多一条根因:客户端的超时阈值不能比这个功能正常的首字延迟还短,否则重试逻辑写得再规矩也会一直被触发
🔁 换个栈怎么对应
| 概念 | Python / FastAPI | Node(Express / Hono) | Go |
|---|---|---|---|
| 令牌桶 | 自己写 / SlowAPI | rate-limiter-flexible |
golang.org/x/time/rate |
| 网关层限流 | Nginx limit_req / 云 API 网关(三边一样) |
同 | 同 |
| 分布式计数 | Redis + Lua 原子脚本(三边一样) | 同 | 同 |
| 熔断器 | 自己写 / pybreaker |
opossum |
sony/gobreaker |
| 配额账本 | 一条带条件的 UPDATE(三边一样) |
同 | 同 |
⭐ 这一章里真正和语言有关的只有令牌桶那 20 行。账本、原子扣减、Retry-After、按三维度记账,换什么栈都一模一样。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| 03 · 后端骨架 | ⭐ 那一章第四节埋的 quota 空壳依赖就是本章第四节的挂载点:限流和配额接在哪、为什么「认证之后、业务之前」这个顺序不用你自己记 |
| 04 · 调用层 | ⭐ 本章第六节那张 calls 表就是它 record() 的落库版本,单价表也在那边。先有记录才谈得上限制,顺序不能反 |
| 成本与容量 | ⭐ 分界要记清:那边是模型推理的容量规划(要几张卡、QPS 撑不撑得住、怎么压单位成本),本章是应用层的护栏(谁能花、花多少、超了怎么办)。产能问题去那边,花钱问题在这里 |
| 可观测性与成本 | 自建推理时成本结构完全不同(按 GPU 小时算,核心指标是利用率),和调 API 按 token 付费是两种账。你要做自建 vs API 的成本对比,去那边拿另一半 |
| KV-Cache | 本章说「缓存命中便宜 3.9 倍」,为什么便宜在那边:命中的部分不用重算前向 |
| 接进真实产品 | 那边的「护栏」管模型说了什么(结构化输出、内容审核),本章的护栏管花了多少钱。两种护栏都要,别互相替代 |
✅ 检查点
- 三层护栏分别管什么?各自的单位是什么?
- 为什么「每分钟 10 次」的限流挡不住配额要挡的事?给出算式。
- 令牌桶的两个参数各对应什么?
Retry-After怎么从它算出来? - 固定窗口的边界漏洞是什么?本章那个实测里两种实现分别放过了几次?
- 为什么配额扣减的条件必须写在
UPDATE的WHERE里? - 示例代码最后一次调用被谁拦下的?u2 的
used为什么停在 40? - 三处拦截各拦什么?只在中间件拦会漏掉什么?
- 为什么「本月 token 数涨了 20%」这句话没有信息量?给出本章的两个倍数。
- 事故里三条监控为什么都没报警?
👀 答案
- 限流管「多快」(次/分钟)、配额管「多少」(token 或钱 / 月)、全局熔断管「今天最多亏多少」(钱/天)。
- 每分钟 10 次 × 1440 分钟 = 一天 14400 次上限。一个用户每分钟 9 次跑一整天,从没触发过限流,但账单是真的。
- 滴速 = 长期允许的速率,桶容量 = 允许多大的突发。
Retry-After= 缺的令牌数 ÷ 滴速;示例 rate=2、burst=5,被拒时算出 0.5 秒。 - 按整分钟分桶,跨桶那一瞬间能放进两倍流量。实测(限「每 60 秒 10 次」):固定窗口放过 20 次,滑动窗口放过 10 次。
- 「先
SELECT看够不够、再UPDATE扣」中间有一道缝,两个并发请求会都看到「还够」。写进WHERE才是一次原子的「判断 + 扣减」。 - 被全局日预算熔断拦的 —— u2 个人配额还够(40+40 ≤ 100),但日预算 120+40 > 150。
used停在 40 是因为抛异常触发了回滚,没留下「全局扣了、个人没过」的脏账。 - 网关拦按 IP 的洪水和超大请求体(最便宜,但不知道谁是谁);中间件按用户限流、查配额(放在认证之后、业务逻辑之前);⭐ 调用层预扣和记账(只有这里知道用了哪个模型、多少 token、有没有命中缓存)。只在中间件拦,一个内部循环调 40 次模型的 Agent 请求会被当成「1 次」放行。
- 同样的 token 数落在不同模型和缓存命中率上,账单差两个数量级:命中缓存 2.64 分 vs 没命中 10.2 分 = 3.9 倍;大模型长上下文 10.2 分 vs 小模型短对话 0.056 分 = 182 倍。所以记账要记钱。
- 根源是后端每一次都成功了(前端因为 5 秒首字超时判定失败,后端照样把整段生成完),所以:① 成本告警按账单发,账单 T+1 才到;② 服务健康看 5xx 和 p99,而流式的 200 在第一个字之前就发出去了,这些请求在服务端记录里全是 200、耗时也正常;③ QPS 只有 2.2(71280 ÷ 32400 秒),淹没在正常波动里。
🛑 可以停在这里
⚡ 走神救援
前提是一句话:AI 应用和普通 Web 应用最大的差别是每个请求真的花钱 —— 普通接口被刷爆代价是变慢,AI 接口被刷爆代价是账单。所以要三层护栏:① 限流管「多快」(次/分钟);② 配额管「多少」(token 或钱/月);③ 全局熔断管「今天最多亏多少」(钱/天)。⭐ 只做第一层的人非常多,但限流挡不住配额要挡的事:每分钟 10 次 × 1440 分钟 = 一天 14400 次,一个用户每分钟 9 次跑一整天,从头到尾没触发过任何限流。 限流用令牌桶:滴速 = 长期速率,桶容量 = 允许多大突发;别开线程定时补令牌,用「距上次过了多久」现算。⭐ 它最值钱的是天然算得出
Retry-After(缺的令牌数 ÷ 滴速;示例 rate=2 / burst=5,被拒时 0.5 秒)。固定窗口计数省内存但有边界漏洞:实测限「每 60 秒 10 次」,固定窗口放过 20 次、滑动窗口放过 10 次,那 20 次落在 2 秒内。⚠️ 多实例部署时桶必须共享(Redis + Lua 把读+算+写做成一次原子操作),否则实际限额乘以进程数。 配额和熔断是同一个形状:一个账本 + 一条把判断写进WHERE的扣减语句。「先 SELECT 看够不够、再 UPDATE 扣」中间那道缝,会让两个并发请求都看到「还够」。示例跑出ok / ok / quota / ok / breaker:最后一次 u2 个人配额还够(40+40 ≤ 100),是日预算拦的(120+40 > 150),回滚让 u2 的used停在 40 没脏掉。⚠️ 预扣在调用前(按上限估),结算在调用后(补差价);熔断触发要报警到人,不是静默 503。 三处都要拦:网关拦按 IP 的洪水(最便宜但不知道谁是谁)、中间件按用户限流查配额(放在认证之后)、⭐ 调用层预扣和记账(只有这里知道用了哪个模型、多少 token、有没有命中缓存)—— 只在中间件拦,一个内部循环调 40 次模型的 Agent 请求会被当成「1 次」放行。超限后三种处置:429+ 必须带Retry-After、降级到小模型(要告诉用户)、转异步排队(排队也要有上限)。 成本归因记三列:user / feature / model,少一列就有一个维度永远查不出来。⚠️ 别只看 token 数:同样 30000+800 token,命中缓存 2.64 分 vs 没命中 10.2 分(3.9 倍);大模型长上下文 10.2 分 vs 小模型短对话 0.056 分(182 倍)。记账要记钱。 💀 事故(构造的教学案例):前端给流式接口设了 5 秒首字超时,而这个功能首字本来就要六到八秒 —— 于是每次请求都在第一帧之前被掐掉重发,没上限没退避;后端又没做断连感知,被掐掉的流照样生成完、钱一分不少。⭐ 形状是「后端每一次都成功了」:11 个标签挂了 9 小时 = 71280 次 × $0.102 = 7270 美元,凌晨 4 点多穿了 $5000 的月预算。三条监控全说正常:账单 T+1 才到、流式的 200 在第一个字之前就发出去了所以记录里全是 200 且耗时正常、QPS 只有 2.2。该补:⭐ 日预算熔断(月预算折日上限 ≈ 167 美元,按 0.22 美元/秒烧,约 12 分钟就拉闸,两天合计约 334 美元而不是 7270)、按用户限流、告警看自己记的账而不是账单(⚠️ 拉闸的是熔断,告警只负责让人看见)、客户端重试要有上限和退避,⭐ 超时阈值不能比正常首字延迟还短。
下一节 👉 13-配置与密钥.md