大模型 Token 缓存与语义去重:后端成本优化的工程实践

cover

一、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 缓存与语义去重是大模型后端成本优化的核心手段。精确缓存处理完全相同的请求,语义缓存覆盖措辞不同但意图相同的请求。落地路线上,建议先实现精确缓存(实现简单、零额外成本),积累数据后评估语义缓存的命中率,再决定是否引入。关键原则:缓存命中率比缓存覆盖率更重要,宁可少命中也不要返回错误答案。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐