《互联网产品的 AI 架构·推理性能与成本工程》9.3 账单对账与差异分析

财务发来的模型账单比内部账多 43,200 微元,团队却只回了一句“口径不同”。本节把差异拆成时间、舍入、价格版本、缓存口径、范围与未知六类,给出从规范化、匹配、分类到责任人闭环的对账流程,以及可直接贴进月报的差异解释模板,并用示例样本上的真实运行结果演示 42,200 微元如何被解释、1,000 微元如何进入待查。

账单比内部账多 4.14%

「回声」十月上旬的供应商账单是 1,086,400 微元,内部计量账是 1,043,200 微元,差 43,200 微元,比例 4.14%。财务问了一句“为什么”,平台同学回了一句“口径不同”。这句话既不是解释,也不是结论。

对账不是把两个总数相减。总数相等可能只是两个方向的错误刚好抵消;总数不等也不代表一定有人算错。真正要做的是:把两边拉到同一粒度,逐行比较,给每一行一个类别、一个责任人、一个动作,最后只剩少数无法解释的金额。

本节给出这条流程与一个可运行的实现。下列金额与输出都来自示例样本上的运行结果,不是生产账单。

差异的六个来源

来源典型证据处理方向
时间上期用量在本期出账;期末用量尚未出账调账或挂待下期
舍入最小计费单位、向上取整接受容差或重算
价格版本合同价生效日、阶梯价、汇率商务核对价表
缓存口径缓存 token 是否按折扣价计费平台核对命中判定
范围附加费、免费额度、税费、抵扣商务与财务核对合同
未知以上都不匹配开差异单,限期闭环

示例样本的 43,200 微元被拆成:价格版本 18,000、上期迟报 12,000、缓存口径 7,000、最小计费单位 3,200、附加费 2,000、未知 1,000。前五项合计 42,200,已解释;剩下 1,000 进入待查。

未知不是失败。把未知藏进“其他”才是失败。待查金额必须单列,并且有责任人和期限。

对账流程:先规范化,再匹配,再分类

第一步是规范化。供应商账单通常按 SKU、项目或 API Key 聚合,内部账按租户与模型聚合。两边都要先映射到同一套维度:usageDay、tenant、model,再补齐 priceVersion、tokenizerVersion、cachedInputTokens、minimumBillableUnits、surchargeMicros 这些证据字段。没有证据字段,分类只能靠猜。

第二步是匹配。粒度选“账期内的使用日 + 租户 + 模型”。上一账期晚出账的用量不能因为 usageDay 不在本期就丢掉,它会作为 timing-prior 行保留;否则这笔钱会被迫进入“未知”。

第三步是分类。顺序很重要:先比 token 数,再比金额。token 不一致时,先看分词器版本,再看缓存 token;金额不一致但 token 一致时,依次看容差、价格版本、缓存口径、最小计费单位、附加费。顺序错了,价格差异会被误判成 token 差异。

第四步是闭环。每一类都绑定责任人、动作与状态:closed 表示已解释或已调账,open 表示需要处理,pending 表示等下期账单。只有 unexplained 为零,财务口径才算平衡。

分类不是标签,是处理动作

同样是“多收 7,000 微元”,价格版本错、缓存口径错、附加费漏记,处理人完全不同。分类的价值在于把金额路由到能修的人手里。

  • price:商务核对合同价与生效日,必要时向供应商申诉。
  • cache-accounting:平台核对缓存命中判定与折扣是否传递到账单。
  • rounding:计量确认最小计费单位,决定接受容差还是重算。
  • scope:商务核对附加费、免费额度、税费与抵扣。
  • timing-prior / timing-pending:财务与计量调账或挂账,不重复计费。
  • tokenizer / token-mismatch:平台统一分词器版本,重算受影响账期。
  • unexplained:财务与平台共同开差异单,7 日内给出结论。

balanced 只回答“未知金额是否为零”,不回答“所有差异都已关闭”。示例样本 balanced=false,因为有 1,000 微元未知;即使未知为零,价格与缓存两类仍然可以是 open,只是金额已经解释清楚。

差异解释模板

月报里最没用的句子是“账单与内部账存在差异,主要系口径不同所致”。有用的解释必须包含:金额、方向、类别、证据、责任人、动作、状态。

账期 2026-11 上旬 2026-11-01 ~ 2026-11-02
内部账 1043200 微元 | 账单 1086400 微元 | 差异 +43200 微元 (4.14%)
已解释 42200 | 待查 1000 | 开放项 4 | 待下期 0
分类:
  price              +18000   1 行  [open] 商务与财务: 核对合同价版本与生效日
  timing-prior       +12000   1 行  [closed] 财务与计量: 确认上期迟报并登记调账
  cache-accounting    +7000   1 行  [open] 平台: 核对缓存命中判定与折扣计费
  rounding            +3200   1 行  [closed] 计量: 核对最小计费单位与向上取整
  scope               +2000   1 行  [open] 商务: 核对附加费、免费额度与税费
  unexplained         +1000   1 行  [open] 财务与平台: 开差异单,7 日内闭环
  matched                 0   2 行  [closed] 计量: 无需处理

这段文本由 formatDifferenceReport 直接生成,不是手写模板。手写模板会在金额变化时忘记改数字,生成器不会。

实现

下面的模块包含记录校验、账期过滤、差异分类、分类汇总与解释模板。它不连接供应商 API,只处理已经入库的规范记录。

// file: src/perf/reconcile.ts
// tier: L1
// verify: node --test test/perf/reconcile.test.ts

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

/** 差异类别决定处理人和动作;“对账失败”不是一个可执行的类别。 */
export type DiffCategory =
  | "matched" | "timing-prior" | "timing-pending" | "rounding" | "price"
  | "cache-accounting" | "tokenizer" | "token-mismatch" | "scope" | "credit"
  | "internal-only" | "invoice-only" | "unexplained";

/**
 * 内部账与供应商账单共用的规范记录。
 * 供应商账单经常只到 SKU 粒度,入库前必须先映射到 tenant + model。
 */
export interface BillingRecord {
  usageDay: string; tenant: string; model: string; kind: "usage" | "credit";
  inputTokens: number; outputTokens: number; cachedInputTokens: number; costMicros: number;
  priceVersion: string; tokenizerVersion: string;
  /** 供应商按最小计费单位向上取整时留下的单位数。 */
  minimumBillableUnits: number;
  /** 区域附加费、数据驻留费等非 token 费用。 */
  surchargeMicros: number;
}

export interface BillingPeriod { label: string; startDay: string; endDay: string; }

export interface ReconcilePolicy {
  /** 只吸收计价舍入,不能把 token 差异也放进来。 */
  costToleranceMicros: number;
  /** 账期最后几天内尚未出账的内部记录,标记为待下期账单。 */
  timingLagDays: number;
}

export interface ReconcileRow {
  key: string; usageDay: string; tenant: string; model: string; category: DiffCategory;
  internal: BillingRecord | null; invoice: BillingRecord | null;
  internalMicros: number; invoiceMicros: number;
  /** 账单减内部账;正数表示账单更高。 */
  deltaMicros: number;
  owner: string; action: string; status: "closed" | "open" | "pending";
}

export interface Reconciliation {
  period: BillingPeriod; rows: ReconcileRow[];
  internalMicros: number; invoiceMicros: number; deltaMicros: number; deltaRate: number;
  explainedMicros: number; unexplainedMicros: number;
  openRows: number; pendingRows: number; balanced: boolean;
  byCategory: Array<{ category: DiffCategory; rows: number; deltaMicros: number }>;
}

const META: Record<DiffCategory, { owner: string; action: string; status: "closed" | "open" | "pending" }> = {
  matched: { owner: "计量", action: "无需处理", status: "closed" },
  "timing-prior": { owner: "财务与计量", action: "确认上期迟报并登记调账", status: "closed" },
  "timing-pending": { owner: "计量", action: "等待下期账单销项", status: "pending" },
  rounding: { owner: "计量", action: "核对最小计费单位与向上取整", status: "closed" },
  price: { owner: "商务与财务", action: "核对合同价版本与生效日", status: "open" },
  "cache-accounting": { owner: "平台", action: "核对缓存命中判定与折扣计费", status: "open" },
  tokenizer: { owner: "平台", action: "统一分词器版本并重算", status: "open" },
  "token-mismatch": { owner: "计量", action: "定位 token 口径差异", status: "open" },
  scope: { owner: "商务", action: "核对附加费、免费额度与税费", status: "open" },
  credit: { owner: "财务", action: "登记抵扣并核销", status: "closed" },
  "internal-only": { owner: "计量", action: "补录账单或发起申诉", status: "open" },
  "invoice-only": { owner: "计量", action: "确认是否为漏记调用", status: "open" },
  unexplained: { owner: "财务与平台", action: "开差异单,7 日内闭环", status: "open" },
};

const UNEXPLAINED: readonly DiffCategory[] = ["unexplained", "internal-only", "invoice-only", "token-mismatch"];
function fail(message: string): never {
  throw new AiError("invalid-request", `reconcile: ${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 = 0): number {
  if (!Number.isInteger(value) || value < min) fail(`${field} must be an integer >= ${min}`);
  return value;
}

function day(value: string, field: string): string {
  text(value, field);
  if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(Date.parse(`${value}T00:00:00Z`))) {
    fail(`${field} must be a YYYY-MM-DD date`);
  }
  return value;
}

function validateRecord(record: BillingRecord): void {
  day(record.usageDay, "usageDay");
  text(record.tenant, "tenant");
  text(record.model, "model");
  if (record.kind !== "usage" && record.kind !== "credit") fail("kind must be usage or credit");
  for (const field of ["inputTokens", "outputTokens", "cachedInputTokens"] as const) {
    integer(record[field], field);
  }
  if (record.cachedInputTokens > record.inputTokens) fail("cachedInputTokens must not exceed inputTokens");
  if (!Number.isInteger(record.costMicros)) fail("costMicros must be an integer");
  if (record.kind === "credit" && record.costMicros > 0) fail("credit costMicros must not be positive");
  if (record.kind === "usage" && record.costMicros < 0) fail("usage costMicros must not be negative");
  text(record.priceVersion, "priceVersion");
  text(record.tokenizerVersion, "tokenizerVersion");
  integer(record.minimumBillableUnits, "minimumBillableUnits");
  integer(record.surchargeMicros, "surchargeMicros");
}

function keyOf(record: BillingRecord): string {
  return [record.usageDay, record.tenant, record.model, record.kind].join("\u0000");
}

function indexRecords(records: readonly BillingRecord[], label: string): Map<string, BillingRecord> {
  const index = new Map<string, BillingRecord>();
  for (const record of records) {
    validateRecord(record);
    const key = keyOf(record);
    if (index.has(key)) fail(`${label}: duplicate record ${key.replaceAll("\u0000", "/")}`);
    index.set(key, { ...record });
  }
  return index;
}
function shiftDay(value: string, offset: number): string {
  const date = new Date(`${value}T00:00:00Z`);
  date.setUTCDate(date.getUTCDate() + offset);
  return date.toISOString().slice(0, 10);
}

function classifyPair(internal: BillingRecord, invoice: BillingRecord, tolerance: number): DiffCategory {
  if (invoice.kind === "credit") return "credit";
  const delta = invoice.costMicros - internal.costMicros;
  const cachedDiffers = invoice.cachedInputTokens !== internal.cachedInputTokens;
  const sameTokens =
    invoice.inputTokens === internal.inputTokens && invoice.outputTokens === internal.outputTokens;
  if (!sameTokens) {
    if (invoice.tokenizerVersion !== internal.tokenizerVersion) return "tokenizer";
    return cachedDiffers ? "cache-accounting" : "token-mismatch";
  }
  if (delta === 0) return "matched";
  if (Math.abs(delta) <= tolerance) return "rounding";
  if (invoice.priceVersion !== internal.priceVersion) return "price";
  if (cachedDiffers) return "cache-accounting";
  if (invoice.minimumBillableUnits > 0) return "rounding";
  if (invoice.surchargeMicros > 0) return "scope";
  return "unexplained";
}

/**
 * 对账口径:账单减内部账。
 * 供应商晚出账的上一期用量会作为 invoice-only 的 timing-prior 行保留,
 * 不能因为 usageDay 不在账期内就丢弃,否则差异会被藏进“其他”。
 */
export function reconcileBilling(
  internalRecords: readonly BillingRecord[],
  invoiceRecords: readonly BillingRecord[],
  period: BillingPeriod,
  policy: ReconcilePolicy,
): Reconciliation {
  text(period.label, "period.label");
  day(period.startDay, "period.startDay");
  day(period.endDay, "period.endDay");
  if (period.startDay > period.endDay) fail("period.startDay must not be after endDay");
  integer(policy.costToleranceMicros, "policy.costToleranceMicros");
  integer(policy.timingLagDays, "policy.timingLagDays");

  const internalIndex = indexRecords(internalRecords, "internal");
  const invoiceIndex = indexRecords(invoiceRecords, "invoice");
  const cutoffDay = shiftDay(period.endDay, -policy.timingLagDays);
  for (const [key, record] of internalIndex) {
    if (record.usageDay < period.startDay || record.usageDay > period.endDay) internalIndex.delete(key);
  }

  const keys = [...new Set([...internalIndex.keys(), ...invoiceIndex.keys()])].sort();
  const rows: ReconcileRow[] = keys.map((key) => {
    const internal = internalIndex.get(key) ?? null;
    const invoice = invoiceIndex.get(key) ?? null;
    const source = internal ?? invoice;
    if (source === undefined) fail(`missing record ${key}`);

    let category: DiffCategory;
    if (internal !== null && invoice !== null) {
      category = classifyPair(internal, invoice, policy.costToleranceMicros);
    } else if (invoice !== null) {
      category = invoice.kind === "credit" ? "credit"
        : invoice.usageDay < period.startDay ? "timing-prior" : "invoice-only";
    } else if (internal !== null) {
      category = internal.usageDay > cutoffDay ? "timing-pending" : "internal-only";
    } else {
      fail(`missing record ${key}`);
    }

    const meta = META[category];
    return {
      key, usageDay: source.usageDay, tenant: source.tenant, model: source.model, category,
      internal, invoice,
      internalMicros: internal?.costMicros ?? 0,
      invoiceMicros: invoice?.costMicros ?? 0,
      deltaMicros: (invoice?.costMicros ?? 0) - (internal?.costMicros ?? 0),
      owner: meta.owner, action: meta.action, status: meta.status,
    };
  });

  const internalMicros = rows.reduce((sum, row) => sum + row.internalMicros, 0);
  const invoiceMicros = rows.reduce((sum, row) => sum + row.invoiceMicros, 0);
  const deltaMicros = invoiceMicros - internalMicros;
  const unexplainedMicros = rows
    .filter((row) => UNEXPLAINED.includes(row.category))
    .reduce((sum, row) => sum + row.deltaMicros, 0);

  const categories = new Map<DiffCategory, { category: DiffCategory; rows: number; deltaMicros: number }>();
  for (const row of rows) {
    const summary = categories.get(row.category);
    if (summary === undefined) categories.set(row.category, { category: row.category, rows: 1, deltaMicros: row.deltaMicros });
    else {
      summary.rows += 1;
      summary.deltaMicros += row.deltaMicros;
    }
  }

  return {
    period, rows, internalMicros, invoiceMicros, deltaMicros,
    deltaRate: internalMicros === 0 ? (deltaMicros === 0 ? 0 : 1) : deltaMicros / internalMicros,
    explainedMicros: deltaMicros - unexplainedMicros,
    unexplainedMicros,
    openRows: rows.filter((row) => row.status === "open").length,
    pendingRows: rows.filter((row) => row.status === "pending").length,
    balanced: unexplainedMicros === 0,
    byCategory: [...categories.values()].sort((left, right) => {
      const byDelta = Math.abs(right.deltaMicros) - Math.abs(left.deltaMicros);
      return byDelta !== 0 ? byDelta : left.category.localeCompare(right.category);
    }),
  };
}

function signed(value: number): string {
  return value > 0 ? `+${value}` : String(value);
}

/** 差异解释模板:金额、类别、责任人与动作必须在同一行里说清。 */
export function formatDifferenceReport(result: Reconciliation): string {
  const lines = [
    `账期 ${result.period.label} ${result.period.startDay} ~ ${result.period.endDay}`,
    `内部账 ${result.internalMicros} 微元 | 账单 ${result.invoiceMicros} 微元 | ` +
      `差异 ${signed(result.deltaMicros)} 微元 (${(result.deltaRate * 100).toFixed(2)}%)`,
    `已解释 ${result.explainedMicros} | 待查 ${result.unexplainedMicros} | ` +
      `开放项 ${result.openRows} | 待下期 ${result.pendingRows}`,
    "分类:",
  ];
  for (const summary of result.byCategory) {
    const meta = META[summary.category];
    lines.push(
      `  ${summary.category.padEnd(16)} ${signed(summary.deltaMicros).padStart(8)}  ` +
        `${String(summary.rows).padStart(2)} 行  [${meta.status}] ${meta.owner}: ${meta.action}`,
    );
  }
  return lines.join("\n");
}

deltaMicros 的符号约定是“账单减内部账”,正数表示账单更高。卷一的内部审计用相反符号,跨表汇总前必须先统一,否则一正一负会把差异抵消成零。

测试与运行结果

测试覆盖示例对账、分类排序、上期迟报、期末待下期、价格/缓存/分词器/附加费/舍入分流、抵扣行、报告模板与非法输入。执行:

node --test test/perf/reconcile.test.ts

本机一次运行的真实输出摘要如下,耗时随机器变化:

ℹ tests 9
ℹ pass 9
ℹ fail 0
ℹ duration_ms 145.396917

示例样本的汇总:内部账 1,043,200,账单 1,086,400,差异 +43,200(4.14%);已解释 42,200,待查 1,000,开放项 4,待下期 0。测试同时断言了分类顺序:token 不一致优先分词器,其次缓存;金额不一致优先价格版本,其次最小计费单位与附加费。

与卷一 12.2 的分工

卷一 12.2 计量、账单与配额对账 已经给出用量事件、两层幂等键、日切偏移与按租户+模型的对账。那一节回答“同一笔调用会不会被记两次、账期归属对不对”。

本节把对账推进到“差异怎么解释、谁来闭环”。它保留卷一的状态骨架,把笼统的 cost-mismatch 细分为价格版本、缓存口径、最小计费单位、附加费与未知,并补上分词器与抵扣,把每一种差异绑定责任人和动作。卷一的字段是输入,本节的分类与模板是输出。

小结

对账的第一步不是比较总数,而是把两边规范化到同一粒度。匹配要保留上期迟报与期末待下期,分类要先 token 后金额,每一类都要有责任人与动作。balanced 只表示未知金额为零,不表示所有差异都已关闭;差异解释模板必须包含金额、方向、类别、证据、责任人与状态。

下一章从“解释过去”转向“验证未来”:基线、假设与实验设计。

阅读导航:上一节:9.2 多维归因方法 · 下一节:10.1 基线、假设与实验设计 。

常见错误与边界

  • 只比较总数。两个方向的错误可能刚好抵消。
  • 把上期迟报丢掉。差异会被迫进入“未知”。
  • 把期末未出账当漏记。先挂 timing-pending,下期再销项。
  • 先比金额后比 token。价格差异会被误判成用量差异。
  • 给 token 设容差。少记调用会被当成正常舍入。
  • 把所有差异归成一类。“对账失败”没有责任人,也没有动作。
  • 未知金额不单列。它会被平均值掩盖,下个月继续出现。
  • 解释只写“口径不同”。没有金额、证据与动作,等于没解释。
  • 用供应商账单驱动实时配额。账单延迟会让配额长期失效。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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