2.1 请求缓存层设计:精确缓存、语义缓存、TTL 与失效


文档摘要

2.1 请求缓存层设计:精确缓存、语义缓存、TTL 与失效 上一章我们算清楚了一笔账:在两成业务里,真正烧钱的是「重复又选错」。这一节我们动手解决「重复」这一半——请求缓存层。读完这一节,你应该能用一句话说清楚自己学到了什么:缓存不是把东西存起来那么简单,选对缓存策略、管好它的失效边界,才真正把 Token 拦在模型门外。 一句话先定调:缓存层到底在拦什么 缓存层的唯一使命,是把「本可以不调用模型就能回答的请求」拦截在模型之外。每一次命中,省下的不只是这一次返回结果所消耗的 Token,还有背后那一次完整的推理算力。对高频重复场景(比如客服话术、代码模板、固定问答),缓存命中率做到六成以上,整体的模型调用量往往能砍掉一半。

2.1 请求缓存层设计:精确缓存、语义缓存、TTL 与失效

上一章我们算清楚了一笔账:在两成业务里,真正烧钱的是「重复又选错」。这一节我们动手解决「重复」这一半——请求缓存层。读完这一节,你应该能用一句话说清楚自己学到了什么:缓存不是把东西存起来那么简单,选对缓存策略、管好它的失效边界,才真正把 Token 拦在模型门外。

一句话先定调:缓存层到底在拦什么

缓存层的唯一使命,是把「本可以不调用模型就能回答的请求」拦截在模型之外。每一次命中,省下的不只是这一次返回结果所消耗的 Token,还有背后那一次完整的推理算力。对高频重复场景(比如客服话术、代码模板、固定问答),缓存命中率做到六成以上,整体的模型调用量往往能砍掉一半。

但缓存有代价:它引入了一层「可能过时、可能错误、可能泄露隐私」的状态。做不好,省下的钱远不够填事故的坑。所以这一节我不只讲「怎么存」,更要讲「什么时候不能存」「什么时候必须失效」。

flowchart LR R[一次用户请求] --> Q{如何判断可缓存?} Q -- 纯只读/幂等 --> OK[允许进入缓存层] Q -- 含用户隐私 --> NO[禁止缓存/脱敏后缓存] Q -- 结果随时间变化 --> T[必须带 TTL 短时缓存] OK --> S[精确或语义命中判断]

精确缓存:最朴素也最可靠的第一道防线

精确缓存(Exact Cache)的逻辑直白到近乎无聊:把请求的完整输入(prompt + 参数)做哈希,作为键;命中则直接返回上次结果。它适合「同一句话会被反复问」的场景——比如固定的产品问答、固定的内部知识检索、批处理任务里的重复样本。

它强在哪

第一,零语义误差。既然键是逐字节相同的输入,返回的就是完全相同的输出,不存在「近似」带来的质量漂移。第二,实现成本极低。一个 Redis 或本地 LRU 就能扛住,不需要任何模型参与判断。第三,可解释、可审计。出问题能直接比对输入是否真的一致。

它弱在哪

最大的短板是键太「脆」。用户输入里但凡多一个空格、换一行、时间戳变了、会话 ID 不同,哈希就不一样,直接未命中。真实业务里,用户的提问经常带着微小变动(「帮我写个 Python 读取 csv 的函数」「用 python 读 csv 怎么写」),精确缓存对这些「同义不同形」的请求完全无能为力。

sequenceDiagram participant U as 用户请求 participant C as 精确缓存(哈希键) participant M as 模型 U->>C: 输入做哈希 → key alt key 已存在 C-->>U: 直接返回历史结果(零 Token) else key 不存在 C->>M: 转发请求 M-->>C: 返回结果 C->>C: 写入 key→结果 C-->>U: 返回结果 end

工程落地要点

  • 哈希前先做规范化(normalization):统一去首尾空白、折叠多余换行、统一大小写规则(按业务,不要盲目全小写,会破坏代码),能显著提升精确命中率。
  • 随请求变化的噪声字段(如 request_id、时间戳、session_id)剥离后再哈希,避免无意义的键膨胀。
  • 对批处理任务,先对输入去重再批量推理,比在线缓存更省事,命中率也更高。

语义缓存:让「意思一样」的请求也命中

语义缓存(Semantic Cache)是对精确缓存的直接补强。它不再比「字面上相不相同」,而是比「意思上近不近」:把请求向量化(embedding),在向量库里检索是否有「语义相似」的历史请求;相似度超过阈值就认为命中,直接复用(或微调后复用)历史结果。

它强在哪

它解决了精确缓存最痛的「同义不同形」问题。用户的自然语言千变万化,但意图高度集中。语义缓存能把这些变体合并到同一份答案上,命中率可以比精确缓存高出一个数量级,尤其适合开放问答、客服、搜索式查询。

它弱在哪——这里 90% 的人会踩坑

第一,相似 ≠ 相同,复用有质量风险。两个看起来像的问题,答案可能差之千里。比如「怎么删除数据库表」和「怎么清空数据库表」,一字之差,操作破坏性天差地别。若直接复用,可能给出危险建议。

第二,embedding 本身有成本和延迟。每次请求都要先调一次 embedding 模型,再查向量库——这本身也是一次调用,且要钱。当你的缓存命中率不够高时,反而会「为缓存多花一笔 embedding 的钱」。

第三,阈值难调。阈值太高,退化成近似精确缓存,命中率上不去;阈值太低,开始乱复用,质量塌方。

flowchart TD A[用户请求] --> B[文本向量化 embedding] B --> C[向量库相似度检索] C --> D{相似度 ≥ 阈值?} D -- 是 --> E[复用历史结果] D -- 否 --> F[调用大模型生成] F --> G[存 embedding+结果] G --> E E --> H[返回]

工程落地要点

  • 阈值建议从 0.92 起步(以 cosine 相似度计),结合业务逐步调。高风险的问答(涉及删除、转账、医疗、法律)宁可把阈值调高甚至关闭语义复用,只走精确缓存。
  • 给复用结果打「来源标记」,并记录被复用的原始问题,便于事后审计「这个答案到底是为谁生成的」。
  • 语义缓存与精确缓存应串联:先查精确(免费、零误差),未命中再查语义(付费、有风险)。这样绝大多数高频请求走精确通道,只有长尾才消耗 embedding。

TTL 与失效:缓存最容易被低估的半边天

很多人以为「缓存设个永不过期最省事」,这是生产事故的典型起点。缓存必须有寿命,因为模型背后的世界在变。

为什么要 TTL

  • 事实会过期:价格、政策、库存、接口文档、汇率,今天对明天可能就错。
  • 模型版本会升级:你换了个更强的模型,旧缓存的答案质量可能已落后。
  • 业务规则会改:返佣比例、风控阈值调整,旧答案直接违规。

所以 TTL(Time To Live)是缓存的「保质期」。读多写少、事实稳定的内容(如语法知识、固定模板)可以设较长 TTL(小时到天级);事实敏感、易变内容(如实时报价、用户个性化结果)必须短 TTL 甚至禁用缓存。

flowchart TD A[缓存写入] --> B[TTL 计时器启动] B --> C{到达 TTL?} C -- 未到 --> D[命中直接返回] C -- 已到 --> E[标记为过期] E --> F[下次请求穿透到模型] F --> G[用新结果覆盖旧缓存] D --> H[返回] G --> H

主动失效比被动 TTL 更关键

TTL 是「到点再说」,但很多变更是突发且重要的(运营改了活动规则、安全补丁发布)。这时需要主动失效

  • 按业务事件失效:活动下线、配置变更时,主动清掉相关缓存键或前缀。
  • 按命名空间失效:把缓存按业务域分桶(如 cache:faq:cache:price:),变更时清整个桶,避免漏删。
  • 版本号失效:在缓存键里带 model_versionprompt_version,模型升级后旧键自然全部失效,无需手动清。

一个真实踩坑:缓存了不该缓存的东西

我曾见过一个团队把「根据用户余额推荐理财产品」的结果也做了缓存,还设了 24 小时 TTL。结果某用户上午充值后,下午问的推荐还是基于上午余额给的——直接给了错配建议,差点引发投诉。教训很硬:凡结果依赖「用户私有状态」或「实时数据」,要么不缓存,要么键里必须包含这些状态的最小快照,且 TTL 极短。 涉及隐私(PII)的内容,更要谨慎,很多合规要求「不缓存用户身份信息」,这类必须脱敏甚至禁止缓存。

精确缓存 vs 语义缓存:一张选型对照表

讲了这么多,很多人还是纠结「到底上哪个」。我把两者的关键差异摊在一张表里,你照着对号入座:

维度 精确缓存 语义缓存
命中判断依据 输入逐字节相同(哈希) 语义向量相似度
额外成本 无(纯计算哈希) 每次需调 embedding 模型
质量风险 零(完全相同才复用) 有(相似≠相同,可能复用错答案)
抗输入扰动 弱(多空格即未命中) 强(同义不同形也能命中)
实现复杂度 极低 中(需向量库+embedding)
适用场景 标准化、批处理、固定问答 自然语言多、意图集中、事实稳定
典型命中率 取决于输入规范性 通常显著高于精确缓存

我的建议永远是串联而非二选一:先精确(免费、零误差),未命中再语义(付费、有风险但补长尾)。这样高频请求走精确通道几乎零成本,只有长尾才消耗 embedding。

flowchart TD A[请求] --> B[精确缓存查询] B -- 命中 --> C[零成本返回] B -- 未命中 --> D[语义缓存查询] D -- 相似度达标 --> E[复用历史结果] D -- 未命中 --> F[调用模型] F --> G[写回精确+语义两层] C --> H[返回] E --> H G --> H

缓存回填时机:写放大与一致性陷阱

缓存被命中是省钱,但「写入缓存」本身也有讲究——尤其是回填时机。常见两种策略:

  • 写穿(Write-Through):模型生成结果后立刻写回缓存。好处是下次一定命中;坏处是「写放大」——如果这条请求以后很少再被问,这次写入就是纯浪费,还占存储。
  • 写回(Write-Back)/ 懒写入:只在被重复命中时才写回。第一次穿透到模型,第二次若又来,说明值得缓存,此时再写。这避免了为「一次性请求」无意义地写缓存。

我更偏向懒写入:对长尾请求,它不会污染缓存;对真正高频的请求,第二次就沉淀成缓存。配合 TTL,缓存里留下的都是「活得久、问得多」的干货。

另一个坑是一致性:当业务数据更新,缓存若没跟着失效,就会返回旧答案。这在前文 TTL 与主动失效已讲过,但特别强调一点——回填时务必带上「数据版本号」或「来源时间戳」,这样即便 TTL 没到,也能通过版本比对发现脏数据。

一个可落地的成本账:缓存到底省了多少

假设你每天有 10 万次请求,平均每次输出 500 Token、单价折算每千 Token 0.01 元。若不做缓存,每天模型输出成本约 10 万 × 500 / 1000 × 0.01 = 50 元(仅输出侧,输入侧另算)。若缓存命中率做到 60%,其中精确命中占四成、语义五成、其余长尾——那么约 6 万次请求直接命中,省下约 30 元/天,一个月近 900 元,一年过万。这还没算输入 Token 和推理算力的节省。对中等规模业务,缓存层往往是「投入半天、回本一周」的划算买卖。

三策略选型地图:我建议你怎么选

作为作者,我替你做个取舍——不要三个都上,按业务形态选主角:

  • 只读、幂等、输入高度标准化(固定问答、批处理、模板生成)→ 主力用精确缓存,成本最低、最稳。
  • 自然语言多、意图集中、事实稳定(客服、知识库问答、搜索)→ 精确 + 语义串联,语义补长尾,但高风险类目单独设高阈值或禁用。
  • 事实易变 / 强隐私 / 强个性化→ 缓存要么极短 TTL,要么按「脱敏后的非私有部分」缓存,绝不缓存完整私有结果。
flowchart TD A[请求是否含私有状态?] -- 是 --> B[脱敏后缓存或禁缓存] A -- 否 --> C[输入是否高度标准化?] C -- 是 --> D[精确缓存为主] C -- 否 --> E[精确+语义串联] E --> F{是否高风险领域?} F -- 是 --> G[语义阈值调高/禁用复用] F -- 否 --> H[语义阈值 0.92 起步]

自检:这一节你带走了什么

回头看开头那句话——缓存层是「把本可以不调模型的请求拦在门外」。要做到这一点,你至少要做出三个决定:用精确还是语义(或串联)、TTL 设多长、哪些内容根本不能缓存。这三个决定比「用什么中间件」重要得多。下一节我们讲「没被缓存拦下的请求」该怎么办——那就是智能路由的战场。

提示:缓存层只解决「重复」。剩下大量一次性、差异化的请求,才是路由要接管的;同时,缓存写回(回填)的时机,也要和路由配合——这一点我们在 2.3 一起串起来讲。


发布者: 作者: 迟到的极客的小龙虾 转发
评论区 (0)
U