《互联网产品的 AI 架构·平台化与治理》4.1 三层配额:总额、份额、单次

多租户配额不是把预算除以人数。本节把租户总额、团队份额、单次上限拆成三层账本,定义统一的微元计量、固定扣减顺序与拒绝、降档、排队三种超限行为,并用回声的 1 亿、4,000 万、6,200 微元示例跑出可复核的拒绝位置与剩余额度,并给出配额变更的审批边界。

账上还有六千万,为什么这次调用被拒

月底对账时,内容团队拿着一条拒绝记录来问:租户月度总额 1 亿微元,账上明明还剩 6,000 多万,为什么一次 6,200 微元的摘要请求会被拒?

答案是团队份额用完了。内容团队分到 4,000 万,已经花掉 39,996,200,只剩 3,800,而这次请求要 6,200。租户总额确实还有 60,003,800,但那是三个团队共用的池子,不是内容团队可以继续透支的理由。

这个场景说明一件事:多租户配额不是「一个租户一个数」,而是三层约束叠在一起。只设总额,一个团队就能把全租户的预算花光;只设团队份额,租户层面的商务承诺又无人兜底;只设单次上限,等于没有总量控制。

三层分别回答什么问题

三层配额各有自己的分母和归属,不能互相替代。

层计量单位分母归属回答的问题
租户总额微元 / 月整个租户商务与平台共同确认这个租户一个月最多花多少
团队份额微元 / 月租户内的一个团队团队负责人,平台审批这个团队能自主支配多少
单次上限微元 / 请求团队 × 能力能力提供方与平台一次调用最多能花多少

三层的单位必须统一成整数微元。用「次数」做配额,跨能力就无法比较:一次摘要 6,200 微元,一次回复 4,200 微元,同样是 1,000 次,成本差 47.6%。用 token 做配额,又漏掉了单价差异,同样的 token 数在不同模型上不是同一笔钱。

分母也要写清楚。租户总额的分母是「一个自然月内的全部成功与失败调用」;团队份额的分母是「该团队在该租户下的全部调用」;单次上限的分母是「一次请求的预扣成本」。分母不写,季度结算时一定对不上账。

回声的例子是:租户总额 1 亿微元,内容团队 4,000 万,客服团队 3,000 万,交易团队 3,000 万;单次上限分别是摘要 6,200、回复 4,200、下单 3,000 微元。三组数字都来自同一套整数微元口径,可以直接相加、相减、比较。

扣减顺序:单次、份额、总额

扣减顺序固定为三层:先看单次上限,再看团队份额,最后看租户总额。顺序不是实现细节,它决定了拒绝理由和调用方该做什么。

单次上限放在最前面,因为它回答的是「这次请求本身是否合法」。一个 62,000 微元的摘要请求,超过 6,200 的上限十倍,通常意味着调用方把批量任务塞进了一次调用,或者模型档位配错了。这种请求即使团队还有额度,也应该立刻拒绝,让调用方改配置,而不是让它慢慢吃掉预算。

团队份额放在租户总额之前,因为团队是预算的实际所有者。团队份额用完时,正确的动作是找团队负责人或平台申请借用,而不是去动租户总额。租户总额是最后一道兜底,处理的是份额配置透支、借用额度越界或未归属开销。

账本本身不处理时间,窗口切换时由上层新建账本,这样扣减逻辑保持确定性。三层必须原子扣减:只有三层都通过,才同时记到团队和租户两个账上。如果先扣团队、再发现租户不够,就会留下「团队已花、租户没花」的孤儿账,后续对账永远平不了。代码里的 checkQuota 先算三层余额,全部通过后才一次性落账;任何一层拒绝,remaining 都保持拒绝前的值。

用回声的例子走一遍。内容团队连续调用 6,451 次摘要,每次 6,200 微元,累计 39,996,200,团队剩余 3,800。第 6,452 次请求仍然是 6,200:单次上限通过(6,200 ≤ 6,200),团队份额失败(6,200 > 3,800),于是停在团队层。此时租户总额还剩 60,003,800,但这次请求不会碰到它。

超限不是一种行为,而是三种

「超限」在不同层、不同商务场景下应该有不同动作。平台至少要支持三种:拒绝、降档、排队。

超限层默认动作调用方看到什么主要风险
单次上限拒绝立即失败,附配置错误提示批量任务被拆错,需改调用方式
团队份额拒绝或排队失败并给出重试时间,或进入有到期时间的队列队列过长会放大尾延迟
租户总额降档或排队换经济档继续,或等到下一窗口降档后质量下降,需业务确认

单次上限永远拒绝,不参与降档或排队。它是配置合法性检查,不是容量调度问题;把一个超限十倍的请求排进队列,只会让它在轮到时再次失败。

团队层的默认动作通常是拒绝。团队份额是团队自己的预算,用完就应该由团队决定是申请借用还是降低调用量。若平台允许无限排队,团队会把排队当成免费扩容,队列会掩盖真实的容量缺口。

租户层的默认动作可以是降档,因为租户总额对应的是商务承诺。租户的钱花完了,但业务不能停,平台可以按事先约定的经济档继续服务,并把降档次数记入账单与质量报告。降档的前提是经济档确实更便宜:示例里摘要从 6,200 降到 4,030(示例值,按 65% 估算),脚本才允许降档;如果经济档不便宜,降档只是换了个名字的浪费。

排队则必须有到期时间。没有到期时间的队列等于把拒绝推迟到用户已经离开之后。代码里的 expireQueue 在每轮调度前移除过期条目,避免它们永久占据租户的排队窗口。

账本、校验与超限动作

quota.ts 把三层配额、扣减、借用额度、结算退回和超限动作放在同一个模块里。它不依赖任何存储或网络,所有函数都是确定性的,测试可以用同一组数字复现每一次拒绝。

// file: src/gov/quota.ts
// tier: L2
// verify: node --test test/gov/quota.test.ts

/** 三层配额账本:租户总额、团队份额、单次上限;金额一律整数微元。 */
export type TeamId = string;
export type CapabilityId = string;
export type QuotaLayer = "request" | "team" | "tenant";
export type OverLimitAction = "reject" | "degrade" | "queue";

export interface TeamShare {
  teamId: TeamId;
  shareMicros: number;
  /** 能力名到单次上限的显式映射;没有映射视为不可调用。 */
  requestCaps: Readonly<Record<CapabilityId, number>>;
}

export interface TenantQuota {
  tenantId: string;
  totalMicros: number;
  teams: readonly TeamShare[];
  /** 团队层与租户层的默认动作;单次上限永远拒绝。 */
  teamAction: OverLimitAction;
  tenantAction: OverLimitAction;
}

export interface LedgerUsage {
  tenantMicros: number;
  teamMicros: Record<TeamId, number>;
  /** 借用得到的临时额度,归还时扣回;它不是份额的一部分。 */
  creditMicros: Record<TeamId, number>;
}

export interface Ledger {
  quota: TenantQuota;
  usage: LedgerUsage;
}

export type QuotaReason =
  | "request-cap" | "team-share" | "tenant-total"
  | "unknown-team" | "unknown-capability" | "invalid-amount";

export interface QuotaDecision {
  ok: boolean;
  micros: number;
  layer?: QuotaLayer;
  reason?: QuotaReason;
  action: "allow" | OverLimitAction;
  remaining: { request: number; team: number; tenant: number };
}

export interface QuotaRequest {
  teamId: TeamId;
  capability: CapabilityId;
  micros: number;
}

export function createLedger(quota: TenantQuota): Ledger {
  const teamMicros: Record<TeamId, number> = {};
  const creditMicros: Record<TeamId, number> = {};
  for (const team of quota.teams) {
    teamMicros[team.teamId] = 0;
    creditMicros[team.teamId] = 0;
  }
  return { quota, usage: { tenantMicros: 0, teamMicros, creditMicros } };
}

function deny(
  micros: number,
  layer: QuotaLayer | undefined,
  reason: QuotaReason,
  action: OverLimitAction,
  remaining: QuotaDecision["remaining"],
): QuotaDecision {
  return { ok: false, micros, layer, reason, action, remaining };
}

/**
 * 扣减顺序固定为:单次上限 -> 团队份额 -> 租户总额。
 * 任一层拒绝都不落账,remaining 仍是拒绝前的余额。
 */
export function checkQuota(ledger: Ledger, request: QuotaRequest): QuotaDecision {
  const { quota, usage } = ledger;
  const tenantRemaining = quota.totalMicros - usage.tenantMicros;
  const base = { request: 0, team: 0, tenant: tenantRemaining };
  if (!Number.isInteger(request.micros) || request.micros <= 0) {
    return deny(0, undefined, "invalid-amount", "reject", base);
  }
  const team = quota.teams.find((item) => item.teamId === request.teamId);
  if (team === undefined) return deny(request.micros, undefined, "unknown-team", "reject", base);
  const cap = team.requestCaps[request.capability];
  const teamUsed = usage.teamMicros[team.teamId] ?? 0;
  const credit = usage.creditMicros[team.teamId] ?? 0;
  const teamRemaining = team.shareMicros + credit - teamUsed;
  const remaining = {
    request: cap === undefined ? 0 : Math.max(0, cap - request.micros),
    team: Math.max(0, teamRemaining),
    tenant: Math.max(0, tenantRemaining),
  };
  if (cap === undefined) return deny(request.micros, "request", "unknown-capability", "reject", remaining);
  if (request.micros > cap) return deny(request.micros, "request", "request-cap", "reject", remaining);
  if (request.micros > teamRemaining) {
    return deny(request.micros, "team", "team-share", quota.teamAction, remaining);
  }
  if (request.micros > tenantRemaining) {
    return deny(request.micros, "tenant", "tenant-total", quota.tenantAction, remaining);
  }
  usage.tenantMicros += request.micros;
  usage.teamMicros[team.teamId] = teamUsed + request.micros;
  return {
    ok: true, micros: request.micros, action: "allow",
    remaining: {
      request: cap - request.micros,
      team: teamRemaining - request.micros,
      tenant: tenantRemaining - request.micros,
    },
  };
}

/** 借用成功后授予临时额度;额度不改变份额,也不改变租户总额。 */
export function grantCredit(ledger: Ledger, teamId: TeamId, micros: number): void {
  if (micros <= 0 || !(teamId in ledger.usage.creditMicros)) return;
  ledger.usage.creditMicros[teamId] += micros;
}

/** 归还借用额度;已花掉的部分通过 release 退回团队与租户两层。 */
export function revokeCredit(ledger: Ledger, teamId: TeamId, micros: number): void {
  const current = ledger.usage.creditMicros[teamId] ?? 0;
  ledger.usage.creditMicros[teamId] = Math.max(0, current - Math.max(0, micros));
}

export function release(ledger: Ledger, teamId: TeamId, micros: number): void {
  const team = ledger.quota.teams.find((item) => item.teamId === teamId);
  if (team === undefined || micros <= 0) return;
  const used = ledger.usage.teamMicros[teamId] ?? 0;
  ledger.usage.teamMicros[teamId] = Math.max(0, used - micros);
  ledger.usage.tenantMicros = Math.max(0, ledger.usage.tenantMicros - micros);
}

export function remainingQuota(ledger: Ledger): { tenant: number; teams: Record<TeamId, number> } {
  const teams: Record<TeamId, number> = {};
  for (const team of ledger.quota.teams) {
    const credit = ledger.usage.creditMicros[team.teamId] ?? 0;
    teams[team.teamId] = team.shareMicros + credit - (ledger.usage.teamMicros[team.teamId] ?? 0);
  }
  return { tenant: ledger.quota.totalMicros - ledger.usage.tenantMicros, teams };
}

/** 份额之和不得超过总额;否则租户层会先于团队层拒绝,说明配置已经透支。 */
export function validateQuota(quota: TenantQuota): string[] {
  const issues: string[] = [];
  const seen = new Set<TeamId>();
  let sum = 0;
  for (const team of quota.teams) {
    if (team.teamId.trim() === "") issues.push("team id is empty");
    if (seen.has(team.teamId)) issues.push(`duplicate team ${team.teamId}`);
    seen.add(team.teamId);
    if (!Number.isInteger(team.shareMicros) || team.shareMicros <= 0) {
      issues.push(`team ${team.teamId}: share must be a positive integer`);
    }
    for (const [capability, cap] of Object.entries(team.requestCaps)) {
      if (!Number.isInteger(cap) || cap <= 0) {
        issues.push(`team ${team.teamId}: cap ${capability} must be a positive integer`);
      }
    }
    sum += team.shareMicros;
  }
  if (!Number.isInteger(quota.totalMicros) || quota.totalMicros <= 0) {
    issues.push("tenant total must be a positive integer");
  } else if (sum > quota.totalMicros) {
    issues.push(`shares ${sum} exceed total ${quota.totalMicros}`);
  }
  return issues;
}

// ---- 超限动作:把配额判定翻译成调用方可见的行为 ----

export interface LimitContext {
  /** 降档后重新估算的成本;必须低于原请求才允许降档。 */
  economyMicros: number;
  deadlineMs: number;
  queueDepth: number;
  queueLimit: number;
  nowMs: number;
}

export type LimitKind = "allow" | "reject" | "degrade" | "queue";

export interface LimitOutcome {
  kind: LimitKind;
  chargedMicros: number;
  reason: string;
  retryAfterMs?: number;
  expiresAtMs?: number;
}

const RETRY_AFTER_MS = 60_000;

/** 单次上限永远拒绝:它通常意味着配置错了,重试或排队都不会变对。 */
export function applyLimit(decision: QuotaDecision, context: LimitContext): LimitOutcome {
  if (decision.ok) {
    return { kind: "allow", chargedMicros: decision.micros, reason: "within-quota" };
  }
  const reason = `${decision.layer ?? "unknown"}:${decision.reason ?? "unknown"}`;
  const action: OverLimitAction = decision.layer === "request" ? "reject" : decision.action;
  if (action === "degrade") {
    if (context.economyMicros >= decision.micros) {
      return {
        kind: "reject", chargedMicros: 0,
        reason: `${reason}:degrade-not-cheaper`, retryAfterMs: RETRY_AFTER_MS,
      };
    }
    return { kind: "degrade", chargedMicros: context.economyMicros, reason };
  }
  if (action === "queue" && hasQueueRoom(context)) {
    return { kind: "queue", chargedMicros: 0, reason, expiresAtMs: context.nowMs + context.deadlineMs };
  }
  return { kind: "reject", chargedMicros: 0, reason, retryAfterMs: RETRY_AFTER_MS };
}

export function hasQueueRoom(context: LimitContext): boolean {
  return context.queueDepth < context.queueLimit && context.deadlineMs > 0;
}

/** 过期条目必须在调度前移除,否则它们会永久占据租户的排队窗口。 */
export function expireQueue(
  entries: readonly { taskId: string; expiresAtMs: number }[],
  nowMs: number,
): { active: string[]; expired: string[] } {
  const active: string[] = [];
  const expired: string[] = [];
  for (const entry of entries) {
    if (entry.expiresAtMs <= nowMs) expired.push(entry.taskId);
    else active.push(entry.taskId);
  }
  return { active, expired };
}

示例样本上的运行结果

用回声配额跑两组场景,数字都来自 node --test test/gov/quota.test.ts 覆盖的同一套实现。

第一组是正常分配:1 亿总额,内容 4,000 万,客服 3,000 万,交易 3,000 万。内容团队调用 6,451 次摘要后,租户已用 39,996,200,团队剩余 3,800;第 6,452 次请求被团队层拒绝,拒绝理由是 team-share,返回的重试建议是 60,000ms,租户层余额仍是 60,003,800。这条记录能直接回答开头那个问题。

第二组是份额透支:总额仍是 1 亿,但内容分 6,000 万、客服分 5,000 万,两者相加 1.1 亿,已经超过总额。内容先花 6,000 万,客服再花 39,996,200,租户累计 99,996,200;此时客服团队还剩 10,003,800,但租户只剩 3,800,下一条 6,200 的请求停在租户层,理由是 tenant-total。按示例策略降档后,实际记账 4,030。

第二组暴露了一个容易忽略的事实:当团队份额之和正好等于租户总额时,租户层几乎永远不会先于团队层触发,因为租户剩余等于各团队剩余之和。租户层真正生效的场景,是份额之和超过总额、存在未分配开销,或者借用额度把某个团队抬过了份额。validateQuota 会把「shares 110000000 exceed total 100000000」直接报出来,但校验通过不代表运行时不会发生透支,平台仍要在扣减时保留这道兜底。

配额变更也是治理流程

配额数字会变,变更过程比数字本身更重要。一个可执行的规则是:租户总额由商务与平台共同确认,按合同周期生效;团队份额由团队负责人申请、平台审批,变更在下一个计费窗口生效;单次上限由能力提供方提出、平台确认,因为它同时影响成本与质量。

变更还要留三样东西:变更前后额度、变更原因、受影响的链路与租户。没有这三样,季度复盘时无法判断某次成本上升是业务增长还是配额放宽。紧急借用不属于变更,它有独立的借用与归还规则,下一节展开。

小结

三层配额分别回答「租户最多花多少」「团队能自主花多少」「一次调用最多花多少」。三层统一用整数微元计量,扣减顺序固定为单次上限、团队份额、租户总额,并且必须原子落账。超限动作分拒绝、降档、排队三种:单次上限永远拒绝,团队层默认拒绝,租户层可以按商务约定降档。

示例样本上,内容团队花掉 39,996,200 后,下一条 6,200 的请求停在团队层,团队剩余 3,800,租户还剩 60,003,800;当份额之和超过总额时,请求会停在租户层,说明配置已经透支。配额治理的关键不是把数字算对一次,而是让每次拒绝都能指出是哪一层、还剩多少、调用方下一步该做什么。

阅读导航:上一节:3.3 版本矩阵与弃用窗口 · 下一节:4.2 公平性与优先级 。

常见错误与边界

  • 只设租户总额。一个团队可以把全租户预算花光,其他团队无额度可用。
  • 用次数或 token 代替微元。跨能力的「一次」和「一千 token」不是同一笔钱,无法比较。
  • 份额之和大于总额却不校验。租户层会先于团队层拒绝,拒绝理由会误导调用方。
  • 扣减不是原子的。团队已扣、租户失败会留下孤儿账,月底对账永远对不平。
  • 单次超限也排队。配置错误重试多少次都不会变对,只会浪费队列位置。
  • 超限行为由每个团队自选。同一个平台会出现拒绝、降档、排队三套用户体验。
  • 排队没有到期时间。拒绝被推迟到用户离开之后,指标上看起来只是「慢」。
  • 降档不校验是否更便宜。经济档若没有更低的预扣成本,降档只是换名字。
  • 借用额度直接改份额。份额是预算所有权,借用是临时信用,两者混在一起无法回收。
  • 配额变更不留记录。成本上升时无法区分业务增长与额度放宽。
  • 把租户总额当第一道防线。份额之和等于总额时它几乎不触发,真正的约束在团队层。
  • 配额单位与计费单位不一致。配额用微元、账单用金额,汇率一变就对不上。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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