大模型 Token 缓存与语义去重:后端成本优化的工程实践
大模型 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 缓存与语义去重是大模型后端成本优化的核心手段。精确缓存处理完全相同的请求,语义缓存覆盖措辞不同但意图相同的请求。落地路线上,建议先实现精确缓存(实现简单、零额外成本),积累数据后评估语义缓存的命中率,再决定是否引入。关键原则:缓存命中率比缓存覆盖率更重要,宁可少命中也不要返回错误答案。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)