《互联网产品的 AI 架构·模型接入与能力边界》8.2 Token 成本模型与配额

「回声」商家版租户被自己的预留限流三天:拦截分支没有结算,也未释放额度,真实花费不到配额三分之一。本节用整数微元、租户/能力/模型三维计量、预留与结算、失败也记账和 TTL 回收,搭一个可测试的成本账本,并复盘这次泄漏从发生到被发现的每一步。

一次预留泄漏把租户打成限流

「回声」商家版租户的当月配额是 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 回收。一次提前返回就能让额度泄漏到租户被限流。
  • 用真实等待测试过期逻辑。注入时钟才能在毫秒边界上稳定断言。
  • 只按租户计量。没有能力和模型维度,就无法判断哪个功能在烧钱、换模型后成本为何突变。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查