《互联网产品的 AI 架构·平台化与治理》4.2 公平性与优先级

配额只回答「能不能花」,公平性回答「资源紧张时先给谁」。本节给出租户、团队、任务价值三层判据,用价值权重压住基础优先级造成的反转,用有上限的老化与强制位防止低价值任务饿死,并在 20,000 微元容量上跑出一轮可复核的调度顺序,并说明公平性验收的四个可计算指标。

容量只够六成时,先给谁

大促当天,回声的共享推理池只剩 20,000 微元可用。队列里同时有内容团队的三个摘要任务、客服团队的一条回复、交易团队的一条下单咨询。按先来先到,内容团队的批量摘要会先跑完;按成本从低到高,下单咨询便宜,但它可能只差一次模型判断就能成交。

两种排法都能给出一个顺序,但都不是治理意义上的公平。先来先到奖励的是「谁先提交」,成本排序奖励的是「谁便宜」,两者都没有回答「资源紧张时,平台承诺先保谁」。

公平性要同时看三层:租户之间的合同份额、团队之间的窗口、任务本身的价值。三层都写清楚,调度顺序才是可解释、可验收的。

三层公平判据

层判据证据从哪里来失效时的表现
租户合同份额与当前用量三层配额账本大租户挤占小租户
团队团队窗口与队列深度调度快照一个团队占满整轮
任务critical / standard / batch链路结构声明低价值任务挡住关键动作

租户层解决「谁跟谁分」。同样是 1,000 次调用,大租户合同里买了 60% 的容量,就不能因为提交得早而拿走 90%。租户层的判据不是「谁先到」,而是「本窗口内已占用多少、合同允许多少」。

团队层解决「租户内部怎么分」。一个租户下可能有内容、客服、交易三个团队,它们的份额已经由 4.1 的配额定义。公平调度要保证单轮里没有哪个团队把窗口占满,否则其他团队即使有份额也排不进去。

任务层解决「同一团队内部先做哪件」。内容团队既有当天要发布的摘要,也有夜间回填的历史摘要;两者都合法,但价值不同。平台不能替业务判断价值,只能要求业务在链路声明里标注价值分类,并对标注负责。

价值分类先于基础优先级

优先级最容易变成「谁嗓门大谁高」。平台给的解法是把两个概念分开:价值分类由链路声明决定,基础优先级由业务在分类内部微调。价值分类的量级远大于基础优先级,后者就无法越过前者。

分类回声中的例子默认权重允许的基础优先级
critical下单咨询、客服回复1,0000–100
standard内容发布前的摘要与审核3000–100
batch夜间回填、离线重排1000–100

示例里 batch 任务把基础优先级拉到 95,critical 只有 10。若只比基础优先级,batch 会排前面;加入价值权重后,critical 得分 1,010,batch 只有 195,顺序被纠正。这不是「critical 永远正确」,而是「基础优先级只能在分类内部比较」。

分类本身要可审计。链路声明里写 critical,必须能回答:这条链路失败会影响什么?是营收动作、对外承诺,还是内部报表?答不上来的,默认降到 standard。分类被滥用一次,整套权重就失去意义。

优先级反转的两个来源

第一个来源是排队顺序。FIFO 会把「先提交」误当成「更重要」,一个夜间回填任务只要比下单咨询早入队几毫秒,就能挡住后者。解法不是取消 FIFO,而是让分数包含价值分类,FIFO 只作为同分时的稳定排序。

第二个来源是共享资源被长期占用。一个 batch 任务拿到工作线程后跑很久,critical 任务只能等它结束。配额与调度能决定「谁先开始」,但不能在模型调用中途抢占。平台能做的是把长任务切小、在节点边界检查优先级、给 critical 预留并发,而不是承诺「随时抢占」。

代码里的分数由四部分组成:价值权重、基础优先级、有上限的老化、强制位。

score = valueWeight[value] + basePriority + min(waitTicks, maxAgingTicks) * agingPerTick
        + (waitTicks >= starvationTicks ? 10000 : 0)

老化的上限是 10 个 tick,每 tick 加 40,最多加 400。这个上限保证 batch 靠等待最多追到 600 分,仍然越不过 critical 的 1,000 分底。强制位则是另一回事,它只在等待超过 12 个 tick 时触发,直接加 10,000 分,让饥饿任务插到最前。

饥饿防护要有配额

只有老化没有强制位,低价值任务在持续高负载下可能永远轮不到;只有强制位没有上限,饥饿防护会变成新的插队工具,每个 batch 任务等够 12 轮就插到 critical 前面。

示例策略给强制位加了三道限制:等待至少 12 个 tick;每轮每个租户最多强制 1 个;强制任务仍然受租户与团队窗口约束。这样最坏情况是 critical 被一个饥饿任务挤后一个位置,而不是整轮被 batch 占满。

这是一个明确的取舍:用一次受控的反转,换低价值任务不被无限期饿死。平台要把这个取舍写进策略,而不是让每个团队自己实现一套「等久了就提优先级」。

调度实现

fairness.ts 只做三件事:给任务打分、按分数排序、在租户与团队窗口内选出一轮。它不读数据库、不调用模型,输入是任务数组与容量,输出是可复核的顺序与每个租户、团队的占用。

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

/** 任务价值分类是公平判据的第一层,不直接用「谁先到」。 */
export type ValueClass = "critical" | "standard" | "batch";

export interface FairTask {
  taskId: string;
  tenantId: string;
  teamId: string;
  value: ValueClass;
  /** 业务声明的基础优先级,0..100;它不能越过价值分类。 */
  basePriority: number;
  waitTicks: number;
  costMicros: number;
}

export interface FairnessPolicy {
  valueWeight: Readonly<Record<ValueClass, number>>;
  agingPerTick: number;
  maxAgingTicks: number;
  /** 单轮中每个租户、每个团队最多被选中的任务数。 */
  perTenantWindow: number;
  perTeamWindow: number;
  /** 等待超过该 tick 数的任务强制进入候选。 */
  starvationTicks: number;
  /** 单轮每租户最多强制几个任务,防止饥饿防护变成新的插队。 */
  maxForced: number;
}

export const DEFAULT_FAIRNESS: FairnessPolicy = {
  valueWeight: { critical: 1000, standard: 300, batch: 100 },
  agingPerTick: 40,
  maxAgingTicks: 10,
  perTenantWindow: 2,
  perTeamWindow: 2,
  starvationTicks: 12,
  maxForced: 1,
};

export interface RankedTask {
  task: FairTask;
  score: number;
  forced: boolean;
}

/**
 * 分数 = 价值权重 + 基础优先级 + 有上限的老化 + 强制位。
 * 价值权重量级远大于优先级,保证 critical 不会被 batch 的高 basePriority 反转。
 */
export function scoreTask(task: FairTask, policy: FairnessPolicy): RankedTask {
  const aged = Math.min(task.waitTicks, policy.maxAgingTicks) * policy.agingPerTick;
  const forced = task.waitTicks >= policy.starvationTicks;
  const score = policy.valueWeight[task.value] + task.basePriority + aged + (forced ? 10_000 : 0);
  return { task, score, forced };
}

export function rankTasks(tasks: readonly FairTask[], policy: FairnessPolicy): RankedTask[] {
  return tasks
    .map((task) => scoreTask(task, policy))
    .sort((left, right) => {
      if (right.score !== left.score) return right.score - left.score;
      return left.task.taskId.localeCompare(right.task.taskId);
    });
}

export interface ScheduleResult {
  admitted: FairTask[];
  deferred: FairTask[];
  order: string[];
  spentMicros: number;
  byTenant: Record<string, { count: number; micros: number }>;
  byTeam: Record<string, { count: number; micros: number }>;
}

function bump(
  table: Record<string, { count: number; micros: number }>,
  key: string,
  micros: number,
): void {
  const bucket = table[key] ?? { count: 0, micros: 0 };
  bucket.count += 1;
  bucket.micros += micros;
  table[key] = bucket;
}

/**
 * 单轮调度:按分数选任务,租户与团队各有一个窗口上限。
 * 容量不够时跳过当前任务继续看后面的小任务,而不是整轮结束。
 */
export function scheduleRound(
  tasks: readonly FairTask[],
  capacityMicros: number,
  policy: FairnessPolicy = DEFAULT_FAIRNESS,
): ScheduleResult {
  const ranked = rankTasks(tasks, policy);
  const admitted: FairTask[] = [];
  const deferred: FairTask[] = [];
  const byTenant: ScheduleResult["byTenant"] = {};
  const byTeam: ScheduleResult["byTeam"] = {};
  const forcedByTenant: Record<string, number> = {};
  let spent = 0;
  for (const entry of ranked) {
    const task = entry.task;
    const tenant = byTenant[task.tenantId] ?? { count: 0, micros: 0 };
    const team = byTeam[task.teamId] ?? { count: 0, micros: 0 };
    const forcedUsed = forcedByTenant[task.tenantId] ?? 0;
    const forcedBlocked = entry.forced && forcedUsed >= policy.maxForced;
    if (
      forcedBlocked ||
      tenant.count >= policy.perTenantWindow ||
      team.count >= policy.perTeamWindow ||
      task.costMicros > capacityMicros - spent
    ) {
      deferred.push(task);
      continue;
    }
    admitted.push(task);
    if (entry.forced) forcedByTenant[task.tenantId] = forcedUsed + 1;
    spent += task.costMicros;
    bump(byTenant, task.tenantId, task.costMicros);
    bump(byTeam, task.teamId, task.costMicros);
  }
  return {
    admitted,
    deferred,
    order: admitted.map((task) => task.taskId),
    spentMicros: spent,
    byTenant,
    byTeam,
  };
}

/** 公平性验收:单租户占用不超过「均分 + 一个任务」视为通过。 */
export function maxTenantDeviation(result: ScheduleResult): number {
  const values = Object.values(result.byTenant).map((item) => item.micros);
  if (values.length === 0) return 0;
  const max = Math.max(...values);
  const min = Math.min(...values);
  return max - min;
}

示例样本上的一轮调度

任务与参数如下,容量 20,000 微元:

任务租户 / 团队分类基础优先级等待 tick成本
a-summary-oldcontent / contentbatch5126,200
a-summary-1content / contentstandard9006,200
a-summary-2content / contentstandard8006,200
b-reply-1support / supportcritical1014,200
c-order-1trade / tradecritical2003,000

运行结果是顺序 a-summary-old → b-reply-1 → c-order-1 → a-summary-1,共花 19,600 微元。三个租户的占用分别是内容 2 个任务 12,400、客服 1 个 4,200、交易 1 个 3,000;a-summary-2 被推迟,原因是内容租户窗口已满,剩余容量也不够它的 6,200。

这个顺序里有两个容易被误读的地方。第一,排第一的是 batch 任务,不是 critical。它等待了 12 个 tick,触发了强制位,得分 10,505;这是饥饿防护的预期行为,且每轮每租户只允许一个。第二,两个 critical 任务排在 standard 前面,但都在强制任务之后;如果把强制位去掉,它们会排在第二、第三位。

a-summary-2 的推迟说明窗口的作用:内容租户已经进了 2 个任务,达到 perTenantWindow,即使容量还剩 400 微元,也不会再放第三个内容任务。如果没有这个窗口,内容团队可以在单轮里占满所有位置,客服与交易只能等下一轮。

公平性怎么验收

公平性不能只看平均成本,要看四个可计算的量:每个租户的实际占用与合同份额的偏差;critical 任务被 batch 越过的次数,且越过的 batch 没有强制标记;队列等待时间的 P99;过期未执行的队列条目比例。

示例阈值可以是:单轮租户占用偏差不超过一个最大任务成本;无标记的反转次数为 0;critical 等待 P99 不超过两个调度周期;过期比例低于 1%。这些是示例口径,不是生产实测;真正上线前要用自己的流量回放校准。

验收还要区分「调度不公」与「容量不足」。如果所有租户都排满、P99 一起上升,问题在容量,不在公平性;如果只有小租户的 P99 上升,而大租户平稳,才说明窗口或权重失效。

小结

公平性有三层判据:租户看合同份额,团队看单轮窗口,任务看价值分类。价值权重量级大于基础优先级,避免高 basePriority 的 batch 反转 critical;老化有上限,保证等待最多追到 600 分;饥饿防护有强制位,但每轮每租户只允许一个。

示例样本上,一轮 20,000 微元容量接受了 4 个任务、花费 19,600,顺序是饥饿的 batch、两个 critical、一个 standard;内容租户因窗口上限推迟了第三个任务。公平不是平均,而是每一层都能说清楚「为什么是它先」。

阅读导航:上一节:4.1 三层配额:总额、份额、单次 · 下一节:4.3 借用、回收与隔离 。

常见错误与边界

  • 用 FIFO 代替公平。先提交不等于更重要,批量任务会挡住关键动作。
  • 跨价值分类比较基础优先级。batch 的 95 分不能压过 critical 的 10 分。
  • 老化没有上限。等待越久分数越高,最终所有任务都变成同一优先级。
  • 强制位没有数量限制。饥饿防护会变成新的插队通道。
  • 只做租户窗口,不做团队窗口。租户内部仍可能被一个团队占满。
  • 窗口设成 1。大任务团队每轮只能进一个,小任务团队反而占便宜。
  • 只看平均份额不看尾部。平均值正常,小租户的 P99 可能已经失控。
  • 把公平当性能优化。公平会牺牲部分吞吐,必须显式接受这个取舍。
  • 队列没有到期时间。排不到的任务不会消失,只会堆积并拉长尾延迟。
  • 价值分类由调用方随意填写。没有审计,所有任务都会变成 critical。
  • 承诺模型调用中途可抢占。平台只能在节点边界调整顺序,不能随时打断。
  • 用生产数字背书示例。本文所有调度结果都来自示例样本,不是线上流量。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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