《互联网产品的 AI 架构·平台化与治理》7.1 预算、账单与归因的分工

预算、账单与归因常被当成同一本账,超支时三个团队只能互相指责。本节区分三本账的时点、单位、责任人与粒度,给出先对齐到最粗粒度再相减的对账流程,并用示例样本上的 1 亿预算、1.008 亿账单与 99.88% 归因覆盖说明未解释缺口该由谁闭环。

月初的承诺,月底的事实,会后的解释

「回声」的平台治理会上,三张纸被同时投到屏幕上。第一张是月初预算:内容团队 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.summarize4,000 万4,120 万4,108 万超预算、未解释
content / content.translate080 万80 万未预算、超预算
support / chat.reply2,400 万2,280 万2,280 万正常
support / support.assist600 万610 万610 万超预算
trade / search.rerank1,200 万1,140 万1,140 万正常
trade / chat.reply1,500 万1,560 万1,560 万超预算
trade / order.place300 万290 万290 万正常

这张表有两个容易读错的地方。第一,超预算和未解释是两件事:content.summarize 既超预算 120 万,又有 12 万没被归因解释;前者找团队,后者找平台。第二,“正常”不等于“花得少”,support.chat.reply 只用了预算的 95%,按 20% 的未用完阈值仍算正常;未用完本身不是问题,长期大量未用完才说明预算编制失真。

content.translate 是最有信息量的一行。它有账单、有归因,却没有预算。归因能解释它为什么花钱,但不能回答“谁批准它上线”。这类缺口的闭环动作在预算流程,不在归因报告。

对账流程

对账不是一次减法,而是一条有顺序的流程。下面五步可以直接落成月末例会的议程。

  1. 冻结账期与版本。确认账单截止日、计量口径版本、价格版本,三者在本次对账中不再变化。
  2. 取共同粒度。比较三本账的粒度,取最粗的一级;把更细的账聚合上来,并记录压掉了多少明细。
  3. 先对预算与账单。逐行算 账单 - 预算,标记超预算、未预算、明显未用完。这一步只回答“花超了没有”,不解释原因。
  4. 再对账单与归因。逐行算 账单 - 归因,标记未解释与归因过度。归因过度通常意味着抵扣、返点或跨期调整没被单独列示。
  5. 分配闭环责任人和日期。超预算归团队负责人,未解释归平台与责任团队,未预算归预算 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%”当成“没有超支”。覆盖率和预算差异是两个维度。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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