给 LLM 应用加语义缓存------用 Redis + Embedding 干掉 40% 的重复调用
上个月我们线上跑的一个客服问答系统,月账单 $1400。老板看了一眼:能砍不?
我翻了下调用日志,发现一个规律:大量用户在问同一类问题。"怎么退款""退款流程是什么""我要退款怎么操作"------意思一样,措辞不同,每次都打了一轮 LLM。这笔钱花得太冤了。
精确匹配缓存(把 prompt 做 hash key)解决不了这个问题。用户问 "怎么退款" 和 "退款流程是啥" 的 hash 完全不同,缓存直接 miss。
想要命中这些"意思一样但说法不同"的请求,得用语义缓存------先把用户输入转成向量,再跟缓存里的向量做相似度比对。超过阈值就直接返回缓存结果,不调 LLM。
下面是我实际跑通的方案,Python + Redis + OpenAI Embedding,代码可以直接抄。
流程
一次请求进来,走这几步:
- 用户发来一个 prompt
- 把 prompt 转成 embedding 向量(1536维,用 text-embedding-3-small)
- 跟 Redis 里存的所有缓存向量做余弦相似度比较
- 相似度 > 阈值(比如 0.95)→ 命中缓存,直接返回
- 没命中 → 调 LLM,拿到结果后把 embedding 和结果一起存进 Redis
核心就是一个余弦相似度计算。两个向量越接近 1,语义越相似。
先装依赖
bash
pip install openai redis numpy
Redis 需要本地跑一个实例。没有的话 brew install redis && redis-server 就行。
完整代码
python
import json
import hashlib
import time
import numpy as np
import openai
import redis
class SemanticCache:
def __init__(
self,
redis_url="redis://localhost:6379/0",
similarity_threshold=0.95,
ttl=3600,
embedding_model="text-embedding-3-small",
):
self.redis_client = redis.from_url(redis_url)
self.threshold = similarity_threshold
self.ttl = ttl
self.embedding_model = embedding_model
self.oai = openai.OpenAI()
# 缓存索引 key 的前缀
self.prefix = "semcache:"
# 所有缓存 key 的集合
self.index_key = "semcache:index"
def _get_embedding(self, text: str) -> list[float]:
"""调 OpenAI 拿 embedding 向量"""
resp = self.oai.embeddings.create(
input=text.strip(),
model=self.embedding_model,
)
return resp.data[0].embedding
def _cosine_similarity(self, a: list[float], b: list[float]) -> float:
"""余弦相似度"""
a_arr = np.array(a)
b_arr = np.array(b)
return float(np.dot(a_arr, b_arr) / (np.linalg.norm(a_arr) * np.linalg.norm(b_arr)))
def get(self, prompt: str) -> dict | None:
"""查缓存。命中返回 {"response": ..., "similarity": ...},没命中返回 None"""
query_vec = self._get_embedding(prompt)
# 遍历所有缓存条目,找最相似的
all_keys = self.redis_client.smembers(self.index_key)
best_sim = 0.0
best_data = None
for key in all_keys:
raw = self.redis_client.get(key)
if raw is None:
# key 过期了,从索引里删掉
self.redis_client.srem(self.index_key, key)
continue
entry = json.loads(raw)
sim = self._cosine_similarity(query_vec, entry["embedding"])
if sim > best_sim:
best_sim = sim
best_data = entry
if best_sim >= self.threshold and best_data:
return {
"response": best_data["response"],
"similarity": round(best_sim, 4),
"cached_prompt": best_data["prompt"],
}
return None
def put(self, prompt: str, response: str):
"""写缓存"""
vec = self._get_embedding(prompt)
key = self.prefix + hashlib.md5(prompt.encode()).hexdigest()
entry = {
"prompt": prompt,
"embedding": vec,
"response": response,
"created_at": time.time(),
}
self.redis_client.setex(key, self.ttl, json.dumps(entry))
self.redis_client.sadd(self.index_key, key)
用法很简单:
python
cache = SemanticCache(similarity_threshold=0.95, ttl=7200)
# 第一次调用------miss,走 LLM
prompt = "怎么退款"
hit = cache.get(prompt)
if hit:
answer = hit["response"]
print(f"缓存命中 (相似度 {hit['similarity']}),原始问题:{hit['cached_prompt']}")
else:
answer = call_llm(prompt) # 你自己的 LLM 调用函数
cache.put(prompt, answer)
print("缓存未命中,已调用 LLM")
# 第二次调用------hit
prompt2 = "退款流程是什么"
hit2 = cache.get(prompt2)
# hit2["similarity"] 大概在 0.96~0.98,命中!
性能问题:遍历全量缓存太慢了
上面的代码有个明显的问题------每次查缓存都要遍历所有条目算相似度。缓存 100 条没感觉,1 万条就卡了。
两个解决方案。
方案一:用 Redis 的向量搜索模块。Redis Stack 自带 RediSearch,支持向量索引和 KNN 查询。
python
from redis.commands.search.field import VectorField, TextField
from redis.commands.search.query import Query
from redis.commands.search.indexDefinition import IndexDefinition, IndexType
class SemanticCacheV2:
"""用 Redis Stack 的向量搜索,不用遍历"""
def __init__(self, redis_url="redis://localhost:6379/0",
similarity_threshold=0.95, ttl=3600):
self.redis_client = redis.from_url(redis_url)
self.threshold = similarity_threshold
self.ttl = ttl
self.oai = openai.OpenAI()
self.index_name = "semcache_idx"
self.prefix = "semcache:"
self._ensure_index()
def _ensure_index(self):
"""创建向量索引,已存在就跳过"""
try:
self.redis_client.ft(self.index_name).info()
except Exception:
schema = (
TextField("$.prompt", as_name="prompt"),
TextField("$.response", as_name="response"),
VectorField(
"$.embedding",
"FLAT",
{
"TYPE": "FLOAT32",
"DIM": 1536,
"DISTANCE_METRIC": "COSINE",
},
as_name="embedding",
),
)
definition = IndexDefinition(
prefix=[self.prefix], index_type=IndexType.JSON
)
self.redis_client.ft(self.index_name).create_index(
schema, definition=definition
)
def get(self, prompt: str) -> dict | None:
query_vec = self._get_embedding(prompt)
vec_bytes = np.array(query_vec, dtype=np.float32).tobytes()
q = (
Query("*=>[KNN 1 @embedding $vec AS score]")
.return_fields("prompt", "response", "score")
.sort_by("score")
.dialect(2)
)
results = self.redis_client.ft(self.index_name).search(
q, query_params={"vec": vec_bytes}
)
if results.total == 0:
return None
doc = results.docs[0]
# RediSearch COSINE 返回的是距离(0~2),不是相似度
# similarity = 1 - distance/2
distance = float(doc.score)
similarity = 1 - distance / 2
if similarity >= self.threshold:
return {
"response": doc.response,
"similarity": round(similarity, 4),
"cached_prompt": doc.prompt,
}
return None
方案二:如果 Redis 版本不支持向量搜索,可以用 FAISS 做本地索引,Redis 只存数据。
python
import faiss
class SemanticCacheWithFaiss:
def __init__(self, dim=1536, threshold=0.95):
self.index = faiss.IndexFlatIP(dim) # 内积,归一化后等价于余弦相似度
self.threshold = threshold
self.entries = [] # 存 prompt + response
def _normalize(self, vec):
arr = np.array([vec], dtype=np.float32)
faiss.normalize_L2(arr)
return arr
def get(self, prompt: str) -> dict | None:
if self.index.ntotal == 0:
return None
query_vec = self._normalize(self._get_embedding(prompt))
scores, indices = self.index.search(query_vec, 1)
if scores[0][0] >= self.threshold:
entry = self.entries[indices[0][0]]
return {
"response": entry["response"],
"similarity": round(float(scores[0][0]), 4),
}
return None
def put(self, prompt: str, response: str):
vec = self._normalize(self._get_embedding(prompt))
self.index.add(vec)
self.entries.append({"prompt": prompt, "response": response})
FAISS 查 10 万条向量也就几毫秒,够用了。
阈值怎么调
这个参数是整个系统最关键的旋钮。调太高(0.99),基本啥都命中不了;调太低(0.85),会把不相关的问题也当缓存返回,用户拿到答非所问的结果。
我的经验值:
| 场景 | 建议阈值 | 原因 |
|---|---|---|
| 客服 FAQ | 0.93-0.95 | 问法变化大但意思固定 |
| 代码生成 | 0.97-0.98 | 一个字的差异就可能导致不同结果 |
| 翻译 | 0.98-0.99 | 输入几乎必须一样才能复用 |
| 通用聊天 | 不建议用 | 上下文依赖太强 |
调参方法:从 0.95 开始,采样 200 对相似 prompt,人工标注"是否应该命中",算 precision 和 recall,找平衡点。
python
# 调参辅助脚本
def evaluate_threshold(pairs, threshold):
"""pairs: [{"q1": ..., "q2": ..., "should_hit": True/False}]"""
tp = fp = tn = fn = 0
for p in pairs:
vec1 = get_embedding(p["q1"])
vec2 = get_embedding(p["q2"])
sim = cosine_similarity(vec1, vec2)
hit = sim >= threshold
if hit and p["should_hit"]:
tp += 1
elif hit and not p["should_hit"]:
fp += 1
elif not hit and p["should_hit"]:
fn += 1
else:
tn += 1
precision = tp / (tp + fp) if (tp + fp) > 0 else 0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0
print(f"阈值={threshold} precision={precision:.3f} recall={recall:.3f}")
踩坑记录
坑 1:embedding 模型换了,缓存全废
我们从 text-embedding-ada-002 换到 text-embedding-3-small 的时候,忘了清缓存。两个模型输出的向量维度不同(1536 vs 可配置),直接算相似度会报错。就算维度一样,不同模型的向量空间也不兼容------同一句话在两个模型里的向量完全不相关。
解决办法:embedding key 里带上模型名。换模型时执行一次全量清理。
python
self.prefix = f"semcache:{embedding_model}:"
坑 2:长 prompt 的 embedding 质量下降
text-embedding-3-small 的输入上限是 8191 token。超长的 system prompt + 用户 prompt 拼一起,很容易超。而且即使不超,超过 500 token 后 embedding 的区分度会明显下降。
我的做法:只对用户输入部分做 embedding,不把 system prompt 算进去。system prompt 相同的请求共享一个缓存命名空间。
python
def cache_key_prompt(self, user_input: str, system_prompt_hash: str) -> str:
"""只用 user_input 做语义匹配,system_prompt 做命名空间隔离"""
self.prefix = f"semcache:{system_prompt_hash}:"
return user_input
坑 3:并发写入的竞态条件
两个相似请求几乎同时到达,都 cache miss,都调了 LLM,都往缓存里写了一条。结果缓存里多了一条重复条目。不影响正确性,但浪费空间,而且遍历方案下会拖慢查询。
用 Redis 的 SETNX 做简单的去重就行:
python
def put(self, prompt: str, response: str):
vec = self._get_embedding(prompt)
key = self.prefix + hashlib.md5(prompt.encode()).hexdigest()
# 已存在就不覆盖
if self.redis_client.exists(key):
return
entry = {"prompt": prompt, "embedding": vec, "response": response}
self.redis_client.setex(key, self.ttl, json.dumps(entry))
self.redis_client.sadd(self.index_key, key)
坑 4:缓存命中时忘了刷新 TTL
缓存命中说明这个问题还有人问,应该续期。不然高频问题的缓存也会过期,白白多调一次 LLM。
python
if similarity >= self.threshold:
# 命中了,续个期
self.redis_client.expire(matched_key, self.ttl)
return cached_response
坑 5:embedding 调用本身也花钱
text-embedding-3-small 每百万 token $0.02,比 GPT-4o 便宜 500 倍。但如果你的缓存命中率只有 5%,意味着 95% 的请求白花了一次 embedding 钱。在缓存还没攒起来的冷启动阶段尤其明显。
解决方案:先用精确匹配缓存兜底(hash key,零成本),miss 了再走语义缓存。两层缓存叠加:
python
def get(self, prompt: str):
# 第一层:精确匹配(免费)
exact_key = "exact:" + hashlib.md5(prompt.strip().lower().encode()).hexdigest()
exact_hit = self.redis_client.get(exact_key)
if exact_hit:
return json.loads(exact_hit)
# 第二层:语义匹配(花一次 embedding 钱)
return self._semantic_get(prompt)
def put(self, prompt: str, response: str):
# 两层都写
exact_key = "exact:" + hashlib.md5(prompt.strip().lower().encode()).hexdigest()
self.redis_client.setex(exact_key, self.ttl, json.dumps({"response": response}))
self._semantic_put(prompt, response)
实测数据
我在那个客服系统上跑了两周,统计了一些数据:
缓存条目数:8400 条(TTL 2 小时)
请求量:日均 12000 次
| 指标 | 上线前 | 上线后 |
|---|---|---|
| LLM 调用量/天 | 12000 | 6800 |
| embedding 调用量/天 | 0 | 12000 |
| LLM 成本/天 | $46 | $26 |
| embedding 成本/天 | $0 | $0.3 |
| 总成本/天 | $46 | $26.3 |
| 缓存命中率 | - | 43% |
| P99 响应时间 | 2.8s | 0.4s(命中时) |
成本降了 43%,命中的请求响应时间从秒级降到毫秒级。embedding 的成本可以忽略不计。
什么时候别用语义缓存
几种情况下语义缓存会帮倒忙:
多轮对话。前面聊了什么直接影响当前回答,光看当前输入做缓存会返回错误结果。除非你把完整对话历史都算进 embedding,但那样命中率会极低。
时效性内容。"今天天气怎么样"今天的答案明天就过期了。TTL 设短一点能缓解,但不如不缓存。
创意生成。"给我写一首诗"每次调用用户都期望不同结果。缓存了反而让人觉得系统在偷懒。
涉及用户个人数据的查询。"我的订单状态"------哪怕两个用户问的一模一样,答案也不同。
总结
语义缓存这事说白了就三步:prompt 转向量、向量做相似度、超过阈值返回缓存。代码不复杂,收益很实在。
在我们的场景里省了将近一半的 LLM 调用费用,同时命中请求的响应速度快了好几倍。对高频重复查询的系统(客服、FAQ、文档问答)很值得一试。
唯一需要花时间的是调相似度阈值。调参不能偷懒------0.93 和 0.95 的差距在实际业务里可能是 10% 的误命中率。用采样数据跑一遍 precision/recall 再决定。