《互联网产品的 AI 架构·平台化与治理》7.2 成本分摊与内部计价

共享池只有一笔钱,按用量、按收益、按固定比例却会算出三张完全不同的账单。本节定义按次、按千 token、按期三种内部计价单位,说明分摊键如何决定谁付钱,给出最大余数法与四类争议的受理口径,并用示例样本上的 1,200 万池子展示 82.4% 与 45% 的差距。

一笔池子,三张账单

平台团队每月维护一组共享设施:网关、评测环境、可观测性、值班。它们不直接对应某条链路,却服务所有团队。这个月的共享池是 1,200 万微元,需要分摊给内容、客服、交易三个团队。

财务先用“按用量”分:客服团队承担 989 万,占 82.4%。客服负责人立刻反对:support.assist 单次 4,200 微元,本来就贵,按用量分摊等于惩罚使用高价值能力的团队。财务改用“按收益”分:客服 540 万,占 45%。内容团队又反对:收益是年初拍的数,凭什么现在拿来当分摊键?最后按“固定比例”分:内容 40%、客服 30%、交易 30%,客服降到 360 万。

同一笔钱,三种分摊键,客服的账单从 989 万变成 360 万,差 629 万。这不是算术问题,而是治理问题:谁有权选择分摊键,选择之后如何解释,出现争议按什么流程处理。

共享池先要有一个共同分母

最直觉的分摊键是“谁用得多谁多付”。但 AI 平台的用量至少有三种:调用次数、token 数、直接成本。它们不能直接相加。内容团队一个月 1,200 万 token,客服团队 900 次 assist 调用,交易团队 3,000 次检索;把 1,200 万、900、3,000 加起来没有意义。

所以共享池的分摊键必须先统一成一个共同分母。示例用“按价格卡算出的直接用量成本”作为分母:内容 216,000 微元、客服 3,972,000 微元、交易 630,000 微元,合计 4,818,000 微元。其中内容是 1,200 万 token 乘 18 微元/千 token;客服是 800 万 token 乘 24 加 900 次 assist 乘 4,200;交易是 3,000 次检索乘 90 加 120 次下单乘 3,000。这个分母既能反映调用量,也能反映不同能力的单价差异。

统一分母之后,还要回答“分母是否公平”。直接成本高的团队,可能只是因为用了更贵的能力,而不是占了更多共享资源。这个质疑合理,也正是“按收益”与“固定比例”存在的理由。分摊键没有唯一正确答案,只有经过各方确认、可复算、可变更的规则。

三种内部计价单位

内部计价单位决定成本如何从平台流向团队。示例用三种,覆盖三类能力。

单位适用能力计价方式主要风险
按次(micros-per-call)动作、检索、客服辅助调用次数 × 单次价格单次价格定错会直接放大到全部调用
按千 token(micros-per-1k-tokens)摘要、回复、生成向上取整到千 token 后计价输入输出长度波动会让账单难预测
按期(period-fixed)网关、评测、可观测性每账期一个固定金额必须再用分摊键分到团队

按次计价适合动作类能力,因为调用方能在发起前算出单次成本;按千 token 计价适合生成类能力,因为成本随上下文长度变化;按期计价只适合无法归到单次调用的共享设施。把共享设施硬拆成按次价格,会得到一个看似精确、实际靠假设支撑的数字。

按 token 计价还有两个口径必须写死:向上取整还是四舍五入,以及输入与输出是否同价。示例统一向上取整到下一个千 token,12,345 token 按 18 微元/千 token 计为 223 微元,而不是 222.21。取整规则不写死,财务和平台每个月都会为几分钱吵一次。

分摊键是一次治理选择

三种分摊方法各有适用条件,不能按“哪个对财务方便”来选。

方法分摊键适用条件主要风险
按用量直接用量成本共享资源消耗与调用量强相关惩罚使用高单价能力的团队
按收益预先确认的收益单位共享设施主要提升业务收益,且收益可测收益数容易被事后调整
按固定比例合同或预算比例长期合作、用量波动大、谈判成本高与真实消耗脱节,失去行为引导

选择分摊键有三个约束。第一,键必须在账期开始前确定,不能等账单出来再挑一个对某方有利的。第二,键必须可复算,任何人拿同一份用量和价格卡都能算出同一个结果。第三,键的变更要有通知期,且不能追溯已关闭账期;否则团队无法做预算。

示例里客服团队对“按用量”的异议,本质不是算错了,而是分摊键与“谁受益”不匹配。平台接受了这个异议,但没有直接改成按收益,而是先按治理流程受理,再在下个账期切换。这个顺序很重要:争议可以改规则,但不能改已经发生的账。

最大余数法:总额必须精确

按比例分摊最容易出的错不是比例,而是取整。1,200 万按 4,818,000 的权重分给三个团队,每一项都可能带小数;如果每项独立四舍五入,三项之和可能比池子多几微元或少几微元。共享池账目上出现悬差,比多分几百块更难解释。

示例用最大余数法:先按权重取整,再把剩下的微元按小数部分从大到小分配,小数部分相同则按团队标识排序。这样既保证总额精确等于池子,也保证结果可复现,不依赖参与者的输入顺序。

示例结果:客服 9,892,902、交易 1,569,116、内容 537,982,合计正好 12,000,000。按同样权重手算,三个基数分别是 9,892,901、1,569,115、537,982,余下 2 微元;交易的小数部分最大,客服次之,各得 1 微元。

示例样本上的分摊结果

同一笔 1,200 万池子,三种方法的结果如下。按用量时,客服承担 82.4%;按收益时降到 45%;按固定比例时降到 30%。最大落差 629 万,正好是客服争议的金额量级。

团队直接用量(微元)按用量按收益按固定比例
内容216,000537,982(4.5%)3,600,000(30%)4,800,000(40%)
客服3,972,0009,892,902(82.4%)5,400,000(45%)3,600,000(30%)
交易630,0001,569,116(13.1%)3,000,000(25%)3,600,000(30%)

这张表说明一个反直觉的事实:按用量并不等于公平。客服的直接用量占 82.4%,是因为 support.assist 单次价格高,而不是因为它占用了 82.4% 的网关或评测资源。共享池的成本驱动因素与业务用量并不总是一回事。

反过来,按收益也不等于客观。收益单位是年初确认的,可能已经过期;按固定比例更不客观,它只是谈判结果。三种方法都能用,关键是把选择理由、适用期和变更条件写进分摊规则,而不是每次出账单再吵。

争议处理

分摊争议有四类,责任人和时限都不同。把四类都丢给财务,是分摊机制失效的最常见原因。

争议类型责任人时限必须附带的证据
计量(metering)平台计量3 个工作日请求 id、原始用量、计费事件
归因(attribution)平台 + 财务5 个工作日归因行、维度假设、基线
价格卡(rate-card)商务 + 财务10 个工作日合同价版本、生效日期
分摊键(allocation-key)治理委员会10 个工作日键变更对各方的影响测算

受理只做三件事:查类别、查证据、算到期日。示例里客服在 11 月 5 日提交分摊键争议,治理委员会 10 个工作日到期,到期日是 11 月 15 日;11 月 16 日仍未闭环就算逾期。受理不判断谁对,判断对错是责任人后续的工单。

争议成立时,调整只在参与者之间转移,池子总额不变。示例把客服的 240 万转给内容,客服降到 7,492,902,内容升到 2,937,982,交易不变,合计仍是 1,200 万。不允许通过“少收一点”来平息争议,否则池子会逐年缩小,最终没人维护共享设施。

实现

下面的模块包含三段:按价格卡计价、按三种键分摊、争议受理与转移。所有金额都是整数微元,所有排序都有确定的次级键。

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

import { AiError } from "../core/errors.ts";

/** 计价单位决定“谁在为什么付费”,不是单纯的价格标签。 */
export type PriceUnit = "micros-per-call" | "micros-per-1k-tokens" | "period-fixed";

export interface PriceCard {
  capability: string;
  unit: PriceUnit;
  /** 每次调用、每千 token 或每账期的整数微元。 */
  priceMicros: number;
}

export const UNIT_GUIDANCE: Record<PriceUnit, string> = {
  "micros-per-call": "动作类能力按次计价,调用方容易预估单次成本",
  "micros-per-1k-tokens": "生成类能力按 token 计价,成本随输入输出长度变化",
  "period-fixed": "共享基础设施按期计价,必须再用分摊键分到团队",
};

export interface Usage {
  calls: number;
  tokens: number;
}

function fail(message: string): never {
  throw new AiError("invalid-request", `allocation: ${message}`);
}

function integer(value: number, field: string, min = 0): number {
  if (!Number.isSafeInteger(value) || value < min) fail(`${field} must be an integer >= ${min}`);
  return value;
}

function text(value: string, field: string): string {
  if (typeof value !== "string" || value.trim() === "") fail(`${field} is required`);
  return value;
}

/** 按价格卡计价;按 token 计价时向上取整到下一个千 token。 */
export function priceUsage(card: PriceCard, usage: Usage): number {
  text(card.capability, "card.capability");
  integer(card.priceMicros, "card.priceMicros");
  integer(usage.calls, "usage.calls");
  integer(usage.tokens, "usage.tokens");
  if (card.unit === "micros-per-call") return usage.calls * card.priceMicros;
  if (card.unit === "micros-per-1k-tokens") {
    return Math.ceil((usage.tokens * card.priceMicros) / 1_000);
  }
  return card.priceMicros;
}

export interface PoolParticipant {
  teamId: string;
  /** 已按价格卡算出的直接用量成本,作为 usage 分摊键。 */
  usageMicros: number;
  /** 收益口径:节省的人力分钟、带来的收入增量等,由各方预先认同。 */
  benefitUnits: number;
  /** 合同约定的固定比例权重。 */
  fixedWeight: number;
}

export type AllocationMethod = "usage" | "benefit" | "fixed";

export interface Allocation {
  teamId: string;
  weight: number;
  allocatedMicros: number;
}

function weightOf(participant: PoolParticipant, method: AllocationMethod): number {
  const weight = method === "usage"
    ? participant.usageMicros
    : method === "benefit" ? participant.benefitUnits : participant.fixedWeight;
  return integer(weight, `weight of ${participant.teamId}`);
}

/**
 * 最大余数法:先按权重取整,再把余下的微元分给余数最大的参与者。
 * 分摊结果必须精确等于池子总额,否则每个月都会留下几微元的悬账。
 */
export function allocatePool(
  poolMicros: number,
  participants: readonly PoolParticipant[],
  method: AllocationMethod,
): Allocation[] {
  integer(poolMicros, "poolMicros", 1);
  if (participants.length === 0) fail("participants must not be empty");
  const seen = new Set<string>();
  const rows = participants.map((participant) => {
    text(participant.teamId, "participant.teamId");
    if (seen.has(participant.teamId)) fail(`duplicate participant ${participant.teamId}`);
    seen.add(participant.teamId);
    return { teamId: participant.teamId, weight: weightOf(participant, method) };
  });
  const totalWeight = rows.reduce((sum, row) => sum + row.weight, 0);
  if (totalWeight === 0) fail(`total ${method} weight must be positive`);

  const exact = rows.map((row) => (poolMicros * row.weight) / totalWeight);
  const allocated = exact.map((value) => Math.floor(value));
  let remainder = poolMicros - allocated.reduce((sum, value) => sum + value, 0);
  const order = rows.map((row, index) => ({
    index, fraction: exact[index] - allocated[index], teamId: row.teamId,
  })).sort((left, right) => {
    if (right.fraction !== left.fraction) return right.fraction - left.fraction;
    return left.teamId.localeCompare(right.teamId);
  });
  for (const item of order) {
    if (remainder === 0) break;
    allocated[item.index] += 1;
    remainder -= 1;
  }

  return rows.map((row, index) => ({
    teamId: row.teamId, weight: row.weight, allocatedMicros: allocated[index],
  })).sort((left, right) => {
    if (right.allocatedMicros !== left.allocatedMicros) {
      return right.allocatedMicros - left.allocatedMicros;
    }
    return left.teamId.localeCompare(right.teamId);
  });
}

export function allocationTotal(allocations: readonly Allocation[]): number {
  return allocations.reduce((sum, allocation) => sum + allocation.allocatedMicros, 0);
}

export type DisputeReason = "metering" | "attribution" | "rate-card" | "allocation-key";

export interface DisputePolicy {
  owner: string;
  slaDays: number;
  evidence: string;
}

/** 争议先按类别找对责任人;把四类争议都丢给财务是最常见的失效方式。 */
export const DISPUTE_POLICIES: Record<DisputeReason, DisputePolicy> = {
  metering: { owner: "平台计量", slaDays: 3, evidence: "请求 id、原始用量与计费事件" },
  attribution: { owner: "平台 + 财务", slaDays: 5, evidence: "归因行、维度假设与基线" },
  "rate-card": { owner: "商务 + 财务", slaDays: 10, evidence: "合同价版本与生效日期" },
  "allocation-key": { owner: "治理委员会", slaDays: 10, evidence: "分摊键变更对各方的影响测算" },
};

export interface ChargebackDispute {
  id: string;
  teamId: string;
  amountMicros: number;
  reason: DisputeReason;
  evidence: string;
  openedDay: string;
}

export interface DisputeScreening {
  accepted: boolean;
  owner: string;
  slaDays: number;
  dueDay: string | null;
  overdue: boolean;
  problems: string[];
}

function isDay(value: string): boolean {
  return /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(`${value}T00:00:00Z`));
}

function addDays(day: string, offset: number): string {
  const date = new Date(`${day}T00:00:00Z`);
  date.setUTCDate(date.getUTCDate() + offset);
  return date.toISOString().slice(0, 10);
}

/** 受理争议只做三件事:查类别、查证据、算到期日;它不判断谁对。 */
export function screenDispute(dispute: ChargebackDispute, todayDay: string): DisputeScreening {
  const policy = DISPUTE_POLICIES[dispute.reason];
  const problems: string[] = [];
  if (policy === undefined) fail(`unknown dispute reason ${String(dispute.reason)}`);
  if (dispute.id.trim() === "") problems.push("missing id");
  if (dispute.teamId.trim() === "") problems.push("missing team");
  if (!Number.isSafeInteger(dispute.amountMicros) || dispute.amountMicros <= 0) {
    problems.push("amount must be a positive integer");
  }
  if (dispute.evidence.trim() === "") problems.push(`missing evidence: ${policy.evidence}`);
  if (!isDay(dispute.openedDay)) problems.push("openedDay must be YYYY-MM-DD");
  if (!isDay(todayDay)) fail("todayDay must be YYYY-MM-DD");
  const accepted = problems.length === 0;
  const dueDay = accepted ? addDays(dispute.openedDay, policy.slaDays) : null;
  return {
    accepted,
    owner: policy.owner,
    slaDays: policy.slaDays,
    dueDay,
    overdue: dueDay !== null && todayDay > dueDay,
    problems,
  };
}

/** 争议成立时只在参与者之间转移,池子总额不变。 */
export function transferAllocation(
  allocations: readonly Allocation[],
  fromTeamId: string,
  toTeamId: string,
  micros: number,
): Allocation[] {
  integer(micros, "micros", 1);
  if (fromTeamId === toTeamId) fail("from and to must differ");
  const from = allocations.find((item) => item.teamId === fromTeamId);
  const to = allocations.find((item) => item.teamId === toTeamId);
  if (from === undefined || to === undefined) fail("unknown participant");
  if (from.allocatedMicros < micros) fail("transfer exceeds allocation");
  return allocations.map((item): Allocation => {
    if (item.teamId === fromTeamId) return { ...item, allocatedMicros: item.allocatedMicros - micros };
    if (item.teamId === toTeamId) return { ...item, allocatedMicros: item.allocatedMicros + micros };
    return { ...item };
  }).sort((left, right) => {
    if (right.allocatedMicros !== left.allocatedMicros) {
      return right.allocatedMicros - left.allocatedMicros;
    }
    return left.teamId.localeCompare(right.teamId);
  });
}

screenDispute 不修改账目,只返回责任人、时限和到期日;真正调账由 transferAllocation 完成。把“受理”和“调账”分开,是为了让每次调账都有前置的争议编号和证据。

示例样本上的运行结果

测试覆盖三种计价单位、最大余数法、三种分摊键、零权重、重复参与者、四类争议与转移。执行 node --test test/gov/allocation.test.ts,示例样本上的运行结果为 8 项通过、0 项失败。

关键输出:按用量分摊为客服 9,892,902、交易 1,569,116、内容 537,982;按收益为客服 5,400,000、内容 3,600,000、交易 3,000,000;按固定比例为内容 4,800,000、客服 3,600,000、交易 3,600,000。三种结果都精确等于 12,000,000。

这些数字来自示例样本上的运行结果,不是生产账单。换成真实的用量与价格卡,函数会给出新的分摊,但最大余数法的总额恒等式不变。

内部计价不等于对外收费

内部计价的目的不是让平台赚钱,而是让使用成本可见、可比较、可约束。它和对外定价有三点不同。

第一,内部计价不追求毛利。平台把共享池原样分给团队,不留利润;一旦留利润,团队会开始绕过平台自建,治理成本反而上升。第二,内部计价允许政策折扣。比如评测环境对首次接入免费,对高频回归收费,目的是引导行为,而不是覆盖成本。第三,内部计价必须可解释。对外价格可以是黑箱,内部账单不行;团队有权知道自己的每一微元是怎么算出来的。

价格卡也不是一成不变。示例建议每季度复核一次:模型单价变了、缓存命中率变了、共享池规模变了,价格卡都要跟着调。但调价要提前公告,且只影响下一个账期;用新价格重算旧账,会让所有历史预算失去意义。

与 7.1 的衔接

7.1 的对账回答“预算、账单、归因对不对得上”,本节回答“共享成本怎么从平台走到团队”。两者通过“团队 + 能力”这个粒度接起来:7.1 的账单行是本节分摊后的结果,本节的分摊键又决定了 7.1 里每个团队看到多少成本。

如果 7.1 发现某行未解释,先查本节的分摊键是否变更;如果本节出现争议,先确认 7.1 的账单是否已经冻结。顺序反了,就会在错误的账上找原因。

小结

共享池分摊的第一步不是选方法,而是统一分母。调用次数、token 数不能直接相加,示例用按价格卡算出的直接用量成本作为共同分母。

三种内部计价单位各有适用面:动作类按次、生成类按千 token、共享设施按期。三种分摊键各有代价:按用量惩罚高单价能力,按收益依赖预先确认的收益数,按固定比例与真实消耗脱节。选择要在账期前完成,变更不能追溯。

整数分摊用最大余数法保证总额精确;争议按计量、归因、价格卡、分摊键四类分流,受理与调账分开,调账只在参与者之间转移。示例样本上,1,200 万池子在三种方法下给客服带来 989 万、540 万、360 万三张账单,差距本身就是治理对象。

阅读导航:上一节:7.1 预算、账单与归因的分工 · 下一节:7.3 优化优先级与投资回报 。

常见错误与边界

  • 把调用次数、token 数直接相加当用量。分母不统一,分摊比例没有意义。
  • 按用量分摊就认为公平。高单价能力会把成本集中到少数团队。
  • 按收益分摊却不锁定收益数。账期结束后再调收益,等于事后改规则。
  • 固定比例长期不复核。业务结构变了,比例还停在两年前。
  • 每项独立四舍五入。分摊总额与池子差几微元,账永远对不平。
  • 用输入顺序决定余数归属。换一个遍历顺序就换一个结果,无法复现。
  • 把四类争议都交给财务。计量、归因、价格、分摊键的责任人不同。
  • 受理争议时直接调账。没有争议编号和证据,调账无法审计。
  • 用少收钱平息争议。池子会逐年缩小,共享设施最终无人维护。
  • 内部计价追求毛利。团队会用自建绕开平台,治理成本更高。
  • 用新价格重算旧账。历史预算与归因全部失效。
  • 把内部计价当对外报价。对外可以黑箱,对内必须可解释。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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