从两个租户串了搜索结果说起
「回声」的搜索摘要能力上线时,缓存实现只有十几行:把用户查询拼成字符串,查一个全局 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.60 | 420 | 37 | 55 | 误命中太多,不可接受 |
| 0.72 | 310 | 4 | 165 | 当前起点 |
| 0.80 | 190 | 1 | 285 | 过于保守,收益不足 |
| 0.90 | 70 | 0 | 405 | 基本只剩复述 |
表中的数字是案例假设值,只用于展示取舍形状。真实项目要按自己的能力分别标定: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。这个顺序不影响最终是否接受,但影响拒绝原因的归因。同一对文本可能同时触发两道护栏,先报更接近业务语义的那一道。
接入顺序与容量控制
在网关里接入三层缓存,顺序应当是:
- 校验请求,拒绝结构错误。结构错误不应进入缓存,也不应消耗模型预算。
- 构造作用域与精确键,查精确缓存。
- 精确未命中时,在同一作用域桶内做语义比较。
- 仍未命中时,用单飞键合并并发请求。
- 调用模型,成功后写入精确缓存与语义索引,并记录 subjectIds。
容量控制要分两层。全局层控制总条目数与总内存;作用域层控制单桶候选数,避免一个大租户把语义比较的扫描成本推高。语义比较是线性扫描,桶内候选超过几百条时,单次查找就会变慢,这时应当先缩桶或改用更粗的分区,而不是继续加阈值。
写入失败不应影响主流程。缓存是加速器,不是事实来源;缓存服务不可用时,请求应当继续走模型,只是变慢变贵。
与后面两节的关系
本节回答「缓存什么、键怎么建、相似度怎么判」。9.2 回答「什么时候必须作废」:提示换版、模型切换、权限调整、用户删除,每一种都有不同的传播范围。9.3 回答「怎么知道缓存有没有用」:命中率要按能力与租户分层,精确命中与语义命中要分开,污染与误命中要单独计数。
三节顺序不能反。没有正确的键,失效只能靠全量清空;没有失效语义,命中率再高也可能是在返回过期或越权的结果;没有分层度量,前两节的问题都不会被看见。
小结
缓存不是「把结果存起来」这么简单。AI 请求的缓存至少分三层:精确缓存保证相同输入得到相同输出,语义缓存用可解释的相似度复用同义改写,单飞避免热点键击穿。
键必须包含租户、能力、权限分区、模型与提示版本五个维度。少一个维度,缓存就会在某个真实流量组合下返回错误结果,而且很难在单租户测试里发现。
语义缓存要用可解释的相似度,并配上硬 token 与动作词两道护栏。阈值是标定出来的,不是普适常数;宁可少命中,不可错命中。
阅读导航:上一节:8.3 质量、成本与延迟的取舍记录 · 下一节:9.2 失效、隐私与多租户隔离 。
常见错误与边界
- 只用查询文本做键。单租户测试永远发现不了,多租户上线第一天就可能串数据。
- 把用户 ID 放进键。隔离看起来更严,但缓存几乎无法复用,成本和延迟都会反弹。
- 规范化时做同义词替换。替换规则本身就是一套语义判断,出错时无法解释。
- 语义缓存不设作用域桶。跨租户比较一次,后果就不可逆。
- 阈值定得过低。短问句大量互相碰撞,命中率好看,正确率下降。
- 只做 TTL 不做容量上限。热点事件会让内存随查询种类线性增长。
- 单飞键用查询文本而不是幂等键。客户端重试仍然可能穿透。
- 把流式响应放进单飞。多个等待者共享同一个流,输出会互相污染。
- 缓存写入失败就回滚主流程。缓存是加速器,不是事实来源。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。