月初的承诺,月底的事实,会后的解释
「回声」的平台治理会上,三张纸被同时投到屏幕上。第一张是月初预算:内容团队 4,000 万微元、客服团队 3,000 万、交易团队 3,000 万,合计 1 亿。第二张是月末账单:实际支出 1.008 亿,超了 80 万。第三张是成本归因报告:它解释了 1.0068 亿,覆盖率 99.88%,剩下 12 万说不清。
会议很快变成互相指责。内容团队说账单里多了一个 content.translate,预算会上根本没提;平台团队说归因已经覆盖 99.88%,剩下的 12 万是计量噪声;财务说预算就是 1 亿,超一分钱都要有人签字。三方都没有说谎,他们说的是三本不同的账。
本节不讨论怎么把单价算得更准,也不重复卷二的多维归因。它只回答一个组织问题:预算、账单、归因各自负责什么,口径差在哪里,月末按什么顺序对账。
三本账回答三个问题
把三本账放在一张表里,差异立刻清楚。预算回答“最多可以花多少”,账单回答“实际花了多少”,归因回答“为什么多花或少花”。三者时点不同、单位不同、负责人也不同。
| 账 | 回答的问题 | 时点 | 单位 | 负责人 | 交付物 |
|---|---|---|---|---|---|
| 预算 | 这个团队最多可以花多少 | 事前 | 分配额度(微元) | 团队负责人 + 财务 | 预算单 |
| 账单 | 这个账期实际花了多少 | 事后 | 计费事件(微元) | 平台计量 + 供应商 | 账单 |
| 归因 | 为什么花得比预期多或少 | 事后 | 差异因子(可正可负) | 平台 + 责任团队 | 归因报告 |
这张表最重要的一列是“单位”。预算的单位是额度,它一旦分配就固定;账单的单位是计费事件,它只能是非负的;归因的单位是差异因子,它可以为负,比如缓存折扣或供应商返点。把三者都叫“成本”,就会自然地想把它们直接相减,而负号一出现,口径就乱了。
第二重要的是“时点”。预算在用量发生前冻结,账单在账期结束后才齐,归因又要等账单和计量都稳定。三本账不可能在同一天完成,所以对账必须有一个明确的冻结日,而不是“看到数字不一样就开会”。
粒度差异不是错误
示例里,预算按“团队 + 能力”编制,共 6 行;账单也按“团队 + 能力”,共 7 行,多出的是一行未预算的新能力;归因按“团队 + 能力 + 路由”,共 12 行,因为同一个能力会走 primary、escalate、economy 等不同路由。
三本账不能直接在路由级相减。预算没有路由维度,硬要在路由级对账,就只能按某个比例把预算拆下去,而这个比例本身就是拍脑袋。正确的做法是先取三者中最粗的粒度,也就是“团队 + 能力”,把更细的账聚合上来,再做差。
聚合会损失明细。示例里归因从 12 行压到 7 行,压掉 5 行路由明细;如果预算只做到团队级,账单会压掉 4 行、归因压掉 9 行。对账报告必须把这个损失写出来,否则读者会误以为 7 行就是全部事实。
这也解释了一个常见争论:平台说“归因已经到路由了”,财务说“预算没有路由”。两边都对,但只有在共同粒度上的数字才可以相减。想做更细的对账,先补预算维度,而不是让归因降级。
一次月末对账
示例样本上的运行结果如下。账期预算 1 亿微元,账单 1.008 亿,归因 1.0068 亿;预算差异 +80 万,未解释净缺口 +12 万,归因覆盖率 99.88%。按行看,超预算合计 270 万,未用完合计 190 万,两者相抵后才是 +80 万。
| 团队 / 能力 | 预算 | 账单 | 归因 | 标记 |
|---|---|---|---|---|
| content / content.summarize | 4,000 万 | 4,120 万 | 4,108 万 | 超预算、未解释 |
| content / content.translate | 0 | 80 万 | 80 万 | 未预算、超预算 |
| support / chat.reply | 2,400 万 | 2,280 万 | 2,280 万 | 正常 |
| support / support.assist | 600 万 | 610 万 | 610 万 | 超预算 |
| trade / search.rerank | 1,200 万 | 1,140 万 | 1,140 万 | 正常 |
| trade / chat.reply | 1,500 万 | 1,560 万 | 1,560 万 | 超预算 |
| trade / order.place | 300 万 | 290 万 | 290 万 | 正常 |
这张表有两个容易读错的地方。第一,超预算和未解释是两件事:content.summarize 既超预算 120 万,又有 12 万没被归因解释;前者找团队,后者找平台。第二,“正常”不等于“花得少”,support.chat.reply 只用了预算的 95%,按 20% 的未用完阈值仍算正常;未用完本身不是问题,长期大量未用完才说明预算编制失真。
content.translate 是最有信息量的一行。它有账单、有归因,却没有预算。归因能解释它为什么花钱,但不能回答“谁批准它上线”。这类缺口的闭环动作在预算流程,不在归因报告。
对账流程
对账不是一次减法,而是一条有顺序的流程。下面五步可以直接落成月末例会的议程。
- 冻结账期与版本。确认账单截止日、计量口径版本、价格版本,三者在本次对账中不再变化。
- 取共同粒度。比较三本账的粒度,取最粗的一级;把更细的账聚合上来,并记录压掉了多少明细。
- 先对预算与账单。逐行算
账单 - 预算,标记超预算、未预算、明显未用完。这一步只回答“花超了没有”,不解释原因。 - 再对账单与归因。逐行算
账单 - 归因,标记未解释与归因过度。归因过度通常意味着抵扣、返点或跨期调整没被单独列示。 - 分配闭环责任人和日期。超预算归团队负责人,未解释归平台与责任团队,未预算归预算 owner 与财务;每项都要有截止日。
流程里有两个不能省的约束。第一,不能把正负缺口先净掉再看:净缺口 +12 万可能由 +30 万和 -18 万组成,净掉之后就找不到真正的异常行。第二,不能在同一个会上既改预算又改归因:预算调整走变更单,归因修正走计量工单,两条线混在一起,下个月还是对不上。
谁负责哪一步
三本账的分工可以压缩成四句话。
- 预算由团队负责人提出、财务汇总、租户 owner 批准;预算错了,责任在编制与批准,不在执行。
- 账单由平台计量产出、供应商账单校验;账单错了,责任在计量与对账,不在业务团队。
- 归因由平台提供方法、责任团队确认杠杆;归因解释不了,责任在平台与对应团队,不在财务。
- 缺口闭环由治理会跟踪;连续两个月同一行未闭环,升级为流程问题,而不是继续挂账。
这四句话解决的是“会开完谁去改”。没有它们,对账会停留在“下个月再看”,而同一个缺口会每个月重新出现。
实现
下面的模块只做三件事:把三本账聚合到共同粒度、逐行标记、汇总缺口。它不读数据库,也不判断谁对。
// file: src/gov/chargeback.ts
// tier: L2
// verify: node --test test/gov/chargeback.test.ts
import { AiError } from "../core/errors.ts";
/** 三本账回答三个不同的问题,不能互相替代。 */
export type LedgerKind = "budget" | "bill" | "attribution";
export type CostGrain = "team" | "team-capability" | "team-capability-route";
export interface LedgerRole {
question: string;
timing: string;
unit: string;
owner: string;
artifact: string;
}
/** 预算是事前承诺,账单是事后事实,归因是差异解释。 */
export const LEDGER_ROLES: Record<LedgerKind, LedgerRole> = {
budget: {
question: "这个团队最多可以花多少", timing: "事前", unit: "分配额度(微元)",
owner: "团队负责人 + 财务", artifact: "预算单",
},
bill: {
question: "这个账期实际花了多少", timing: "事后", unit: "计费事件(微元)",
owner: "平台计量 + 供应商", artifact: "账单",
},
attribution: {
question: "为什么花得比预期多或少", timing: "事后", unit: "差异因子(可正可负)",
owner: "平台 + 责任团队", artifact: "归因报告",
},
};
export const GRAIN_DEPTH: Record<CostGrain, number> = {
team: 1, "team-capability": 2, "team-capability-route": 3,
};
export interface CostEntry {
teamId: string;
capability: string;
route?: string;
micros: number;
/** 归因账专用:这一行解释的是量、结构、单价还是范围差异。 */
driver?: "volume" | "mix" | "rate" | "scope" | "credit";
}
export interface Ledger {
kind: LedgerKind;
grain: CostGrain;
entries: readonly CostEntry[];
}
export type ReconFlag =
| "aligned" | "over-budget" | "unspent" | "unbudgeted" | "unattributed" | "attribution-over";
export interface ReconRow {
teamId: string;
capability: string;
budgetMicros: number;
billedMicros: number;
attributedMicros: number;
/** 账单减预算;正数是超支。 */
varianceMicros: number;
/** 账单减归因;正数是尚未解释的部分。 */
gapMicros: number;
flags: ReconFlag[];
}
export interface ReconTotals {
budgetMicros: number;
billedMicros: number;
attributedMicros: number;
varianceMicros: number;
netGapMicros: number;
grossGapMicros: number;
overBudgetMicros: number;
unspentMicros: number;
coverageBps: number;
}
export interface Reconciliation {
grain: CostGrain;
rows: ReconRow[];
totals: ReconTotals;
/** 每本账被压掉的明细数量,提示对账的粒度损失。 */
collapsed: Record<LedgerKind, number>;
balanced: boolean;
overBudget: boolean;
}
export interface ReconcileOptions {
/** 实际支出低于预算多少算“明显未用完”,默认 20%。 */
unspentThresholdBps?: number;
}
function fail(message: string): never {
throw new AiError("invalid-request", `chargeback: ${message}`);
}
function text(value: string, field: string): string {
if (typeof value !== "string" || value.trim() === "") fail(`${field} is required`);
return value;
}
function integer(value: number, field: string, min = Number.MIN_SAFE_INTEGER): number {
if (!Number.isSafeInteger(value) || value < min) fail(`${field} must be an integer >= ${min}`);
return value;
}
function grainDepth(grain: CostGrain): number {
const depth = GRAIN_DEPTH[grain];
if (depth === undefined) fail(`unknown grain ${String(grain)}`);
return depth;
}
function keyAt(entry: CostEntry, grain: CostGrain): string {
const depth = grainDepth(grain);
const parts = [entry.teamId];
if (depth >= 2) parts.push(entry.capability);
if (depth >= 3) parts.push(entry.route ?? "*");
return parts.join("\u0000");
}
function aggregate(ledger: Ledger, grain: CostGrain): Map<string, ReconRow> {
const buckets = new Map<string, ReconRow>();
const depth = grainDepth(grain);
for (const entry of ledger.entries) {
text(entry.teamId, "entry.teamId");
text(entry.capability, "entry.capability");
if (depth >= 3) text(entry.route ?? "", "entry.route");
const min = ledger.kind === "attribution" ? Number.MIN_SAFE_INTEGER : 0;
integer(entry.micros, "entry.micros", min);
const key = keyAt(entry, grain);
const bucket = buckets.get(key);
if (bucket === undefined) {
buckets.set(key, {
teamId: entry.teamId,
capability: depth >= 2 ? entry.capability : "*",
budgetMicros: ledger.kind === "budget" ? entry.micros : 0,
billedMicros: ledger.kind === "bill" ? entry.micros : 0,
attributedMicros: ledger.kind === "attribution" ? entry.micros : 0,
varianceMicros: 0, gapMicros: 0, flags: [],
});
} else if (ledger.kind === "budget") bucket.budgetMicros += entry.micros;
else if (ledger.kind === "bill") bucket.billedMicros += entry.micros;
else bucket.attributedMicros += entry.micros;
}
return buckets;
}
/**
* 三本账只能在对齐到最粗粒度后相减。
* 谁更细,谁的明细就在对齐时被压掉;对账报告必须把损失写出来。
*/
export function reconcileCostLedgers(
budget: Ledger,
bill: Ledger,
attribution: Ledger,
options: ReconcileOptions = {},
): Reconciliation {
if (budget.kind !== "budget") fail("first ledger must be budget");
if (bill.kind !== "bill") fail("second ledger must be bill");
if (attribution.kind !== "attribution") fail("third ledger must be attribution");
const threshold = integer(options.unspentThresholdBps ?? 2_000, "unspentThresholdBps", 0);
if (threshold > 10_000) fail("unspentThresholdBps must not exceed 10000");
const ledgers = [budget, bill, attribution];
for (const ledger of ledgers) {
const seen = new Set<string>();
for (const entry of ledger.entries) {
const key = keyAt(entry, ledger.grain);
if (seen.has(key)) fail(`${ledger.kind}: duplicate entry ${key.replaceAll("\u0000", "/")}`);
seen.add(key);
}
}
const depth = Math.min(...ledgers.map((ledger) => grainDepth(ledger.grain)));
const grain = (Object.keys(GRAIN_DEPTH) as CostGrain[]).find((item) => GRAIN_DEPTH[item] === depth);
if (grain === undefined) fail("cannot determine common grain");
const maps = {
budget: aggregate(budget, grain),
bill: aggregate(bill, grain),
attribution: aggregate(attribution, grain),
};
const keys = [...new Set([...maps.budget.keys(), ...maps.bill.keys(), ...maps.attribution.keys()])].sort();
const rows = keys.map((key): ReconRow => {
const fromBudget = maps.budget.get(key);
const fromBill = maps.bill.get(key);
const fromAttribution = maps.attribution.get(key);
const source = fromBudget ?? fromBill ?? fromAttribution;
if (source === undefined) fail(`missing row ${key}`);
const row: ReconRow = {
teamId: source.teamId, capability: source.capability,
budgetMicros: fromBudget?.budgetMicros ?? 0,
billedMicros: fromBill?.billedMicros ?? 0,
attributedMicros: fromAttribution?.attributedMicros ?? 0,
varianceMicros: 0, gapMicros: 0, flags: [],
};
row.varianceMicros = row.billedMicros - row.budgetMicros;
row.gapMicros = row.billedMicros - row.attributedMicros;
if (row.budgetMicros === 0 && row.billedMicros > 0) row.flags.push("unbudgeted");
if (row.billedMicros > row.budgetMicros) row.flags.push("over-budget");
if (row.budgetMicros > 0 && row.billedMicros * 10_000 < row.budgetMicros * (10_000 - threshold)) {
row.flags.push("unspent");
}
if (row.gapMicros > 0) row.flags.push("unattributed");
if (row.gapMicros < 0) row.flags.push("attribution-over");
if (row.flags.length === 0) row.flags.push("aligned");
return row;
});
const totals: ReconTotals = {
budgetMicros: 0, billedMicros: 0, attributedMicros: 0, varianceMicros: 0,
netGapMicros: 0, grossGapMicros: 0, overBudgetMicros: 0, unspentMicros: 0, coverageBps: 10_000,
};
for (const row of rows) {
totals.budgetMicros += row.budgetMicros;
totals.billedMicros += row.billedMicros;
totals.attributedMicros += row.attributedMicros;
totals.varianceMicros += row.varianceMicros;
totals.netGapMicros += row.gapMicros;
totals.grossGapMicros += Math.abs(row.gapMicros);
totals.overBudgetMicros += Math.max(0, row.varianceMicros);
totals.unspentMicros += Math.max(0, -row.varianceMicros);
}
if (totals.billedMicros !== 0) {
totals.coverageBps = Math.round((totals.attributedMicros * 10_000) / totals.billedMicros);
}
return {
grain, rows, totals,
collapsed: {
budget: budget.entries.length - maps.budget.size,
bill: bill.entries.length - maps.bill.size,
attribution: attribution.entries.length - maps.attribution.size,
},
balanced: totals.grossGapMicros === 0,
overBudget: rows.some((row) => row.flags.includes("over-budget") || row.flags.includes("unbudgeted")),
};
}
unspentThresholdBps 是必填的政策参数,默认 20%。它必须显式写进报告,因为“未用完”是管理判断,不是会计事实;换一个阈值,标记就会变。
示例样本上的运行结果
测试覆盖共同粒度、三本账汇总、标记组合、团队级预算、未用完阈值、负向归因与非法输入。执行 node --test test/gov/chargeback.test.ts,示例样本上的运行结果为 7 项通过、0 项失败。
关键输出是:预算 100,000,000,账单 100,800,000,归因 100,680,000,预算差异 +800,000,净缺口 +120,000,绝对缺口 120,000,覆盖率 9,988 基点;超预算 2,700,000,未用完 1,900,000。归因从 12 行压到 7 行,压掉 5 行路由明细。
这些数字不是生产实测,只用于说明对账口径。换一组预算或账单,函数会给出同一套标记,但结论必须重新计算。
与卷二归因的分工
卷二 9.2 回答“成本为什么涨”,用多维视角与三因子分解把差异落到提示版本和路由;9.3 回答“内部账与供应商账单为什么对不上”,用差异分类闭环。本节回答的是第三层:预算、账单、归因三本组织账如何对齐。
三者的关系是串行的:先用 9.3 保证账单可信,再用 9.2 解释账单变化,最后用本节把解释接回预算责任。跳过前两步,本节的对账只会把不可信的账单和不可复现的归因拼在一起,得到一个看起来很完整、实际无法行动的结论。
小结
预算、账单、归因是三本账,不是一本账的三个视图。预算事前承诺、账单事后记录、归因事后解释;单位分别是额度、计费事件和可正可负的差异因子;负责人分别是团队与财务、平台计量、平台与责任团队。
对账的关键动作是取共同粒度。三本账只能在对齐到最粗粒度后相减,更细的账要聚合,聚合损失要写进报告。示例样本上,1 亿预算对 1.008 亿账单,差异 +80 万;归因覆盖 99.88%,未解释 12 万。超预算找团队,未解释找平台,未预算找预算流程。
阅读导航:上一节:6.3 质量报告与季度复盘 · 下一节:7.2 成本分摊与内部计价 。
常见错误与边界
- 把预算当账单用。预算没有实际用量,不能拿来算单位成本。
- 把归因当预算用。归因解释过去,预算约束未来,方向相反。
- 在路由级对账预算。预算没有路由维度,只能靠拍比例拆。
- 三本账直接相减不看粒度。更细的账被静默压掉,差异无法复现。
- 用净缺口代替逐行缺口。正负相抵会藏掉真正的异常行。
- 把未解释缺口当计量噪声。连续两个月同一行未解释,就是流程问题。
- 未预算的新能力只补归因不补预算。下个月它还会以同样方式出现。
- 对账会上同时改预算和归因。两条线混在一起,下个月仍然对不上。
- 只处理超预算,不处理长期未用完。预算编制失真会让下一轮分配失去依据。
- 把“归因覆盖率 100%”当成“没有超支”。覆盖率和预算差异是两个维度。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。