为大模型缓存建立可验证的失效规则
文章将过期回答定位为缓存依赖管理问题,提出显式记录答案依赖的数据来源、访问权限和提示词版本。通过定义变更触发的失效规则,约束精确缓存、语义缓存及检索相关缓存的复用条件。
AI 深度解读
AI 客服在 80 毫秒内返回了退款说明,语气笃定,内容却已经过时:政策一小时前更新,语义缓存仍在复用旧答案。问题出在缓存,而不是模型生成。换模型、改提示词,都修不好这条返回路径。
作者给出的办法是建立「新鲜度契约」:缓存答案必须记录适用范围、来源版本和失效条件,每次返回前证明自己仍然有效。文章没有性能对照实验,但把 AI 应用里容易遗漏的缓存正确性问题,拆成了一套可以实现和验证的工程流程。
同样叫缓存,复用的东西可能完全不同
作者先区分了四层缓存,因为它们出错时的影响范围并不一样。
Prompt 或 prefix caching 复用相同稳定前缀的模型计算,后面仍会生成新答案。精确响应缓存直接返回同一规范化请求的历史答案,请求一致也不能保证事实没变。语义缓存进一步放宽条件,只要问题相似就可能复用答案,因此还要判断两次请求的意图和权限是否一致。
检索与工具结果缓存则位于生成之前。它缓存的是模型接下来要使用的材料,一份过时的检索结果可能持续污染新生成的回答。重新调用模型,也未必能摆脱旧事实。
这四层不能共用一个全局 TTL(生存时间)。文档删除后,检索缓存可能需要立刻失效,而稳定前缀的计算结果仍然可以保留;替代文档尚未完成 embedding,也不能成为继续提供旧文档的理由。
作者用 24 小时 TTL 举例:系统已经知道答案有误,它却可能继续具备接近一天的返回资格。更新缓慢、风险低的帮助说明或许能接受;账户状态、价格、权限和政策通常不能只靠等待过期。
缓存命中只证明找到了旧结果,不证明旧结果现在还能用。
怎样让一条答案证明自己仍然有效
作者建议先为每条可缓存路由确定五件事,再设计缓存键:适用范围、依赖、新鲜度预算、失效触发条件,以及返回时留下的证据。
适用范围必须成为查询边界。至少隔离 tenant;涉及个人事实或权限时,还要纳入用户或角色。不能先从所有租户的向量中找相似答案,再补权限过滤。
下面的命名空间代码把范围约束放到了查找之前:
const namespace = [
"support-answer",
scope.tenantId,
scope.principalId ?? "shared",
scope.roleSetHash,
scope.region,
].join(":");
需要盯住的是 scope.principalId ?? "shared":允许共享应当是路由明确作出的决定,不能因为问题看起来通用,就默认答案适合所有人。
依赖记录解决另一个问题:究竟哪些变化会让答案失效?作者设计的 CachedAnswer 除了 value、namespace、createdAt、expiresAt,还保存 sourceVersions、promptVersion、modelRouteVersion、可选的 embeddingVersion、policyVersion,以及包含 sourceId、可选 chunkId 的 provenance。
其中 sourceVersions 最关键。它把答案和具体来源版本绑在一起,例如 policy/refunds 对应 rev-41。版本可以是内容哈希、单调递增修订号或变更流序号,要求是来源变化时可靠地变化。这样退款政策更新,只需处理依赖它的答案。
新鲜度预算则按路由的错误成本决定。作者举例,产品状态可以采用零分钟预算,直接跳过响应缓存;通用排障说明可能允许保留数天。这是设计示例,并非经过测量得出的通用配置。
最后,把返回资格集中到一个函数里:
function canServe(entry: CachedAnswer, live: LiveContext) {
if (entry.namespace !== makeNamespace(live.scope)) {
return { action: "bypassed", reason: "scope_mismatch" };
}
if (live.now >= new Date(entry.expiresAt)) {
return { action: "bypassed", reason: "ttl_expired" };
}
if (entry.promptVersion !== live.promptVersion) {
return { action: "invalidated", reason: "prompt_version_changed" };
}
if (entry.policyVersion !== live.policyVersion) {
return { action: "invalidated", reason: "policy_version_changed" };
}
if (entry.modelRouteVersion !== live.modelRouteVersion) {
return { action: "invalidated", reason: "model_route_changed" };
}
for (const [sourceId, version] of Object.entries(entry.sourceVersions)) {
if (live.sourceVersions[sourceId] !== version) {
return { action: "invalidated", reason: `source_changed:${sourceId}` };
}
}
return { action: "served", reason: "fresh" };
}
注意遍历 entry.sourceVersions 的那一段:缓存还没过期,只要当前来源版本不匹配,也必须失效。语义缓存应在取得候选后、返回文本前调用 canServe;候选集合本身仍须提前按范围隔离。
返回证据也应结构化。作者要求记录 served、bypassed 或 invalidated 及原因,并带上 requestId、route、cacheKeyHash,按需附加 entryAgeMs 和 sourceVersions。排查缓存不需要把完整提示词、答案写进日志,必要时再关联受访问控制的追踪记录。
文档改完以后,失效通知怎样追上缓存
有了版本字段,还得让来源变更真正进入缓存系统。作者倾向于通过 outbox 或 change-feed,在修改来源的同一事务中留下变更事件,避免文档已经更新,通知却漏发。
原文事件名为 knowledge.source.changed,示例记录了租户 acme 的 policy/refunds 从 rev-41 变为 rev-42,发生时间是 2026-10-31T10:15:00Z。
消费者需要更新当前版本注册表,把相关响应缓存和检索缓存标记为过时,并在需要时安排重新建立向量索引。作者更倾向于先标记失效,保留排查证据;前提是所有读取路径都严格拒绝这些记录。
重复和乱序事件必须单独处理。rev-42 已经生效后,旧事件不能让注册表退回 rev-41。删除事件也必须立即撤销来源,不能等待索引重建。
测试因此不能只检查命中率。文档更新与删除、角色降级与租户切换、提示词与工具策略发布、embedding 与模型路由版本变化,都要覆盖。事件延迟、重复、乱序,以及看似相似但必须拒绝的问题,也属于回归范围。作者要求在 CI 和连接真实缓存适配器的预发布环境中验证,因为漏洞可能藏在旁路读取、积压的 worker 或未隔离的索引中。
监控同样需要超出命中率:返回记录的年龄、事件到失效的延迟、各原因失效次数、错误语义命中、清理覆盖率和各路由缓存决策,都应进入观察范围。权限和权威事实已有明确版本时,不能只让另一个 LLM 判断答案是否安全。
我的判断
我赞同作者把「答案是否还能返回」做成独立工程规则。它能把一次含糊的「AI 答错了」,还原成来源版本、权限范围或失效链路的具体问题。相比全量清空,按来源失效也更有利于保留其他有效缓存。
但这是一套设计建议。原文没有实测数据,也没给出与纯 TTL、全量清空相比的延迟、成本或错误率对照,不能据此认定所有团队都值得承担这份复杂度。
它还有两个明显前提。首先,系统要能完整识别答案依赖。新增文档即使改变了应有答案,也未必出现在旧答案的 sourceVersions 中,原文没有展开这种情况。其次,当前版本注册表必须足够及时;事件尚未被消费时,旧答案仍可能通过检查,零分钟预算的路由尤其需要谨慎。
示例代码也没有检查已经定义的 embeddingVersion,更没有实现语义意图判断。canServe 是资格检查骨架,不能直接视为完整生产实现。低风险、更新少的内容,用短 TTL 可能已经足够;高风险路由则值得为版本和撤销机制投入更多工作。
能拿来做什么
- 给现有 Node AI 接口梳理缓存层次,分别列出响应、检索和工具结果缓存;对账户状态等要求即时准确的路由,先评估是否应跳过响应缓存。
- 把租户和权限边界放进缓存命名空间及向量候选分区,再集中实现
canServe,让每次返回都留下明确的决策原因。 - 从退款政策这类高影响来源开始,打通更新事件、版本注册表和定向失效,并验证删除、重复通知与乱序通知不会放出旧答案。
- 遇到「回答流畅但内容过时」的反馈时,先查缓存决策和来源版本,阻断相关记录,再比较来源变更、事件产生和消费者处理时间,补上回归验证后恢复缓存。