从 92% 命中率却更慢说起
「回声」的缓存上线第三周,看板上的命中率从 61% 升到 92%。团队准备庆祝,值班工程师却提了一个问题:同一周的 P95 延迟涨了 18%,模型调用成本只降了 9%。
三个数字放在一起不合常理。如果 92% 的请求都没有调用模型,成本应当大幅下降,延迟也应当明显改善。实际结果相反,说明「命中」这个计数掩盖了某些东西。
拆开看,原因有三层。
第一,命中率是按请求数算的。命中率上升,主要是因为大量短查询命中了语义缓存,而这些短查询原本的模型调用就便宜、就快。真正昂贵的长查询仍然未命中,成本自然降不下来。
第二,语义命中不是免费的。每次未命中精确缓存,系统都要在同一作用域桶内做一次线性扫描。桶里候选越多,扫描越慢。命中率上升的同时,单次未命中的查找成本也上升了,P95 被拖长。
第三,看板把「精确命中」与「语义命中」加在一起,也把所有租户加在一起。一个大租户贡献了绝大多数流量,它的高命中率掩盖了小租户的零命中;语义命中里还有一部分是误命中,用户重新提问后产生了第二次调用,但第一次已经被计为命中。
这就是命中率度量的三个陷阱:口径混在一起、层级混在一起、正确性没有单独计数。本节把这三个问题拆开,写成可运行的指标模块。
文中出现的比例、延迟与成本,除非注明为本机运行输出,都是案例假设值,用于演示判断过程。
三个口径:请求、token、成本
「命中率」至少有三个合理的口径,它们回答不同的问题。
| 口径 | 计算方式 | 回答的问题 | 适用场景 |
|---|---|---|---|
| 请求级 | 命中请求数 / 总请求数 | 有多少请求没有走模型 | 容量规划、上游限流 |
| token 级 | 命中请求本应消耗的输入 token / 总输入 token | 省下了多少上下文传输 | 上下文预算、带宽 |
| 成本级 | 命中请求本应产生的成本 / 总成本 | 省了多少钱 | 财务核算、报价 |
请求级最常用,也最容易被误读。一次长上下文请求的成本可能是一次短请求的五十倍,但它们在请求级命中率里权重相同。如果命中的都是短请求,请求级命中率会很好看,成本级命中率却很低。
成本级口径需要「本应产生的成本」,这要求缓存条目记录生成时的模型与 token 用量。这不是额外负担:ChatResponse.usage 里已经有这些字段,写入缓存时一并保存即可。
三个口径不要求同时上线,但看板上必须写清楚当前展示的是哪一个。把请求级命中率当成成本节省指标,是最常见的汇报错误。
必须分层:能力 × 租户
聚合命中率会产生辛普森悖论:整体上升,每个分组都下降。缓存场景里这很常见,因为流量结构会变化。
看一个案例假设的例子。
| 租户 | 上周请求数 | 上周命中率 | 本周请求数 | 本周命中率 |
|---|---|---|---|---|
| 大租户 A | 9,000 | 80% | 90,000 | 70% |
| 小租户 B | 1,000 | 20% | 10,000 | 10% |
| 合计 | 10,000 | 74% | 100,000 | 64% |
这个例子里整体与分组同向,只是差距被流量放大。真正危险的是反向情况:大租户命中率上升、小租户下降,但大租户流量占比提高,整体仍然上升。只看总数,会得出「一切变好」的结论,而小租户可能已经在承受零命中。
因此指标至少要按两个维度切分:能力与租户。能力的命中率决定这一类请求的成本结构;租户的命中率决定是否存在隔离或冷启动问题。最细粒度是「租户 × 能力」,它既能定位到具体客户,也能定位到具体功能。
分层不是把同一份数据复制多份,而是从同一个桶聚合出不同视图。实现上可以先按「租户 × 能力」计数,再向上汇总;这样任何一层都不会与最细粒度冲突。
精确命中与语义命中必须分开
精确命中是确定性的:键相同,结果就相同。语义命中是概率性的:相似度超过阈值,系统猜测意图相同。两者的错误率、成本与延迟都不同,不能加在一起。
至少要分开记四个数:
- 精确命中数:确定性复用。
- 语义命中数:概率性复用。
- 语义候选拒绝数:评估过但因阈值或护栏被拒绝的次数。
- 语义误命中数:用户反馈或人工抽检确认错误的比例。
有了这四个数,才能算两个更有意义的指标。
语义精确率 =(语义命中数 − 误命中数)/ 语义命中数。它回答「语义层猜对了多少」。一个 90% 命中率但精确率只有 70% 的语义缓存,实际上在制造三成错误。
调整后命中率 =(精确命中 + 语义命中 − 误命中)/ 总请求。它把已知错误从收益里扣掉,比原始命中率更接近真实节省。
误命中不会自己消失。用户遇到错误答案后,通常会换一种问法再问一次,这次可能精确命中,也可能再次语义命中。第一次错误已经被计为命中,第二次又被计为命中,指标上看起来一片繁荣,用户体验却在下降。只有显式记录误命中,才能把这段隐藏成本暴露出来。
缓存污染的两个来源
命中率只统计「有没有复用」,不统计「复用得好不好」。缓存污染是后者,至少有两个来源。
第一个来源是抖动。一条结果写入后很快被失效,说明它要么基于易变事实,要么键设计不稳定。比如摘要里包含实时库存,库存一变就失效;或者查询文本里带时间戳,每分钟都是新键。抖动本身会消耗写入与清理成本,还会让命中率虚高:写入后立刻被读一次,也算一次命中。
抖动的度量方式是「写入后多久被失效」。在窗口内被失效的写入计为一次抖动,抖动率 = 抖动次数 / 写入次数。窗口长度应当与业务事实的变化频率匹配,默认六十秒只是一个起点。
第二个来源是语义误命中。它不删除条目,但会让错误结果被反复复用。误命中率高的缓存,命中率越高,危害越大。
两个来源要分开看。抖动是「条目活得太短」,误命中是「条目活得太久且被错误匹配」。一个要缩短生命周期,一个要提高判定门槛,修复方向相反。
阈值的副作用要在度量里看见
9.1 把语义阈值定为 0.72。这个值是否合适,不能只看命中率,要看三个数一起变化。
| 阈值调整 | 语义命中 | 候选拒绝 | 误命中 | 解读 |
|---|---|---|---|---|
| 下调 | 上升 | 下降 | 上升 | 多命中,但错误变多 |
| 上调 | 下降 | 上升 | 下降 | 更保守,收益变少 |
| 不变但候选变多 | 上升 | 上升 | 可能上升 | 桶变大,扫描变慢,边界样本增多 |
表中的数字是案例假设值,只表示方向。真正要观察的是「候选拒绝数」与「误命中数」的比值。如果拒绝数很高而误命中很低,说明阈值偏保守,可以小幅下调;如果误命中在上升,无论命中率多好看,都应当上调。
候选拒绝数还有一个诊断价值:它说明语义层「看到了多少机会」。如果拒绝数接近零,说明桶里根本没有相似候选,问题不在阈值,而在缓存覆盖或作用域划分。
代码:分层指标
下面的实现按「租户 × 能力」维护计数桶,向外提供整体、按能力、按租户与最细粒度四种视图。它只做计数与聚合,不持有缓存条目,也不读取查询文本。
// file: src/cache/metrics.ts
// tier: L1
// verify: node --test test/cache/metrics.test.ts
import type { CapabilityId, TenantId } from "../core/types.ts";
export type LookupOutcome = "exact" | "semantic" | "miss";
export interface MetricKey {
capability: CapabilityId;
tenantId: TenantId;
}
export interface MetricBucket {
lookups: number;
exact: number;
semantic: number;
misses: number;
semanticRejected: number;
semanticFalsePositives: number;
writes: number;
invalidations: number;
churn: number;
similaritySum: number;
}
export interface HitRate {
lookups: number;
exact: number;
semantic: number;
misses: number;
hitRate: number;
exactRate: number;
semanticRate: number;
/** 语义命中里经用户反馈确认正确的比例。 */
semanticPrecision: number;
/** 扣掉已知误命中后的命中率。 */
adjustedHitRate: number;
/** 写入后很快被失效的比例,反映缓存污染。 */
churnRate: number;
}
export interface MetricsSnapshot {
overall: HitRate;
byCapability: Record<string, HitRate>;
byTenant: Record<string, HitRate>;
byCapabilityTenant: Record<string, HitRate>;
semanticRejected: number;
semanticFalsePositives: number;
averageSemanticSimilarity: number;
}
export interface MetricsOptions {
now?: () => number;
/** 写入后多久内被失效算作一次抖动,默认 60 秒。 */
pollutionWindowMs?: number;
}
function emptyBucket(): MetricBucket {
return {
lookups: 0,
exact: 0,
semantic: 0,
misses: 0,
semanticRejected: 0,
semanticFalsePositives: 0,
writes: 0,
invalidations: 0,
churn: 0,
similaritySum: 0,
};
}
function bucketKey(key: MetricKey): string {
return `${key.tenantId}\u0000${key.capability}`;
}
function merge(target: MetricBucket, source: MetricBucket): void {
target.lookups += source.lookups;
target.exact += source.exact;
target.semantic += source.semantic;
target.misses += source.misses;
target.semanticRejected += source.semanticRejected;
target.semanticFalsePositives += source.semanticFalsePositives;
target.writes += source.writes;
target.invalidations += source.invalidations;
target.churn += source.churn;
target.similaritySum += source.similaritySum;
}
function toRate(bucket: MetricBucket): HitRate {
const hits = bucket.exact + bucket.semantic;
const semanticCorrect = Math.max(0, bucket.semantic - bucket.semanticFalsePositives);
return {
lookups: bucket.lookups,
exact: bucket.exact,
semantic: bucket.semantic,
misses: bucket.misses,
hitRate: bucket.lookups === 0 ? 0 : hits / bucket.lookups,
exactRate: bucket.lookups === 0 ? 0 : bucket.exact / bucket.lookups,
semanticRate: bucket.lookups === 0 ? 0 : bucket.semantic / bucket.lookups,
semanticPrecision: bucket.semantic === 0 ? 1 : semanticCorrect / bucket.semantic,
adjustedHitRate:
bucket.lookups === 0 ? 0 : Math.max(0, bucket.exact + semanticCorrect) / bucket.lookups,
churnRate: bucket.writes === 0 ? 0 : bucket.churn / bucket.writes,
};
}
export class HitRateMetrics {
private buckets = new Map<string, MetricBucket>();
private writeTimes = new Map<string, number>();
private now: () => number;
private pollutionWindowMs: number;
constructor(options: MetricsOptions = {}) {
this.now = options.now ?? (() => Date.now());
this.pollutionWindowMs = options.pollutionWindowMs ?? 60_000;
}
recordLookup(key: MetricKey, outcome: LookupOutcome, similarity = 0): void {
const bucket = this.bucket(key);
bucket.lookups += 1;
if (outcome === "exact") {
bucket.exact += 1;
} else if (outcome === "semantic") {
bucket.semantic += 1;
bucket.similaritySum += similarity;
} else {
bucket.misses += 1;
}
}
/** 语义层评估过但被阈值或守卫拒绝,计入候选拒绝数。 */
recordSemanticRejected(key: MetricKey): void {
this.bucket(key).semanticRejected += 1;
}
recordSemanticFalsePositive(key: MetricKey): void {
this.bucket(key).semanticFalsePositives += 1;
}
recordWrite(key: MetricKey, cacheKey: string): void {
this.bucket(key).writes += 1;
this.writeTimes.set(cacheKey, this.now());
}
recordInvalidation(key: MetricKey, cacheKey: string): void {
const bucket = this.bucket(key);
bucket.invalidations += 1;
const writtenAt = this.writeTimes.get(cacheKey);
if (writtenAt !== undefined) {
this.writeTimes.delete(cacheKey);
if (this.now() - writtenAt <= this.pollutionWindowMs) {
bucket.churn += 1;
}
}
}
reset(): void {
this.buckets.clear();
this.writeTimes.clear();
}
snapshot(): MetricsSnapshot {
const overall = emptyBucket();
const byCapability = new Map<string, MetricBucket>();
const byTenant = new Map<string, MetricBucket>();
const byCapabilityTenant: Record<string, HitRate> = {};
for (const bucket of this.buckets.values()) {
merge(overall, bucket);
}
for (const [rawKey, bucket] of this.buckets) {
const [tenantId, capability] = rawKey.split("\u0000");
const capBucket = byCapability.get(capability) ?? emptyBucket();
merge(capBucket, bucket);
byCapability.set(capability, capBucket);
const tenantBucket = byTenant.get(tenantId) ?? emptyBucket();
merge(tenantBucket, bucket);
byTenant.set(tenantId, tenantBucket);
byCapabilityTenant[`${tenantId}/${capability}`] = toRate(bucket);
}
return {
overall: toRate(overall),
byCapability: Object.fromEntries(
[...byCapability].map(([capability, bucket]) => [capability, toRate(bucket)]),
),
byTenant: Object.fromEntries(
[...byTenant].map(([tenantId, bucket]) => [tenantId, toRate(bucket)]),
),
byCapabilityTenant,
semanticRejected: overall.semanticRejected,
semanticFalsePositives: overall.semanticFalsePositives,
averageSemanticSimilarity:
overall.semantic === 0 ? 0 : overall.similaritySum / overall.semantic,
};
}
private bucket(key: MetricKey): MetricBucket {
const rawKey = bucketKey(key);
let bucket = this.buckets.get(rawKey);
if (!bucket) {
bucket = emptyBucket();
this.buckets.set(rawKey, bucket);
}
return bucket;
}
}
几个实现细节值得说明。
第一,recordSemanticRejected 不计入 lookups。拒绝发生在一次未命中查找的内部,如果把它也算作一次请求,命中率会被稀释,分母与真实请求数不一致。
第二,误命中不重复计入 semantic。它是对已有语义命中的事后修正,只在计算精确率与调整后命中率时扣除。这样原始计数保持不变,修正可以追溯。
第三,抖动按缓存键记录写入时间,而不是按查询文本。缓存键已经包含作用域,同一个文本在不同租户下是两个键,抖动也应当分别计算。
第四,聚合使用「合并桶再算比率」,而不是「对比率取平均」。后者会给小样本分组过高的权重,是分层指标最常见的实现错误。
看板怎么摆
看板不是把指标堆在一起,而是按「发现异常 → 定位范围 → 判断原因」的顺序组织。
| 区域 | 展示内容 | 回答的问题 |
|---|---|---|
| 顶部 | 请求级、成本级命中率各一行 | 整体有没有变化 |
| 分层 | 租户 × 能力热力图 | 哪个客户、哪个功能异常 |
| 语义 | 命中、拒绝、误命中、精确率 | 语义层是否在制造错误 |
| 污染 | 抖动率、写入量、失效量 | 缓存是否在空转 |
| 延迟 | 命中与未命中的 P95 分开 | 命中是否真的更快 |
顶部必须同时放请求级与成本级。只放一个,就会重演开头那次「92% 却更慢」的误判。
分层热力图要能下钻到租户。小租户的零命中在聚合视图里几乎看不见,但在热力图上是一个明显的空格。
延迟要按命中类型分开。精确命中通常比语义命中快,语义命中又比未命中快;如果语义命中的 P95 接近未命中,说明扫描成本已经吃掉了收益,这时应当缩桶或降低候选数,而不是继续提高命中率。
度量不能做什么
命中率是手段,不是目标。把命中率写进 OKR,团队会自然地优化它:降低阈值、放宽键、延长 TTL、把不该缓存的请求也缓存。这些做法都能让数字变好,同时让产品变差。
度量也不能替代正确性验证。缓存返回的是「之前算过的结果」,它是否正确,取决于写入时是否正确、失效是否及时、作用域是否隔离。这些都不是命中率能回答的问题。
度量还有采样偏差。只统计成功返回的请求,会漏掉超时与失败;只统计有日志的租户,会漏掉未接入日志的客户。看板上的分母,往往比分子更值得检查。
小结
命中率不是单一数字。请求级、token 级、成本级回答不同问题,混用会得出相反结论。
聚合命中率会掩盖分层差异。至少按能力与租户切分,最细粒度到「租户 × 能力」,并用合并桶再算比率的方式聚合。
精确命中与语义命中必须分开,语义命中还要单独记录拒绝数与误命中数。调整后命中率与语义精确率,比原始命中率更接近真实收益。
缓存污染有两个方向:抖动让条目活得太短,误命中让条目活得太久且被错误匹配。两者的修复方向相反,必须分开度量。
阅读导航:上一节:9.2 失效、隐私与多租户隔离 · 下一节:10.1 模型不可用时产品该做什么 。
常见错误与边界
- 只报一个命中率。请求级与成本级可能反向,汇报时必须写清口径。
- 把语义命中与精确命中相加。两者错误率差一个数量级,合并后无法判断风险。
- 只看整体不看分层。大租户的高命中会掩盖小租户的零命中。
- 对比率取平均做聚合。小样本分组会被过度加权,结果与真实值偏离。
- 不记录语义误命中。错误被计为收益,命中率越高,产品越差。
- 把抖动当命中。写入后立刻被读一次,指标好看,实际没有节省。
- 用命中率做目标。团队会通过放宽键与阈值来优化数字,而不是优化产品。
- 命中与未命中的延迟混在一起。语义扫描的成本会被平均掉,看不出收益是否成立。
- 缓存不可用就拒绝请求。缓存是加速器,不是事实来源。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。