Administrator
发布于 2026-06-08 / 728 阅读
15

AI 场景下的缓存体系重构

语义缓存上线一个月,命中率 8.3%

三月份我们给客服 Agent 上了语义缓存,预期是「相似问题不用重复问模型,能省一大笔钱」。上线一个月看数据,命中率 8.3%,省下的钱还不够维护缓存本身的成本。

这篇文章记录我们怎么把命中率从 8.3% 提到 41%,以及中途推翻重做的一版设计。

先看为什么这么低

第一版实现很直白:把用户问题向量化,和缓存里的历史问题算余弦相似度,超过阈值就返回缓存的答案。

// 第一版:简单粗暴
public Optional<String> lookup(String question) {
    float[] qv = embeddingModel.embed(question);

    List<CachedQA> candidates = vectorStore.search(qv, 1);
    if (!candidates.isEmpty() && candidates.get(0).score() > 0.85) {
        return Optional.of(candidates.get(0).answer());
    }
    return Optional.empty();
}

阈值 0.85 是我们拍脑袋定的。我把一个月里所有未命中的查询拉出来分析,归了类:

未命中原因占比例子
确实没有相似问题34%长尾的个性化问题
语义相似但答案不同28%「我的订单到哪了」vs「你的订单到哪了」(不同用户)
阈值太严被拒23%相似度 0.82 但实际可以复用
缓存已被清除15%TTL 或容量淘汰

第二类最典型。缓存命中了,但答案是针对另一个用户的——我们差点出事故:用户 A 问「我的订单到哪了」,缓存里存着用户 B 的答案,返回了 B 的物流信息。

发现得早(内测阶段用户反馈的),但这暴露了根本问题:我们把「语义相似」当成了「可以复用答案」,这两件事差得远

重构:缓存要分层,不能一把梭

第二版我们把缓存拆成了三层,每层的 key 设计、相似度要求、TTL 都不一样。

缓存什么Key命中要求TTL
L1 精确缓存标准问答对问题归一化后的字符串 hash完全一致7 天
L2 语义缓存通用知识问答向量 + 租户 + 意图标签相似度 > 0.78 且同租户同意图24 小时
L3 检索缓存RAG 召回结果查询向量 + 知识库版本相似度 > 0.88随知识库版本失效

L1:把能确定的东西先确定下来

我们统计发现,客服问题里有 31% 是「标准问题」——退款政策、配送范围、保修条款这类。这些问题的答案是固定的,完全可以精确匹配。

@Service
public class ExactAnswerCache {

    public Optional<String> lookup(String question) {
        String normalized = normalize(question);

        // 1. 先查规则库:这类问题根本不该过模型
        return policyRepo.matchByKeyword(normalized)
            .map(Policy::getStandardAnswer)
            .or(() -> Optional.ofNullable(
                cache.get(normalizedHash(normalized))));
    }

    /** 归一化:去空格、去标点、繁简转换、同义词替换 */
    private String normalize(String q) {
        String s = q.trim().toLowerCase(Locale.ROOT);
        s = s.replaceAll("[\\s\\p{Punct}]+", "");
        s = SYNONYM_MAP.entrySet().stream()
            .reduce(s, (acc, e) -> acc.replace(e.getKey(), e.getValue()),
                    (a, b) -> b);
        return s;
    }
}

归一化这一步很关键。我们统计过原始问题的重复率只有 12%,归一化之后是 29%。主要是把「怎么退款」「如何退款」「退款怎么操作」这类表述统一。

这层命中率 29%,但它是纯内存查询,成本几乎为零,而且完全确定性——不会出现前面那种串答案的问题。

L2:语义缓存必须带约束条件

这层是重构重点。核心改动是:语义相似只是必要条件,还要满足「同租户 + 同意图 + 无用户特定信息」三个约束

@Service
public class SemanticAnswerCache {

    private static final double SIM_THRESHOLD = 0.78;

    public Optional<String> lookup(String question, String tenantId) {
        Intent intent = classifier.classify(question);      // 意图分类,便宜的小模型

        // 涉及用户特定信息的意图,直接不查语义缓存
        if (intent.isUserSpecific()) {
            return Optional.empty();
        }

        float[] qv = embeddingModel.embed(question);

        SearchRequest req = SearchRequest.builder()
            .queryVector(qv)
            .topK(3)
            .similarityThreshold(SIM_THRESHOLD)
            .filterExpression(
                "tenant_id == '" + tenantId + "' && intent == '" + intent.id() + "'")
            .build();

        return vectorStore.search(req).stream()
            .filter(d -> !containsPersonalData(d.getText()))   // 兜底检查
            .findFirst()
            .map(d -> d.getMetadata().get("answer").toString());
    }
}

三个约束的作用:

  • 同租户:不同租户的知识库不同,答案不能通用;
  • 同意图:把候选范围收窄,「退款流程」的回答不会被「物流查询」命中;
  • 排除用户特定意图:查订单、查物流、查余额这类,直接不查缓存。

意图分类器用最便宜的小模型(qwen-turbo),单次 0.00003 元,2ms。它把语义缓存的候选池从「所有历史问答」缩小到「同租户同意图的历史问答」,既提升了安全性,也提升了命中率——因为在小范围内,相似度阈值可以更宽松。

// 阈值从 0.85 降到 0.78,但因为范围收窄了,准确率反而上升
// 实测:阈值 0.85 无意图过滤 → 命中率 8.3%,误命中率 2.1%
//       阈值 0.78 带意图过滤 → 命中率 26.4%,误命中率 0.3%

误命中率是我们最关注的指标。它指的是「缓存命中了但答案不对」的比例。我们用一个抽样校验来测:每天随机抽 200 条缓存命中的回答,让模型判断「这个回答是否准确回应了这个问题」。

L3:检索缓存,这层收益最大

第三层不在「问答」层面,在「检索」层面。用户问题不同,但召回的文档片段经常是同一批。

@Service
public class RetrievalCache {

    /** key 里带知识库版本号,版本变了自动失效 */
    public List<Document> retrieve(String query, String tenantId) {
        String kbVersion = kbVersionService.current(tenantId);
        String cacheKey = "rag:" + tenantId + ":" + kbVersion;

        float[] qv = embeddingModel.embed(query);

        return retrievalCache.get(cacheKey, qv, this::doRetrieve);
    }

    private List<Document> doRetrieve(float[] qv) {
        return vectorStore.search(SearchRequest.builder()
            .queryVector(qv).topK(20).build());
    }
}

这层的语义缓存实现和 L2 类似,但缓存的是「文档 ID 列表」而不是「答案」,所以即使用户不同、问题表述不同,只要召回的文档一样就能复用。

命中率 38%,而且它省的不只是 embedding 和检索的开销,还有后续 rerank 的费用(0.0012 元/次)。

失效策略

这块我们踩过坑,单独说。

坑:TTL 一刀切

第一版所有缓存都是 24 小时 TTL。问题是:

  • 知识库更新了,答案还是旧的(用户看到了过时的政策);
  • 高频问题的缓存刚过期就被大量重新计算,形成「缓存雪崩」——我们在某个时间点看到模型调用量突然涨了 4 倍。

解法是按内容类型分 TTL + 主动失效

@Configuration
public class CacheConfig {

    @Bean
    CacheManager cacheManager(KbVersionService kbVersion) {
        // 1. 按类型设置不同 TTL
        Map<String, CacheConfigSpec> specs = Map.of(
            "answer.policy",  spec(Duration.ofDays(7)),    // 政策类,变化慢
            "answer.general", spec(Duration.ofHours(12)),  // 通用类
            "answer.dynamic", spec(Duration.ofMinutes(10)),// 动态类(库存、价格)
            "retrieval",      spec(Duration.ofHours(6))
        );

        // 2. 主动失效:知识库更新时清对应租户的缓存
        kbVersion.onChange((tenantId, oldV, newV) -> {
            log.info("kb updated, invalidating cache for tenant {}", tenantId);
            cacheManager.evictByPrefix("rag:" + tenantId + ":");
            cacheManager.evictByPrefix("answer:" + tenantId + ":");
        });

        return build(specs);
    }
}

主动失效的实现是知识库更新流程里发一个事件,缓存服务订阅。这个比 TTL 靠谱得多——TTL 是「假设什么时候会变」,主动失效是「变了就清」,后者才是对的

防雪崩

三个措施:

  1. TTL 加随机抖动。不要所有 key 同一时间过期:ttl * (0.85 + random() * 0.3)
  2. 热点 key 提前刷新。命中次数 Top 1000 的 key,在 TTL 剩余 20% 时异步刷新;
  3. 缓存未命中时限流。这个最重要——如果大量请求同时未命中,要限流保护下游模型。
// 缓存击穿保护:同一 key 的并发回源只放一个
private final ConcurrentHashMap<String, CompletableFuture<String>> inflight
    = new ConcurrentHashMap<>();

public String getOrLoad(String key, Supplier<String> loader) {
    String hit = cache.getIfPresent(key);
    if (hit != null) return hit;

    return inflight.computeIfAbsent(key, k ->
        CompletableFuture.supplyAsync(loader, refreshPool)
            .whenComplete((v, e) -> {
                if (v != null) cache.put(k, v);
                inflight.remove(k);
            })
    ).join(30, TimeUnit.SECONDS);
}

加了这个之后,我们一次缓存集体失效时的模型调用尖峰从 4 倍降到 1.3 倍。

结果

指标重构前重构后
L1 精确命中率12%29%
L2 语义命中率8.3%26.4%
L3 检索命中率0(没有这层)38%
综合缓存命中率8.3%41.2%
误命中率2.1%0.3%
单次对话成本¥0.041¥0.026
P99 延迟3.2s1.9s
月成本-36%

P99 从 3.2 秒降到 1.9 秒,这个提升比成本更让用户有感知——命中缓存的请求是毫秒级返回的。

缓存本身的成本:Redis 集群(8 GB)月费 1,200 元,embedding 调用(算相似度)月费约 2,400 元。合计 3,600 元,相对省下的 2.6 万元,ROI 是 7.2 倍。

几条经验

  • 先做精确缓存,再做语义缓存。精确缓存零风险、零成本、命中率还高(我们 29%)。一上来就做语义缓存是本末倒置;
  • 语义相似不等于答案可用。必须叠加业务约束(租户、意图、是否含个人信息)。我们的教训是差点把 B 用户的物流信息发给 A 用户;
  • 缓存检索结果比缓存答案更划算。召回结果的复用率更高(38% vs 26%),而且不会因为用户不同而失效;
  • TTL 是兜底,主动失效才是正道。知识库更新、配置变更、模型切换都应该触发主动清缓存;
  • 一定要监控误命中率。命中率高不等于好——如果缓存的答案不对,命中率越高危害越大。我们每天抽样 200 条做校验。

先到这

《AI 场景下的缓存体系重构》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。

参考