一次预留泄漏把租户打成限流
「回声」商家版租户的当月配额是 40,000,000 微元。某个周一上午,这个租户的所有 AI 请求开始返回 quota 错误。客服看到的是“额度已用完”,财务看到的本月实际消耗只有 12,000,000 微元。
复盘找到一条内容拦截分支:网关先调用 reserve 预留 8,000 微元,提供方返回 content_filter,代码直接 return,既没有 settle 也没有 release。每次被拦截都泄漏一笔预留,三天累积三千五百多次,可用额度被占满。真实花费没到三分之一,租户却被自己没用掉的预留限流了。
这个事故说明配额系统里有两个不同的数字:花了多少,和占了多少。只记前者,后者就会变成黑洞。
文中出现的价格、配额与比例,除非特别标注为本机测试输出,都是案例假设值,用来演示计量口径,不是任何真实厂商的报价或线上数据。
成本为什么用整数微元
模型按 token 计价,价格通常写成“每百万 token 多少货币单位”。如果直接用浮点数乘除,一次请求的成本可能是 0.00012300000000000001,累积到月末账单会出现分位漂移;不同语言、不同精度的服务对同一个数字四舍五入,对账时永远差几分钱。
本书统一用整数微元:1 微元等于百万分之一货币单位。模型档案里存“微元 / 百万 token”,单次成本按下面的口径计算:
cost = ceil(inputTokens * inputPrice / 1_000_000)
+ ceil(outputTokens * outputPrice / 1_000_000)
两次向上取整,而不是最后取一次。输入和输出是两个计费项,分开取整才能和提供方的账单行对齐。代价是极小请求会略微高估,但高估可控,浮点漂移不可控。本节实验用输入 1 微元/token、输出 2 微元/token 的 mock 价格,即每百万 token 1,000,000 与 2,000,000 微元,只用于演示整数口径,不对应任何真实厂商。
三个维度计量
计量键至少要有三个维度:租户、能力、模型。
- 租户:谁该为这笔钱负责;缺失后无法配额、收费和隔离。
- 能力:哪个产品功能在花钱;缺失后智能回复会补贴批量审核,单位经济算不清。
- 模型:路由变化如何影响成本;缺失后换模型成本突变,找不到原因。
先预留,再结算
配额不能等请求结束再扣。一次请求从进入到结束可能两秒,高峰期同一个租户有几百个并发;如果都等结算时才检查额度,几百个请求会同时通过检查,然后把额度打穿。
正确顺序是:进入网关时按最坏情况预留,调用结束后按真实用量结算,差额释放。
// file: src/cost/token-cost.ts
// tier: L1
// verify: node --test test/cost/token-cost.test.ts
import { AiError } from "../core/errors.ts";
import { estimateCostMicros } from "../protocol/chat.ts";
import { systemClock } from "../core/types.ts";
import type { CapabilityId, Clock, ModelProfile, TenantId, Usage } from "../core/types.ts";
export interface TenantQuota {
tenantId: TenantId;
limitMicros: number;
}
export interface ReserveInput {
reservationId: string;
tenantId: TenantId;
capability: CapabilityId;
model: ModelProfile;
inputTokens: number;
maxOutputTokens: number;
}
export type ReservationState = "reserved" | "settled" | "released";
export interface Reservation {
reservationId: string;
tenantId: TenantId;
capability: CapabilityId;
modelId: string;
reservedMicros: number;
state: ReservationState;
createdAtMs: number;
actualMicros?: number;
releasedBy?: "unused" | "expired";
}
export interface Settlement {
reservationId: string;
actualMicros: number;
releasedMicros: number;
overageMicros: number;
quotaExceeded: boolean;
}
export interface MeterEntry {
tenantId: TenantId;
capability: CapabilityId;
modelId: string;
requests: number;
failures: number;
reservedMicros: number;
settledMicros: number;
}
function fail(message: string, kind: "invalid-request" | "quota" = "invalid-request"): never {
throw new AiError(kind, `token-cost: ${message}`);
}
function requireInteger(value: number, field: string, min: number): void {
if (!Number.isInteger(value) || value < min) fail(`${field} must be an integer >= ${min}`);
}
function requireText(value: string, field: string): void {
if (value.trim() === "") fail(`${field} is required`);
}
/**
* 内存账本:先按最坏情况预留,再用真实用量结算。
* 预留占用额度,结算释放差额;失败请求同样走 settle,因此不会漏计成本。
*/
export class TokenLedger {
private readonly quotas: Map<TenantId, TenantQuota>;
private readonly clock: Clock;
private readonly reservations: Map<string, Reservation>;
private readonly committedMicros: Map<TenantId, number>;
private readonly heldMicros: Map<TenantId, number>;
private readonly meters: Map<string, MeterEntry>;
constructor(quotas: TenantQuota[], clock: Clock = systemClock) {
this.quotas = new Map();
this.clock = clock;
this.reservations = new Map();
this.committedMicros = new Map();
this.heldMicros = new Map();
this.meters = new Map();
for (const quota of quotas) {
requireText(quota.tenantId, "tenantId");
requireInteger(quota.limitMicros, "limitMicros", 0);
if (this.quotas.has(quota.tenantId)) fail(`duplicate quota for tenant "${quota.tenantId}"`);
this.quotas.set(quota.tenantId, { ...quota });
}
}
private quota(tenantId: TenantId): TenantQuota {
const quota = this.quotas.get(tenantId);
if (!quota) fail(`unknown tenant "${tenantId}"`);
return quota;
}
availableMicros(tenantId: TenantId): number {
const quota = this.quota(tenantId);
return quota.limitMicros - (this.committedMicros.get(tenantId) ?? 0) - (this.heldMicros.get(tenantId) ?? 0);
}
/** 已经结算的真实成本,包含失败请求。 */
settledMicros(tenantId: TenantId): number {
this.quota(tenantId);
return this.committedMicros.get(tenantId) ?? 0;
}
private releaseHold(reservation: Reservation): void {
const held = this.heldMicros.get(reservation.tenantId) ?? 0;
this.heldMicros.set(reservation.tenantId, Math.max(0, held - reservation.reservedMicros));
}
private meter(tenantId: TenantId, capability: CapabilityId, modelId: string): MeterEntry {
const key = `${tenantId}\u0000${capability}\u0000${modelId}`;
let entry = this.meters.get(key);
if (!entry) {
entry = {
tenantId,
capability,
modelId,
requests: 0,
failures: 0,
reservedMicros: 0,
settledMicros: 0,
};
this.meters.set(key, entry);
}
return entry;
}
reserve(input: ReserveInput): Reservation {
requireText(input.reservationId, "reservationId");
requireText(input.capability, "capability");
this.quota(input.tenantId);
if (this.reservations.has(input.reservationId)) {
fail(`duplicate reservation "${input.reservationId}"`);
}
requireInteger(input.inputTokens, "inputTokens", 0);
requireInteger(input.maxOutputTokens, "maxOutputTokens", 1);
if (input.maxOutputTokens > input.model.maxOutputTokens) {
fail(`maxOutputTokens exceeds model "${input.model.id}" limit`);
}
const cost = estimateCostMicros(input.model, input.inputTokens, input.maxOutputTokens);
const available = this.availableMicros(input.tenantId);
if (available < cost) {
fail(`tenant "${input.tenantId}" needs ${cost} micros but only ${available} available`, "quota");
}
const reservation: Reservation = {
reservationId: input.reservationId,
tenantId: input.tenantId,
capability: input.capability,
modelId: input.model.id,
reservedMicros: cost,
state: "reserved",
createdAtMs: this.clock.now(),
};
this.reservations.set(reservation.reservationId, reservation);
this.heldMicros.set(input.tenantId, (this.heldMicros.get(input.tenantId) ?? 0) + cost);
const meter = this.meter(input.tenantId, input.capability, input.model.id);
meter.requests += 1;
meter.reservedMicros += cost;
return { ...reservation };
}
/**
* 用真实用量结算。costMicros 由网关按同一整数口径归一化,是账单事实。
* 实际超过预留时照实记账并标记超额,绝不把差额吞掉。
*/
settle(reservationId: string, usage: Usage, outcome: "success" | "failure"): Settlement {
const reservation = this.reservations.get(reservationId);
if (!reservation) fail(`unknown reservation "${reservationId}"`);
if (reservation.state !== "reserved") fail(`reservation "${reservationId}" is already ${reservation.state}`);
requireInteger(usage.inputTokens, "usage.inputTokens", 0);
requireInteger(usage.outputTokens, "usage.outputTokens", 0);
requireInteger(usage.costMicros, "usage.costMicros", 0);
const actualMicros = usage.costMicros;
const releasedMicros = Math.max(0, reservation.reservedMicros - actualMicros);
const overageMicros = Math.max(0, actualMicros - reservation.reservedMicros);
this.releaseHold(reservation);
this.committedMicros.set(
reservation.tenantId,
(this.committedMicros.get(reservation.tenantId) ?? 0) + actualMicros,
);
reservation.state = "settled";
reservation.actualMicros = actualMicros;
const meter = this.meter(reservation.tenantId, reservation.capability, reservation.modelId);
meter.settledMicros += actualMicros;
if (outcome === "failure") meter.failures += 1;
return {
reservationId,
actualMicros,
releasedMicros,
overageMicros,
quotaExceeded: this.availableMicros(reservation.tenantId) < 0,
};
}
/** 只用于请求尚未到达提供方就被拒绝的场景;已发生的调用必须走 settle。 */
releaseUnused(reservationId: string): Reservation {
const reservation = this.reservations.get(reservationId);
if (!reservation) fail(`unknown reservation "${reservationId}"`);
if (reservation.state !== "reserved") fail(`reservation "${reservationId}" is already ${reservation.state}`);
this.releaseHold(reservation);
reservation.state = "released";
reservation.releasedBy = "unused";
const meter = this.meter(reservation.tenantId, reservation.capability, reservation.modelId);
meter.requests = Math.max(0, meter.requests - 1);
meter.reservedMicros = Math.max(0, meter.reservedMicros - reservation.reservedMicros);
return { ...reservation };
}
/**
* 回收超过 TTL 仍未结算的预留,修复“预留泄漏把租户额度占满”的故障。
* 这些请求确实到达过网关,因此保留请求数并计为失败,只释放额度。
*/
reclaimExpired(ttlMs: number): Reservation[] {
requireInteger(ttlMs, "ttlMs", 1);
const now = this.clock.now();
const reclaimed: Reservation[] = [];
for (const reservation of this.reservations.values()) {
if (reservation.state !== "reserved") continue;
if (now - reservation.createdAtMs < ttlMs) continue;
this.releaseHold(reservation);
reservation.state = "released";
reservation.releasedBy = "expired";
const meter = this.meter(reservation.tenantId, reservation.capability, reservation.modelId);
meter.failures += 1;
meter.reservedMicros = Math.max(0, meter.reservedMicros - reservation.reservedMicros);
reclaimed.push({ ...reservation });
}
return reclaimed.sort((left, right) => left.reservationId.localeCompare(right.reservationId));
}
/** 按租户、能力、模型三个维度输出计量快照,顺序稳定。 */
meterSnapshot(): MeterEntry[] {
return [...this.meters.values()]
.map((entry) => ({ ...entry }))
.sort(
(left, right) =>
left.tenantId.localeCompare(right.tenantId) ||
left.capability.localeCompare(right.capability) ||
left.modelId.localeCompare(right.modelId),
);
}
}
reserve 用 inputTokens + maxOutputTokens 估算上限,因为它要在还不知道输出长度时回答“这个请求最多可能花多少”。预留偏大是安全的,偏小会让并发击穿配额。
settle 用网关归一化后的 usage.costMicros 作为账单事实。实际小于预留时释放差额;实际大于预留时照实记账,并返回 overageMicros。预留只是额度控制手段,不是账单上限——把超额部分吞掉,月底对账就会少钱。availableMicros 同时减掉已结算和已预留,所以并发请求看到的是同一个不断缩小的余额,这是预留存在的唯一理由。
失败也要计量
失败请求有三种成本形态:超时按已经生成的 token 计费,内容拦截通常照收输入,提供方错误可能完全不计费但请求已经发生。三种都必须走 settle,把 outcome 记为 failure。如果把失败当成“没花钱”直接释放预留,计量里会同时丢掉成本和失败率:成本被低估,故障率也看不见。
releaseUnused 只用于请求还没到达提供方就被拒绝的场景,比如参数校验失败、鉴权失败。它会把请求数减回去,因为这次调用没有产生任何模型工作量。区分“没发生”和“发生了但失败”,是计量准确性的分界线。
配额不是账单
配额是事前控制,账单是事后事实。两者会不一致,而且应该允许不一致。
四个数字要分开看:已结算代表真实成本,用于账单与单位经济;已预留代表在途请求占用的上限,用于并发控制;可用等于配额减已结算减已预留,用于准入;超额是实际成本超过预留的部分,用于告警和调整预留系数。
当实际成本超过配额时,settle 仍然记账,quotaExceeded 标记为 true,availableMicros 变成负数。不要因为“已经超了”就拒绝记账,也不要让负数静默存在——它应该触发告警,提示预留估算偏小或有人在绕过准入。
泄漏的回收
即使代码写对,进程崩溃、网络分区、回调丢失仍会让预留停在 reserved。所以账本需要一个 TTL 回收:超过约定时间仍未结算的预留,释放额度,保留请求数,计为失败。reclaimExpired 依赖注入时钟,测试可以把时间拨到任意一刻,不需要真的等。回收是兜底,不是正常路径:每次回收都应该有日志和告警,因为它的意思是“有一批请求的结果我们永远不知道了”。
案例里那条拦截分支,如果在 reserve 时记录了创建时间,TTL 设为 10 分钟,泄漏最多持续 10 分钟,而不是三天。回收不能替代修复,但能把故障从“租户被限流三天”缩小到“十分钟内少量额度被占”。
测试与真实输出
测试覆盖九个场景:预留、结算、额度不足、失败计量、超额、未使用释放、过期回收、三维分组、非法输入。执行:
node --test test/cost/token-cost.test.ts
本机一次运行的真实输出如下,耗时随机器变化:
✔ 预留按最坏输出计费并占用可用额度 (0.624583ms)
✔ 结算释放预留差额,只把真实用量记入账单 (0.46625ms)
✔ 额度不足时拒绝预留并给出 quota 错误 (0.2525ms)
✔ 失败请求同样按真实用量结算并计入失败数 (0.102334ms)
✔ 实际成本超过预留时照实记账并标记超额 (0.078ms)
✔ 未到达提供方的请求可以释放预留且不留下计量 (0.099833ms)
✔ 过期的预留被回收,额度恢复但请求记为失败 (0.125125ms)
✔ 计量按租户、能力、模型三个维度稳定分组 (8.527792ms)
✔ 重复预留、未知租户与超模型上限都被拒绝 (0.2185ms)
ℹ tests 9
ℹ suites 0
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 171.705792
第四个测试值得注意:失败请求的用量是输入 100、输出 110,成本 320 微元,settle 照常扣款并把 failures 加一。如果这条路径走的是 releaseUnused,这 320 微元就会从账单里消失。
小结
Token 成本模型要回答三个问题:一次请求花了多少、谁该负责、还剩多少额度。整数微元解决精度,租户/能力/模型三维解决归属,预留与结算解决并发下的额度控制。预留按最坏情况,结算按真实用量,失败也要记账。配额和账单允许不一致:配额是事前控制,账单是事后事实;不一致本身不是 bug,静默的不一致才是。
下一节把质量、成本与延迟放在同一张桌面上:当三个目标冲突时,如何用 ADR 记录取舍,以及什么条件会触发重新评审。
阅读导航:上一节:8.1 端到端延迟预算怎么拆 · 下一节:8.3 质量、成本与延迟的取舍记录 。
常见错误与边界
- 用浮点数记账。单次看没问题,累积到月末一定对不上,且无法复现。
- 只在成功时计量。超时、拦截、提供方错误都产生成本,漏记会同时低估成本和故障率。
- 等请求结束再检查配额。并发请求会同时通过检查,然后把额度打穿。
- 把预留当账单上限。实际超出时吞掉差额,财务对账会少钱。
- 失败一律释放预留。把“发生了但失败”当成“没发生”,计量和告警都会失真。
- 不设 TTL 回收。一次提前返回就能让额度泄漏到租户被限流。
- 用真实等待测试过期逻辑。注入时钟才能在毫秒边界上稳定断言。
- 只按租户计量。没有能力和模型维度,就无法判断哪个功能在烧钱、换模型后成本为何突变。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。