客服机器人上线两周,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 ms | 980 ms | 把"咋退款"改写成"如何申请退款" |
| Embedding 调用 | 127 ms | 240 ms | 走内部模型服务 |
| 向量检索 top50 | 48 ms | 110 ms | pgvector,12 万条 |
| Rerank top5 | 382 ms | 690 ms | bge-reranker |
| LLM 生成 | 3520 ms | 7400 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);
}
}
几个取舍说明一下:
- 用
\u0001分隔而不是冒号或竖线,防止用户问题里带这些字符造成键碰撞。 - knowledge base 版本号进键。知识库每次全量重建就递增
kb_version,老答案自然失效,不用手动清缓存。 - temperature 格式化成两位小数,避免
0.7和0.70000001生成两个键。 - 加了 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.88 | 28.4% | 6.7% |
| 0.91 | 21.1% | 2.3% |
| 0.93 | 16.8% | 0.4% |
| 0.96 | 9.2% | 0.1% |
抽检错误率是我和运营两个人各看了 500 条命中样本判定的。0.88 那档错得离谱,出现了"怎么退款"命中"怎么充值"的情况。0.93 那档剩下 2 条争议,都是金额相关的问题,后来我把含数字的问句单独排除在语义缓存之外,宁可不命中。
这段是现在最脆的地方:语义缓存的错误是静默的,用户拿到的是一个看起来很像正确答案的错误答案。我们在返回的答案里加了 fromCache 标记,前端对命中语义缓存的回答降低置信度展示,同时在埋点里上报,每周抽检一次。
上线后的数字
灰度三天,全量一周,数据如下:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 首 token 延迟 P95 | 3860 ms | 620 ms |
| 整轮耗时 P95 | 11400 ms | 3100 ms |
| 缓存命中率(L1+L2) | 0 | 34.7% |
| 缓存命中率(L3 语义) | 0 | 16.8% |
| LLM 调用量(日均) | 94 万次 | 46 万次 |
| 推理卡月度成本 | 7.8 万元 | 4.8 万元 |
整轮 P95 没有降到 L1 命中的 80ms 水平,是因为还有一半流量是缓存未命中的长尾问题,这部分该慢还是慢。但用户投诉归零了,运营也不提回滚了。
下篇预告
这篇先把《多级缓存应对 AI 应用的高延迟》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。