缓存失效策略:推理结果缓存不能只用 TTL

📅 2026/7/18 22:25:21
缓存失效策略:推理结果缓存不能只用 TTL
缓存失效策略推理结果缓存不能只用 TTL基础设施不需要漂亮话。一个简单的 TTL 设定 300 秒在模型热更新后可能让用户看到旧模型的结果而这个错误至少持续 300 秒。一、TTL 的隐藏缺陷失效不等于无效推理结果缓存使用 TTLTime To Live是最自然的做法——给缓存结果设置一个过期时间过期后自动清除下次请求重新推理。但 TTL 有一个根本性的缺陷它只在时间维度上判断过期完全不关心缓存内容本身是否已经过时。具体场景用户 A 在 10:00:00 提问公司最新的财报数据是什么LLM 推理后生成回答缓存 TTL 设为 600 秒。10:05:00知识库更新了 Q2 财报数据新的文档片段入库向量索引更新。10:05:30用户 B 提出同样的问题。缓存命中未到 600 秒返回旧的回答。用户 B 看到的是包含 Q1 财报数据的回答而实际上 Q2 数据已经可用。这个错误不是因为模型推理出错而是因为缓存不知道底层数据已经变化继续提供过期的结果。flowchart TD A[推理请求] -- B{缓存命中?} B --|命中| C{TTL 过期?} C --|否| D{依赖数据变更?} D --|否| E[返回缓存结果] D --|是| F[废弃缓存, 重新推理] C --|是| F B --|未命中| F F -- G[推理执行] G -- H[写入缓存 更新依赖版本号] H -- I[返回结果] D -.-|依赖追踪| J[知识库版本: v142br/模型版本: llama3-70bbr/Prompt 版本: p3] J -.- K{依赖版本变更?} K --|是| D style D fill:#fff3e0 style J fill:#e3f2fd style K fill:#e3f2fd二、基于版本的失效策略更可靠的缓存失效策略不是在时间到了时让缓存过期而是在数据变了时主动让缓存过期。这需要建立一个版本依赖体系。推理结果的正确性依赖于以下要素依赖项版本标识变更频率影响范围知识库数据kb_version每日数次涉及该数据的查询模型版本model_version每周-每月所有查询Prompt 模板prompt_version不定期所有查询推理参数config_hash不定期所有查询缓存 Key 应该包含这些版本信息func buildCacheKey(query string, ctx *InferContext) string { parts : []string{ infer, ctx.ModelVersion, // llama3-70b-v2 ctx.KBVersion, // kb-20240718-001 ctx.PromptHash, // sha256:a1b2c3... sha256Hash(query), // 输入哈希 } return strings.Join(parts, :) }当任何一个依赖项的版本变更时缓存 Key 自然变化旧缓存自动失效无需等待 TTL 过期。对于知识库版本不需要给整个知识库打一个全局版本号。更细粒度的做法是按文档 ID 集合构建版本// 推理时记录了检索到的源文档 type CacheMeta struct { SourceDocIDs []string KBVersionAt string } // 检查依赖文档是否被更新 func (c *CacheMeta) IsStale(kbClient KBClient) bool { for _, docID : range c.SourceDocIDs { if kbClient.GetVersion(docID) c.KBVersionAt { return true } } return false }这种基于源的失效策略精确度高——只有真正引用了被修改文档的缓存才会失效而不是整个缓存池全部清空。三、主动失效事件驱动的缓存清理基于版本的失效是被动的——新请求到来时检查版本并决定是否使用缓存。在某些场景下还需要主动失效——当数据变更发生时立即清理受影响的缓存。以知识库更新为例文档被更新 → Kafka 事件document.updated。缓存失效服务消费事件 → 查询哪些推理结果引用了该文档。清理这些缓存 → 下次相同请求将重新推理。实现这个机制需要一个反向索引记录每个文档被哪些缓存 Key 引用。// 推理结果缓存 // Key: infer:{model}:{kb_ver}:{input_hash} // Value: 推理结果 JSON // 反向索引 // Key: refdoc:{doc_id} // Value: Set of cache keys that reference this document func InvalidateByDocument(docID string) error { // 1. 查询哪些缓存引用了该文档 cacheKeys, err : redis.SMembers(ctx, refdoc:docID).Result() if err ! nil { return err } // 2. 批量删除缓存 if len(cacheKeys) 0 { pipe : redis.Pipeline() for _, key : range cacheKeys { pipe.Del(ctx, key) } _, err pipe.Exec(ctx) } // 3. 清理反向索引 redis.Del(ctx, refdoc:docID) return err }主动失效的代价是需要维护额外的索引结构。在 Redis 中每个文档的反向索引是一个 Set。如果一个热门文档被 10 万个缓存 Key 引用这个 Set 有 10 万个成员SMEMBERS操作会阻塞 Redis单线程模型。解决方案是使用SSCAN分批处理func InvalidateByDocumentBatched(docID string, batchSize int64) error { var cursor uint64 for { keys, cursor, err : redis.SScan(ctx, refdoc:docID, cursor, , batchSize).Result() if err ! nil { return err } if len(keys) 0 { redis.Del(ctx, keys...) } if cursor 0 { break } } redis.Del(ctx, refdoc:docID) return nil }四、多策略组合TTL 版本 主动失效在生产环境中三种策略各有其角色应该组合使用而非互斥策略触发方式延迟适用范围实现复杂度TTL时间到期最长 TTL 时间兜底策略低版本控制请求时检查实时下次请求模型/Prompt 变更中主动失效事件触发实时知识库数据变更高TTL 是兜底策略。即使版本控制或主动失效漏掉了某些缓存TTL 确保了缓存最长存活时间。TTL 的值应该设置得较长如 24 小时而不是作为主要的失效机制。版本控制是一般策略。模型版本和 Prompt 模板的变更频率低全局影响大。在缓存 Key 中嵌入版本号是最简洁的解决方案。主动失效用于高频变更场景。知识库数据可能每天更新数十次每次更新影响一部分缓存。主动失效可以确保受影响缓存立即被清理而 TTL 和版本控制只能保证在下次请求时生效。一个完整的缓存读取流程func GetCachedResult(query string, ctx *InferContext) (*Answer, error) { key : buildCacheKey(query, ctx) data, err : cache.Get(ctx, key) if err redis.Nil { return nil, ErrCacheMiss } if err ! nil { return nil, err } var cached CachedResult msgpack.Unmarshal(data, cached) // 检查源文档是否已变更主动失效的后备检查 for _, docID : range cached.SourceDocIDs { if kbClient.GetVersion(docID) cached.KBVersionAt { cache.Del(ctx, key) // 异步删除过期缓存 return nil, ErrCacheMiss } } return cached.Answer, nil }五、总结缓存失效策略的权衡TTL 单独作为缓存失效策略的适用场景非常有限——仅当底层数据不会变化或变化的影响可接受时。对于推理结果缓存数据源知识库、模型都会变化仅靠 TTL 不足以保证结果正确性。推荐的三层组合策略第一层预防在缓存 Key 中嵌入依赖版本号。模型或 Prompt 变更时自动全局失效。第二层检测从缓存读取时检查源文档版本。发现过期则删除缓存并触发重新推理。第三层兜底TTL 设定为 24 小时。确保漏网之鱼不会永远存活。最后缓存失效的监控非常重要。至少需要三个指标缓存命中率含命中但过期的比例。主动失效事件的延迟从数据变更到缓存被清理。过期缓存被命中的次数说明主动失效有遗漏。如果主动失效的覆盖率低于 95%说明反向索引的维护有漏洞需要排查。如果过期缓存命中率超过 1%说明主动失效的延迟过高需要优化事件处理的吞吐。