《互联网产品的 AI 架构·推理性能与成本工程》9.1 成本模型与隐形成本

「回声」的模型账单比 token 单价乘出来的数字高三成,团队却只盯着单价。本节把重试、缓存未命中、失败请求、空闲容量与跨区传输拆成可核对的实际成本与计划偏差,给出整数微元的成本模型、每千次成功请求口径,并用示例样本上的真实测试输出说明隐藏成本藏在哪里。

账单比乘法算出来的多三成

「回声」的 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 成本内。
  • 失败请求记零成本。已经处理的输入不会因为超时而退回。
  • 用业务请求数摊成本。没有拿到结果的请求也应该进入容量口径。
  • 用平均利用率掩盖峰值预留。预留按时间付费,不按平均用量付费。
  • 跨区传输只算请求体。响应、日志、副本和备份都会计费。
  • 用浮点数累加金额。几千行之后,几厘钱的差异会变成对账噪声。
  • 只做月度总账。没有每千次成功请求口径,就无法比较不同模型与不同版本。
  • 把计划值当成实际值。计划值只能用来算偏差,不能拿来报销。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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