大模型 Token 缓存与语义去重:后端成本优化的工程实践
一、Token 消耗的"温水煮青蛙":大模型后端的隐性成本
大模型应用后端面临一个严峻的成本问题:相同或相似的请求被重复发送到 LLM,每次都消耗完整的 Token。用户反复询问"今天天气如何",每次都消耗 500+ Token 的 Prompt;不同用户询问"Python 如何读取 CSV",语义相同但措辞不同,无法命中缓存,每次都重新调用 LLM。
按 GPT-4 的定价计算,一个日均 10 万次调用的应用,如果 30% 的请求可以通过缓存命中,每月可节省数千美元。Token 缓存与语义去重,是大模型后端从"能用"走向"经济可用"的关键优化。
二、Token 缓存的分层架构
Token 缓存分为两层:精确匹配缓存(基于请求哈希)和语义匹配缓存(基于向量相似度)。前者命中率高但覆盖窄,后者覆盖宽但需要向量检索。
flowchart TD A[用户请求] --> B{精确缓存命中?} B -->|命中| C[直接返回缓存结果] B -->|未命中| D{语义缓存命中?} D -->|相似度 > 阈值| E[返回缓存结果 + 微调] D -->|未命中| F[调用 LLM] F --> G[结果写入精确缓存] F --> H[请求向量写入语义缓存] G --> I[返回结果] E --> I C --> I精确缓存使用请求的完整 Prompt 哈希作为 Key,适合完全相同的重复请求。语义缓存将 Prompt 转换为向量,通过余弦相似度查找语义相近的历史请求,适合措辞不同但意图相同的请求。
三、工程化实现
3.1 精确匹配缓存
// exact_cache.go package cache import ( "crypto/sha256" "encoding/hex" "time" ) type ExactCache struct { store map[string]*CacheEntry maxItems int ttl time.Duration } type CacheEntry struct { Response string CreatedAt time.Time HitCount int } func NewExactCache(maxItems int, ttl time.Duration) *ExactCache { return &ExactCache{ store: make(map[string]*CacheEntry), maxItems: maxItems, ttl: ttl, } } // 生成请求的缓存 Key func (c *ExactCache) generateKey(prompt string, model string) string { h := sha256.New() h.Write([]byte(prompt + "|" + model)) return hex.EncodeToString(h.Sum(nil)) } // 查询缓存 func (c *ExactCache) Get(prompt string, model string) (string, bool) { key := c.generateKey(prompt, model) entry, exists := c.store[key] if !exists { return "", false } // 检查 TTL if time.Since(entry.CreatedAt) > c.ttl { delete(c.store, key) return "", false } entry.HitCount++ return entry.Response, true } // 写入缓存 func (c *ExactCache) Set(prompt string, model string, response string) { // LRU 淘汰:超过容量时删除最久未访问的条目 if len(c.store) >= c.maxItems { c.evictOldest() } key := c.generateKey(prompt, model) c.store[key] = &CacheEntry{ Response: response, CreatedAt: time.Now(), HitCount: 0, } } func (c *ExactCache) evictOldest() { var oldestKey string var oldestTime time.Time first := true for k, v := range c.store { if first || v.CreatedAt.Before(oldestTime) { oldestKey = k oldestTime = v.CreatedAt first = false } } delete(c.store, oldestKey) }3.2 语义匹配缓存
// semantic_cache.go package cache import ( "context" "math" "sort" ) type SemanticCache struct { embeddings []*CacheVector embedder Embedder threshold float64 maxItems int } type CacheVector struct { Prompt string Response string Embedding []float64 CreatedAt int64 } type Embedder interface { Embed(ctx context.Context, text string) ([]float64, error) } type SearchResult struct { Prompt string Response string Similarity float64 } func NewSemanticCache(embedder Embedder, threshold float64, maxItems int) *SemanticCache { return &SemanticCache{ embeddings: make([]*CacheVector, 0), embedder: embedder, threshold: threshold, maxItems: maxItems, } } // 语义搜索:找到与请求最相似的历史缓存 func (c *SemanticCache) Search(ctx context.Context, prompt string) (*SearchResult, error) { queryVec, err := c.embedder.Embed(ctx, prompt) if err != nil { return nil, err } var results []SearchResult for _, entry := range c.embeddings { sim := cosineSimilarity(queryVec, entry.Embedding) if sim >= c.threshold { results = append(results, SearchResult{ Prompt: entry.Prompt, Response: entry.Response, Similarity: sim, }) } } if len(results) == 0 { return nil, nil } // 返回相似度最高的结果 sort.Slice(results, func(i, j int) bool { return results[i].Similarity > results[j].Similarity }) return &results[0], nil } // 写入语义缓存 func (c *SemanticCache) Set(ctx context.Context, prompt string, response string) error { vec, err := c.embedder.Embed(ctx, prompt) if err != nil { return err } if len(c.embeddings) >= c.maxItems { c.embeddings = c.embeddings[1:] } c.embeddings = append(c.embeddings, &CacheVector{ Prompt: prompt, Response: response, Embedding: vec, CreatedAt: 0, }) return nil } func cosineSimilarity(a, b []float64) float64 { var dot, normA, normB float64 for i := range a { dot += a[i] * b[i] normA += a[i] * a[i] normB += b[i] * b[i] } if normA == 0 || normB == 0 { return 0 } return dot / (math.Sqrt(normA) * math.Sqrt(normB)) }3.3 两级缓存协调器
// cache_coordinator.go package cache import ( "context" "fmt" ) type CacheCoordinator struct { exact *ExactCache semantic *SemanticCache } func NewCacheCoordinator( exact *ExactCache, semantic *SemanticCache, ) *CacheCoordinator { return &CacheCoordinator{exact: exact, semantic: semantic} } // 查询缓存:先精确后语义 func (cc *CacheCoordinator) Get( ctx context.Context, prompt string, model string, ) (string, bool, error) { // 第一层:精确匹配 if resp, hit := cc.exact.Get(prompt, model); hit { return resp, true, nil } // 第二层:语义匹配 result, err := cc.semantic.Search(ctx, prompt) if err != nil { return "", false, fmt.Errorf("语义搜索失败: %w", err) } if result != nil { return result.Response, true, nil } return "", false, nil } // 写入缓存:同时写入两层 func (cc *CacheCoordinator) Set( ctx context.Context, prompt string, model string, response string, ) error { cc.exact.Set(prompt, model, response) if err := cc.semantic.Set(ctx, prompt, response); err != nil { // 语义缓存写入失败不影响精确缓存 return fmt.Errorf("语义缓存写入失败: %w", err) } return nil }四、Token 缓存的 Trade-offs
语义缓存的准确性风险:余弦相似度 0.92 的两个请求,语义可能接近但答案不同。"Python 如何读取 CSV"和"Python 如何写入 CSV"的向量相似度可能超过 0.9,但答案完全不同。阈值设置过高会降低命中率,过低会返回错误答案。建议对事实性问答使用 0.95 以上的阈值,对开放式对话使用 0.90。
Embedding 调用的额外成本:语义缓存每次查询都需要一次 Embedding 调用,虽然比 LLM 便宜(约为 1/100),但在高 QPS 场景下仍是一笔不小的开销。优化策略是:对短 Prompt(< 50 字)优先使用精确缓存,只对长 Prompt 启用语义缓存。
缓存一致性问题:LLM 的回答具有随机性,相同 Prompt 可能得到不同答案。缓存命中时返回的是历史答案,可能不是最优答案。对于需要准确性的场景(如代码生成),建议在缓存结果中标注"来自缓存"并允许用户选择重新生成。
缓存淘汰策略的影响:LRU 淘汰可能删除高频但时间较早的缓存条目。对于大模型应用,建议使用 LFU(最不经常使用)策略,保留高频命中的条目。
五、总结
Token 缓存与语义去重是大模型后端成本优化的核心手段。精确缓存处理完全相同的请求,语义缓存覆盖措辞不同但意图相同的请求。落地路线上,建议先实现精确缓存(实现简单、零额外成本),积累数据后评估语义缓存的命中率,再决定是否引入。关键原则:缓存命中率比缓存覆盖率更重要,宁可少命中也不要返回错误答案。