账单比乘法算出来的多三成
「回声」的 AI 摘要功能上线第二个月,财务把模型账单发到群里:这个月比预算高了约 35%。值班同学打开自己的估算表,算法很简单:成功请求 98,000 次,每次输入 2,000 token、输出 300 token,输入 250 微元/百万 token、输出 1,000 微元/百万 token,乘出来是 60,760 微元。内部成本看板却是 82,000 微元(含预留容量与跨区传输;供应商账单只覆盖其中的 token 与部分传输项)。
他第一反应是价格表过期了。核对合同后发现单价没变。多出来的 21,240 微元里,有一部分是失败请求已经消耗的输入,有一部分是预留了却没用满的容量,还有一部分是跨区复制的日志。它们都不在“token 单价 × token 数”这条公式里。
本节把这些钱逐项拆开。所有金额都来自示例样本上的运行结果,不是生产账单,也不是线上实测。
显性成本与四类实际隐藏成本
先把口径定清楚。显性成本是成功请求的 token 费用;隐藏成本是实际发生、但不包含在成功 token 行里的费用。二者相加才是这个账期的实际总成本。
| 成本项 | 是否随 token 线性变化 | 典型来源 | 常见漏记方式 |
|---|---|---|---|
| 显性 token | 是 | 成功请求的输入与输出 | 只按全价输入算,漏掉缓存折扣 |
| 失败尝试 | 是 | 已处理输入后超时、限流 | 异常分支直接记零 |
| 重复成功 | 是 | 客户端重试,两次都成功 | 只按业务请求数计费 |
| 空闲容量 | 否 | 峰值预留、专属吞吐 | 只算实际用量 |
| 跨区传输 | 否 | 请求/响应复制、日志外送 | 只算请求体,不算副本与日志 |
示例样本的账是这样:显性 60,760 微元,失败尝试 2,240 微元,空闲容量 7,000 微元,跨区传输 12,000 微元,实际总成本 82,000 微元。隐藏部分 21,240 微元,占 25.9%。每千次成功请求 837 微元,而只算显性 token 是 620 微元。
重试与失败:同一笔业务请求可能付两次
先区分三个数:业务请求 100,000 次,尝试 112,000 次,成功 98,000 次。差的 14,000 次尝试里,7,000 次在网关侧就被拒绝,没有到达提供方;另外 7,000 次已经处理了输入才失败,这部分成本真实发生。
失败尝试的输入成本是 7,000 × 2,000 = 14,000,000 token。按实际 40% 缓存命中率折算,是 2,240 微元。它不会因为结果失败而退回,也不能记成零。
还有一种更隐蔽的情况:提供方已经处理完,客户端在超时后重试,第二次也成功。业务上只有一次请求,计费上却有两次成功。模型里用 successes - logicalRequests 表示重复成功数;示例样本没有触发,但字段必须留着,否则这类问题永远看不见。
判断失败尝试是否计费,关键看它有没有到达提供方。failedBeforeProvider 计零成本,failedAfterInput 按已处理输入计费。把两者混成一个 failures 数字,会让“失败成本”要么虚高,要么被平均掉。
缓存未命中是偏差,不是第二笔费用
缓存最容易算错的地方,是把“未命中多付的钱”再加到总成本上。它已经包含在显性 token 成本里,再加一次就是重复计算。
示例里计划缓存命中率 60%,实际只有 40%。成功请求的输入是 196,000,000 token。多出的 20% 按全价而不是缓存价计费,差额是 39,200,000 × (250 - 25) / 1,000,000 = 8,820 微元。
所以正确写法是:
计划显性 token = 51,940 微元
实际显性 token = 60,760 微元
缓存偏差 = +8,820 微元
计划总成本 = 73,180 微元
实际总成本 = 82,000 微元
实际 - 计划 = +8,820 微元
cacheVarianceMicros 是有符号的,实际命中率高于计划时是负数,表示省了钱。cacheMissMicros 只保留正数部分,给看板单列。两者都不能直接加进实际总成本。
缓存偏差的根因通常不在价格,而在键设计、前缀稳定性或流量结构变化。9.1 只负责把它量化;键怎么建、前缀怎么排,是第 3 章和第 5 章的内容。
空闲容量与跨区传输不随 token 线性变化
预留容量按时间付费,不按调用次数付费。示例预留了 20,000 微元,利用率 65%,空闲 7,000 微元。哪怕当天一个请求都没有,这 20,000 微元也照付。用“平均利用率”摊成本会掩盖峰值:白天打满、夜里空转,平均 65% 看起来健康,实际峰值仍然需要按 100% 准备。
跨区传输按字节和副本数计价。示例传输 600,000,000 字节,单价 20,000 微元/GB,单副本是 12,000 微元。每多一个副本就多一份。常见的漏项是只算请求体,不算响应、日志、向量库同步和备份复制。
这两项的共同点是:它们由容量决策和拓扑决定,不能靠“少调几次模型”线性优化。成本归因时要把它们单独列,否则会被 token 的波动淹没。
成本模型:把实际值与计划值分开
模型输出两组数:实际值和计划值。实际值回答“这个账期到底花了多少”,计划值回答“如果缓存、重试、利用率都按设计运行,本应花多少”。两者的差就是偏差,偏差再按成本项拆开。
attempts = successes + failedAfterInput + failedBeforeProvider
explicit = successInput × (cachedShare × cachedPrice + (1 - cachedShare) × inputPrice)
+ successOutput × outputPrice
hidden = failedToken + duplicateWaste + idle + transfer
total = explicit + hidden
plannedTotal = plannedExplicit + failedToken + duplicateWaste + idle + transfer
cacheVariance = explicit - plannedExplicit
所有金额用整数微元,向上取整只发生在每一行乘法之后。逐 token 取整会把 1 微元的误差放大几万次;最后一次性取整又会让明细与总额对不上。折中做法是每一行(成功输入、成功输出、失败输入、传输)各取整一次,行与行之间可以精确相加。
实现
下面的模块把价格、尝试结构、缓存计划、容量预留与跨区传输放在一起。它不访问账单系统,也不发请求,只做确定性计算。
// file: src/perf/cost.ts
// tier: L1
// verify: node --test test/perf/cost.test.ts
import { AiError } from "../core/errors.ts";
/** 价格卡统一用微元 / 百万 token,避免在累加过程中引入浮点误差。 */
export interface PriceCard {
inputMicrosPerMTok: number;
outputMicrosPerMTok: number;
/** 缓存命中的输入单价;通常远低于全价输入。 */
cachedInputMicrosPerMTok: number;
}
/** 一次账期内的尝试结构:成功、已产生输入后失败、尚未到达提供方就失败。 */
export interface AttemptMix {
/** 业务请求数,不是尝试数。一个业务请求可能对应多次尝试。 */
logicalRequests: number;
/** 成功并计费的尝试数;可能大于业务请求数,代表重复成功。 */
successes: number;
/** 已经处理了部分或全部输入才失败的尝试,成本真实发生。 */
failedAfterInput: number;
/** 在网关侧就被拒绝的尝试,没有产生提供方成本。 */
failedBeforeProvider: number;
inputTokensPerAttempt: number;
outputTokensPerSuccess: number;
}
export interface CachePlan {
/** 容量与成本预估时假设的缓存命中比例。 */
plannedCachedInputShare: number;
/** 实际发生的缓存命中比例。 */
actualCachedInputShare: number;
}
export interface CapacityPlan {
/** 为峰值预留的容量成本,无论是否被用满都要付。 */
reservedMicros: number;
/** 预留容量的实际利用率,0 到 1。 */
utilization: number;
}
export interface TransferPlan {
/** 跨区传输的字节数,包含请求、响应与日志复制。 */
bytes: number;
microsPerGB: number;
/** 同一份数据写到几个区域;1 表示不复制。 */
replicas: number;
}
export interface CostScenario {
price: PriceCard;
attempts: AttemptMix;
cache: CachePlan;
capacity: CapacityPlan;
transfer: TransferPlan;
}
export interface CostBreakdown {
successInputTokens: number;
successOutputTokens: number;
/** 成功请求的显性 token 成本,已经按实际缓存命中率折算。 */
successTokenMicros: number;
/** 如果缓存命中率达到计划值,成功请求本应付出的 token 成本。 */
plannedSuccessTokenMicros: number;
failedInputTokens: number;
/** 失败尝试已经发生的 token 成本,不会因为结果失败而退回。 */
failedTokenMicros: number;
/** 超过业务请求数的成功尝试,即同一请求被计费了两次。 */
duplicateSuccesses: number;
duplicateWasteMicros: number;
/** 实际减计划的有符号差额;正数表示缓存未命中导致多付。 */
cacheVarianceMicros: number;
/** 只保留多付的部分,便于看板单列;它已经包含在 successTokenMicros 内。 */
cacheMissMicros: number;
/** 预留容量中未被使用的那一部分。 */
idleMicros: number;
transferMicros: number;
explicitMicros: number;
/** 成功 token 之外的实际成本:失败、重复成功、空闲容量与跨区传输。 */
hiddenMicros: number;
totalMicros: number;
/** 按计划缓存命中率推算的总成本;与实际总成本之差即缓存偏差。 */
plannedTotalMicros: number;
costPerThousandSuccessMicros: number;
costPerThousandLogicalMicros: number;
}
function fail(message: string): never {
throw new AiError("invalid-request", `cost: ${message}`);
}
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 ratio(value: number, field: string): number {
if (!Number.isFinite(value) || value < 0 || value > 1) fail(`${field} must be in [0, 1]`);
return value;
}
/** 向上取整只发生在一次乘法之后,避免逐 token 舍入把成本放大。 */
export function tokenMicros(tokens: number, microsPerMTok: number): number {
integer(tokens, "tokens");
integer(microsPerMTok, "microsPerMTok");
if (tokens === 0 || microsPerMTok === 0) return 0;
return Math.ceil((tokens * microsPerMTok) / 1_000_000);
}
/** 输入成本拆成缓存命中与未命中两段,分别套用不同单价。 */
export function cachedInputMicros(inputTokens: number, cachedShare: number, price: PriceCard): number {
integer(inputTokens, "inputTokens");
ratio(cachedShare, "cachedShare");
const cachedTokens = Math.round(inputTokens * cachedShare);
return (
tokenMicros(cachedTokens, price.cachedInputMicrosPerMTok) +
tokenMicros(inputTokens - cachedTokens, price.inputMicrosPerMTok)
);
}
/** 跨区传输按 GB 计价,字节先换算成 GB 再向上取整。 */
export function transferMicros(bytes: number, microsPerGB: number, replicas = 1): number {
integer(bytes, "bytes");
integer(microsPerGB, "microsPerGB");
integer(replicas, "replicas", 1);
if (bytes === 0 || microsPerGB === 0) return 0;
return Math.ceil((bytes * microsPerGB * replicas) / 1_000_000_000);
}
/**
* 成本模型把“显性 token 成本”和四类隐藏成本分开:
* 失败尝试、重复成功、缓存未命中、空闲容量与跨区传输。
* 所有金额都是整数微元,可逐项相加并与账单对齐。
*/
export function estimateCost(scenario: CostScenario): CostBreakdown {
const { price, attempts, cache, capacity, transfer } = scenario;
integer(price.inputMicrosPerMTok, "price.inputMicrosPerMTok");
integer(price.outputMicrosPerMTok, "price.outputMicrosPerMTok");
integer(price.cachedInputMicrosPerMTok, "price.cachedInputMicrosPerMTok");
if (price.cachedInputMicrosPerMTok > price.inputMicrosPerMTok) {
fail("cached input price must not exceed full input price");
}
integer(attempts.logicalRequests, "attempts.logicalRequests", 1);
integer(attempts.successes, "attempts.successes");
integer(attempts.failedAfterInput, "attempts.failedAfterInput");
integer(attempts.failedBeforeProvider, "attempts.failedBeforeProvider");
integer(attempts.inputTokensPerAttempt, "attempts.inputTokensPerAttempt");
integer(attempts.outputTokensPerSuccess, "attempts.outputTokensPerSuccess");
ratio(cache.plannedCachedInputShare, "cache.plannedCachedInputShare");
ratio(cache.actualCachedInputShare, "cache.actualCachedInputShare");
integer(capacity.reservedMicros, "capacity.reservedMicros");
ratio(capacity.utilization, "capacity.utilization");
const successInputTokens = attempts.successes * attempts.inputTokensPerAttempt;
const successOutputTokens = attempts.successes * attempts.outputTokensPerSuccess;
const successTokenMicros =
cachedInputMicros(successInputTokens, cache.actualCachedInputShare, price) +
tokenMicros(successOutputTokens, price.outputMicrosPerMTok);
const failedInputTokens = attempts.failedAfterInput * attempts.inputTokensPerAttempt;
const failedTokenMicros = cachedInputMicros(failedInputTokens, cache.actualCachedInputShare, price);
const duplicateSuccesses = Math.max(0, attempts.successes - attempts.logicalRequests);
const duplicateWasteMicros =
cachedInputMicros(
duplicateSuccesses * attempts.inputTokensPerAttempt,
cache.actualCachedInputShare,
price,
) + tokenMicros(duplicateSuccesses * attempts.outputTokensPerSuccess, price.outputMicrosPerMTok);
const plannedSuccessTokenMicros =
cachedInputMicros(successInputTokens, cache.plannedCachedInputShare, price) +
tokenMicros(successOutputTokens, price.outputMicrosPerMTok);
const cacheVarianceMicros = successTokenMicros - plannedSuccessTokenMicros;
const cacheMissMicros = Math.max(0, cacheVarianceMicros);
const idleMicros = Math.ceil(capacity.reservedMicros * (1 - capacity.utilization));
const transferCostMicros = transferMicros(transfer.bytes, transfer.microsPerGB, transfer.replicas);
const explicitMicros = successTokenMicros;
const hiddenMicros = failedTokenMicros + duplicateWasteMicros + idleMicros + transferCostMicros;
const totalMicros = explicitMicros + hiddenMicros;
return {
successInputTokens,
successOutputTokens,
successTokenMicros,
plannedSuccessTokenMicros,
failedInputTokens,
failedTokenMicros,
duplicateSuccesses,
duplicateWasteMicros,
cacheVarianceMicros,
cacheMissMicros,
idleMicros,
transferMicros: transferCostMicros,
explicitMicros,
hiddenMicros,
totalMicros,
plannedTotalMicros:
plannedSuccessTokenMicros + failedTokenMicros + duplicateWasteMicros + idleMicros + transferCostMicros,
costPerThousandSuccessMicros:
attempts.successes === 0 ? 0 : Math.ceil((totalMicros * 1000) / attempts.successes),
costPerThousandLogicalMicros: Math.ceil(
(totalMicros * 1000) / attempts.logicalRequests,
),
};
}
/** 把总成本按成功请求数摊开,回答“每次可用结果到底花了多少”。 */
export function effectiveCostPerSuccessMicros(breakdown: CostBreakdown): number {
return breakdown.costPerThousandSuccessMicros / 1000;
}
cacheMissMicros 被刻意排除在 hiddenMicros 之外。注释里写得很直白:它已经包含在 successTokenMicros 内。很多成本看板就是在这里把同一笔钱算了两遍。
测试与示例样本运行结果
测试覆盖整数取整、缓存分段计价、传输副本、失败尝试、重复成功、空闲容量、缓存偏差与非法输入。执行:
node --test test/perf/cost.test.ts
本机一次运行的真实输出摘要如下,耗时随机器变化:
ℹ tests 11
ℹ pass 11
ℹ fail 0
ℹ duration_ms 207.575667
示例样本的关键结果:
| 指标 | 数值 |
|---|---|
| 显性 token 成本 | 60,760 微元 |
| 计划显性 token 成本 | 51,940 微元 |
| 失败尝试成本 | 2,240 微元 |
| 缓存偏差 | +8,820 微元 |
| 空闲容量 | 7,000 微元 |
| 跨区传输 | 12,000 微元 |
| 实际总成本 | 82,000 微元 |
| 计划总成本 | 73,180 微元 |
| 每千次成功请求 | 837 微元 |
| 每千次业务请求 | 820 微元 |
每千次成功请求与每千次业务请求差 17 微元,对应的是 2,000 次没有拿到结果的请求。对客报价应该用前者,容量预算应该用后者,两个口径混用会让毛利和容量同时失真。
与卷一 12.2 的分工
卷一 12.2 计量、账单与配额对账
解决的是“事件怎么记”:eventId、requestId、输入输出 token、整数微元、两层幂等键、日切偏移、按租户与模型聚合。本节不重复这些字段,而是在它们之上补一层成本口径:哪些钱不在 token 行里,失败与空闲怎样进入总成本,缓存偏差为什么不能重复相加。
一句话分工:卷一给字段和账本,卷二给成本模型和归因方法。字段不全时先补字段;字段齐了但账单仍然解释不了,才轮到本节和下一节。
小结
实际总成本由显性 token 成本与四类隐藏成本相加得到:失败尝试、重复成功、空闲容量、跨区传输。缓存未命中是显性成本相对计划的偏差,不能作为第二笔费用再加一次。金额统一用整数微元,每行只取整一次;对外用每千次成功请求,对内用每千次业务请求。
下一节把同一笔成本按租户、能力、模型、提示版本和路由决策拆开,回答“哪一项变了,谁能改”。
阅读导航:上一节:8.3 配额与预算的容量映射 · 下一节:9.2 多维归因方法 。
常见错误与边界
- 只乘 token 单价。失败、重试、空闲与传输都不在乘法里。
- 把缓存未命中当成额外一行再加一次。它已经包含在实际 token 成本内。
- 失败请求记零成本。已经处理的输入不会因为超时而退回。
- 用业务请求数摊成本。没有拿到结果的请求也应该进入容量口径。
- 用平均利用率掩盖峰值预留。预留按时间付费,不按平均用量付费。
- 跨区传输只算请求体。响应、日志、副本和备份都会计费。
- 用浮点数累加金额。几千行之后,几厘钱的差异会变成对账噪声。
- 只做月度总账。没有每千次成功请求口径,就无法比较不同模型与不同版本。
- 把计划值当成实际值。计划值只能用来算偏差,不能拿来报销。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。