一次「命中率不错」的缓存评审
「回声」上线结果缓存一个月后,团队开了一次评审。看板上的整体命中率是 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.reply | 300 | 240 | 80.00% | 26,880 | 1,020 | 2,160 | 23,700 | 赚钱 |
| search.summary | 200 | 100 | 50.00% | 24,800 | 2,400 | 4,500 | 17,900 | 赚钱 |
| ticket.classify | 100 | 5 | 5.00% | 175 | 745 | 8 | −578 | 亏损 |
| 合计 | 600 | 345 | 57.50% | 51,855 | 4,165 | 6,668 | 41,022 | 整体赚钱 |
整体净收益 41,022 微元,相当于把无缓存时的 92,000 微元降到 50,978 微元,成本下降 44.59%。这个数字比命中率 57.50% 低,差额正是缓存自身的开销与误命中。
ticket.classify 的问题不是「命中率低」这一句话能概括的。它的单次生成只要 40 微元,而每次请求的查找要 4 微元、95 个条目的存储要 285 微元;即使命中,服务成本也要 5 微元。省下的 175 微元覆盖不了 745 微元的固定开销。
盈亏平衡命中率
把三个能力分别代入公式:
| 能力 | 每请求固定开销 | 每次命中净省 | 盈亏平衡命中率 | 实际命中率 | 判断 |
|---|---|---|---|---|---|
| chat.reply | 3.40 | 103.00 | 3.30% | 80.00% | 远超门槛 |
| search.summary | 12.00 | 203.00 | 5.91% | 50.00% | 远超门槛 |
| ticket.classify | 7.45 | 33.50 | 22.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 缓存分层与容量 。
常见错误与边界
- 用请求级命中率代替成本级收益。长请求与短请求在命中率里权重相同,在账单上不是。
- 只算存储,不算查找与失效。后两项按请求或按事件发生,往往比存储更贵。
- 把误命中当成「少量误差」。它是一次真实错误交付,必须按代价从收益里扣。
- 用整体净收益掩盖亏损能力。一个能力亏钱,推广后亏损会按流量放大。
- 条目只写不读也算命中。写一次读一次的条目,经济上可能只是把生成换成了查找。
- 盈亏平衡命中率不随模型价格更新。模型降价后门槛会变,旧结论不能直接复用。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。