语义缓存实战:用向量相似度给大模型 API 降本

大模型 API 按 token 计费,相似问题高频重复调用既烧钱又拖慢响应。语义缓存(Semantic Cache)换了一个思路:把用户提问编码成向量,用余弦相似度匹配”意思相近”的历史提问,一旦命中就直接返回缓存答案,跳过昂贵的模型推理。本文从原理讲到落地,用最低成本给你的 AI 服务装上”记忆”。

为什么语义缓存在 2026 年变得关键?随着大模型嵌入搜索、客服、Copilot 等高频场景,API 账单随调用量线性增长,而大量提问其实是”换汤不换药”的重复。传统缓存对自然语言无能为力,语义缓存恰好补上这块短板,是 LLM 工程里投入产出比最高的优化之一。

一、精确缓存与语义缓存的根本区别

传统 HTTP 缓存只做精确匹配:同一个 URL 或同一段 prompt 一字不差才算命中,差一个字就 miss。但真实业务里,”怎么重置 MySQL 密码”和”MySQL 密码忘了怎么改”是同一个问题,精确缓存完全无能为力。语义缓存用 embedding 把语义压成向量,相近意思自然会聚到向量空间里临近的位置,从而被命中。

举个典型例子:用户问”MySQL 怎么改 root 密码””root 密码忘记了怎么办””重置 MySQL 管理员密码步骤”——三句话用词不同,但意图完全相同。精确缓存对它们一视同仁地全部 miss,语义缓存却能在向量空间识别出它们是同一簇,只让第一条真正打到模型,其余直接复用。这就是为什么语义缓存能把”看似不同”的提问归并到同一份答案上。

维度精确缓存语义缓存
匹配方式字符串完全一致embedding 余弦相似度 ≥ 阈值
命中率低(仅完全相同)高(近义、换种说法也可命中)
适用场景幂等查询、静态资源大模型问答、相似 prompt
实现复杂度低(哈希表)中(需 embedding + 向量检索)
主要风险几乎无误命中(语义相近但答案不同)

二、核心原理:Embedding + 向量检索 + 阈值

语义缓存三步走:① 收到提问,用 embedding 模型转成向量;② 在已缓存向量的集合里做最近邻检索,计算余弦相似度;③ 相似度 ≥ 设定阈值则命中,直接返回缓存答案;否则调用大模型,把”提问向量 + 答案”写回缓存。中文场景建议用多语言模型(如 paraphrase-multilingual-MiniLM-L12-v2),避免语义漂移。

工程上有几个要点:embedding 输出先做 L2 归一化,这样余弦相似度等价于内积,检索更快;向量维度通常在 384~1536 之间,维度越高语义越细但存储与计算越贵,中小场景 384 维足够;相似度计算推荐余弦而非欧氏距离,它对向量模长不敏感,更适合文本语义。归一化还能让不同长度的提问在同一尺度上比较。

2.1 最小可用实现

import numpy as np
from sentence_transformers import SentenceTransformer

# 多语言模型,兼顾中英文语义
model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")

class SemanticCache:
    def __init__(self, threshold=0.92):
        self.threshold = threshold
        self.vectors = []   # 缓存的提问向量
        self.answers = []   # 对应的回答

    def _cosine(self, a, b):
        return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))

    def get(self, query):
        q = model.encode(query)
        best, ans = -1.0, None
        for vec, cached in zip(self.vectors, self.answers):
            sim = self._cosine(q, vec)
            if sim > best:
                best, ans = sim, cached
        return (ans, best) if best >= self.threshold else (None, best)

    def put(self, query, answer):
        self.vectors.append(model.encode(query))
        self.answers.append(answer)

# 用法
cache = SemanticCache(threshold=0.92)
ans, sim = cache.get("怎么重置 MySQL 密码")
if ans is None:
    ans = call_llm("怎么重置 MySQL 密码")   # 伪代码:调用大模型
    cache.put("怎么重置 MySQL 密码", ans)

三、开源方案:LangChain SemanticCache

不想自己维护向量索引,可直接用 LangChain 的语义缓存,它把 embedding 与存储都封装好了,接上任意 Embeddings 实例即可生效。底层默认用 SQLite 存向量,小规模零运维:

from langchain_community.cache import SQLiteSemanticCache
from langchain_openai import OpenAIEmbeddings
import langchain

# 任意 Embeddings 实现均可,这里以 OpenAI 为例
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

langchain.llm_cache = SQLiteSemanticCache(
    database_path=".langchain.db",
    embeddings=embeddings,
)

# 之后所有走 LangChain LLM 的调用会自动走语义缓存
# 语义相近的提问会直接命中,不再消耗 token

除了 LangChain,GPTCache 是另一个活跃的开源项目,支持把向量存到 SQLite、Milvus、Chroma 等多种后端,并内置了相似的评估器与预处理链,适合不想绑定某个框架的团队。两者思路一致:embedding 负责”理解意思”,向量存储负责”快速找邻居”,评估器负责”判断是否够像”。选型时看团队是否已在用 LangChain 生态即可。

四、Redis 持久化缓存

内存字典重启即丢,生产环境建议落到 Redis。把 embedding 以 JSON 存入 hash,检索时拉取全部向量做线性扫描(万级以内足够),或对接 Redis Query Engine / 外部向量库做近似最近邻(ANN):

import json, redis
import numpy as np
from sentence_transformers import SentenceTransformer

r = redis.Redis(host="127.0.0.1", port=6379, db=0)
model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")

def cache_put(q, answer, threshold=0.92):
    vec = model.encode(q).tolist()
    r.hset("sem_cache", q, json.dumps({"vec": vec, "ans": answer}))

def cache_get(q, threshold=0.92):
    vec = model.encode(q)
    for key, val in r.hgetall("sem_cache").items():
        item = json.loads(val)
        cached = np.array(item["vec"])
        sim = float(np.dot(vec, cached) / (np.linalg.norm(vec) * np.linalg.norm(cached)))
        if sim >= threshold:
            return item["ans"], sim
    return None, 0.0

当缓存规模到十万、百万级,线性扫描会变慢。这时应引入近似最近邻(ANN)索引,例如 HNSW、IVF,或直接使用专业向量库 Milvus / Chroma / Qdrant(参见 Chroma 向量数据库实战)。它们在召回率与延迟之间做权衡,能把检索从 O(N) 降到亚线性,是语义缓存上量的关键。

五、阈值怎么定

阈值决定”多像才算同一个问题”。太高(0.98)命中少、省得少;太低(0.80)容易误命中,把 A 的答案错给 B。建议从 0.90 起步灰度,监控误命中率再微调。

一个直观参考:用多语言模型测试,”怎么重置 MySQL 密码”与”MySQL 密码忘了怎么改”的余弦相似度通常约 0.93,落在 0.90 阈值之上能正常命中;而”怎么备份 MySQL 数据库”与前述问题相似度往往低于 0.80,不会误命中。可见 0.90 附近是一个兼顾命中与安全的甜区,先把灰度区间设在这里最稳。

相似度阈值典型命中率误命中风险适用
0.80高(同义歧义易错)容错场景、内部工具
0.90中高大多数问答
0.95答案敏感、需严谨
0.98极低近乎精确匹配

六、生产落地注意事项

缓存污染:答案会变(价格、政策、API 行为),务必设 TTL 定期失效,或按业务版本号隔离命名空间。与 RAG 缓存区分:RAG 缓存的是检索结果,语义缓存缓存的是最终答案,二者可叠加使用。写入放大:高频新提问会持续写向量,建议异步批量落库、控制单库规模。监控:埋点统计命中率与误命中投诉,命中率低于 20% 说明阈值过高或问题多样性太强,收益有限。

多租户隔离:不同客户的提问不应共享缓存,否则会泄露彼此答案,应按 tenant_id 分命名空间。隐私合规:含 PII(手机号、身份证)的回答不要进缓存,或先脱敏再存。回源策略:命中但相似度落在”灰色区间”(如 0.85~0.90)时,可并行回源校验,用真实答案兜底,兼顾体验与正确率。这三点是语义缓存从 Demo 走向生产必须补的功课。

向量底座可直接复用已有的 Chroma 方案(见 Chroma 向量数据库实战),embedding 与 RAG 同源(见 RAG 进阶);更大层面的降本思路也可对照 推理成本优化 一起看。

七、成本收益测算

假设日均 10 万次调用、均价 $0.002/次、语义命中率 40%:每天省 4 万次 × $0.002 = $80,月省约 $2400,而 embedding 与存储的边际成本几乎可忽略。命中率越高、调用量越大,收益越可观。下面给出不同规模下的月度节省估算:

日调用量命中率月省(单价 $0.002)
1 万40%约 $240
10 万40%约 $2400
100 万40%约 $24000
100 万60%约 $36000

提升命中率有套路:对提问做归一化(去多余空格、统一标点、转小写英文)、剥离语气词与问候语、必要时先做意图分类再缓存,都能显著减少”同义不同形”的 miss。配合 0.90 阈值,多数客服与问答场景命中率能稳在 35%~50%,这部分节省是纯增量,不影响回答质量。

结语

语义缓存是大模型服务性价比最高的优化之一:改动小、收益大、实现简单。先把阈值调到 0.90 灰度观察误命中,再逐步放开,你的 API 账单会立刻好看起来。它和向量库、RAG、推理成本优化共同构成一套完整的 LLM 工程降本组合拳。当你把语义缓存、向量检索与成本监控串起来,大模型服务就从”每次都现算”进化成”该算的算、能省的省”。

上一篇 Helm 实战:Kubernetes 应用打包与版本化部署