语义缓存是安全隐患,而非性能提升
语义缓存命中是唯一一种能在不到一毫秒的时间内,将错误答案发送给错误用户的 LLM 优化方式。SQL 缓存之所以会返回你或他人的数据行,是因为有人写错了 join —— 这种故障模式属于查询 bug。而语义缓存返回另一个租户的响应,是因为两个 embedding 在 0.03 的余弦距离内落到了一起,这正是系统完全按设计运行的结果。缓存完成了它的工作,问题在于这份工作本身。
大多数团队将语义缓存作为一种成本方案来推行 —— 每个 AI 工程 Slack 频道里都流传着一份“削减 70% 账单”的 PPT —— 并且像对待 Redis TTL 一样审查缓存键(cache key):完全不审。这种审查通常交由性能团队负责。安全团队永远看不到设计文档,因为没有人会为“我们增加了一条更快的路径”提交安全审查。六个月后,某人的合规审计发现,“我无法登录我的账户,我的电子邮件是 [email protected]”和“我无法登录我的账户,我的电子邮件是 [email protected]”在向量化后都处于“我无法登 录我的账户”的阈值内,于是缓存愉快地向 Bob 提供了原本为 Jane 生成的响应,其中包含了她账户请求的密码重置链接。
这篇文章将讨论为什么语义缓存值得拥有与 SQL 谓词相同的审查严谨性、如何通过缓存键设计从结构上防止跨用户泄露,以及你需要什么样的审计追踪来区分“缓存命中提供了正确答案”与“缓存命中在亚毫秒级延迟下提供了他人的答案”。
为什么 Embedding 相似性是错误的等价类
传统的缓存键是一个哈希值。两个输入只有在哈希值完全相同时才是“相同的”,而优秀的哈希函数保证了细微的输入变化会产生不相关的输出。这种雪崩效应(avalanche property)正是基于哈希的缓存机制安全的原因:除非两个查询在字节级别完全一致,否则缓存查找机制不会混淆两个语义不同的查询。
语义缓存刻意抛弃了这一特性。它的核心意义就在于,让“我该如何重置密码?”和“我忘记密码了”命中同一个缓存条目。查找机制奖励局部性 —— 细微的输入变化产生细微的 embedding 变化 —— 并通过相似性阈值(通常为 0.85–0.95 的余弦相似度)来决定什么算作“足够接近”。这与密码学中的抗碰撞概念是不相容的。2026 年的一篇论文精确地形式化了这种权衡,并展示了攻击者可以构造出处于阈值“剃刀边缘”的输入:在语义上足以承载不同的意图,但在数学上又足够相似以命中目标查询的缓存条目。他们的 CacheAttack 框架在针对主流工业实现的 响应劫持测试中,报告了 86% 的成功率。
关键不在于对抗性输入是主要风险。主要风险是良性碰撞 —— 普通用户在并无恶意的情况下,提出了一些相似的问题,但其答案取决于缓存键未捕获的上下文。授权范围是上下文。租户身份是上下文。用户个性化也是上下文。这些都不存在于自然语言查询的 embedding 中,因为 embedding 根本不知道这些东西的存在。缓存键只是 embedding “认为”用户所问的内容。而正确的答案取决于用户被“允许”知道什么。
命中率仪表盘无法显示的故障模式
缓存的可观测性是围绕命中率、延迟和节省的成本构建的。这些指标中没有一个能对跨用户泄露发出警报。泄露表现为一次耗时 0.5 毫秒并返回了存储响应的缓存命中。仪表盘显示一切正常。
以下是我在三个不同团队的生产级语义缓存部署中亲眼见过的泄露形式:
授权范围坍塌。 像“显示我的待处理工单”这样的查询,其缓存响应(即实际的工单列表)仅以查询的 embedding 为键。下一个询问“显示我的待处理工单”的用户命中了缓存,并看到了第一个用户的工单。这听起来太蠢了,不应该发布,但我确实见过它发布了两次,两次都是因为团队将语义缓存视为“带有向量功能的 Redis”,而忘记了响应是个性化的。
个性化溢出。 “推荐一个健身方案”返回了一个缓存响应,其中提到了前一个用户的年龄、受伤史和器械情况。查询 embedding 并不知 晓这些信息,而响应却将其泄露了。这种泄露很难检测,因为这两个查询确实具有相同的意图 —— 缓存“应该”匹配。Bug 在于该响应根本不该被缓存,或者应该在剥离个性化变量后再进行缓存。
权限绕过。 一个免费层级的用户询问“总结这次财报电话会议”,并得到了昨天为付费层级用户缓存的响应 —— 其中包含了免费层级无权访问的专业分析师评论。缓存愉快地提供了响应,因为访问层级不是缓存键的一部分。该用户免费获得了功能升级,而产品团队的指标显示免费层级的参与度异常高,却没人将其与缓存关联起来。
撤销后过期失效。 用户 A 向用户 B 共享了一个文档,随后撤销了访问权限。用户 B 询问了一个关于该文档的问题;缓存提供了撤销前的答案。检索层正确地从新查询中排除了该文档 —— 但缓存完全绕过了检索过程。在缓存条目过期前,用户可以阅读他们已无权访问的内容。
跨租户语言冲突。 租户 Acme 和租户 Globex 都询问“我们的第四季度营收预测是多少?”。这两个查询的 embedding 几乎完全相同,因为自然语言形式是一致的,尽管语义上指向的文档、模型和答案完全不同。在没有租户前缀的全局缓存中,一个租户的预测数据会被提供给另一个租户。
以上每一种情况在标准的缓存仪表盘中都是不可见的。它们会出现在支持工单、合规审计中,甚至在最糟糕的情况下,出现在新闻报道里。
缓存键即变相的授权谓词
这种修 复是结构性的,而非统计性的。语义缓存键(Semantic cache key)是一种契约,规定了哪些其他查询可以命中该条目。该契约的设计应像按用户 ID 过滤的 SQL WHERE 子句一样受到严格审查,因为从功能上讲,它确实就是如此。
在任何多用户系统中,语义缓存的最小可行安全键包含三个必需组件和一个可选组件:
- 租户 ID(Tenant ID)作为强制硬性前缀。 它不是嵌入(embedding)的一部分,而是一个直接添加到缓存命名空间前的字面字符串。跨租户查询必须通过结构设计而非相似度分数来确保无法命中缓存。如果你想跨租户共享条目,请将其写在设计文档中,并让非性能团队的成员审阅。
- 授权范围(Authorization scope)作为强制硬性前缀。 包括角色、权限和影响响应形态的功能标志(Feature flags)。免费层级和付费层级属于不同的命名空间。管理员和普通成员属于不同的命名空间。在一种权限集下生成的响应绝对不能在另一种权限集下提供,仅此而已。
- 用户 ID(User ID)作为包含用户特定内容的响应的强制硬性前缀。 默认设置应该是按用户分区;提升到共享分区必须是一个经过明确审查的决定,且需要证明该响应与用户无关。
- 提示词版本、模型版本和检索索引版本作为后缀组件。 这虽不是安全属性,但会产生交互影响:当你升级嵌入模型时,每个现有条目都处于不同的向量空间,查找会悄无声息地降级。版本化强制执行受控的更替,而不是缓慢的损坏。
语义相似度匹配发生在硬性前缀定义的分区内部,绝不能跨分区进行。这是最重要的直觉反转:缓存是许多共享基础设施的小型特定范围缓存, 而不是一个带有可选范围的大缓存。那些将范围框架化为 vary-by 过滤器或可选元数据标签的供应商把事情搞反了 —— 范围是命名空间,相似度是该命名空间内的查找方式。
授权感知的失效机制并非可选
即便拥有完美的缓存键,条目变陈旧的方式也是传统缓存从未处理过的。在语义缓存上线之前,需要接入三个触发器:
权限变更。 每当用户的角色、权限或文档访问权限发生变化时,键前缀中包含该用户的所有缓存条目,以及在其现已撤销的权限下生成的每个条目,都必须失效。在实践中,这意味着缓存条目需要在写入时记录其授权指纹,以便后续失效操作可以针对它们。大多数语义缓存库并不这样做;它们只存储嵌入和响应,别无他物。
文档或知识库变更。 基于 RAG 的响应绑定到检索语料库的特定快照。当源文档被编辑、删除或重新分类时,由此衍生的每个缓存响应都会过时 —— 如果变更涉及内容删减,这可能会非常危险。写入缓存时的溯源元数据(哪些文档 ID 和版本贡献了此响应)使这个问题变得可控。如果没有它,唯一安全的选择就是全量清空,而这会摧毁你的命中率。
模型变更。 新模型版本可能具有不同的安全边界、不同的拒绝模式和不同的事实知识。在旧模型下生成的缓存响应不会被新模型的策略重新评估。如果新模型本会拒绝该查询,而缓存却提供了一个当前模型不会产生的响应 —— 这正是那种会 被外部红队发现,而你的评估套件却发现不了的一致性问题。
让这一切变得可管理的模式是写入时的溯源封袋(Provenance envelope):每个缓存条目不仅存储 (query_embedding, response),还存储 (query_embedding, response, tenant_id, user_id, auth_scope, doc_versions, model_version, prompt_version, generated_at)。失效变成了针对封袋的查询:“删除所有 user_id = X 的条目”,“删除所有 doc_versions 包含 Y 的条目”,“删除所有 model_version < Z 的条目”。没有封袋,你就没有精准的失效机制,只能依靠 TTL —— 而对于“该用户刚被解雇,其缓存响应包含机密战略”这种情况,TTL 是错误的工具。
区分缓存命中与泄露的审计追踪
问题的另一半在于,目前的语义缓存可观测性无法告诉你缓存命中是否正确。大多数实现中的“命中”事件仅记录:查询嵌入、匹配条目的嵌入、相似度分数、延迟。这些都无法告诉你该响应对于发起请求的用户是否合适。
安全审查真正能接受的审计日志每个命中事件需要包含三个额外字段:
- 授权决策差异。 发起请求的用户拥有什么范围?缓存条目是在什么范围内生成的?如果两者有任何不同,该命中默认就是可疑的,应该被拦截、通过调用模型重新验证,或标记以供人工审查。
- 溯源对账。 生成该条目的文档对于当前用户是否仍然可读?它们是否仍为当前版本?绕过检索的缓存命中仍应记录“本应检索到的内容”,以便使差异可见。
- 相似度区间分类。 0.99 余弦相似度的命中与 0.86 的命中在性质上是不同的。后者处于对抗性碰撞存在的区间,按相似度区间聚合命中可以让你发现攻击模式 —— 例如,单个租户上 0.86 命中的突然激增就是有人在探测阈值的特征。
很少有团队会针对这些进行埋点。大多数缓存命中只是一个简单的计数器增加。你第一次能产生这种审计追踪通常是在事故发生之后,因为事故才是让人开始询问你是否具备这种能力的原因。
像审查 SQL 谓词一样审查缓存键
让语义缓存 (semantic caching) 在生产环境中稳健运行的思维转变在于:不再将其视为带有安全隐患的性能优化手段,而是将其视为具有性能优势的授权数据通路。
你不会在没有 WHERE user_id = ? 谓词的情况下发布一个返回用户数据的 SQL 查询,而且你会在代码审查时像审查权限检查一样审查该谓词。语义缓存键 (semantic cache key) 就是以不同语法表达的相同谓词。它定义了谁有权接收此响应。它只是恰好被指定为 (tenant_prefix, scope_prefix, user_prefix, embedding),而不是 WHERE tenant_id = ? AND user_id = ?。
在任何语义缓存进入生产流量之前,有三个最低要求应该是准入门槛:(1) 作用域 (scope) 和身份 (identity) 必须是硬前缀 (hard prefixes),绝不能是可选的 vary-by 标签;(2) 每个条目都有足够的来源信封 (provenance envelope) 来驱动具备授权感知的失效机制 (entitlement-aware invalidation);(3) 每次命中都会发出一条包含授权差异 (authorization-diff) 和相似度区间 (similarity-band) 字段的审计记录,并保留足够长的时间以便进行事故重建。70% 的成本收益是真实的,性能团队在这点上没说错。但如果你通过偶尔向错误的用户提供数据来节省成本,那么这笔收益就不属于你。
已经运行具有行级安全性 (row-level security) 检索系统的工程师对此已经具备了大部分肌肉记忆。这种转换非常直观:缓存键就是谓词,来源信封就是审计日志,而相似度阈值是运行在硬授权检查 之后 (而不是替代它) 的软过滤器。以此方式构建,安全属性就是“从结构上杜绝此类发生”。以另一种方式构建——嵌入 (embedding) 优先,作用域作为元数据——安全属性则是“目前还没出事”。
- https://arxiv.org/abs/2601.23088
- https://arxiv.org/html/2403.02694v1
- https://www.nature.com/articles/s41598-026-36721-w
- https://www.giskard.ai/knowledge/cross-session-leak-when-your-index-assistant-becomes-a-data-breach
- https://medium.com/@instatunnel/semantic-cache-poisoning-corrupting-the-fast-path-e14b7a6cbc1f
- https://learn.microsoft.com/en-us/azure/api-management/llm-semantic-cache-lookup-policy
- https://github.com/zilliztech/GPTCache
- https://blaxel.ai/blog/multi-tenant-isolation-ai-agents
