🏠 总目录📚 本教程 12 · 限流与成本护栏
📑 本页目录(点开跳转)

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 小时。

这个事故的形状要看清楚:后端每一次都成功了。 前端认定失败、后端认定成功,两边的判断从头到尾没对上过 —— 这正是下面三条监控全说「正常」的根源。

为什么没被发现。 三条监控全都显示「正常」: 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 倍」,为什么便宜在那边:命中的部分不用重算前向
接进真实产品 那边的「护栏」管模型说了什么(结构化输出、内容审核),本章的护栏管花了多少钱。两种护栏都要,别互相替代

✅ 检查点

  1. 三层护栏分别管什么?各自的单位是什么?
  2. 为什么「每分钟 10 次」的限流挡不住配额要挡的事?给出算式。
  3. 令牌桶的两个参数各对应什么?Retry-After 怎么从它算出来?
  4. 固定窗口的边界漏洞是什么?本章那个实测里两种实现分别放过了几次?
  5. 为什么配额扣减的条件必须写在 UPDATEWHERE 里?
  6. 示例代码最后一次调用被谁拦下的?u2 的 used 为什么停在 40?
  7. 三处拦截各拦什么?只在中间件拦会漏掉什么?
  8. 为什么「本月 token 数涨了 20%」这句话没有信息量?给出本章的两个倍数。
  9. 事故里三条监控为什么都没报警?
👀 答案
  1. 限流管「多快」(次/分钟)、配额管「多少」(token 或钱 / 月)、全局熔断管「今天最多亏多少」(钱/天)。
  2. 每分钟 10 次 × 1440 分钟 = 一天 14400 次上限。一个用户每分钟 9 次跑一整天,从没触发过限流,但账单是真的。
  3. 滴速 = 长期允许的速率桶容量 = 允许多大的突发Retry-After = 缺的令牌数 ÷ 滴速;示例 rate=2、burst=5,被拒时算出 0.5 秒
  4. 按整分钟分桶,跨桶那一瞬间能放进两倍流量。实测(限「每 60 秒 10 次」):固定窗口放过 20 次,滑动窗口放过 10 次
  5. 「先 SELECT 看够不够、再 UPDATE 扣」中间有一道缝,两个并发请求会都看到「还够」。写进 WHERE 才是一次原子的「判断 + 扣减」。
  6. 全局日预算熔断拦的 —— u2 个人配额还够(40+40 ≤ 100),但日预算 120+40 > 150。used 停在 40 是因为抛异常触发了回滚,没留下「全局扣了、个人没过」的脏账。
  7. 网关拦按 IP 的洪水和超大请求体(最便宜,但不知道谁是谁);中间件按用户限流、查配额(放在认证之后、业务逻辑之前);⭐ 调用层预扣和记账(只有这里知道用了哪个模型、多少 token、有没有命中缓存)。只在中间件拦,一个内部循环调 40 次模型的 Agent 请求会被当成「1 次」放行。
  8. 同样的 token 数落在不同模型和缓存命中率上,账单差两个数量级:命中缓存 2.64 分 vs 没命中 10.2 分 = 3.9 倍大模型长上下文 10.2 分 vs 小模型短对话 0.056 分 = 182 倍。所以记账要记钱
  9. 根源是后端每一次都成功了(前端因为 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

打卡记录保存在你的浏览器里,首页能看到总进度