多级缓存架构:推理结果缓存、特征缓存和模型缓存分层设计
基础设施不需要漂亮话。当一个用户重复问同一个问题时,回答不该消耗昂贵的 GPU 算力。
一、从一次 3 秒的推理看缓存的层级
用户在一个知识库问答产品中输入问题"Kubernetes Pod 的 QoS 类型有哪些?"。系统经历了以下链路:
- 文本 Embedding 模型将问题向量化:45ms(GPU)
- 向量检索 Top-5 文档片段:12ms(CPU + 向量索引)
- LLM 推理生成回答:2800ms(GPU)
总计 2857ms,其中 98% 的时间花在 LLM 推理上。
5 分钟后,另一个用户问了完全相同的问题。链路完全重复,又消耗了 2800ms 的 GPU 推理时间。如果系统有任何一层缓存命中,这 2800ms 的 GPU 算力可以被省下来。
多级缓存的核心思想是:在请求链路的每一层都检查是否可以复用已有的计算结果。对于 RAG 场景,自然的缓存分层是:
| 层级 | 缓存内容 | 命中条件 | 节省的算力 |
|---|---|---|---|
| L1:结果缓存 | 完整回答文本 | 完全相同的输入 | LLM 推理(最昂贵) |
| L2:特征缓存 | 文本 Embedding 向量 | 相同的文本片段 | Embedding 推理 + 检索 |
| L3:模型缓存 | 模型权重文件 | 模型已加载到显存 | 模型加载时间 |
flowchart TD A[用户查询] --> B{L1: 结果缓存} B -->|命中| R1[直接返回回答<br/>延迟 < 5ms] B -->|未命中| C[文本 Embedding] C --> D{L2: 特征缓存} D -->|命中| E[跳过 Embedding<br/>直接检索] D -->|未命中| F[GPU Embedding 推理] F --> G[向量检索] E --> G G --> H[LLM 推理生成] H --> I[写入 L1 结果缓存] I --> R2[返回回答] style B fill:#e8f5e9 style D fill:#fff3e0 style R1 fill:#c8e6c9二、L1:推理结果缓存的 Key 设计与语义匹配
结果缓存是最直接、收益最高的缓存层。但"完全相同的问题"在生产环境中并不常见——用户可能换个表述问同一件事。
精确匹配缓存:对原始输入做 SHA256 哈希作为 Key。命中率取决于用户群的提问习惯。对于搜索引擎类产品(用户倾向于输入关键词),精确命中率通常在 5%-15%。
语义匹配缓存:对输入做 Embedding 后,在缓存池中检索语义相似的已有回答。当相似度 > 0.95 时,直接复用。这种方式可以将命中率提升到 25%-40%,但代价是每次请求都需要计算一次 Embedding。
type ResultCache struct { exact *redis.ClusterClient // 精确匹配缓存 vector *VectorIndex // 语义缓存索引 } func (c *ResultCache) Get(ctx context.Context, query string) (*Answer, bool) { // 先尝试精确匹配 key := fmt.Sprintf("result:%x", sha256.Sum256([]byte(query))) if ans, err := c.exact.Get(ctx, key).Bytes(); err == nil { return unmarshalAnswer(ans), true } // 再尝试语义匹配 emb := embedding(ctx, query) candidates := c.vector.Search(emb, 5, 0.95) if len(candidates) > 0 { return candidates[0].Answer, true } return nil, false }语义匹配缓存有一个陷阱:相似度阈值的设定。设得太高(>0.98),命中率提升不明显;设得太低(<0.90),可能出现"答非所问"。实践中 0.95 是经验上的安全阈值。
三、L2:特征缓存的细粒度复用
特征缓存主要针对文本 Embedding。在 RAG 流水线中,需要 Embedding 的数据有两类:
- 用户查询:每次请求都不同,缓存收益有限。
- 知识库文档片段:稳定不变,缓存收益极高。
对于知识库文档,正确的做法是在文档入库时计算并持久化 Embedding。但很多团队忽略了文档更新时的增量 Embedding——每次更新任意一个文档片段,都触发全量重新 Embedding。这导致了大量重复计算。
特征缓存的 Key 设计:
emb:{version}:{content_sha256}其中version是 Embedding 模型的版本号。当模型升级(如从 ada-002 切换到 text-embedding-3-small)时,只需要修改 version 前缀,旧缓存自然失效,新缓存逐步填充。
特征缓存存储的数据量通常很大。一个 1536 维的 float32 向量占 6KB。100 万条知识库片段占用 6GB。如果使用 Redis 存储全部 Embedding,内存成本较高。折中方案是使用 Redis 作为热缓存,PostgreSQL + pgvector 作为全量持久化存储,二者通过 LRU 策略保持一致性。
四、L3:模型缓存的层级存储
模型缓存与其他缓存不同——它的"命中"不是按请求计算的,而是按 Pod 生命周期计算的。一个模型文件被加载到 GPU 显存后,只要 Pod 不回收,就不需要重复加载。
在这个层级,缓存优化的方向不是提高命中率,而是缩短命中后的加载路径:
L3-A: GPU 显存(已加载) → 0ms(热 Pod) L3-B: 本地 NVMe → 1-60s(冷 Pod,本地有模型) L3-C: 网络存储 / 对象存储 → 2-20 分钟(冷 Pod,需下载模型)这是典型的层级存储架构。关键策略:
- Keep-Warm Pod 池:维护 N 个预加载模型的热 Pod,请求优先路由到热 Pod。
- 节点亲和调度:推理调度器优先将 Pod 调度到已缓存该模型的节点。
- 模型预热 DaemonSet:在 GPU 节点上运行 DaemonSet,提前将常用模型拉取到本地 NVMe。
对于多模型平台,一个节点可能同时需要缓存 10+ 个模型。本地 NVMe 容量有限,需要在 DaemonSet 中实现基于使用频率的淘汰策略:
磁盘使用率 > 80% → 淘汰最近 7 天未被加载过的模型 磁盘使用率 > 90% → 淘汰最近 24 小时未被加载过的模型 磁盘使用率 > 95% → 只保留当前正在运行的模型,触发告警五、总结:缓存的 ROI 要从最贵的算力开始算
多级缓存架构的正确建设顺序是:先做最顶层(收益最大),再逐层向下延伸。
- L1 结果缓存:ROI 最高,一行 Redis SET 能省下价值数美元的 GPU 推理。
- L2 特征缓存:ROI 中等,收益主要来自于避免知识库全量重新 Embedding。
- L3 模型缓存:ROI 体现在服务可用性上(减少冷启动时间),而不是单次请求的成本。
还有一个容易被忽视的设计点:缓存穿透的一致性保障。当 L1 结果缓存未命中时,应使用分布式锁或 singleflight 模式,避免相同请求并发穿透缓存层,造成 GPU 资源的重复消耗。Go 语言中golang.org/x/sync/singleflight是实现这个功能的推荐方案。
最后,所有缓存层都应该暴露 Prometheus 指标:命中率、缓存大小、淘汰数量。没有这些指标的多级缓存架构,等于在黑盒中做决策。