给 LLM 应用加语义缓存——用 Redis + Embedding 干掉 40% 的重复调用

给 LLM 应用加语义缓存------用 Redis + Embedding 干掉 40% 的重复调用

上个月我们线上跑的一个客服问答系统,月账单 $1400。老板看了一眼:能砍不?

我翻了下调用日志,发现一个规律:大量用户在问同一类问题。"怎么退款""退款流程是什么""我要退款怎么操作"------意思一样,措辞不同,每次都打了一轮 LLM。这笔钱花得太冤了。

精确匹配缓存(把 prompt 做 hash key)解决不了这个问题。用户问 "怎么退款" 和 "退款流程是啥" 的 hash 完全不同,缓存直接 miss。

想要命中这些"意思一样但说法不同"的请求,得用语义缓存------先把用户输入转成向量,再跟缓存里的向量做相似度比对。超过阈值就直接返回缓存结果,不调 LLM。

下面是我实际跑通的方案,Python + Redis + OpenAI Embedding,代码可以直接抄。

流程

一次请求进来,走这几步:

  1. 用户发来一个 prompt
  2. 把 prompt 转成 embedding 向量(1536维,用 text-embedding-3-small)
  3. 跟 Redis 里存的所有缓存向量做余弦相似度比较
  4. 相似度 > 阈值(比如 0.95)→ 命中缓存,直接返回
  5. 没命中 → 调 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 再决定。

相关推荐
洞窝技术2 小时前
用AI的方式思考:思维链模式的提示词优化
aigc
算力百科小星2 小时前
第三维度的 “链式反应”:2026 年 6 款 3D 漫画
人工智能·aigc
BenedictHook2 小时前
美图设计室:搭载Seedance2.0,在线使用地址
aigc·ai视频生成·美图设计室·seedance2
皮尔卡Q3 小时前
六、插件功能开发-聊天机器人实时联网获取信息
aigc
晨航3 小时前
扣子(Coze)+ GPT-Image-2制作育儿漫画,人物一致性和鱼泡处理,好用哭
人工智能·aigc
老赵聊算法、大模型备案4 小时前
从剪映、即梦 AI 被罚,读懂 AI 生成内容标识硬性合规要求
人工智能·算法·安全·aigc
默 语5 小时前
从 0 到 1 实战:魔珐星云 SDK 搭建实时交互屏幕助手(附可直接运行源码)
gpt·microsoft·开源·prompt·aigc·ai写作·agi
算力百科小智5 小时前
6款3D漫剧工具深度体验,核心功能对比刨析
人工智能·ai作画·aigc
墨风如雪14 小时前
算个账也要开顶配 AI?我让 AI 自己劝我换了个小的
aigc