每个模型都涨了一点,账单却涨了 37%
「回声」十月下半月的模型成本比上半月多了 296,600 微元,涨幅 37.55%。成本看板按模型拆开:标准档多了 237,000 微元,轻量档多了 59,600 微元。两个模型都在涨,看起来像是“模型普遍变贵了”。
这个结论没法行动。模型不是价格表,它是路由的结果。标准档涨得多,可能是因为调用量增加,可能是因为更多请求被升级到标准档,也可能是因为每次调用的输入变长了。三种原因对应三个团队,处理方式完全不同。
本节先把成本拆成可精确相加的格,再给出五个视角与三因子分解。下列金额与比例都来自示例样本上的运行结果,不是生产账单。
先做格级对账:差异必须能精确相加
一个成本格由五个维度加两个度量组成:tenant、capability、promptVersion、model、route,加上调用次数 calls 与成本 costMicros。同一账期内,同一个维度组合只能出现一次。
对账用全外连接,不用内连接。内连接会丢掉只在旧账期存在的格和只在新账期存在的格,而这两类格恰恰是改版与下线的主要成本来源。示例样本有 8 个格:3 个新增、3 个下线、2 个匹配,所有格的成本差相加正好是 296,600 微元。
这一步是后面所有分析的地基。格级差异对不上总账,任何维度拆解都只是看起来合理。
一个维度只能解释 100%
按租户拆:A 多 163,000,B 多 130,500,C 多 3,100。按模型拆:标准档多 237,000,轻量档多 59,600。按能力拆:chat.reply 多 166,100,chat.summary 多 136,000,search.rerank 少 5,500。
每个视角相加都等于 296,600。这不是四个互相独立的结论,而是同一笔钱的四种解释。把租户、模型、能力、路由四个视角的“贡献”相加,会得到 118.64 万,是总差异的四倍。
规则很简单:同一层级内可以相加,不同视角之间不能相加。看板可以并排展示多个视角,但不能做“总贡献 = 租户 + 模型 + 能力”的汇总。
从模型走到可行动维度
| 维度 | 回答的问题 | 责任团队 | 可动的杠杆 |
|---|---|---|---|
| 租户 | 谁该为这笔钱负责 | 商务与配额 | 定价、套餐、配额与结算 |
| 能力 | 哪个产品功能在烧钱 | 产品 | 能力合并、降级或下线 |
| 模型 | 钱花在哪个执行档位 | 模型平台 | 选型与路由目标模型 |
| 提示版本 | 同一能力为何单次变贵 | 提示工程 | 压缩上下文、修改提示 |
| 路由 | 为什么流量去了更贵的档 | 网关 | 升级阈值与降级顺序 |
模型视角解释力最强,却最不可行动。平台不能简单“把标准档关掉”,那会直接改变质量。提示版本与路由才是能在一周内改动、又能解释清楚代价的维度。
示例里标准档多出的 237,000 微元,可以拆成两个互不重叠的格组:升级路由 101,000 与摘要能力 136,000。升级路由有 110,000 次调用,单次成本从 1.8 涨到 1.9;它的调用占比从 5.45% 升到 8.98%,同时每次更贵。把这两个变化分开,才能决定是收紧升级阈值,还是接受成本换质量。
量、结构、单价:把 37% 拆成三块
三因子分解把总差异拆成三部分。设基线调用量为 Q0、成本为 C0,当期调用量为 Q1、成本为 C1,每个格的单次成本为 r、调用占比为 s:
volume = (Q1 - Q0) × C0 / Q0
mix = Q1 × Σ (s1 - s0) × r0
rate = (C1 - C0) - volume - mix
volume 是业务量变化,mix 是流量在不同单价格之间的结构变化,rate 是同一格内单次成本的变化。三项相加必须等于总差异;最后一项吸收取整,示例样本的取整调整是 0。
分解前必须先选 grain。示例用“租户 + 能力 + 模型 + 路由”,故意不把 promptVersion 放进去。放进去后,提示从 v12 改到 v13 会变成“旧格下线、新格上线”,全部成本差会被误判成 rate,mix 失去意义。grain 是分析假设,必须在报告里写出来。
示例样本的结果是:量 +89,750(30.3%),结构 +82,250(27.7%),单价 +124,600(42.0%)。单价项可以继续落到具体改动:v13 在轻量档上每次多 0.08 微元,乘 620,000 次是 49,600;v13 在标准档上每次多 0.1 微元,乘 110,000 次是 11,000;v6 让摘要每次多 0.4 微元,乘 160,000 次是 64,000。三项合计正好 124,600。
示例样本上的归因结果
| 视角 | 取值与差异(微元) |
|---|---|
| 租户 | A +163,000;B +130,500;C +3,100 |
| 能力 | chat.reply +166,100;chat.summary +136,000;search.rerank -5,500 |
| 模型 | 标准档 +237,000;轻量档 +59,600 |
| 路由 | primary +195,600;escalate +101,000 |
| 提示版本 | v13 +643,000;v12 -480,000;v6 +256,000;v5 -120,000;v7 -5,500;v9 +3,100 |
提示版本视角的正负号最有信息量:v13 与 v6 是成本上升项,v12 与 v5 的负值只是被替换,不是“省了钱”。看板如果只展示正数,会漏掉改版的真实代价。
实现
下面的模块只做三件事:格级全外连接、单维度汇总、量/结构/单价分解。它不读数据库,也不发请求。
// file: src/perf/attribution.ts
// tier: L1
// verify: node --test test/perf/attribution.test.ts
import { AiError } from "../core/errors.ts";
/** 模型解释“钱花在谁身上”,提示版本与路由解释“能不能改”。 */
export type DimensionName = "tenant" | "capability" | "promptVersion" | "model" | "route";
export const DIMENSIONS: readonly DimensionName[] = ["tenant", "capability", "promptVersion", "model", "route"];
export interface CostCell {
tenant: string;
capability: string;
promptVersion: string;
model: string;
route: string;
calls: number;
costMicros: number;
}
export interface CostPeriod {
label: string;
cells: readonly CostCell[];
}
export interface CellDelta {
tenant: string;
capability: string;
promptVersion: string;
model: string;
route: string;
baselineCalls: number;
currentCalls: number;
baselineMicros: number;
currentMicros: number;
deltaMicros: number;
deltaCalls: number;
status: "matched" | "new" | "retired";
}
export interface DimensionView {
value: string;
baselineCalls: number;
currentCalls: number;
baselineMicros: number;
currentMicros: number;
deltaMicros: number;
/** 该取值对总差异的贡献比例;总差异为 0 时记为 0。 */
shareOfDelta: number;
}
export interface Decomposition {
grain: readonly DimensionName[];
baselineCalls: number;
currentCalls: number;
baselineMicros: number;
currentMicros: number;
deltaMicros: number;
volumeMicros: number;
mixMicros: number;
/** 同一格内单次成本变化;最后一项吸收取整。 */
rateMicros: number;
roundingAdjustmentMicros: number;
}
/** 可行动维度:每个取值都要能落到一个团队和一根杠杆上。 */
export const DIMENSION_ACTION: Record<DimensionName, { owner: string; lever: string }> = {
tenant: { owner: "商务与配额", lever: "定价、套餐、配额与结算规则" },
capability: { owner: "产品", lever: "能力合并、降级或下线" },
promptVersion: { owner: "提示工程", lever: "压缩上下文、修改提示与输出约束" },
model: { owner: "模型平台", lever: "选型与路由目标模型" },
route: { owner: "网关", lever: "升级阈值、路由策略与降级顺序" },
};
function fail(message: string): never {
throw new AiError("invalid-request", `attribution: ${message}`);
}
function text(value: string, field: string): string {
if (typeof value !== "string" || value.trim() === "") fail(`${field} is required`);
return value;
}
function count(value: number, field: string): number {
if (!Number.isInteger(value) || value < 0) fail(`${field} must be a non-negative integer`);
return value;
}
function keyOf(cell: Pick<CostCell, DimensionName>): string {
return DIMENSIONS.map((dimension) => cell[dimension]).join("\u0000");
}
function validateCell(cell: CostCell): void {
for (const dimension of DIMENSIONS) text(cell[dimension], dimension);
count(cell.calls, "calls");
count(cell.costMicros, "costMicros");
if (cell.calls === 0 && cell.costMicros !== 0) fail("a cell with zero calls must have zero cost");
}
/** 同一账期内同一个维度组合只能出现一次,重复意味着上游已经聚合错了。 */
export function indexCells(period: CostPeriod): Map<string, CostCell> {
text(period.label, "period.label");
const cells = new Map<string, CostCell>();
for (const cell of period.cells) {
validateCell(cell);
const key = keyOf(cell);
if (cells.has(key)) fail(`${period.label}: duplicate cell ${key.replaceAll("\u0000", "/")}`);
cells.set(key, { ...cell });
}
return cells;
}
/** 全外连接求差:新增格与下线格都保留,差异才能与总账对上。 */
export function deltaRows(baseline: CostPeriod, current: CostPeriod): CellDelta[] {
const before = indexCells(baseline);
const after = indexCells(current);
const keys = [...new Set([...before.keys(), ...after.keys()])].sort();
const rows = keys.map((key): CellDelta => {
const left = before.get(key);
const right = after.get(key);
const source = right ?? left;
if (source === undefined) fail(`missing cell ${key}`);
const baselineCalls = left?.calls ?? 0;
const currentCalls = right?.calls ?? 0;
const baselineMicros = left?.costMicros ?? 0;
const currentMicros = right?.costMicros ?? 0;
return {
tenant: source.tenant,
capability: source.capability,
promptVersion: source.promptVersion,
model: source.model,
route: source.route,
baselineCalls,
currentCalls,
baselineMicros,
currentMicros,
deltaMicros: currentMicros - baselineMicros,
deltaCalls: currentCalls - baselineCalls,
status: left === undefined ? "new" : right === undefined ? "retired" : "matched",
};
});
return rows.sort((left, right) => {
const byDelta = Math.abs(right.deltaMicros) - Math.abs(left.deltaMicros);
return byDelta !== 0 ? byDelta : keyOf(left).localeCompare(keyOf(right));
});
}
/**
* 按单个维度汇总。每个视角都能把总差异解释 100%,
* 但不同视角之间不能相加,否则同一笔钱会被重复计算。
*/
export function viewBy(rows: readonly CellDelta[], dimension: DimensionName): DimensionView[] {
if (!DIMENSIONS.includes(dimension)) fail(`unknown dimension ${dimension}`);
const buckets = new Map<string, DimensionView>();
for (const row of rows) {
const value = row[dimension];
const bucket = buckets.get(value);
if (bucket === undefined) {
buckets.set(value, {
value,
baselineCalls: row.baselineCalls,
currentCalls: row.currentCalls,
baselineMicros: row.baselineMicros,
currentMicros: row.currentMicros,
deltaMicros: row.deltaMicros,
shareOfDelta: 0,
});
} else {
bucket.baselineCalls += row.baselineCalls;
bucket.currentCalls += row.currentCalls;
bucket.baselineMicros += row.baselineMicros;
bucket.currentMicros += row.currentMicros;
bucket.deltaMicros += row.deltaMicros;
}
}
const totalDelta = rows.reduce((sum, row) => sum + row.deltaMicros, 0);
const views = [...buckets.values()];
for (const view of views) view.shareOfDelta = totalDelta === 0 ? 0 : view.deltaMicros / totalDelta;
return views.sort((left, right) => {
const byDelta = Math.abs(right.deltaMicros) - Math.abs(left.deltaMicros);
return byDelta !== 0 ? byDelta : left.value.localeCompare(right.value);
});
}
function grainIndex(period: CostPeriod, grain: readonly DimensionName[]): Map<string, { calls: number; costMicros: number }> {
const buckets = new Map<string, { calls: number; costMicros: number }>();
for (const cell of period.cells) {
validateCell(cell);
const key = grain.map((dimension) => cell[dimension]).join("\u0000");
const bucket = buckets.get(key);
if (bucket === undefined) buckets.set(key, { calls: cell.calls, costMicros: cell.costMicros });
else {
bucket.calls += cell.calls;
bucket.costMicros += cell.costMicros;
}
}
return buckets;
}
/**
* 量、结构、单价三因子分解。grain 必须稳定:
* 把 promptVersion 放进去,提示改版会被误判成“新格”,全部差异落到单价项。
*/
export function decompose(
baseline: CostPeriod,
current: CostPeriod,
grain: readonly DimensionName[],
): Decomposition {
if (grain.length === 0) fail("grain must not be empty");
if (new Set(grain).size !== grain.length) fail("grain must not contain duplicates");
for (const dimension of grain) if (!DIMENSIONS.includes(dimension)) fail(`unknown dimension ${dimension}`);
const before = grainIndex(baseline, grain);
const after = grainIndex(current, grain);
const keys = [...new Set([...before.keys(), ...after.keys()])].sort();
let baselineCalls = 0;
let currentCalls = 0;
let baselineMicros = 0;
let currentMicros = 0;
for (const key of keys) {
baselineCalls += before.get(key)?.calls ?? 0;
currentCalls += after.get(key)?.calls ?? 0;
baselineMicros += before.get(key)?.costMicros ?? 0;
currentMicros += after.get(key)?.costMicros ?? 0;
}
if (baselineCalls === 0 || currentCalls === 0) fail("both periods must contain at least one call");
const deltaMicros = currentMicros - baselineMicros;
const rawVolume = (currentCalls - baselineCalls) * (baselineMicros / baselineCalls);
let rawMix = 0;
for (const key of keys) {
const left = before.get(key);
const right = after.get(key);
if (left === undefined || right === undefined) continue;
const shareBefore = left.calls / baselineCalls;
const shareAfter = right.calls / currentCalls;
rawMix += (shareAfter - shareBefore) * (left.costMicros / left.calls);
}
rawMix *= currentCalls;
const volumeMicros = Math.round(rawVolume);
const mixMicros = Math.round(rawMix);
const rateMicros = deltaMicros - volumeMicros - mixMicros;
return {
grain: [...grain],
baselineCalls,
currentCalls,
baselineMicros,
currentMicros,
deltaMicros,
volumeMicros,
mixMicros,
rateMicros,
roundingAdjustmentMicros: rateMicros - Math.round(deltaMicros - rawVolume - rawMix),
};
}
decompose 的 grain 是必填参数,不设默认值。默认值会让调用方在不写假设的情况下得到一个看似权威的数字,而错误往往就出在假设里。
测试与运行结果
测试覆盖格级相加、五个视角、三因子恒等、grain 选择、新增/下线格与非法输入。执行:
node --test test/perf/attribution.test.ts
本机一次运行的真实输出摘要如下,耗时随机器变化:
ℹ tests 9
ℹ pass 9
ℹ fail 0
ℹ duration_ms 146.558333
测试里专门断言了 volume + mix + rate = delta,以及把 promptVersion 放进 grain 后 rate 会显著变大。后者不是“另一种算法”,而是一个反例:粒度选错,归因就会把改版伪装成单价变化。
与卷一 12.2 的衔接
卷一 12.2 计量、账单与配额对账
定义了用量事件与对账口径:谁用的、什么时候用的、用了多少、该付多少。本节在这些字段上增加 capability、promptVersion 与 route 三个归因维度,并要求每个维度都能落到责任团队。
没有卷一的事件字段,本节连格都建不起来;只有卷一的事件字段,账单仍然只能回答“哪个模型贵”,回答不了“为什么贵、谁能改”。字段是前提,归因是下一步。
小结
先把成本格做成可精确相加的全外连接,再按单个维度汇总。每个视角都能解释 100%,但不同视角不能相加。模型视角解释力强却不可行动,提示版本与路由才是可以改的杠杆。量、结构、单价三因子必须选稳定的 grain,并把 grain 写进报告。
下一节处理最后一段:内部账与供应商账单为什么会对不上,差异如何分类、解释与闭环。
阅读导航:上一节:9.1 成本模型与隐形成本 · 下一节:9.3 账单对账与差异分析 。
常见错误与边界
- 用内连接对账。新增与下线的格被丢掉,差异永远对不上。
- 把多个维度的贡献相加。同一笔钱被重复计算几次。
- 只按模型归因。模型是执行结果,不是可行动的改动。
- 把提示改版放进
grain。改版被误判成新格,单价项虚高。 - 不写
grain假设。换一个粒度就换一个结论,报告无法复现。 - 只看正数差异。被替换的旧版本负值是理解改版代价的另一半。
- 用调用占比代替成本占比。便宜模型占 90% 的调用,可能只占 50% 的成本。
- 归因到团队却不给杠杆。没有可改参数的归因只是一份好看的报表。
- 把归因结果直接当预算。归因解释过去,预算约束未来,两者口径不同。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。