一笔池子,三张账单
平台团队每月维护一组共享设施:网关、评测环境、可观测性、值班。它们不直接对应某条链路,却服务所有团队。这个月的共享池是 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,000 | 537,982(4.5%) | 3,600,000(30%) | 4,800,000(40%) |
| 客服 | 3,972,000 | 9,892,902(82.4%) | 5,400,000(45%) | 3,600,000(30%) |
| 交易 | 630,000 | 1,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 数直接相加当用量。分母不统一,分摊比例没有意义。
- 按用量分摊就认为公平。高单价能力会把成本集中到少数团队。
- 按收益分摊却不锁定收益数。账期结束后再调收益,等于事后改规则。
- 固定比例长期不复核。业务结构变了,比例还停在两年前。
- 每项独立四舍五入。分摊总额与池子差几微元,账永远对不平。
- 用输入顺序决定余数归属。换一个遍历顺序就换一个结果,无法复现。
- 把四类争议都交给财务。计量、归因、价格、分摊键的责任人不同。
- 受理争议时直接调账。没有争议编号和证据,调账无法审计。
- 用少收钱平息争议。池子会逐年缩小,共享设施最终无人维护。
- 内部计价追求毛利。团队会用自建绕开平台,治理成本更高。
- 用新价格重算旧账。历史预算与归因全部失效。
- 把内部计价当对外报价。对外可以黑箱,对内必须可解释。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。