《互联网产品的 AI 架构·推理性能与成本工程》5.1 结果缓存的经济性判据

命中率 57.5% 的示例样本里,仍有一个能力每 100 次请求净亏 578 微元。本节把命中率换成净收益公式,逐项拆开查找、存储、失效与误命中成本,给出可复算的盈亏平衡命中率与亏损判据,并用 7 项测试验证 600 条样本上的每一个数字。

一次「命中率不错」的缓存评审

「回声」上线结果缓存一个月后,团队开了一次评审。看板上的整体命中率是 57.5%,负责人认为「省了一半的钱」,准备把缓存推广到所有能力。

财务同事问了一个更朴素的问题:省下来的钱,减去缓存自己的开销,还剩多少?把 600 条示例请求按能力拆开重算后,答案并不均匀:chat.reply 与 search.summary 都在赚钱,ticket.classify 每 100 次请求净亏 578 微元。整体仍然是赚的,但「整体赚」不能证明「每个能力都该开缓存」。如果只按整体命中率做推广,团队会把一个亏损能力复制到更多场景。

本节讨论的就是这个判断:缓存值不值得开。文中金额、命中数与比例均来自 ai-lab 的示例样本运行结果,不是任何真实产品的生产实测;成本一律用整数微元,延迟与吞吐不在本节口径内。

命中率不是收益

命中率回答「有多少请求没有调用模型」,它不回答「省了多少钱」。两者之间至少差三件事。

第一,请求的生成成本不同。一次长摘要可能要 260 微元,一次分类只要 40 微元,它们在请求级命中率里权重相同。第二,命中也要付服务成本:取回结果、反序列化、传输,语义命中还要付相似度计算。第三,未命中不只损失一次生成,还要付查找成本;而缓存自身的存储与失效,则在每个统计窗口里持续发生。

因此「命中率 57.5%」既可能是一门好生意,也可能是一门用低价请求撑起命中数、再用高价请求付账单的坏生意。要判断,必须把命中按金额加权。

净收益公式

设一个能力在窗口内有 N 次请求、H 次命中,单次生成成本 Cg,单次命中服务成本 Ch,每次请求的查找成本 Cl,窗口内 E 个条目的存储成本 Cs,失效事件成本 Ci,误命中率 p 与单次误命中代价 Ce。净收益是:

净收益 = H × (Cg − Ch) − N × Cl − E × Cs − 失效次数 × Ci − H × p × Ce

前三项容易理解,后两项常被漏掉。误命中不是「命中率的分母问题」,它是一次真实的错误交付:用户拿到看似合理但错误的答案,可能触发返工、投诉或补偿。把 H × p × Ce 从收益里扣掉,语义缓存才不会被高命中率美化。

公式还可以改写成更常用的判据。把固定开销摊到每次请求,得到盈亏平衡命中率:

命中率* = (Cl + (E × Cs + 失效次数 × Ci) / N) / (Cg − Ch − p × Ce)

命中率低于这个值,缓存一定亏;高于它,也只是「开始赚钱」,还要看赚多少。

缓存的三种成本

存储是最容易被看见的成本,却不是唯一成本。示例样本上,三项开销分别是查找 2,200 微元、存储 1,385 微元、失效 580 微元,合计 4,165 微元,占毛收益 51,855 微元的 8.03%。

成本触发时机容易被忽略的原因
查找每次请求,命中与未命中都付它发生在「省钱的生成」之前,账单上不显眼
存储按条目数与保留时长持续发生内存看起来是固定的,扩容时才被看见
失效每次版本推进、权限变更、删除权传播它同时产生传播成本与「误删后重建」成本

查找成本决定了「便宜但命中率低」的缓存可能不如「贵但命中率高」的缓存;存储成本决定了条目该留多久;失效成本决定了版本与权限的粒度。三者都不是「缓存命中后才有」的成本,所以不能只从节省里按比例抽成。

示例样本:三个能力的收支

600 条示例请求按能力汇总后,代入上面的公式得到:

能力请求命中命中率毛收益查找+存储+失效误命中净收益结论
chat.reply30024080.00%26,8801,0202,16023,700赚钱
search.summary20010050.00%24,8002,4004,50017,900赚钱
ticket.classify10055.00%1757458−578亏损
合计60034557.50%51,8554,1656,66841,022整体赚钱

整体净收益 41,022 微元,相当于把无缓存时的 92,000 微元降到 50,978 微元,成本下降 44.59%。这个数字比命中率 57.50% 低,差额正是缓存自身的开销与误命中。

ticket.classify 的问题不是「命中率低」这一句话能概括的。它的单次生成只要 40 微元,而每次请求的查找要 4 微元、95 个条目的存储要 285 微元;即使命中,服务成本也要 5 微元。省下的 175 微元覆盖不了 745 微元的固定开销。

盈亏平衡命中率

把三个能力分别代入公式:

能力每请求固定开销每次命中净省盈亏平衡命中率实际命中率判断
chat.reply3.40103.003.30%80.00%远超门槛
search.summary12.00203.005.91%50.00%远超门槛
ticket.classify7.4533.5022.24%5.00%不足门槛

这张表解释了为什么「低命中率」本身不是罪名:chat.reply 只要有 3.3% 的命中率就不亏,ticket.classify 却需要 22.24%。差异来自分母:生成越便宜、命中服务越贵、误命中代价越高,每次命中能净省的钱越少,固定开销就越难摊薄。

反过来,如果一个能力的 Cg − Ch − p × Ce 小于等于零,盈亏平衡命中率是无穷大:无论命中多少都救不回来。语义缓存把 Ce 估得太低,最容易制造这种局面。

什么情况下缓存反而亏

把上面的账归纳成六条判据,任意一条成立都要重新算:

  • 生成本身很便宜,命中服务与查找却按请求计费,固定开销摊不薄。
  • 复用间隔大于条目寿命,条目在第二次读之前就过期或被淘汰。
  • 语义误命中代价高,而单次命中省下的钱不足以覆盖期望错误成本。
  • 条目唯一率高,存储按唯一键增长,命中却集中在少数键上。
  • 失效频繁且粒度粗,一次版本推进误删大量仍有效的结果。
  • 租户或能力切得太碎,每份缓存都装不满,命中率被容量碎片吃掉。

最后一条在 5.2 用容量曲线展开。本节先给一个更细的判据:单条目边际收益。

条目边际收益 = 期望复用次数 × (Cg − Ch) − 条目存储成本

一个条目如果只被再读一次,省下的钱可能还不够它占用的存储;如果一次都不会被再读,它从写入那一刻起就是负债。整体命中率会把「写一次读一次」算成 100% 命中,但经济上它可能只是把一次生成换成了另一次更贵的查找。

与卷一第 9 章的分工

卷一 9.1 回答「键怎么设计」:作用域五项、规范化、精确与语义的边界、单飞。9.2 回答「失效怎么传播」:版本纪元、权限分区、用户删除权。9.3 回答「命中率怎么度量」:请求级、token 级、成本级三个口径。本节不重复这些机制,只回答一个卷一没有回答的问题:在这些机制都正确的前提下,这笔缓存生意值不值得做。键设计决定「能不能复用」,经济性判据决定「该不该复用」。两者都成立,缓存才应该上线;只满足前者,团队会得到一套正确但亏钱的缓存。

实现

下面的模块只做经济核算:输入是一组按能力汇总的请求、命中、成本与误命中数据,输出净收益、盈亏平衡命中率与亏损能力清单。它不访问缓存,也不读取日志;所有金额都是整数微元。

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

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

/** 一个能力在统计窗口内的缓存收支,金额一律是整数微元。 */
export interface CapabilityCacheEcon {
  capability: string;
  requests: number;
  hits: number;
  /** 一次未命中原本要花的生成成本。 */
  generationMicros: number;
  /** 一次命中从缓存取回并传输的成本。 */
  hitServeMicros: number;
  /** 每次请求都要付的查找成本,命中与未命中都算。 */
  lookupMicros: number;
  /** 窗口内保留的条目数。 */
  entries: number;
  /** 每个条目在窗口内的存储摊销。 */
  storageMicrosPerEntry: number;
  invalidationEvents: number;
  invalidationMicrosPerEvent: number;
  /** 语义命中里被判定为错误的比例。 */
  falseHitRate: number;
  /** 一次误命中的返工、投诉与补偿成本。 */
  falseHitCostMicros: number;
}

export interface CacheCostBreakdown {
  lookupMicros: number;
  storageMicros: number;
  invalidationMicros: number;
  totalMicros: number;
}

export interface CapabilityCacheReport {
  capability: string;
  requests: number;
  hits: number;
  hitRate: number;
  grossBenefitMicros: number;
  overhead: CacheCostBreakdown;
  falseHitCostMicros: number;
  netBenefitMicros: number;
  netPerRequestMicros: number;
  /** 每请求固定开销除以“每次命中净省”,低于它缓存必亏。 */
  breakEvenHitRate: number;
  /** 净收益占毛收益的比例,用来区分“赚一点”和“值得做”。 */
  marginRatio: number;
  verdict: "profitable" | "marginal" | "loses";
}

export interface CacheEconomicsReport {
  requests: number;
  hits: number;
  hitRate: number;
  grossBenefitMicros: number;
  overhead: CacheCostBreakdown;
  falseHitCostMicros: number;
  netBenefitMicros: number;
  netPerRequestMicros: number;
  noCacheMicros: number;
  withCacheMicros: number;
  costReduction: number;
  byCapability: CapabilityCacheReport[];
  losingCapabilities: string[];
}

function fail(message: string): never {
  throw new AiError("invalid-request", `cache-econ: ${message}`);
}

function requireInteger(value: number, field: string, min: number): number {
  if (!Number.isInteger(value) || value < min) fail(`${field} 必须是 >= ${min} 的整数`);
  return value;
}

function requireRatio(value: number, field: string): number {
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
    fail(`${field} 必须落在 [0, 1]`);
  }
  return value;
}

function validate(item: CapabilityCacheEcon): void {
  if (item.capability.trim() === "") fail("capability 不能为空");
  requireInteger(item.requests, "requests", 1);
  requireInteger(item.hits, "hits", 0);
  if (item.hits > item.requests) fail(`${item.capability}: hits 不能大于 requests`);
  requireInteger(item.generationMicros, "generationMicros", 0);
  requireInteger(item.hitServeMicros, "hitServeMicros", 0);
  requireInteger(item.lookupMicros, "lookupMicros", 0);
  requireInteger(item.entries, "entries", 0);
  requireInteger(item.storageMicrosPerEntry, "storageMicrosPerEntry", 0);
  requireInteger(item.invalidationEvents, "invalidationEvents", 0);
  requireInteger(item.invalidationMicrosPerEvent, "invalidationMicrosPerEvent", 0);
  requireRatio(item.falseHitRate, "falseHitRate");
  requireInteger(item.falseHitCostMicros, "falseHitCostMicros", 0);
}

/**
 * 盈亏平衡命中率 = 每请求固定开销 / 每次命中的净节省。
 * 分母里扣掉命中服务成本与期望误命中成本,所以“命中率高”本身不构成理由。
 */
export function breakEvenHitRate(item: CapabilityCacheEcon): number {
  validate(item);
  const fixedPerRequest =
    item.lookupMicros +
    (item.entries * item.storageMicrosPerEntry + item.invalidationEvents * item.invalidationMicrosPerEvent) /
      item.requests;
  const savingPerHit =
    item.generationMicros - item.hitServeMicros - item.falseHitRate * item.falseHitCostMicros;
  if (savingPerHit <= 0) return Number.POSITIVE_INFINITY;
  return fixedPerRequest / savingPerHit;
}

/** 单个能力的缓存账:毛收益减查找、存储、失效与误命中,剩下的才是净收益。 */
export function evaluateCapabilityCache(item: CapabilityCacheEcon): CapabilityCacheReport {
  validate(item);
  const grossBenefitMicros = item.hits * (item.generationMicros - item.hitServeMicros);
  const overhead: CacheCostBreakdown = {
    lookupMicros: item.requests * item.lookupMicros,
    storageMicros: item.entries * item.storageMicrosPerEntry,
    invalidationMicros: item.invalidationEvents * item.invalidationMicrosPerEvent,
    totalMicros: 0,
  };
  overhead.totalMicros =
    overhead.lookupMicros + overhead.storageMicros + overhead.invalidationMicros;
  const falseHitCostMicros = Math.round(item.hits * item.falseHitRate * item.falseHitCostMicros);
  const netBenefitMicros = grossBenefitMicros - overhead.totalMicros - falseHitCostMicros;
  const marginRatio = grossBenefitMicros === 0 ? 0 : netBenefitMicros / grossBenefitMicros;
  return {
    capability: item.capability,
    requests: item.requests,
    hits: item.hits,
    hitRate: item.hits / item.requests,
    grossBenefitMicros,
    overhead,
    falseHitCostMicros,
    netBenefitMicros,
    netPerRequestMicros: netBenefitMicros / item.requests,
    breakEvenHitRate: breakEvenHitRate(item),
    marginRatio,
    verdict: netBenefitMicros < 0 ? "loses" : marginRatio < 0.2 ? "marginal" : "profitable",
  };
}

function sumOverhead(reports: readonly CapabilityCacheReport[]): CacheCostBreakdown {
  const total = reports.reduce(
    (acc, report) => ({
      lookupMicros: acc.lookupMicros + report.overhead.lookupMicros,
      storageMicros: acc.storageMicros + report.overhead.storageMicros,
      invalidationMicros: acc.invalidationMicros + report.overhead.invalidationMicros,
      totalMicros: acc.totalMicros + report.overhead.totalMicros,
    }),
    { lookupMicros: 0, storageMicros: 0, invalidationMicros: 0, totalMicros: 0 },
  );
  return total;
}

/** 汇总时先算每个能力的净收益再相加,避免用整体命中率掩盖某个亏损能力。 */
export function evaluateCacheEconomics(
  items: readonly CapabilityCacheEcon[],
): CacheEconomicsReport {
  if (items.length === 0) fail("没有能力样本,无法评估缓存经济性");
  const byCapability = items.map(evaluateCapabilityCache);
  const requests = byCapability.reduce((sum, report) => sum + report.requests, 0);
  const hits = byCapability.reduce((sum, report) => sum + report.hits, 0);
  const grossBenefitMicros = byCapability.reduce((sum, report) => sum + report.grossBenefitMicros, 0);
  const falseHitCostMicros = byCapability.reduce((sum, report) => sum + report.falseHitCostMicros, 0);
  const netBenefitMicros = byCapability.reduce((sum, report) => sum + report.netBenefitMicros, 0);
  const noCacheMicros = items.reduce(
    (sum, item) => sum + item.requests * item.generationMicros,
    0,
  );
  return {
    requests,
    hits,
    hitRate: hits / requests,
    grossBenefitMicros,
    overhead: sumOverhead(byCapability),
    falseHitCostMicros,
    netBenefitMicros,
    netPerRequestMicros: netBenefitMicros / requests,
    noCacheMicros,
    withCacheMicros: noCacheMicros - netBenefitMicros,
    costReduction: noCacheMicros === 0 ? 0 : netBenefitMicros / noCacheMicros,
    byCapability,
    losingCapabilities: byCapability
      .filter((report) => report.verdict === "loses")
      .map((report) => report.capability),
  };
}

/**
 * 命中的复用窗口必须先覆盖“写入 + 失效传播 + 下一次读到”的时间。
 * 返回窗口内可被复用的次数;小于 1 表示条目活不到下一次命中。
 */
export function reuseWindowHits(reuseIntervalMs: number, entryLifetimeMs: number): number {
  if (!Number.isFinite(reuseIntervalMs) || reuseIntervalMs <= 0) {
    fail("reuseIntervalMs 必须是正有限数");
  }
  if (!Number.isFinite(entryLifetimeMs) || entryLifetimeMs < 0) {
    fail("entryLifetimeMs 必须是非负有限数");
  }
  return Math.floor(entryLifetimeMs / reuseIntervalMs);
}

/**
 * 单个条目的边际收益:被再读一次省下的生成成本,减去它占用的存储。
 * 用来判断“这条结果值不值得留”,而不是只看整体命中率。
 */
export function entryMarginalMicros(
  generationMicros: number,
  hitServeMicros: number,
  storageMicrosPerEntry: number,
  expectedReuses: number,
): number {
  requireInteger(generationMicros, "generationMicros", 0);
  requireInteger(hitServeMicros, "hitServeMicros", 0);
  requireInteger(storageMicrosPerEntry, "storageMicrosPerEntry", 0);
  if (!Number.isFinite(expectedReuses) || expectedReuses < 0) {
    fail("expectedReuses 必须是非负有限数");
  }
  return (
    expectedReuses * (generationMicros - hitServeMicros) - storageMicrosPerEntry
  );
}

evaluateCapabilityCache 把毛收益、三项开销与误命中分开返回,是为了让评审能指出「亏在哪一项」,而不是只看到一个负数。breakEvenHitRate 单独导出,方便在需求评审阶段先用估算值做一次门槛判断。

verdict 用净收益是否为正分三档,而不是只看盈亏平衡。净收益为正但利润率低于 20% 的能力标为 marginal:它可以继续跑,但不应该被当成成功案例推广。利润率的分界是策略值,团队可以调整,但必须和样本一起版本化。

测试与运行结果

测试覆盖整体核算、分能力核算、盈亏平衡、误命中吃光分母、复用窗口、条目边际收益与非法输入。执行:

node --test test/perf/cache-econ.test.ts

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

ℹ tests 7
ℹ pass 7
ℹ fail 0
ℹ duration_ms 184.97075

示例样本的 600 条请求是固定的:同样的输入在任何机器上都得到 41,022 微元净收益与 44.59% 的成本下降。这不是为了让数字好看,而是为了让下一次调整(换模型、改 TTL、调语义阈值)能与这一版逐项对比。

落地顺序

第一步,先把「命中」按能力与金额加权,得到每个能力的毛收益。第二步,把查找、存储、失效三项开销按实际发生率填进去,得到净收益。第三步,算盈亏平衡命中率,与实际命中率比较,决定开、关或只开精确层。顺序不能反:先开缓存再补账,团队会倾向于为已经上线的功能寻找合理化数字;先算账再上线,亏损能力在第一天就会被拦住。

小结

结果缓存的经济性由净收益决定,而不是命中率。净收益等于命中次数乘每次命中的净省,再减去查找、存储、失效与误命中成本;盈亏平衡命中率把这些固定开销摊回每次命中。示例样本上整体成本下降 44.59%,但 ticket.classify 净亏 578 微元,说明「整体赚」不能替代分能力核算。

下一节讨论钱应该花在哪一层:本地、共享与语义缓存的容量与查找成本不同,同一个能力放到不同层,净收益会完全不同。

阅读导航:上一节:4.3 生成长度的非线性成本 · 下一节:5.2 缓存分层与容量 。

常见错误与边界

  • 用请求级命中率代替成本级收益。长请求与短请求在命中率里权重相同,在账单上不是。
  • 只算存储,不算查找与失效。后两项按请求或按事件发生,往往比存储更贵。
  • 把误命中当成「少量误差」。它是一次真实错误交付,必须按代价从收益里扣。
  • 用整体净收益掩盖亏损能力。一个能力亏钱,推广后亏损会按流量放大。
  • 条目只写不读也算命中。写一次读一次的条目,经济上可能只是把生成换成了查找。
  • 盈亏平衡命中率不随模型价格更新。模型降价后门槛会变,旧结论不能直接复用。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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