从数据管道到推理服务:链路中每个环节的缓存都值得审视

从数据管道到推理服务:链路中每个环节的缓存都值得审视

基础设施不需要漂亮话。缓存不是加在某个地方就能收工的——它应该出现在延迟最大、成本最高、重复计算最多的每一个节点上。

一、从端到端延迟看缓存的机会地图

一个典型的 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 逐项排查:

  1. 数据管道:文档解析结果是否有缓存?分块结果是否有缓存?Embedding 向量是否有缓存?分块策略和 Embedding 模型版本是否已纳入缓存 Key?
  2. 推理请求入口:请求解析是否需要缓存?HTTP 请求体是否有缓存空间(如 GET 请求的 ETag)?
  3. 检索环节:高频问题是否命中检索缓存?检索缓存是否按知识库版本隔离?相同 question 不同 topK 是否分开缓存?
  4. Rerank 环节:query + document set 的组合是否有缓存?Rerank 模型版本是否纳入 Key?
  5. 推理生成:推理结果缓存是否支持精确匹配 + 语义匹配?是否有 singleflight 防护?失效策略是否组合了 TTL + 版本 + 主动失效?
  6. 监控:每层的命中率、延迟分布、内存占用是否都有 Prometheus 指标?是否有端到端的缓存延迟对比(命中 vs 未命中)?

缓存在 AI 系统中不是锦上添花的优化,而是架构层面的设计决策。一个没有缓存的 AI 推理系统,等于每一次用户请求都在烧 GPU 的钱。审视全链路的缓存机会,本质上是在审视"哪些计算是重复的,哪些算力是可以省下来的"。

© 版权声明

相关文章