《互联网产品的 AI 架构·模型接入与能力边界》9.3 命中率的度量与陷阱

「回声」的缓存命中率升到 92%,端到端延迟却变差了。本节把命中率拆成请求、token 与成本三个口径,说明为什么必须按能力与租户分层、精确命中与语义命中要分开、缓存污染与语义误命中要单独计数,并给出可运行的 TypeScript 分层指标实现。

从 92% 命中率却更慢说起

「回声」的缓存上线第三周,看板上的命中率从 61% 升到 92%。团队准备庆祝,值班工程师却提了一个问题:同一周的 P95 延迟涨了 18%,模型调用成本只降了 9%。

三个数字放在一起不合常理。如果 92% 的请求都没有调用模型,成本应当大幅下降,延迟也应当明显改善。实际结果相反,说明「命中」这个计数掩盖了某些东西。

拆开看,原因有三层。

第一,命中率是按请求数算的。命中率上升,主要是因为大量短查询命中了语义缓存,而这些短查询原本的模型调用就便宜、就快。真正昂贵的长查询仍然未命中,成本自然降不下来。

第二,语义命中不是免费的。每次未命中精确缓存,系统都要在同一作用域桶内做一次线性扫描。桶里候选越多,扫描越慢。命中率上升的同时,单次未命中的查找成本也上升了,P95 被拖长。

第三,看板把「精确命中」与「语义命中」加在一起,也把所有租户加在一起。一个大租户贡献了绝大多数流量,它的高命中率掩盖了小租户的零命中;语义命中里还有一部分是误命中,用户重新提问后产生了第二次调用,但第一次已经被计为命中。

这就是命中率度量的三个陷阱:口径混在一起、层级混在一起、正确性没有单独计数。本节把这三个问题拆开,写成可运行的指标模块。

文中出现的比例、延迟与成本,除非注明为本机运行输出,都是案例假设值,用于演示判断过程。

三个口径:请求、token、成本

「命中率」至少有三个合理的口径,它们回答不同的问题。

口径计算方式回答的问题适用场景
请求级命中请求数 / 总请求数有多少请求没有走模型容量规划、上游限流
token 级命中请求本应消耗的输入 token / 总输入 token省下了多少上下文传输上下文预算、带宽
成本级命中请求本应产生的成本 / 总成本省了多少钱财务核算、报价

请求级最常用,也最容易被误读。一次长上下文请求的成本可能是一次短请求的五十倍,但它们在请求级命中率里权重相同。如果命中的都是短请求,请求级命中率会很好看,成本级命中率却很低。

成本级口径需要「本应产生的成本」,这要求缓存条目记录生成时的模型与 token 用量。这不是额外负担:ChatResponse.usage 里已经有这些字段,写入缓存时一并保存即可。

三个口径不要求同时上线,但看板上必须写清楚当前展示的是哪一个。把请求级命中率当成成本节省指标,是最常见的汇报错误。

必须分层:能力 × 租户

聚合命中率会产生辛普森悖论:整体上升,每个分组都下降。缓存场景里这很常见,因为流量结构会变化。

看一个案例假设的例子。

租户上周请求数上周命中率本周请求数本周命中率
大租户 A9,00080%90,00070%
小租户 B1,00020%10,00010%
合计10,00074%100,00064%

这个例子里整体与分组同向,只是差距被流量放大。真正危险的是反向情况:大租户命中率上升、小租户下降,但大租户流量占比提高,整体仍然上升。只看总数,会得出「一切变好」的结论,而小租户可能已经在承受零命中。

因此指标至少要按两个维度切分:能力与租户。能力的命中率决定这一类请求的成本结构;租户的命中率决定是否存在隔离或冷启动问题。最细粒度是「租户 × 能力」,它既能定位到具体客户,也能定位到具体功能。

分层不是把同一份数据复制多份,而是从同一个桶聚合出不同视图。实现上可以先按「租户 × 能力」计数,再向上汇总;这样任何一层都不会与最细粒度冲突。

精确命中与语义命中必须分开

精确命中是确定性的:键相同,结果就相同。语义命中是概率性的:相似度超过阈值,系统猜测意图相同。两者的错误率、成本与延迟都不同,不能加在一起。

至少要分开记四个数:

  • 精确命中数:确定性复用。
  • 语义命中数:概率性复用。
  • 语义候选拒绝数:评估过但因阈值或护栏被拒绝的次数。
  • 语义误命中数:用户反馈或人工抽检确认错误的比例。

有了这四个数,才能算两个更有意义的指标。

语义精确率 =(语义命中数 − 误命中数)/ 语义命中数。它回答「语义层猜对了多少」。一个 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 模型不可用时产品该做什么 。

常见错误与边界

  • 只报一个命中率。请求级与成本级可能反向,汇报时必须写清口径。
  • 把语义命中与精确命中相加。两者错误率差一个数量级,合并后无法判断风险。
  • 只看整体不看分层。大租户的高命中会掩盖小租户的零命中。
  • 对比率取平均做聚合。小样本分组会被过度加权,结果与真实值偏离。
  • 不记录语义误命中。错误被计为收益,命中率越高,产品越差。
  • 把抖动当命中。写入后立刻被读一次,指标好看,实际没有节省。
  • 用命中率做目标。团队会通过放宽键与阈值来优化数字,而不是优化产品。
  • 命中与未命中的延迟混在一起。语义扫描的成本会被平均掉,看不出收益是否成立。
  • 缓存不可用就拒绝请求。缓存是加速器,不是事实来源。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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