Administrator
发布于 2024-09-09 / 8639 阅读
49

多级缓存应对 AI 应用的高延迟

客服机器人上线两周,P95 首字延迟 4.2 秒

9 月初,我们给内部工单系统做的智能客服上了线。第三天开始收到投诉:"问一句话要等四五秒才出字"。我拉了 Grafana 看,数据比投诉描述的还难看:

gateway_request_duration_seconds{quantile="0.95",uri="/api/chat"}  4.23
gateway_request_duration_seconds{quantile="0.99",uri="/api/chat"}  8.71
llm_first_token_latency_seconds{quantile="0.95"}                   3.86

整轮回答平均 11.4 秒。作为对比,原来的关键词检索是 180ms。用户体感差距太大,运营那边已经开始要求回滚了。

先拆解链路,看时间花在哪

我没有一上来就加缓存。先在网关里打了分段耗时日志,跑了一天,取了 12 万条请求的分布:

阶段平均耗时P95 耗时说明
问题改写(LLM)410 ms980 ms把"咋退款"改写成"如何申请退款"
Embedding 调用127 ms240 ms走内部模型服务
向量检索 top5048 ms110 mspgvector,12 万条
Rerank top5382 ms690 msbge-reranker
LLM 生成3520 ms7400 ms输出约 380 token

大头一目了然:两次 LLM 调用占了 93%。优化方向也就清楚了——能不调模型就不调模型

重复度比想象中高

我顺手统计了一下问题分布,把用户输入做了归一化(去空格、全角转半角、繁简转换)后取 MD5 前八位:

$ awk -F'\t' '{print $3}' chat.log | sort | uniq -c | sort -rn | head -5
  18342 如何申请退款
   9127 发货时间要多久
   7403 发票怎么开
   6108 保修期多长
   4982 怎样修改收货地址

总共 47 万条请求,去重后只有 8.9 万条。Top 200 个问题覆盖了 41.3% 的流量。这个数字决定了缓存方案值不值得做。

三级缓存怎么搭

我的分层原则:按「失效频率」和「共享范围」两个维度切

  • L1 本地缓存(Caffeine):存热问题和向量结果,单机 5000 条,TTL 10 分钟。命中不走网络。
  • L2 Redis:存完整答案,跨 8 个实例共享,TTL 按业务设。命中不走模型。
  • L3 语义缓存:存问题向量,用相似度匹配,命中直接复用相似问题的答案。这层解决"问法不同但意思一样"。

缓存键设计是这里最容易翻车的地方

第一版我写的键是这样的,上线两小时就被打脸:

// 错误示范
String key = "chat:" + DigestUtils.md5Hex(question);

翻车点有两个。一是用户多打一个空格就穿透;二是知识库更新了,答案还是旧的,运营拿着"退款政策 7 天"的缓存答案去对客,实际政策已经改成 15 天了。

第二版我把所有影响输出的变量全塞进键里:

public final class CacheKeyBuilder {
    private static final String SCHEMA = "v3";

    public static String exact(String templateId, String modelName, String modelVersion,
                               double temperature, int topK, String kbVersion, String normalizedQuestion) {
        String raw = String.join("\u0001",
                SCHEMA, templateId, modelName, modelVersion,
                String.format(Locale.ROOT, "%.2f", temperature),
                String.valueOf(topK), kbVersion, normalizedQuestion);
        return "chat:exact:" + DigestUtils.sha256Hex(raw).substring(0, 32);
    }
}

几个取舍说明一下:

  1. \u0001 分隔而不是冒号或竖线,防止用户问题里带这些字符造成键碰撞。
  2. knowledge base 版本号进键。知识库每次全量重建就递增 kb_version,老答案自然失效,不用手动清缓存。
  3. temperature 格式化成两位小数,避免 0.70.70000001 生成两个键。
  4. 加了 schema 版本号,改键结构时换个前缀就能整体弃用旧数据。

归一化函数:

static String normalize(String q) {
    String s = Normalizer.normalize(q, Normalizer.Form.NFKC); // 全角转半角
    s = s.replaceAll("\\s+", "");                             // 去所有空白
    s = s.toLowerCase(Locale.ROOT);
    return s;
}

L2 的实现

@Component
@RequiredArgsConstructor
public class AnswerCache {
    private final StringRedisTemplate redis;
    private final Cache<String, Answer> local = Caffeine.newBuilder()
            .maximumSize(5_000)
            .expireAfterWrite(Duration.ofMinutes(10))
            .recordStats()
            .build();

    public Answer get(String key) {
        Answer a = local.getIfPresent(key);
        if (a != null) {
            Metrics.counter("cache.hit", "level", "l1").increment();
            return a;
        }
        String json = redis.opsForValue().get(key);
        if (json != null) {
            a = JsonUtils.read(json, Answer.class);
            local.put(key, a);
            Metrics.counter("cache.hit", "level", "l2").increment();
            return a;
        }
        Metrics.counter("cache.miss").increment();
        return null;
    }

    public void put(String key, Answer answer, Duration ttl) {
        local.put(key, answer);
        redis.opsForValue().set(key, JsonUtils.write(answer), ttl);
    }
}

有个细节:L1 的 TTL 故意比 L2 短很多。多实例部署时,本地缓存没法主动失效,靠短 TTL 兜底,代价是偶发的几秒不一致,客服场景能接受。

向量结果单独缓存

Embedding 和检索这两段虽然只有 175ms,但它们是每个请求都跑的,量大了开销可观。而且这部分的复用粒度比答案细——同一个知识库片段会被很多问题命中。

// 缓存的是检索出来的 docId 列表,不是原文
String retrieveKey = "chat:vec:" + kbVersion + ":" + DigestUtils.sha256Hex(normalizedQuestion);
List<String> docIds = retrieveCache.get(retrieveKey, k -> {
    float[] emb = embeddingClient.embed(normalizedQuestion);
    return vectorStore.search(emb, 50);
});

这样存的好处是缓存体积小(50 个 ID 大概 1KB),Redis 内存压力小,10 万条才 100MB。

语义缓存:收益最大,也最危险

L3 才是真正解决延迟的那层。做法是:把问题向量存进 Redis Stack 的向量索引,新问题进来先做相似度检索,超过阈值就复用旧答案。

# 创建索引
FT.CREATE idx:qvec ON HASH PREFIX 1 chat:sem: SCHEMA \
  question TEXT \
  answer_id TAG \
  vec VECTOR HNSW 6 TYPE FLOAT32 DIM 768 DISTANCE_METRIC COSINE

阈值定在 0.93(余弦相似度)。这个数字是试出来的:

阈值命中率抽检错误率
0.8828.4%6.7%
0.9121.1%2.3%
0.9316.8%0.4%
0.969.2%0.1%

抽检错误率是我和运营两个人各看了 500 条命中样本判定的。0.88 那档错得离谱,出现了"怎么退款"命中"怎么充值"的情况。0.93 那档剩下 2 条争议,都是金额相关的问题,后来我把含数字的问句单独排除在语义缓存之外,宁可不命中。

这段是现在最脆的地方:语义缓存的错误是静默的,用户拿到的是一个看起来很像正确答案的错误答案。我们在返回的答案里加了 fromCache 标记,前端对命中语义缓存的回答降低置信度展示,同时在埋点里上报,每周抽检一次。

上线后的数字

灰度三天,全量一周,数据如下:

指标优化前优化后
首 token 延迟 P953860 ms620 ms
整轮耗时 P9511400 ms3100 ms
缓存命中率(L1+L2)034.7%
缓存命中率(L3 语义)016.8%
LLM 调用量(日均)94 万次46 万次
推理卡月度成本7.8 万元4.8 万元

整轮 P95 没有降到 L1 命中的 80ms 水平,是因为还有一半流量是缓存未命中的长尾问题,这部分该慢还是慢。但用户投诉归零了,运营也不提回滚了。

下篇预告

这篇先把《多级缓存应对 AI 应用的高延迟》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。

参考