《互联网产品的 AI 架构·平台化与治理》4.3 借用、回收与隔离

低峰借用、高峰归还是额度治理里最容易口头约定、最难回收的一环。本节定义借用的四个前置条件与到期、高峰、强制三级回收顺序,用回声的 1,000 万借用示例跑出债务 500 万的回收结果,并给出缓存键、向量命名空间、日志与限流的租户隔离校验,并说明借用协议应写入的治理流程。

借来的额度不是免费额度

凌晨两点,内容团队向交易团队借了 1,000 万微元,用来跑当天的发布前摘要。双方口头约定「低峰借、高峰还」。上午十点,交易团队的使用率从 40% 涨到 82%,系统触发回收;此时内容团队已经花掉 4,500 万,超过自己的 4,000 万份额 500 万。

回收成功了,但内容团队欠下 500 万债务,下一窗口要先扣回。如果没有这条债务记录,借用就会变成一次性的额度转移,交易团队的高峰容量被永久拿走。

借用解决的是「低峰闲置、高峰紧缺」的时间错配。它必须同时回答四个问题:谁能借、借多少、什么时候还、还不上怎么办。少回答一个,借用就会退化成抢占。

借用的四个前置条件

条件判据示例值不满足时
借出方处于低峰使用率低于阈值低于 50%拒绝,提示 not-low-peak
借出方有闲置份额 − 已用 − 已借出18,000,000拒绝,提示 insufficient-free
借入方未超上限借入总额不超过自身份额比例25%拒绝,提示 over-borrow-limit
有明确到期到期 tick 可计算tick 20不允许创建无到期借用

第一条保护借出方。低峰是借用的前提,不是礼貌用语;借出方使用率已经 60%,再借出去就是在拿自己的高峰容量做人情。

第二条防止重复借出。同一笔闲置额度不能借给两个团队,所以计算空闲时要减去已经借出的部分。lentBy 从 Loan 列表实时汇总,而不是在团队余额上维护一个「已借出」字段,避免两套账不一致。

第三条保护借入方自己。一个团队最多借到自身份额的 25%,示例里内容团队 4,000 万的 25% 是 1,000 万。没有这个上限,借入方会把借来的额度当成常态预算,下一窗口根本还不上。

第四条让回收有依据。没有到期时间的借用不是借用,是永久调拨;平台必须在创建时记录 dueTick,否则回收只能靠人记得。

回收顺序:到期、高峰、强制

回收按三级顺序执行,同一笔贷款只回收一次,先命中的原因生效。

顺序触发条件动作通知对象
1当前 tick ≥ 到期 tick全额回收借入方与借出方
2借出方使用率 ≥ 高峰阈值(示例 80%)全额回收借入方与双方负责人
3窗口结束或事故处置强制回收平台值班与双方负责人

到期排第一,因为它是合同约定,优先级高于运行时的临时判断。高峰排第二,因为借出方的容量承诺不能被借用破坏;示例里交易团队使用率到 82%,超过 80% 阈值,即使贷款还没到期也要收回。

强制回收排最后,它是平台干预,不是常规路径。强制回收必须带事故单或窗口结束标记,否则每次容量紧张都可以用「强制」绕过前两条。

回收后要计算债务:借入方已用超过自身份额的部分,记入下一窗口优先扣回。示例里内容团队已用 4,500 万,份额 4,000 万,债务 500 万。债务不是罚款,它是账务事实;不记录债务,下一窗口的配额就会凭空多出 500 万。

借用期间的三个账务事实

第一,借用不改变份额。份额是预算所有权,借用是临时信用;把借用直接加到份额上,回收时就要改份额,审批记录会变得不可追溯。

第二,借用不改变租户总额。借来的额度仍然花在同一个租户里,租户总额不变;变的只是团队层的可用余额。

第三,借出方的可用余额要减去已借出。交易团队份额 3,000 万、已用 1,200 万、借出 1,000 万,可用只剩 800 万;如果只算「份额减已用」,它会以为自己还有 1,800 万,高峰时必然超卖。

隔离:数据、缓存、限流

多租户共用一套推理平台,隔离不能只靠「查询时记得加 where」。至少有三个面要显式校验。

面必须包含租户的位置典型事故
缓存缓存键、向量命名空间、对象前缀命中其他租户的摘要,泄露内容
日志日志汇(sink)不得跨租户共用值班在一条流里看到所有租户原文
限流限流桶按租户拆分一个大租户打满桶,小租户全被限流

缓存键是最容易漏的一项。两个租户提交了相同的输入摘要,若键里没有租户,第二个租户会直接读到第一个租户的结果。即使内容相同,读取行为本身也泄露了「另一个租户问过什么」。示例键 t:tenant-a:c:content.summarize:sha:abc 与 t:tenant-b:c:content.summarize:sha:abc 必须不同。

向量命名空间和对象前缀同理。向量检索如果共用一个命名空间,相似度结果会跨租户召回;对象存储如果共用一个前缀,生命周期策略和访问策略都无法按租户生效。

日志与限流是组织隔离,不是数据隔离。日志共用一个 sink,出事故时很难证明「值班只看了自己租户」;限流共用一个桶,大租户的突发流量会让小租户一起被拒。

借用、回收与隔离的实现

tenancy.ts 用一份 Loan 列表推导借入借出,避免在团队余额上维护重复字段;回收按到期、高峰、强制的顺序生成事件;隔离校验检查模板是否含 {tenant},并找出跨租户共用的日志汇与限流桶。

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

/** 团队余额只记份额与已用;借入借出由 Loan 列表推导,避免两套账。 */
export interface TeamBalance { teamId: string; shareMicros: number; usedMicros: number }

export interface Loan {
  id: string;
  lenderId: string;
  borrowerId: string;
  micros: number;
  dueTick: number;
}

export interface TenancyState { balances: TeamBalance[]; loans: Loan[] }

export interface BorrowRule {
  /** 借入方最多借到自身份额的比例。 */
  maxBorrowRatio: number;
  /** 借出方使用率低于该比例才算低峰。 */
  lowPeakRatio: number;
  /** 借出方使用率高于该比例触发回收。 */
  highPeakRatio: number;
}

export const DEFAULT_BORROW: BorrowRule = {
  maxBorrowRatio: 0.25,
  lowPeakRatio: 0.5,
  highPeakRatio: 0.8,
};

export type BorrowReason =
  | "ok" | "unknown-team" | "not-low-peak" | "insufficient-free" | "over-borrow-limit";

export interface BorrowCheck {
  ok: boolean;
  reason: BorrowReason;
  freeMicros: number;
  allowedMicros: number;
}

export interface BorrowResult extends BorrowCheck { loan?: Loan }

function findBalance(state: TenancyState, teamId: string): TeamBalance | undefined {
  return state.balances.find((item) => item.teamId === teamId);
}

export function lentBy(state: TenancyState, teamId: string): number {
  return state.loans
    .filter((loan) => loan.lenderId === teamId)
    .reduce((sum, loan) => sum + loan.micros, 0);
}

export function borrowedBy(state: TenancyState, teamId: string): number {
  return state.loans
    .filter((loan) => loan.borrowerId === teamId)
    .reduce((sum, loan) => sum + loan.micros, 0);
}

/** 使用率用整数百分比比较,避免浮点在边界上抖动。 */
function atLeastRatio(balance: TeamBalance, percent: number): boolean {
  return balance.usedMicros * 100 >= balance.shareMicros * percent;
}

export function canBorrow(
  state: TenancyState,
  lenderId: string,
  borrowerId: string,
  micros: number,
  rule: BorrowRule = DEFAULT_BORROW,
): BorrowCheck {
  const lender = findBalance(state, lenderId);
  const borrower = findBalance(state, borrowerId);
  if (lender === undefined || borrower === undefined) {
    return { ok: false, reason: "unknown-team", freeMicros: 0, allowedMicros: 0 };
  }
  const freeMicros = lender.shareMicros - lender.usedMicros - lentBy(state, lenderId);
  const cap = Math.floor(borrower.shareMicros * rule.maxBorrowRatio);
  const allowedMicros = Math.max(0, cap - borrowedBy(state, borrowerId));
  if (atLeastRatio(lender, rule.lowPeakRatio * 100)) {
    return { ok: false, reason: "not-low-peak", freeMicros, allowedMicros };
  }
  if (micros > freeMicros) {
    return { ok: false, reason: "insufficient-free", freeMicros, allowedMicros };
  }
  if (micros > allowedMicros) {
    return { ok: false, reason: "over-borrow-limit", freeMicros, allowedMicros };
  }
  return { ok: true, reason: "ok", freeMicros, allowedMicros };
}

export function borrow(
  state: TenancyState,
  lenderId: string,
  borrowerId: string,
  micros: number,
  dueTick: number,
  rule: BorrowRule = DEFAULT_BORROW,
): BorrowResult {
  const check = canBorrow(state, lenderId, borrowerId, micros, rule);
  if (!check.ok) return check;
  const loan: Loan = {
    id: `loan-${state.loans.length + 1}`,
    lenderId,
    borrowerId,
    micros,
    dueTick,
  };
  state.loans.push(loan);
  return { ...check, loan };
}

export type ReclaimReason = "due" | "high-peak" | "forced";

export interface ReclaimEvent {
  loanId: string;
  lenderId: string;
  borrowerId: string;
  micros: number;
  reason: ReclaimReason;
  /** 回收后借入方超出自身份额的部分,下一窗口优先扣回。 */
  debtMicros: number;
}

/**
 * 回收顺序:先到期,再借出方进入高峰,最后才是强制回收。
 * 同一笔贷款只回收一次,先命中的原因生效。
 */
export function reclaim(
  state: TenancyState,
  tick: number,
  rule: BorrowRule = DEFAULT_BORROW,
  force = false,
): ReclaimEvent[] {
  const ordered: { loan: Loan; reason: ReclaimReason }[] = [];
  const used = new Set<string>();
  const due = state.loans
    .filter((loan) => tick >= loan.dueTick)
    .sort((left, right) => left.dueTick - right.dueTick || left.id.localeCompare(right.id));
  for (const loan of due) {
    ordered.push({ loan, reason: "due" });
    used.add(loan.id);
  }
  const peaked = state.loans
    .filter((loan) => !used.has(loan.id))
    .filter((loan) => {
      const lender = findBalance(state, loan.lenderId);
      return lender !== undefined && atLeastRatio(lender, rule.highPeakRatio * 100);
    })
    .sort((left, right) => left.lenderId.localeCompare(right.lenderId) || left.id.localeCompare(right.id));
  for (const loan of peaked) {
    ordered.push({ loan, reason: "high-peak" });
    used.add(loan.id);
  }
  if (force) {
    for (const loan of state.loans) {
      if (!used.has(loan.id)) ordered.push({ loan, reason: "forced" });
    }
  }
  const events: ReclaimEvent[] = [];
  for (const entry of ordered) {
    const loan = entry.loan;
    state.loans = state.loans.filter((item) => item.id !== loan.id);
    const borrower = findBalance(state, loan.borrowerId);
    const debtMicros = borrower === undefined
      ? 0
      : Math.max(0, borrower.usedMicros - borrower.shareMicros);
    events.push({
      loanId: loan.id, lenderId: loan.lenderId, borrowerId: loan.borrowerId,
      micros: loan.micros, reason: entry.reason, debtMicros,
    });
  }
  return events;
}

/** 缓存键必须含租户;这个函数是唯一允许拼缓存键的入口。 */
export function cacheKey(tenantId: string, capability: string, digest: string): string {
  return `t:${tenantId}:c:${capability}:${digest}`;
}

export interface IsolationSpec {
  tenantId: string;
  cacheKeyTemplate: string;
  vectorNamespace: string;
  objectPrefix: string;
  logSink: string;
  rateLimitBucket: string;
}

export type IsolationIssueCode =
  | "empty-tenant" | "cache-key-without-tenant" | "vector-namespace-without-tenant"
  | "object-prefix-without-tenant" | "shared-log-sink" | "shared-rate-bucket";

export interface IsolationIssue { tenantId: string; code: IsolationIssueCode; detail: string }

function requireTenant(
  issues: IsolationIssue[],
  tenantId: string,
  code: IsolationIssueCode,
  template: string,
): void {
  if (!template.includes("{tenant}")) issues.push({ tenantId, code, detail: template });
}

/** 隔离校验:模板必须含 {tenant},日志与限流桶不得跨租户复用。 */
export function validateIsolation(specs: readonly IsolationSpec[]): IsolationIssue[] {
  const issues: IsolationIssue[] = [];
  const sinks = new Map<string, string[]>();
  const buckets = new Map<string, string[]>();
  for (const spec of specs) {
    if (spec.tenantId.trim() === "") {
      issues.push({ tenantId: spec.tenantId, code: "empty-tenant", detail: "tenant id is empty" });
    }
    requireTenant(issues, spec.tenantId, "cache-key-without-tenant", spec.cacheKeyTemplate);
    requireTenant(issues, spec.tenantId, "vector-namespace-without-tenant", spec.vectorNamespace);
    requireTenant(issues, spec.tenantId, "object-prefix-without-tenant", spec.objectPrefix);
    sinks.set(spec.logSink, [...(sinks.get(spec.logSink) ?? []), spec.tenantId]);
    buckets.set(spec.rateLimitBucket, [...(buckets.get(spec.rateLimitBucket) ?? []), spec.tenantId]);
  }
  for (const [sink, tenants] of sinks) {
    if (tenants.length > 1) {
      for (const tenantId of tenants) issues.push({ tenantId, code: "shared-log-sink", detail: sink });
    }
  }
  for (const [bucket, tenants] of buckets) {
    if (tenants.length > 1) {
      for (const tenantId of tenants) issues.push({ tenantId, code: "shared-rate-bucket", detail: bucket });
    }
  }
  return issues;
}

示例样本上的一次借还

初始状态:内容团队份额 4,000 万、已用 1,800 万(45%);交易团队份额 3,000 万、已用 1,200 万(40%);客服团队 3,000 万、已用 1,200 万。

内容团队向交易团队借 1,000 万,到期 tick 20。检查结果:交易团队使用率 40%,低于 50% 低峰线;空闲 1,800 万,足够;内容团队借入上限是 1,000 万,正好用满。于是生成 loan-1,金额 1,000 万,到期 tick 20。

到 tick 12,交易团队使用率升到 82%,超过 80% 高峰线。回收先检查到期:12 < 20,未到期;再检查高峰:82% ≥ 80%,命中。事件显示 reason: high-peak,回收 1,000 万。此时内容团队已用 4,500 万,超过自身份额 500 万,事件里的 debtMicros 是 5,000,000。

如果回收发生在 tick 20 或之后,同一笔贷款会先命中的是 due,理由会变成到期而不是高峰。两种理由都回收全额,但账务解释不同:到期是履约,高峰是保护借出方。

隔离校验的示例结果

两条合规配置(tenant-a、tenant-b 各自独立的缓存模板、向量命名空间、对象前缀、日志汇、限流桶)返回 0 条问题。把 tenant-a 的缓存键改成不含 {tenant}、向量命名空间改成 vec、对象前缀改成 obj/,再把 tenant-b 的日志汇与限流桶改成和 tenant-a 相同,校验返回 7 条问题,去重后是 5 类:缓存键缺租户、向量命名空间缺租户、对象前缀缺租户、日志汇共用、限流桶共用。

7 条而不是 5 条,是因为共用日志汇和共用限流桶会分别给两个租户各报一次。这个设计让每个租户都能在自己的报告里看到问题,而不是只在平台侧看到一条汇总。

借用协议要写进治理流程

借用不能只靠两个团队口头约定。一份可执行的借用记录至少包含:借出方、借入方、金额、低峰证据、到期 tick、回收顺序、债务处理、双方负责人。平台按记录自动回收,而不是等人来还。

隔离校验则要进入租户开通流程:新租户上线前跑一次 validateIsolation,缓存、向量、对象、日志、限流五项全部通过才允许接入。上线后再按季度复查,防止后续配置漂移。

小结

借用要满足四个前置条件:借出方低峰、有真实空闲、借入方未超比例上限、有明确到期。回收按到期、高峰、强制三级执行,先命中的原因生效;回收后借入方超出份额的部分记成债务,下一窗口优先扣回。借用不改变份额,也不改变租户总额,只改变团队层的临时可用余额。

示例样本上,内容团队借 1,000 万,交易团队使用率到 82% 时触发高峰回收,内容团队因已用 4,500 万留下 500 万债务。隔离要同时覆盖缓存键、向量命名空间、对象前缀、日志汇与限流桶;缓存键不含租户,相同输入会让两个租户命中同一份结果。

阅读导航:上一节:4.2 公平性与优先级 · 下一节:5.1 评测集的治理:版本、来源与污染 。

常见错误与边界

  • 把借用直接加到份额。回收时要改份额,审批与账务都无法追溯。
  • 没有到期时间。借用会变成永久调拨,借出方的高峰容量被长期占用。
  • 只算「份额减已用」,不减已借出。同一笔空闲会被借给多个团队。
  • 借入没有比例上限。借入方把临时信用当常态预算,下一窗口还不上。
  • 回收只做到期,不做高峰。借出方进入高峰时无法保护自己的承诺。
  • 强制回收没有事故单。每次容量紧张都可以用「强制」绕过前两条。
  • 回收后不记债务。下一窗口配额凭空多出已花掉的借用额。
  • 缓存键不含租户。相同输入会跨租户命中,泄露内容与提问行为。
  • 向量检索共用命名空间。相似度结果跨租户召回,且无法按租户删除。
  • 日志汇跨租户共用。事故复盘时无法证明值班只看了授权范围。
  • 限流桶不按租户拆分。大租户的突发流量让小租户一起被拒。
  • 只在开通时校验隔离。后续配置漂移会让已经通过的租户重新共用一个桶。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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