《互联网产品的 AI 架构·模型接入与能力边界》9.1 精确缓存、语义缓存与结果复用

「回声」的搜索摘要缓存只按查询文本建键,两个租户问同一句退款政策时互相串了结果。本节把精确缓存、语义缓存与结果复用拆成三层,讲清作用域键的五个要素、字符二元组相似度与 0.72 阈值、订单号与动作词两道护栏,并给出可运行的 TypeScript 实现与测试。

从两个租户串了搜索结果说起

「回声」的搜索摘要能力上线时,缓存实现只有十几行:把用户查询拼成字符串,查一个全局 Map,命中就返回,未命中就调模型。上线第一周命中率很漂亮,第二周客服收到一条奇怪的反馈:某企业租户在自己的后台搜索「退款政策」,摘要里出现了另一家租户的内部退货流程,连审批人姓名都在。

排查结果并不复杂。缓存键只有查询文本,没有租户、没有权限域、没有模型版本。两家租户问了同一句话,后到的请求直接命中了先到的结果。更麻烦的是,这个错误不会在单测里出现,因为单测只有一个租户;也不会在压测里出现,因为压测用的查询分布很散。它只在真实的多租户流量里、在恰好问了同一句话时出现。

同一个月还有两个相关事故。运营把搜索摘要的提示从「三条以内」改成「五条以内」,旧缓存继续返回三条的旧口径,持续了六个小时。另一个是语义缓存把「怎么退款」和「怎么退货」当成同一个问题,用户问退货流程,系统返回退款到账时间。

三个事故对应三件事:键要包含隔离维度,版本要进入键,相似度不能单独决定复用。本节先把缓存分成三层,再把键设计和相似度判定写成可运行的代码。失效与删除权留到 9.2,命中率度量留到 9.3。

文中出现的命中率、耗时、租户规模,除非注明为本机运行输出,都是案例假设值,用来演示判断过程。

三层缓存各自解决什么

AI 请求链路上的缓存不是一个东西,而是三个位置、三种判定方式。

层判定方式典型收益主要风险
精确缓存作用域键与规范化输入完全相等重复提问、重试、幂等重放键漏维度导致跨租户串数据
语义缓存同一作用域内文本相似度超过阈值同义改写、口语化追问相似但意图相反的问题被合并
结果复用同键并发只发一次请求热点问题、缓存击穿慢请求拖住所有等待者

精确缓存最可靠,因为它不猜测意图。只要键相同,结果就应当相同。它的收益来自重复:用户刷新页面、客户端重试、同一批运营同时打开同一个看板,都会产生完全相同的输入。

语义缓存收益更大,但它是概率性的。两句话相似不等于两个问题相同。「退款」和「退货」在字符层面只差一个字,在业务上却是两条完全不同的流程。所以语义缓存必须配两道护栏,而且只适合那些「答错代价可控」的能力。

结果复用不存储结果,只合并并发。它解决的是缓存击穿:热点键刚过期,一百个请求同时到达,全部穿透到模型。单飞让第一个请求去调用,其余等待同一个 Promise。它和缓存的区别是,请求结束后结果不一定留下。

三层可以叠加,但顺序固定:先精确、再语义、最后单飞。任何一层命中都不应继续往下走。

键设计:把隔离写进键里

精确缓存的键不是查询文本,而是「作用域 + 规范化输入」。作用域至少包含五项。

维度为什么必须进键漏掉的后果
tenantId租户之间的数据不可见跨租户串结果,最严重的一类事故
capability同一句话在不同能力下含义不同搜索摘要被聊天回复复用
permissionScope同一租户内可见范围不同高管看得到基层无权看的内容
modelId不同模型的输出不可互换换模型后仍返回旧模型结果
promptVersion提示口径变化会改变输出改版后旧口径继续生效

permissionScope 不是用户 ID,而是权限分区标识。把用户 ID 放进键会让缓存几乎无法复用;把权限分区放进键,能在「同权重复用」和「隔离」之间取平衡。分区如何划分属于权限系统的问题,缓存只要求它稳定、可比较。

规范化只做无歧义的变换:Unicode NFKC、转小写、合并连续空白、去掉首尾空白。不要做同义词替换、不要删标点、不要截断。规范化越激进,键越容易碰撞,而碰撞在精确缓存里就是错误命中。

拼接时使用不会出现在正文里的分隔符。直接用 | 或 : 拼接,遇到包含这些字符的输入就可能产生歧义:("a|b", "c") 和 ("a", "b|c") 会拼成同一个字符串。代码里用 \u0000 分隔作用域字段,用 \u0001 分隔作用域与输入。

精确缓存:LRU 与 TTL 要同时有

只有 TTL 没有容量上限,缓存会在热点事件里无限增长;只有容量上限没有 TTL,过期事实会一直留在内存里。两者解决的是不同问题:TTL 控制「多久后事实可能变化」,LRU 控制「内存能装多少」。

实现上用 Map 的插入顺序即可。读取命中时先删除再插入,把该项移到队尾;写入新项后如果超出容量,删除队首。这个顺序就是「最近使用」的顺序,不需要额外的双向链表。

TTL 要在读取时判断,而不是靠定时器扫描。定时器在大量键的情况下成本高,而且在进程被冻结或休眠时不可靠。读取时发现过期就删除并按未命中处理,逻辑简单,结果确定。

还有一个容易忽略的细节:精确命中也要刷新 LRU 顺序。否则一个每天被读一万次的热键,可能因为写入时间早而被淘汰,而一个刚写入、再也没人读的键却留着。

语义缓存:用可解释的字符相似度

本节不引入向量库。向量检索适合大规模语义召回,但它带来 embedding 模型、索引服务、维度版本和额外的隐私面。对于一个按作用域分桶、每桶只存几百条结果的场景,字符相似度足够用,而且每个判断都能解释。

做法是把文本规范化后切成字符二元组,再用 Dice 系数比较:

  • 文本「订单什么时候发货」切成「订单」「单什」「什么」「么时」「时候」「候发」「发货」。
  • 两段文本的交集大小乘以二,除以两段二元组数量之和。
  • 结果在 0 到 1 之间,完全相同的文本为 1。

选择二元组而不是单字,是因为单字重合太多。「退款」和「退货」共享「退」字,但业务含义相反;二元组至少保留了「退款」「退货」这个局部结构。选择 Dice 而不是编辑距离,是因为 Dice 对长度差异更宽容,且计算只需要集合操作。

阈值默认 0.72。这个数字不是普适常数,而是从样本里标定出来的起点。下一节会给出一张样本表,说明它为什么不是 0.9,也不是 0.5。

语义比较只在同一个作用域桶内进行。桶的键与精确键的前半段相同,因此跨租户、跨能力、跨权限分区、跨模型、跨提示版本都不会互相比较。这一条比阈值更重要:阈值决定「像不像」,作用域决定「有没有资格比较」。

两道护栏:硬 token 与动作词

只靠相似度会出错,所以判定要加两道与业务无关的护栏。

第一道是硬 token。把文本里的拉丁字母串和数字串提取出来,要求两侧集合完全相等。订单号 A100 与 B200、型号 Pro 与 Air、日期 2026-10 与 2026-11,这些字符的权重在二元组里被稀释,但对业务是决定性的。只要有一侧多出或缺少一个硬 token,就拒绝语义复用。

第二道是动作词。维护一组在业务上互斥的动作词,例如「退款」「退货」「换货」「取消」。规则很简单:某个动作词在一侧出现、在另一侧不出现,就拒绝。它不判断语义,只判断「这两个动作不能混」。

本机跑出的两个分数可以说明两道护栏的必要性:

查询缓存中的问题Dice 相似度裸阈值加护栏后
订单什么时候发货我的订单什么时候发货0.875命中命中
我昨天买的耳机要退货请问多久到账我昨天买的耳机要退款请问多久到账0.867命中拒绝(动作词)
怎么退款怎么退货0.667拒绝拒绝
订单 A100 到哪了订单 B200 到哪了0.625拒绝拒绝(硬 token)

第二行是关键。两句话只差一个动作词,相似度已经超过阈值;如果没有动作词护栏,它会被当成同一个问题。第三行说明短句的相似度天然偏低,阈值不能为了多命中而降到 0.6 附近,否则大量短问句会互相碰撞。

护栏的代价是保守:某些真正同义的改写会被拒绝,比如「退钱」与「退款」。这不是缺陷,而是取舍。语义缓存的原则是「宁可少命中,不可错命中」,因为错误命中的修复成本远高于一次模型调用。

阈值怎么定:一张样本表

阈值不能拍脑袋,也不能一次定死。可行的方法是收集一批真实查询,人工标注「是否可复用」,然后看不同阈值下的误命中与漏命中。

阈值语义命中数误命中漏命中判断
0.604203755误命中太多,不可接受
0.723104165当前起点
0.801901285过于保守,收益不足
0.90700405基本只剩复述

表中的数字是案例假设值,只用于展示取舍形状。真实项目要按自己的能力分别标定:FAQ 摘要可以更激进,订单状态、金额、权限相关的结果应当更保守,甚至关闭语义层。

阈值也不是唯一旋钮。还可以按能力配置是否启用语义缓存、最大候选数、最短文本长度。短于六个字符的查询不进入语义比较,因为样本太少,分数不稳定。

结果复用:单飞与幂等键

缓存击穿发生在「键刚过期」或「冷启动」时。一百个相同请求同时未命中,如果每个都去调用模型,成本会瞬间放大一百倍。

单飞的实现是一个 Map<string, Promise>。第一个请求创建 Promise 并放入 Map,后续请求直接拿到同一个 Promise。Promise 结束后无论成功失败都从 Map 删除,这样下一次请求可以重新尝试。

单飞的键应当与缓存键一致,但可以额外带上业务幂等键。idempotencyKey 表示「同一个业务动作」,比查询文本更稳定。用户双击提交、客户端重试、消息队列重投,都应当落到同一个单飞键上。

单飞不能滥用。流式响应一旦开始,多个等待者共享同一个流会互相干扰;个性化结果即使输入文本相同,也不应当共享。单飞只适用于「输入相同则输出应当相同」的幂等能力。

代码:键与相似度

下面这份代码只做三件事:构造作用域键、规范化输入、判断两段文本是否可复用。它不依赖任何第三方库,也不包含存储实现;存储、LRU 与单飞在 src/cache/cache.ts 中复用这些函数。

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

import type { CapabilityId, TenantId } from "../core/types.ts";

/** 缓存作用域:任何一项不同,结果都不可复用。 */
export interface CacheScope {
  tenantId: TenantId;
  capability: CapabilityId;
  permissionScope: string;
  modelId: string;
  promptVersion: string;
}

export const DEFAULT_GUARD_TERMS = ["退款", "退货", "换货", "取消"] as const;
const PUNCT = /[\s,。!?、;:,.!?;:'"“”‘’()()\[\]【】<>《》·\-—_/\\]+/g;

/** 作用域键使用不会出现在正文里的分隔符,避免拼接歧义。 */
export function buildScopeKey(scope: CacheScope): string {
  return [
    scope.tenantId,
    scope.capability,
    scope.permissionScope,
    scope.modelId,
    scope.promptVersion,
  ].join("\u0000");
}

export function canonicalize(input: string): string {
  return input.normalize("NFKC").toLowerCase().replace(/\s+/g, " ").trim();
}

export function buildExactKey(scope: CacheScope, input: string): string {
  return `${buildScopeKey(scope)}\u0001${canonicalize(input)}`;
}

function bigrams(text: string): Set<string> {
  const chars = Array.from(canonicalize(text).replace(PUNCT, ""));
  const grams = new Set<string>();
  if (chars.length === 1) {
    grams.add(chars[0]);
  }
  for (let i = 0; i + 1 < chars.length; i += 1) {
    grams.add(chars[i] + chars[i + 1]);
  }
  return grams;
}

/** Dice 系数:2×交集 / 两侧规模之和,取值 0 到 1。 */
export function similarity(a: string, b: string): number {
  const left = bigrams(a);
  const right = bigrams(b);
  if (left.size === 0 || right.size === 0) {
    return 0;
  }
  let shared = 0;
  for (const gram of left) {
    if (right.has(gram)) {
      shared += 1;
    }
  }
  return (2 * shared) / (left.size + right.size);
}

function hardTokens(text: string): Set<string> {
  return new Set(canonicalize(text).match(/[a-z]+|\d+/g) ?? []);
}

export interface SimilarityVerdict {
  score: number;
  accepted: boolean;
  guardConflict: boolean;
  hardTokenConflict: boolean;
  reason: string;
}

/** 相似度只是第一道门;动作词与订单号/型号必须完全一致。 */
export function evaluateSimilarity(
  query: string,
  candidate: string,
  threshold: number,
  guardTerms: readonly string[] = DEFAULT_GUARD_TERMS,
): SimilarityVerdict {
  const score = similarity(query, candidate);
  const queryTokens = hardTokens(query);
  const candidateTokens = hardTokens(candidate);
  const hardTokenConflict =
    [...queryTokens].some((token) => !candidateTokens.has(token)) ||
    [...candidateTokens].some((token) => !queryTokens.has(token));
  const guardConflict = guardTerms.some(
    (term) => query.includes(term) !== candidate.includes(term),
  );
  const accepted = score >= threshold && !hardTokenConflict && !guardConflict;
  let reason = "match";
  if (guardConflict) {
    reason = "guard-term";
  } else if (hardTokenConflict) {
    reason = "hard-token";
  } else if (score < threshold) {
    reason = "below-threshold";
  }
  return { score, accepted, guardConflict, hardTokenConflict, reason };
}

代码里有三个值得单独说明的决定。

第一,hardTokens 只提取拉丁字母与数字,不提取中文。中文的动作判断交给 guardTerms,因为中文分词需要词典,而词典本身又需要维护。把「哪些词互斥」显式列出来,比引入一个分词器更可控。

第二,evaluateSimilarity 返回的 reason 不是给用户看的,而是给度量与排障用的。9.3 会把 guard-term、hard-token、below-threshold 分别计数,才能判断阈值是太松还是太紧。

第三,护栏的判断顺序是动作词优先于硬 token。这个顺序不影响最终是否接受,但影响拒绝原因的归因。同一对文本可能同时触发两道护栏,先报更接近业务语义的那一道。

接入顺序与容量控制

在网关里接入三层缓存,顺序应当是:

  1. 校验请求,拒绝结构错误。结构错误不应进入缓存,也不应消耗模型预算。
  2. 构造作用域与精确键,查精确缓存。
  3. 精确未命中时,在同一作用域桶内做语义比较。
  4. 仍未命中时,用单飞键合并并发请求。
  5. 调用模型,成功后写入精确缓存与语义索引,并记录 subjectIds。

容量控制要分两层。全局层控制总条目数与总内存;作用域层控制单桶候选数,避免一个大租户把语义比较的扫描成本推高。语义比较是线性扫描,桶内候选超过几百条时,单次查找就会变慢,这时应当先缩桶或改用更粗的分区,而不是继续加阈值。

写入失败不应影响主流程。缓存是加速器,不是事实来源;缓存服务不可用时,请求应当继续走模型,只是变慢变贵。

与后面两节的关系

本节回答「缓存什么、键怎么建、相似度怎么判」。9.2 回答「什么时候必须作废」:提示换版、模型切换、权限调整、用户删除,每一种都有不同的传播范围。9.3 回答「怎么知道缓存有没有用」:命中率要按能力与租户分层,精确命中与语义命中要分开,污染与误命中要单独计数。

三节顺序不能反。没有正确的键,失效只能靠全量清空;没有失效语义,命中率再高也可能是在返回过期或越权的结果;没有分层度量,前两节的问题都不会被看见。

小结

缓存不是「把结果存起来」这么简单。AI 请求的缓存至少分三层:精确缓存保证相同输入得到相同输出,语义缓存用可解释的相似度复用同义改写,单飞避免热点键击穿。

键必须包含租户、能力、权限分区、模型与提示版本五个维度。少一个维度,缓存就会在某个真实流量组合下返回错误结果,而且很难在单租户测试里发现。

语义缓存要用可解释的相似度,并配上硬 token 与动作词两道护栏。阈值是标定出来的,不是普适常数;宁可少命中,不可错命中。

阅读导航:上一节:8.3 质量、成本与延迟的取舍记录 · 下一节:9.2 失效、隐私与多租户隔离 。

常见错误与边界

  • 只用查询文本做键。单租户测试永远发现不了,多租户上线第一天就可能串数据。
  • 把用户 ID 放进键。隔离看起来更严,但缓存几乎无法复用,成本和延迟都会反弹。
  • 规范化时做同义词替换。替换规则本身就是一套语义判断,出错时无法解释。
  • 语义缓存不设作用域桶。跨租户比较一次,后果就不可逆。
  • 阈值定得过低。短问句大量互相碰撞,命中率好看,正确率下降。
  • 只做 TTL 不做容量上限。热点事件会让内存随查询种类线性增长。
  • 单飞键用查询文本而不是幂等键。客户端重试仍然可能穿透。
  • 把流式响应放进单飞。多个等待者共享同一个流,输出会互相污染。
  • 缓存写入失败就回滚主流程。缓存是加速器,不是事实来源。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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