第2144篇:LLM应用的语义缓存——用向量相似度实现智能结果复用
第2144篇:LLM应用的语义缓存——用向量相似度实现智能结果复用
适读人群:关注LLM应用成本和性能的后端工程师 | 阅读时长:约18分钟 | 核心价值:掌握语义缓存的工程实现,在不影响质量的前提下,将相同或相似问题的LLM调用成本降低40-60%
传统缓存的逻辑是精确匹配:"今天天气怎么样?" 和 "今天天气怎么样?" 因为标点不同,会被当做两个不同的key,都去调用LLM。
语义缓存的思路不同:如果两个问题的语义相似度超过阈值,它们应该共享同一个缓存条目。"今天天气如何" 和 "今天的天气怎么样" ,应该命中同一个缓存。
我们在一个企业内部问答系统上实测:引入语义缓存后,LLM调用量减少了43%,平均响应时间从2.8秒降到了1.1秒(命中缓存时几乎是零延迟)。成本直接降了将近一半。
语义缓存的原理
/**
* 语义缓存 vs 精确缓存
*
* ===== 精确缓存(传统缓存)=====
*
* Key:问题字符串(精确匹配)
* Value:LLM的回答
*
* 命中条件:完全相同的字符串
*
* 问题:
* - "如何退款" 和 "怎么退款" 不命中
* - "请问" + "如何退款" 和 "如何退款" 不命中
* - 命中率通常只有5-10%
*
* ===== 语义缓存 =====
*
* Key:问题的语义向量(Embedding)
* Value:LLM的回答
*
* 命中条件:语义相似度 >= 阈值(如0.92)
*
* 优势:
* - 语义相似的问题命中同一条缓存
* - 命中率可以达到40-60%
*
* 需要权衡:
* - 相似度阈值过高:命中率低,接近精确缓存
* - 相似度阈值过低:不同问题命中同一缓存,答案不准确
* - 最佳阈值:业务场景决定,通常0.90-0.95
*
* ===== 适合语义缓存的场景 =====
*
* ✓ FAQ类问答(问法多变但意图相同)
* ✓ 文档理解(同一文档的相似查询)
* ✓ 知识库问答(用户重复询问相同知识点)
*
* ✗ 不适合语义缓存的场景:
* - 需要实时数据的问题("今天股价是多少")
* - 对细微差别敏感的问题("A和B的区别")
* - 用户个性化的问题("我的订单状态")
*/语义缓存核心实现
/**
* 语义缓存服务
*
* 核心流程:
* 1. 将查询向量化
* 2. 在缓存向量库中找最相似的已缓存查询
* 3. 相似度 >= 阈值:返回缓存结果
* 4. 相似度 < 阈值:调用LLM,存入缓存
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class SemanticCacheService {
private final EmbeddingModel embeddingModel;
private final VectorStore cacheVectorStore; // 专门用于缓存的向量库
private final ChatLanguageModel llm;
// 语义相似度阈值(调这个参数是关键)
@Value("${semantic.cache.similarity.threshold:0.92}")
private double similarityThreshold;
// 缓存条目的TTL
@Value("${semantic.cache.ttl.hours:24}")
private int cacheTtlHours;
// 缓存统计
private final AtomicLong cacheHits = new AtomicLong(0);
private final AtomicLong cacheMisses = new AtomicLong(0);
/**
* 带语义缓存的LLM调用
*
* @param query 用户查询
* @param systemPrompt 系统提示(影响回答风格,参与缓存key)
* @return 回答结果
*/
public CachedLlmResult queryWithCache(String query, String systemPrompt) {
long startMs = System.currentTimeMillis();
// 1. 生成查询向量(包含system prompt的摘要,不同prompt的相似查询不共享缓存)
String cacheKey = buildCacheKey(query, systemPrompt);
float[] queryVector = embeddingModel.embed(cacheKey).content().vector();
// 2. 搜索最相似的缓存条目
List<VectorStore.SearchResult> similar = cacheVectorStore.search(queryVector, 1, null);
if (!similar.isEmpty()) {
VectorStore.SearchResult topResult = similar.get(0);
if (topResult.getScore() >= similarityThreshold) {
// 缓存命中!
cacheHits.incrementAndGet();
String cachedAnswer = topResult.getContent();
log.debug("语义缓存命中: similarity={:.3f}, latency={}ms",
topResult.getScore(), System.currentTimeMillis() - startMs);
// 更新缓存条目的访问时间(热度更新)
updateCacheAccessTime(topResult.getId());
return CachedLlmResult.fromCache(cachedAnswer, topResult.getScore());
}
}
// 3. 缓存未命中,调用LLM
cacheMisses.incrementAndGet();
String answer;
try {
List<ChatMessage> messages = buildMessages(query, systemPrompt);
answer = llm.generate(messages);
} catch (Exception e) {
log.error("LLM调用失败: {}", e.getMessage());
throw new RuntimeException("LLM调用失败", e);
}
long llmLatencyMs = System.currentTimeMillis() - startMs;
// 4. 将结果存入缓存
storeCacheEntry(cacheKey, queryVector, query, answer);
log.debug("语义缓存未命中,LLM调用: latency={}ms", llmLatencyMs);
return CachedLlmResult.fromLlm(answer, llmLatencyMs);
}
/**
* 构建缓存Key
*
* 查询内容 + 系统提示的摘要(取前100字)
* 不同system prompt的相同问题,不共享缓存
*/
private String buildCacheKey(String query, String systemPrompt) {
if (systemPrompt == null || systemPrompt.isBlank()) {
return query;
}
String promptSummary = systemPrompt.substring(0, Math.min(100, systemPrompt.length()));
return query + "\n[ctx:" + promptSummary + "]";
}
/**
* 存入缓存条目
*/
private void storeCacheEntry(String cacheKey, float[] vector,
String originalQuery, String answer) {
String cacheId = "cache-" + DigestUtils.md5Hex(cacheKey);
// 存入缓存向量库
// Content存储的是答案(供检索后直接返回)
// Metadata存储查询原文和时间(用于管理和调试)
cacheVectorStore.add(VectorStore.Document.builder()
.id(cacheId)
.content(answer) // 存答案,不是查询
.vector(vector) // 查询的向量
.metadata(Map.of(
"originalQuery", originalQuery.substring(0, Math.min(200, originalQuery.length())),
"cachedAt", LocalDateTime.now().toString(),
"expiresAt", LocalDateTime.now().plusHours(cacheTtlHours).toString()
))
.build());
log.debug("缓存条目已存储: id={}", cacheId);
}
/**
* 更新缓存访问时间(LRU支持)
*/
private void updateCacheAccessTime(String cacheId) {
cacheVectorStore.updateMetadata(cacheId,
Map.of("lastAccessedAt", LocalDateTime.now().toString()));
}
private List<ChatMessage> buildMessages(String query, String systemPrompt) {
List<ChatMessage> messages = new ArrayList<>();
if (systemPrompt != null && !systemPrompt.isBlank()) {
messages.add(new SystemMessage(systemPrompt));
}
messages.add(new UserMessage(query));
return messages;
}
/**
* 缓存统计
*/
public CacheStats getStats() {
long hits = cacheHits.get();
long misses = cacheMisses.get();
long total = hits + misses;
double hitRate = total == 0 ? 0 : (double) hits / total;
return new CacheStats(hits, misses, hitRate);
}
public record CachedLlmResult(String answer, boolean isFromCache,
double cacheScore, long llmLatencyMs) {
public static CachedLlmResult fromCache(String answer, double score) {
return new CachedLlmResult(answer, true, score, 0);
}
public static CachedLlmResult fromLlm(String answer, long latencyMs) {
return new CachedLlmResult(answer, false, 0, latencyMs);
}
}
public record CacheStats(long hits, long misses, double hitRate) {}
}缓存失效与管理
/**
* 语义缓存管理器
*
* 负责:
* 1. 缓存过期清理(TTL)
* 2. 容量控制(LRU淘汰)
* 3. 主动失效(知识库更新时)
* 4. 缓存质量监控
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class SemanticCacheManager {
private final VectorStore cacheVectorStore;
private final SemanticCacheService cacheService;
/**
* 定期清理过期缓存
*
* 每天凌晨2点执行
*/
@Scheduled(cron = "0 0 2 * * *")
public void cleanExpiredEntries() {
log.info("开始清理过期缓存");
// 获取所有缓存条目
List<VectorStore.Document> allEntries = cacheVectorStore.listAll(
Map.of("type", "cache_entry"));
int removed = 0;
LocalDateTime now = LocalDateTime.now();
for (VectorStore.Document entry : allEntries) {
String expiresAtStr = entry.metadata() != null ?
entry.metadata().getOrDefault("expiresAt", "").toString() : "";
if (!expiresAtStr.isEmpty()) {
LocalDateTime expiresAt = LocalDateTime.parse(expiresAtStr);
if (expiresAt.isBefore(now)) {
cacheVectorStore.delete(entry.id());
removed++;
}
}
}
log.info("过期缓存清理完成: removed={}", removed);
}
/**
* 主动失效特定主题的缓存
*
* 当知识库某个主题更新时,清除相关缓存
* 防止用户拿到过时的答案
*/
public int invalidateByTopic(String topic) {
log.info("主动失效缓存: topic={}", topic);
// 把topic向量化,找相似的缓存条目
float[] topicVector = cacheService.embeddingModel().embed(topic).content().vector();
// 找相似度 >= 0.85 的缓存条目(主题相关的都清除)
List<VectorStore.SearchResult> related = cacheVectorStore.search(topicVector, 50, null);
int invalidated = 0;
for (VectorStore.SearchResult result : related) {
if (result.getScore() >= 0.85) {
cacheVectorStore.delete(result.getId());
invalidated++;
log.debug("缓存条目已失效: id={}, score={}", result.getId(), result.getScore());
}
}
log.info("主题缓存失效完成: topic={}, invalidated={}", topic, invalidated);
return invalidated;
}
/**
* 缓存质量评估
*
* 随机抽样检查缓存命中的准确性:
* 缓存的答案是否还适合这个问题?
* (缓存存的是answer,查询时用query向量匹配,
* 需要验证缓存的answer确实能回答命中的query)
*/
public CacheQualityReport evaluateCacheQuality(int sampleSize,
ChatLanguageModel judgeLlm) {
List<VectorStore.Document> samples = cacheVectorStore.listRandom(sampleSize);
int qualified = 0;
for (VectorStore.Document sample : samples) {
String originalQuery = sample.metadata() != null ?
sample.metadata().getOrDefault("originalQuery", "").toString() : "";
String cachedAnswer = sample.content();
if (originalQuery.isBlank()) continue;
// 用LLM判断答案是否适合这个问题
String judgePrompt = """
判断以下答案是否能回答这个问题。只回答YES或NO。
问题:%s
答案:%s
""".formatted(originalQuery, cachedAnswer.substring(0, Math.min(300, cachedAnswer.length())));
try {
String judgment = judgeLlm.generate(judgePrompt).trim().toUpperCase();
if (judgment.startsWith("YES")) qualified++;
} catch (Exception e) {
log.warn("缓存质量评估失败: {}", e.getMessage());
}
}
double qualityRate = samples.isEmpty() ? 0 : (double) qualified / samples.size();
return new CacheQualityReport(samples.size(), qualified, qualityRate);
}
public record CacheQualityReport(int sampleSize, int qualifiedCount, double qualityRate) {}
}分层缓存架构
/**
* 分层语义缓存
*
* L1:内存缓存(精确匹配,毫秒级,有限容量)
* L2:Redis语义缓存(语义匹配,10ms级,共享跨实例)
* L3:本地向量库缓存(语义匹配,100ms级,大容量)
*
* 命中顺序:L1 → L2 → L3 → LLM
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class TieredSemanticCache {
private final SemanticCacheService semanticCache;
// L1:Caffeine本地精确缓存
private final Cache<String, String> l1Cache = Caffeine.newBuilder()
.maximumSize(500)
.expireAfterWrite(Duration.ofMinutes(30))
.build();
public String query(String userQuery, String systemPrompt) {
// L1:精确匹配(最快)
String l1Key = DigestUtils.md5Hex(systemPrompt + ":" + userQuery);
String l1Result = l1Cache.getIfPresent(l1Key);
if (l1Result != null) {
log.debug("L1缓存命中: key={}", l1Key.substring(0, 8));
return l1Result;
}
// L2/L3:语义缓存
SemanticCacheService.CachedLlmResult result =
semanticCache.queryWithCache(userQuery, systemPrompt);
String answer = result.answer();
// 回写L1缓存
l1Cache.put(l1Key, answer);
return answer;
}
public CacheHierarchyStats getStats() {
SemanticCacheService.CacheStats semanticStats = semanticCache.getStats();
return new CacheHierarchyStats(
l1Cache.estimatedSize(),
semanticStats.hits(),
semanticStats.misses(),
semanticStats.hitRate()
);
}
public record CacheHierarchyStats(long l1Size, long semanticHits,
long semanticMisses, double semanticHitRate) {}
}实践建议
相似度阈值调优是语义缓存的核心工作
阈值设0.9和0.95,命中率和准确率差别很大。我们的调优过程:先用0.90上线,收集一周数据,然后随机抽取100条缓存命中的记录,人工判断答案是否准确。发现有15%的命中其实是不同问题但被错误共享了(比如"退款流程"和"换货流程",语义相似度0.91)。把阈值调到0.93后,准确率提升到了97%,命中率从45%降到了38%,权衡可以接受。每个业务场景的最佳阈值不同,必须用自己的数据做测试。
实时性要求高的问题要排除在语义缓存之外
不是所有问题都适合缓存。"今天几号"、"现在几点"、"当前汇率"、包含用户个人信息的问题——这类必须实时回答,不能缓存。工程实现:在调用语义缓存之前,先做一次"可缓存性检测":检测问题中是否包含"今天"、"现在"、"我的"、"最新"等时效性关键词,如果有,跳过缓存直接调LLM。这个简单规则能过滤掉绝大多数不该缓存的问题。
监控缓存命中率,确保它真的在省钱
语义缓存引入了额外的向量化成本(每次查询要先Embed)和向量库查询成本。如果缓存命中率很低,这些额外成本反而让总成本增加了。上线之后要监控:命中率、平均节省的成本(每次命中省了一次LLM调用)、命中准确率(用用户的点踩率作为信号)。如果命中率长期低于20%,可能这个业务场景不适合语义缓存,或者阈值设置有问题。
