大模型推理结果的多级缓存策略——从命中率调优到过期一致性保障
大模型推理结果的多级缓存策略——从命中率调优到过期一致性保障一、相同问题的第 1000 次回答——推理请求的重复性浪费对某 AI 产品的推理请求进行 30 天回溯分析发现在全部请求中语义相似度高于 95% 的几乎相同的请求占比达 18.7%。典型场景产品 FAQ用户在帮助页搜索如何退款每天出现约 2000 次每次都要走一遍完整的推理链路。代码补全同样的上下文前缀func (s *Service) GetUser(id string)在不同用户的编辑器中反复出现。翻译 API同一段英文翻译成中文的请求缓存失效后立刻重新推理。每次重复推理消耗约 0.5 秒的 GPU 时间和约 1500 tokens 的上下文窗口。如果 18.7% 的请求能命中缓存推理集群的 GPU 利用率可降低约 15%同时用户感知延迟从 800ms 降到可在大部分时候 5ms缓存命中时。二、多级缓存的架构设计flowchart TB A[推理请求] -- B{请求语义向量化} B -- C{缓存查找} subgraph L1[L1: 精确匹配缓存] L1A[Prompt Hash → 结果br/命中率 ~5%br/延迟 1ms] end subgraph L2[L2: 语义相似缓存] L2A[Embedding 向量br/Annoy/HNSW 检索br/相似度 0.95 → 返回br/命中率 ~12%br/延迟 5ms] end subgraph L3[L3: Prompt 前缀缓存] L3A[Radix Tree 前缀匹配br/共享 KV Cachebr/命中率 ~8%br/延迟节省 30-50%] end C --|精确命中| L1 C --|L1未命中| L2 C --|L2未命中| L3 L1 -- D[返回缓存结果] L2 -- D L3 -- E[复用KV Cachebr/减少推理时间] C --|全部未命中| F[完整推理] E -- F F -- G[缓存结果写入br/更新索引]2.1 三级缓存的职责划分缓存级匹配方式延迟命中率存储成本过期策略L1 精确匹配MD5(prompt) → 结果 1ms~5%低TTL 1h LRUL2 语义相似Embedding 最近邻 5ms~12%中TTL 1h 时效标记L3 KV Cache 复用Radix Tree 前缀匹配节省 30-50% 推理时间~8%高GPU 显存随 KV Cache 驱逐2.2 语义相似缓存的向量检索L2 层的核心挑战是从百万级历史 prompt 中快速找到语义相似的请求。AnnoyApproximate Nearest Neighbors Oh Yeah通过构建随机投影树在 O(log n) 时间内近似检索 Top-K 最近邻内存占用仅为暴力搜索的 1/100。选 Annoy 而非 Faiss 的原因Annoy 的索引文件可独立于进程加载mmap多个推理 Worker 可以共享同一份只读索引避免每个 Worker 各自加载一份索引占用内存。三、三级缓存的工程实现3.1 Prompt 哈希 LRU 精确匹配// exact_cache.go —— L1 精确匹配缓存 package cache import ( crypto/md5 encoding/hex sync time lru github.com/hashicorp/golang-lru/v2/expirable ) // // ExactMatchCache L1 层基于 Prompt 哈希的精确匹配 // // 使用 LRU 过期策略单 Worker 内存占用控制在 256MB // type ExactMatchCache struct { // 使用支持 TTL 过期的 LRU 实现 // maxSize10000: 最多缓存 10000 个结果 // 每个缓存项平均 2KB → 总内存约 20MB cache *lru.LRU[string, *CachedResult] mu sync.RWMutex stats CacheStats } type CachedResult struct { Response string json:response TokenCount int json:token_count CreatedAt time.Time json:created_at ExpiresAt time.Time json:expires_at } func NewExactMatchCache(maxSize int, ttl time.Duration) *ExactMatchCache { return ExactMatchCache{ cache: lru.NewLRU[string, *CachedResult]( maxSize, // onEvict: 条目被驱逐时的回调用于统计 func(key string, value *CachedResult) { // 记录驱逐事件用于容量规划 }, ttl, // 自动过期时间 ), } } // HashPrompt 计算 Prompt 的 MD5 哈希 // 注意去除了多余空格和换行保证 hash 一致性 func (c *ExactMatchCache) HashPrompt(prompt string) string { // 归一化去除首尾空格合并连续空格 normalized : normalizePrompt(prompt) hash : md5.Sum([]byte(normalized)) return hex.EncodeToString(hash[:]) } // Get 精确匹配查找 func (c *ExactMatchCache) Get( prompt string, ) (*CachedResult, bool) { key : c.HashPrompt(prompt) c.mu.RLock() defer c.mu.RUnlock() result, ok : c.cache.Get(key) if !ok { c.stats.IncMiss() return nil, false } // 二次校验过期LRU 的 TTL 已保证了大部分情况 if time.Now().After(result.ExpiresAt) { c.stats.IncExpired() return nil, false } c.stats.IncHit() return result, true } // Set 缓存推理结果 func (c *ExactMatchCache) Set( prompt string, result *CachedResult, ) { key : c.HashPrompt(prompt) c.mu.Lock() c.cache.Add(key, result) c.mu.Unlock() }3.2 语义相似缓存Annoy 向量检索# semantic_cache.py —— L2 语义相似缓存 import numpy as np import pickle import hashlib import time from annoy import AnnoyIndex from sentence_transformers import SentenceTransformer from typing import Optional, Tuple from dataclasses import dataclass, field from collections import OrderedDict dataclass class SemanticCacheEntry: 语义缓存条目 prompt: str response: str embedding: np.ndarray created_at: float expires_at: float hit_count: int 0 # 命中次数影响淘汰优先级 class SemanticCache: L2 语义相似缓存 基于 Annoy 近似最近邻检索 Sentence-BERT Embedding 在百万级历史 Prompt 中查找语义相似的结果。 def __init__( self, model_name: str all-MiniLM-L6-v2, dim: int 384, max_entries: int 500_000, similarity_threshold: float 0.95, ttl_seconds: int 3600, ): Args: model_name: Sentence-BERT 模型 dim: Embedding 向量维度 max_entries: 最大缓存条目数 similarity_threshold: 语义相似度阈值 ttl_seconds: 默认过期时间秒 self.model SentenceTransformer(model_name) self.dim dim self.max_entries max_entries self.similarity_threshold similarity_threshold self.ttl_seconds ttl_seconds # Annoy 索引用于向量检索 # n_trees100: 检索精度和速度的平衡 self.index AnnoyIndex(dim, angular) self.id_counter 0 # 单调递增 ID与 Annoy 索引对应 # 内存存储Annoy ID → 完整缓存条目 self.entries: OrderedDict[int, SemanticCacheEntry] ( OrderedDict()) def get( self, prompt: str ) - Optional[Tuple[str, float]]: 语义相似缓存查找 Args: prompt: 用户输入的文本 Returns: (缓存的响应文本, 相似度) 或 None # 1. 将 prompt 向量化 vec self.model.encode( [prompt], convert_to_numpyTrue )[0].astype(float32) # 2. Annoy 检索 Top-3 最近邻 # search_k-1 表示使用 build 时的 n_trees 参数 ids, distances self.index.get_nns_by_vector( vec, 3, search_k-1, include_distancesTrue) if not ids: return None # 3. 遍历最近邻检查相似度和过期 for annoy_id, dist in zip(ids, distances): entry self.entries.get(annoy_id) if entry is None: continue # 转换 angular 距离为余弦相似度 # angular 2 * arccos(cosine) / π # cosine ≈ cos(dist * π / 2) similarity float(np.cos(dist * np.pi / 2)) if similarity self.similarity_threshold: continue # 检查过期 if time.time() entry.expires_at: continue # 命中 entry.hit_count 1 return (entry.response, similarity) return None def set( self, prompt: str, response: str, ttl_seconds: Optional[int] None, ): 缓存推理结果 Args: prompt: 用户输入 response: 推理结果 ttl_seconds: 自定义过期时间None 使用默认值 # 1. 检查容量超过上限则淘汰 while len(self.entries) self.max_entries: # 淘汰策略优先淘汰过期条目其次淘汰命中率最低的 self._evict_one() # 2. 向量化 vec self.model.encode( [prompt], convert_to_numpyTrue )[0].astype(float32) # 3. 创建条目 now time.time() entry SemanticCacheEntry( promptprompt, responseresponse, embeddingvec, created_atnow, expires_atnow (ttl_seconds or self.ttl_seconds), ) # 4. 写入 Annoy 索引 annoy_id self.id_counter self.id_counter 1 self.index.add_item(annoy_id, vec) # 5. 存储条目 self.entries[annoy_id] entry # 移动到最后OrderedDict 按插入顺序用于 LRU 淘汰 self.entries.move_to_end(annoy_id) def _evict_one(self): 淘汰一个条目 if not self.entries: return # 策略 1淘汰过期的 now time.time() for aid, entry in list(self.entries.items()): if now entry.expires_at: del self.entries[aid] return # 策略 2淘汰命中次数最少的LRU 兜底 # 最少命中的在 OrderedDict 前面 oldest_id next(iter(self.entries)) del self.entries[oldest_id] def save_index(self, path: str): 持久化 Annoy 索引到磁盘 self.index.build(100) # 最终构建 self.index.save(path) def load_index(self, path: str): 从磁盘加载 Annoy 索引 self.index.load(path)3.3 过期策略的分层设计缓存的过期策略不能一刀切。不同类型的请求对时效性的容忍度不同请求类型缓存过期时间缓存时效标记翻译请求24 小时无翻译结果不变产品 FAQ1 小时last_updated标记FAQ 可能被修改代码补全30 分钟代码库 commit hash代码变更后过期事实性问答无缓存不缓存事实可能变化实时数据查询5 分钟data_freshness标记时效标记机制对于 FAQ 这类大部分时候不变偶尔可能更新的数据使用last_updated标记。缓存时记录当前 FAQ 的last_updated值查询时对比——如果 FAQ 已更新则缓存自动失效无需等待 TTL。四、缓存一致性的一类特殊问题——语义分歧4.1 相似但不应缓存的 Prompt语义相似缓存面临的一个风险是相似但不相同的 prompt 返回了相同的结果Prompt A: 如何提高员工效率 → 工作管理方面的回复 Prompt B: 如何降低员工效率 → 完全不相关的回复对 Embedding 模型来说A 和 B 的余弦相似度可达 0.93——提高和降低在向量空间中的差异被大量共享词淹没了。缓解对相似匹配加一道关键词差异检查。如果两个 prompt 的差异词中包含否定词不、没、方向词提高/降低、增加/减少即使相似度高也不走缓存。4.2 存储成本与命中率的平衡语义缓存的需求是尽可能多地缓存但这与内存成本直接冲突。Annoy 索引的 100 万条目在 384 维下内存占用约 1.5GB加上原始文本存储每个条目约 2KB总内存约 3.5GB。在推理节点已经紧张的显存预算下这 3.5GB 可能挤压模型可用的 KV Cache 空间。折中策略语义缓存放 Redis 集群而非推理节点本地内存命中延迟从 1ms 升到 3ms但不占用 GPU 节点的宝贵内存。五、总结推理结果缓存的核心不是存更多而是存对的和有用的三级分层L1 精确哈希 1ms、L2 语义相似 5ms、L3 KV Cache 前缀复用节省 GPU 推理时间各司其职。时效标记不同类型的请求用不同过期策略——FAQ 用last_updated标记、代码补全用 commit hash、实时查询不缓存。语义相似的安全阀关键词差异检查防止相似但含义相反的 prompt 误命中缓存。存储外移语义缓存放 Redis 而非推理节点本地避免侵占宝贵的 GPU 显存。