账单比内部账多 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 设容差。少记调用会被当成正常舍入。
- 把所有差异归成一类。“对账失败”没有责任人,也没有动作。
- 未知金额不单列。它会被平均值掩盖,下个月继续出现。
- 解释只写“口径不同”。没有金额、证据与动作,等于没解释。
- 用供应商账单驱动实时配额。账单延迟会让配额长期失效。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。