从数据管道到推理服务:链路中每个环节的缓存都值得审视
从数据管道到推理服务:链路中每个环节的缓存都值得审视
基础设施不需要漂亮话。缓存不是加在某个地方就能收工的——它应该出现在延迟最大、成本最高、重复计算最多的每一个节点上。
一、从端到端延迟看缓存的机会地图
一个典型的 RAG(检索增强生成)问答系统,端到端的请求链路拆解如下:
| 环节 | 操作 | 耗时 | 是否可缓存 |
|---|---|---|---|
| 1 | 请求解析与校验 | <1ms | 否 |
| 2 | 用户问题 Embedding | 40ms (GPU) | 否(每次问题不同) |
| 3 | 向量检索 Top-K 文档 | 8ms | 是(相似问题检索结果相同) |
| 4 | 文档片段重排序 | 15ms | 是(相同文档不变) |
| 5 | Prompt 组装 | <1ms | 否 |
| 6 | LLM 推理生成 | 2800ms (GPU) | 是(结果缓存,收益最大) |
| 7 | 后处理与格式化 | 2ms | 否 |
在这 7 个环节中,3 个环节有缓存机会。如果不加任何缓存,端到端延迟约 2866ms。如果在环节 6 命中缓存,延迟直接降至 50ms 以内——这就是缓存 98% 的延迟削减效果。
但如果不审视链路,通常只会看到"结果缓存"这一个机会。而忽视了:
- 环节 3 的检索结果缓存(不同用户的相似问题检索 Top-K 结果高度重叠)。
- 环节 4 的 Rerank 结果缓存(相同文档集合的排序结果不变)。
- 数据管道的中间结果缓存(文档处理、分块、Embedding 等可以增量复用)。
flowchart LR
subgraph 数据管道
A1[原始文档] --> A2[文档解析] --> A3[智能分块] --> A4[Embedding] --> A5[向量索引]
A2 -.->|缓存| C1[(解析结果)]
A3 -.->|缓存| C2[(分块结果)]
A4 -.->|缓存| C3[(Embedding向量)]
end
subgraph 推理服务
B1[用户查询] --> B2[Query Embedding] --> B3[向量检索] --> B4[Rerank] --> B5[Prompt组装] --> B6[LLM推理]
B3 -.->|缓存| C4[(检索结果)]
B4 -.->|缓存| C5[(Rerank结果)]
B6 -.->|缓存| C6[(推理结果)]
end
style C1 fill:#e8f5e9
style C2 fill:#e8f5e9
style C3 fill:#e8f5e9
style C4 fill:#fff3e0
style C5 fill:#fff3e0
style C6 fill:#ffcdd2
二、数据管道中的中间结果缓存
数据管道(Ingestion Pipeline)的中间结果缓存是最容易被忽视的。典型流程:原始文档 → 格式解析 → 智能分块 → Embedding 向量化 → 写入索引。
解析结果缓存:对于 PDF/Word/HTML 等富文本文档,解析是 CPU 密集操作。一个 50 页的 PDF,使用 PyMuPDF 解析需要 2-5 秒。如果文档内容不变,解析结果应该被缓存。
def parse_document(doc_id: str, raw_data: bytes) -> ParsedDocument:
cache_key = f"parse:{doc_id}:{sha256(raw_data).hexdigest()}"
cached = redis.get(cache_key)
if cached:
return msgpack.unpackb(cached)
parsed = pymupdf.open(raw_data)
result = extract_text_and_structure(parsed)
redis.setex(cache_key, 86400 * 7, msgpack.packb(result)) # 7天
return result
分块结果缓存:文档经过分块策略(固定长度、语义分块、递归分块等)后的结果。分块算法变更时需要全局失效,可以通过在 cache key 中加入分块策略版本号实现:
chunk:{doc_id}:{doc_hash}:{chunk_strategy_version}
Embedding 结果缓存:这是数据管道中 ROI 最高的缓存。单个文档的 Embedding 计算消耗 GPU 时间,且可能被多次使用(同一文档被不同的索引引用)。详细内容已在第 6 篇文章中讨论。
三、检索环节的缓存设计
向量检索结果在以下场景高度可缓存:
- 高频热点问题:知识库类产品中,"如何入职""请假流程"等常见问题每天被搜索数百次,检索 Top-K 结果完全相同。
- 批量相似查询:用户可能对同一个主题连续追问 3-5 个问题,这些问题共享相同的向量检索空间。
- 会话内缓存:同一会话中,用户问题的检索上下文有局部性。
检索缓存的实现:
type SearchCache struct {
client *redis.ClusterClient
index *VectorIndex
ttl time.Duration
}
func (c *SearchCache) Search(query string, topK int) ([]*Document, error) {
key := fmt.Sprintf("search:%x:%d", sha256.Sum256([]byte(query)), topK)
cached, err := c.client.Get(ctx, key).Bytes()
if err == nil {
var docs []*Document
msgpack.Unmarshal(cached, &docs)
return docs, nil
}
// 未命中,执行真实检索
docs, err := c.index.Search(query, topK)
if err != nil {
return nil, err
}
// 写入缓存(异步)
go func() {
data, _ := msgpack.Marshal(docs)
c.client.Set(ctx, key, data, c.ttl)
}()
return docs, nil
}
检索缓存的失效需要与知识库版本绑定。当知识库更新时,检索缓存应选择性失效——只清理涉及被修改文档的缓存。但实际上,向量检索的 Top-K 结果可能因为任意文档的修改而改变,这种"选择性失效"的维护成本高于收益。实践中更简单的做法是将知识库版本嵌入缓存 Key:
search:{kb_version}:{query_hash}:{topK}
当知识库版本变更时,旧版本的缓存自然不再被使用。
Rerank 环节的缓存设计与此类似。Rerank 模型(如 bge-reranker-v2-m3)对一组文档-查询对计算相关性分数。如果相同的 query + 相同的文档集合,Rerank 结果完全可缓存。Key 设计:
rerank:{reranker_model_version}:{query_hash}:{doc_ids_hash}
四、缓存的全链路协同
多层缓存在同一条链路中同时存在,需要协同管理:
(1)命中率传递效应
上游缓存命中后,下游的检索和推理步骤全部跳过。这意味着下游缓存的命中率统计会夸大——很多请求在上游就返回了,根本没有走到下游的缓存层。在构建命中率看板时,需要以"总请求数"为分母而非"到达该层的请求数"。
(2)一致性窗口
不同层的缓存有不同的 TTL 和失效时机:
- 推理结果缓存:跟随模型版本、知识库版本失效。
- 检索结果缓存:跟随知识库版本失效。
- 中间结果缓存:跟随原始文档内容和算法版本失效。
存在一个时间窗口,其中检索缓存已刷新(反映了新的知识库)但推理结果缓存尚未失。此时会命中旧的推理结果,导致回答与当前检索结果不一致。这个窗口的大小取决于主动失效的延迟。通过统一的知识库版本号作为所有缓存层的一级前缀,可以避免这个不一致:
{kb_version}:{layer}:{params}
当 kb_version 变更时,所有层的缓存同时全局失效。
(3)缓存穿透的协同防护
多层缓存意味着多层穿透风险。如果在推理结果缓存层使用了 singleflight 模式,但在检索缓存层没有使用,大量并发请求会在检索层产生重复计算。每一层独立做穿透防护是必要的。
五、总结:缓存审视的 checklist
从数据管道到推理服务的全链路缓存审视,可以按这个 checklist 逐项排查:
- 数据管道:文档解析结果是否有缓存?分块结果是否有缓存?Embedding 向量是否有缓存?分块策略和 Embedding 模型版本是否已纳入缓存 Key?
- 推理请求入口:请求解析是否需要缓存?HTTP 请求体是否有缓存空间(如 GET 请求的 ETag)?
- 检索环节:高频问题是否命中检索缓存?检索缓存是否按知识库版本隔离?相同 question 不同 topK 是否分开缓存?
- Rerank 环节:query + document set 的组合是否有缓存?Rerank 模型版本是否纳入 Key?
- 推理生成:推理结果缓存是否支持精确匹配 + 语义匹配?是否有 singleflight 防护?失效策略是否组合了 TTL + 版本 + 主动失效?
- 监控:每层的命中率、延迟分布、内存占用是否都有 Prometheus 指标?是否有端到端的缓存延迟对比(命中 vs 未命中)?
缓存在 AI 系统中不是锦上添花的优化,而是架构层面的设计决策。一个没有缓存的 AI 推理系统,等于每一次用户请求都在烧 GPU 的钱。审视全链路的缓存机会,本质上是在审视"哪些计算是重复的,哪些算力是可以省下来的"。