大模型语义缓存完全指南:从一次"同一个问题换种说法、模型又花钱重答一遍"看懂语义缓存

2024 年我做一个面向用户的智能客服。逻辑很简单:用户问一个问题,后端把问题发给大模型,模型生成答案返回给用户。第一版做得很直接:来一个问题调一次模型。本地测上线初期都挺好,可上线一段时间后两个问题浮上来。一是成本,每次提问都是一次实打实的付费 API 调用,用户量一大账单很吓人;二是延迟,大模型生成完整答案要好几秒,用户每问一句都得干等。我翻后台日志想看是什么问题这么烧钱,结果翻出一个扎眼的事实:用户问的问题高度重复,怎么退货、退货流程是什么、我想退货怎么操作、如何申请退款翻来覆去就那么几十个高频问题,只是每个用户措辞不一样。同一个意思的问题系统却在一遍遍花钱花时间让模型从头重新生成几乎一样的答案。我第一反应又是条件反射:重复加个缓存啊。我做了个最朴素的缓存:问题文本当 key 答案当 value,结果缓存命中率低得可怜几乎是 0。我盯着日志想了很久才明白错在一个根本认知上:我做的缓存是拿问题字面文本做精确匹配的,可自然语言根本不是这么回事,怎么退货和我想退货怎么操作在代码看来是两个毫不相干的字符串,精确匹配永远匹配不上,但语义上它们是同一件事。对自然语言查询来说 key 不该是字面文本而该是意思。本文从头梳理:为什么 LLM 调用又慢又贵且大量重复、精确匹配缓存为什么几乎不命中、语义缓存的本质是什么、怎么用 embedding 和相似度实现一个语义缓存,以及相似度阈值、缓存污染、时效性这些把语义缓存真正做对要避开的坑。

2024 年我做一个面向用户的智能客服。逻辑很简单:用户在对话框里问一个问题,我的后端把这个问题发给大模型,模型生成一段答案,返回给用户。第一版做得很直接:来一个问题,调一次模型。本地测、上线初期,都挺好。可上线一段时间后,两个问题慢慢浮上来。第一个是成本:每一次提问,都是一次实打实的 API 调用,都在花钱,用户量一大,月底的账单很吓人。第二个是延迟:大模型生成一段完整答案,要好几秒,用户每问一句,都得对着光标干等。我去翻后台日志,想看看到底是些什么问题这么烧钱,结果翻出一个很扎眼的事实:用户问的问题,高度重复。"怎么退货""退货流程是什么""我想退货怎么操作""如何申请退款"……翻来覆去,就是那么几十个高频问题,只是每个用户的措辞不一样。同一个意思的问题,我的系统却在一遍又一遍地花钱、花时间,让模型从头重新生成一段几乎一样的答案。我第一反应,又是条件反射:"重复?加个缓存啊。"我做了一个最朴素的缓存:用问题的文本当 key,模型的答案当 value,来一个新问题,先拿它的文本去缓存里查一下,查到就直接返回,查不到再调模型。我以为这下成本和延迟都能压下来一大截。结果——缓存命中率低得可怜,几乎是 0。这个缓存,形同虚设。我盯着日志看了很久才彻底想明白,我错在一个根本的认知上:我做的这个缓存,是拿"问题的字面文本"去做精确匹配的。可自然语言根本不是这么回事——"怎么退货"和"我想退货怎么操作",在我的代码看来,是两个毫不相干的字符串,精确匹配永远匹配不上;但在语义上,它们问的是同一件事。用户几乎不可能用一模一样的字面,去问同一个问题。所以我的缓存 key 选错了:对自然语言查询来说,key 不该是"字面文本",而该是"意思"。我需要的,是一个能"问题换了种说法、也照样命中"的缓存——它得理解"意思",而不是死抠字面。这,就是语义缓存。我以为缓存不过是"key 查 value",结果真做下来才发现,给大模型做缓存是另一套思路。那次之后我才认真把它从头搞明白。这篇文章就把它梳理一遍:为什么 LLM 调用又慢又贵、且大量是重复的,精确匹配缓存为什么几乎不命中,语义缓存的本质是什么,怎么用 embedding 和相似度实现一个语义缓存,以及相似度阈值、缓存污染、时效性这些把语义缓存真正做对要避开的坑。

问题背景

先把那次的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。

现象:一个智能客服,每个用户问题都直接调一次大模型。上线后两个问题暴露:一是成本,每次提问都是一次付费 API 调用;二是延迟,模型生成完整答案要好几秒。日志显示用户问题高度重复,只是措辞各异。用问题文本做 key 的精确匹配缓存,命中率几乎为 0。

我当时的错误认知:"用问题文本当 key、答案当 value 做个缓存,重复的问题就能命中、不必再调模型了。"

真相:自然语言查询不能用字面文本做 key。"怎么退货"和"我想退货怎么操作"是两个完全不同的字符串,精确匹配永远命中不了,但语义上是同一个问题。要让缓存"换种说法也能命中",key 必须是语义——用 embedding 把问题转成向量,按向量相似度来判断"是不是问的同一件事"。这就是语义缓存:它缓存的不是字符串,是"意思"。

要把语义缓存做好,需要几块认知:

  • 为什么 LLM 调用又慢又贵,而且其中大量是语义重复的;
  • 为什么用问题文本做精确匹配的缓存,几乎不可能命中;
  • 语义缓存的本质——用"意思"而不是"字面"做缓存的 key;
  • 怎么用 embedding 和相似度检索,实现一个语义缓存;
  • 相似度阈值、缓存污染、时效性这些工程坑怎么处理。

一、为什么 LLM 调用又慢又贵、还大量重复

先把这件最根本的事钉死:每一次 LLM 调用,都是一次又慢又贵的真实计算;而在客服这类场景里,这些计算大量是"重复劳动"

和查一次数据库不同,调用一次大模型,是让一个庞大的神经网络实打实地跑一遍推理:它——生成一段完整答案,延迟以计;它——按 token 计费,每一次调用都在花真金白银。下面这段代码,就是我那个"来一个问题调一次模型"的第一版:

from openai import OpenAI

client = OpenAI()


def ask_naive(question: str) -> str:
    # 反面教材:每个问题都直接调模型,不管它是不是刚被问过。
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "你是客服助手,简洁回答。"},
            {"role": "user", "content": question},
        ],
    )
    return resp.choices[0].message.content
    # 问题:1000 个用户问"怎么退货",哪怕措辞一样,
    # 这里也会【老老实实调 1000 次模型】—— 1000 次延迟、1000 份钱,
    # 而它们本该共用同一个答案。

这段代码本身没有 bug,它能正确地回答每一个问题。它的问题在于浪费:它把每一个问题都当成"从没见过的新问题"来对待。可现实是,客服场景的问题分布极度集中——绝大多数用户,翻来覆去问的就是那几十个高频问题(怎么退货、怎么改地址、怎么开发票……)。这意味着,模型生成的答案里,有极大比例是在重复生成几乎相同的内容。

这就是缓存的价值所在:一个高频问题,第一次被问到时调模型、生成答案,然后把答案存起来;后面再有人问同样的问题,就直接返回存好的答案——不调模型,零延迟、零成本。逻辑完全正确。可一旦动手做,第一个坎就来了:到底怎么算"同样的问题"?

二、精确匹配缓存为什么几乎不命中

最自然的做法,也是我第一版做的——用问题的文本当 key,做一个普通的 dict 缓存:

_exact_cache = {}


def ask_with_exact_cache(question: str) -> str:
    """反面教材:用问题文本做精确匹配的缓存。"""
    # 用问题的字面文本当 key 去查
    if question in _exact_cache:
        return _exact_cache[question]        # 命中:直接返回
    answer = ask_naive(question)             # 没命中:调模型
    _exact_cache[question] = answer          # 存起来
    return answer
    # 问题:key 是字面文本。"怎么退货" 和 "我想退货怎么操作"
    # 是两个不同的 key,永远互相命不中。用户措辞稍有不同,
    # 缓存就形同虚设 —— 命中率几乎为 0。

这段代码,在缓存数字、缓存 ID 这类场景下是完全正确的——因为那些 key 是标准化的,123 永远等于 123。但把它用在自然语言上,它就垮了。原因只有一句话:同一个问题,有无数种说法

"怎么退货""退货流程""我想退货怎么操作""如何申请退款""退款咋弄"——这五句话,在语义上几乎是同一个问题,理应命中同一份缓存。可在 dict 眼里,它们是五个完全不同的字符串、五个互不相干的 key。第一个用户问"怎么退货",答案被缓存了;第二个用户问"我想退货怎么操作",question in _exact_cache 返回 False——没命中,又调一次模型。自然语言的表达是发散的,而精确匹配要求字节级别完全一致,这两件事根本对不上

问题的根子,在于key 选错了。我们想缓存的,是"这个问题的意思";可我们拿来当 key 的,是"这个问题的字面"。意思相同、字面不同,缓存就失效。要修这个 bug,就得换一种 key——一种能代表"意思"的 key。

三、语义缓存的本质:用"意思"做 key

上一节的死结是:字面不能代表意思。语义缓存要做的,就是给"意思"找一个能比较的表示

这个表示,叫 embedding(向量)。embedding 模型能把一段文本,转换成一个由几百上千个数字组成的向量。它的神奇之处在于:这个转换是按语义来的——两段文本意思越接近,它们的向量在空间里就越靠近。"怎么退货"和"我想退货怎么操作"这两句话,字面差得远,但它们的 embedding 向量,会挨得非常近;而"怎么退货"和"今天天气怎么样",向量则离得很远

有了它,缓存的逻辑就彻底变了。精确匹配缓存问的是:"有没有一个 key,和新问题的文本一字不差?"——这个问法太死。语义缓存问的是:"缓存里,有没有一个老问题,它的向量和新问题的向量足够接近?"——如果有,就说明它们问的是同一件事,直接返回那个老问题的答案。判断"足够接近",用的是向量之间的相似度(常用余弦相似度,值越接近 1 越像)。所以语义缓存的 key,不再是字符串,而是向量;命中的判断,不再是"相等",而是"相似度超过某个阈值"。理解了这一点,就可以动手实现它了。

四、实现语义缓存:embed 与相似度检索

先把两个基础工具准备好:一个把文本转成向量的 embed,一个算两个向量相似度的 cosine_similarity

import numpy as np


def embed(text: str) -> np.ndarray:
    """把一段文本转成语义向量(embedding)。"""
    resp = client.embeddings.create(
        model="text-embedding-3-small", input=text)
    return np.array(resp.data[0].embedding)


def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
    """余弦相似度:两个向量越"同向",值越接近 1。"""
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))

注意 embed 本身也是一次 API 调用,但它远比一次完整的对话生成便宜、快——这是语义缓存"划算"的前提:用一次廉价的 embedding,去省下一次昂贵的生成。接着是语义缓存的核心——它存一批"(问题向量,答案)",查询时逐个比相似度:

class SemanticCache:
    """语义缓存:按向量相似度命中,而不是按字面相等。"""

    def __init__(self, threshold: float = 0.92):
        self.threshold = threshold     # 相似度超过它,才算命中
        self.entries = []              # 每项是 (问题向量, 问题原文, 答案)

    def get(self, question: str):
        """查缓存:找有没有一个老问题,和它语义足够接近。"""
        if not self.entries:
            return None
        q_vec = embed(question)
        # 在所有缓存条目里,找相似度最高的那一个
        best_sim, best_answer = 0.0, None
        for vec, _text, answer in self.entries:
            sim = cosine_similarity(q_vec, vec)
            if sim > best_sim:
                best_sim, best_answer = sim, answer
        # 最像的那个,相似度也得过阈值,才算命中
        if best_sim >= self.threshold:
            return best_answer
        return None

这段 get 是整个语义缓存的心脏。它和第二节那个 question in _exact_cache 的根本区别在于:它不问"相等",它遍历所有缓存过的老问题,算出新问题和每一个的相似度,挑出最像的那个,再看它像不像得过阈值。"我想退货怎么操作"进来,它会发现缓存里"怎么退货"那一条,相似度高达 0.95,过了 0.92 的阈值——命中,直接返回答案。字面不同,也拦不住它了。

五、缓存写入与完整流程

有了"查",还需要""——没命中、调了模型之后,要把这条新的"问题—答案"存进缓存,供后来人命中:

    def add(self, question: str, answer: str):
        """把一条新的问答存进缓存:存的是问题的向量。"""
        q_vec = embed(question)
        self.entries.append((q_vec, question, answer))

getadd 串起来,就是完整的"带语义缓存的提问"。它的逻辑和第二节那个精确缓存结构一样——先查、命中就返回、没命中就调模型再存——区别只在于,"查"和"存"都换成了语义的:

semantic_cache = SemanticCache(threshold=0.92)


def ask_with_semantic_cache(question: str) -> str:
    """带语义缓存的提问:先按语义查缓存,没命中才调模型。"""
    cached = semantic_cache.get(question)
    if cached is not None:
        return cached                 # 语义命中:零延迟、零成本返回
    # 没命中:老老实实调一次模型
    answer = ask_naive(question)
    semantic_cache.add(question, answer)   # 把这次问答存进缓存
    return answer

对比第一节的 ask_naive:1000 个用户用 1000 种说法问"怎么退货",ask_naive 会调1000 次模型;而 ask_with_semantic_cache,只有第一个人那次真正调了模型,后面 999 次,全都语义命中、被缓存接住——它们字面五花八门,但意思都一样。成本和延迟,就这样被压下去了一大截。但要注意:真实系统里,entries 不会用一个 list 逐个遍历(量大了慢),而是放进向量数据库,用专门的索引做相似度检索;这里用 list,是为了把原理讲清楚。原理通了,但要把语义缓存真正做对,还有几个绕不开的坑。

六、工程坑:相似度阈值、缓存污染与时效性

语义缓存的主干通了,但有几个工程坑,不处理就会出事。

坑 1:相似度阈值的拿捏,是语义缓存的生死线。阈值定得太高(比如 0.99),那几乎只有字面也几乎一样的问题才命中,语义缓存退化成了精确缓存,命中率上不去;阈值定得太低(比如 0.80),灾难就来了——两个看起来有点像、其实问的不是一回事的问题会被判定为命中,用户会拿到一个驴唇不对马嘴的答案。最危险的是反义词:"怎么开通会员"和"怎么取消会员",字面高度相似、向量也相当接近,阈值一松,就可能把"取消"的问题命中成"开通"的答案。阈值必须靠真实问题去测,在"命中率"和"答错率"之间谨慎权衡,宁可偏高一点。

def evaluate_threshold(test_pairs: list, threshold: float):
    """用人工标注的相似/不相似问题对,评估某个阈值好不好。"""
    right = 0
    for q1, q2, should_hit in test_pairs:
        sim = cosine_similarity(embed(q1), embed(q2))
        hit = sim >= threshold
        if hit == should_hit:          # 判断和人工标注一致
            right += 1
    accuracy = right / len(test_pairs)
    print(f"阈值 {threshold}: 判定准确率 {accuracy:.2%}")
    return accuracy
    # 拿一批 (问题A, 问题B, 它俩是否真的同义) 跑一遍,
    # 试几个阈值,挑判定准确率最高、且偏保守的那个。

坑 2:小心"缓存污染"——别把错的答案缓存住。缓存是会被反复命中的。如果模型某一次生成了一个错误答案,而你不加判断地把它存了进去,那么之后每一个语义相近的问题,都会命中并返回这个错误答案——一次错误,被放大成持续的、批量的错误。所以写入缓存前,值得加一道过滤:明显异常的回答(太短、报错信息、模型说"我不知道")不该进缓存

def should_cache(question: str, answer: str) -> bool:
    """写入缓存前的过滤:不合格的答案不准进缓存。"""
    if not answer or len(answer) < 10:
        return False                  # 太短,多半是异常回答
    bad_signs = ["我不知道", "无法回答", "抱歉,出错"]
    if any(sign in answer for sign in bad_signs):
        return False                  # 模型自己都没答好,别缓存
    # 含个人信息的问答通常是一次性的,不该被别人命中
    if "我的订单" in question or "我的账号" in question:
        return False
    return True

坑 3:缓存有时效性,答案会过期。客服答案不是一成不变的——退货政策改了、活动规则变了,缓存里那条旧答案就成了错的。语义缓存必须有失效机制:给每条缓存设过期时间(TTL),到期重新生成;更重要的是,当业务知识发生变更时,要能主动清掉相关的缓存,别让用户拿着过期答案做决定。

坑 4:个性化、一次性的问题,不该进语义缓存。"我的订单 88 号到哪了"——这种问题的答案因人而异、因时而异,它天然不该被缓存,更不该被别的用户命中。语义缓存只适合那些答案与提问者无关通用知识型问题。哪些该缓、哪些不该缓,要在写入前就判断清楚(上面的 should_cache 已经拦了一道)。下面这张图,把一次带语义缓存的提问完整路径串起来:

关键概念速查

概念 / 手段 说明
LLM 调用又慢又贵 每次调用都是真实推理,延迟以秒计、按 token 计费
问题高度重复 客服场景问题分布极度集中,大量是语义相同的重复劳动
精确匹配缓存失效 同一问题有无数种说法,字面做 key 几乎永远命不中
语义缓存的本质 用意思而非字面做 key,问题换种说法也能命中
embedding 向量 把文本转成向量,语义越近的文本向量在空间里越靠近
余弦相似度 衡量两个向量的接近程度,值越接近 1 越相似
相似度阈值 超过阈值才算命中,太高退化成精确缓存,太低会答错
反义词陷阱 开通与取消字面相似向量也近,阈值过松会张冠李戴
缓存污染 错误答案被缓存后会被反复命中,写入前要过滤异常回答
时效性与个性化 答案会过期要设 TTL,个性化一次性问题不该进语义缓存

避坑清单

  1. 每次 LLM 调用都是又慢又贵的真实推理,客服场景问题高度重复,大量是重复生成。
  2. 用问题字面文本做 key 的精确匹配缓存,因同一问题说法无数种,命中率几乎为 0。
  3. 语义缓存的本质是用意思而非字面做 key,问题换种说法也要能命中同一份缓存。
  4. 用 embedding 把问题转成向量,语义越近向量越靠近,命中判断用相似度而非相等。
  5. embedding 调用远比完整生成便宜,用一次廉价 embedding 省一次昂贵生成才划算。
  6. 语义缓存的 get 要遍历找相似度最高的老问题,且最高相似度须过阈值才算命中。
  7. 相似度阈值是生死线,太高退化成精确缓存,太低会把不同问题判成命中而答错。
  8. 警惕反义词陷阱,开通与取消字面相似向量也近,阈值宁可偏高也别偏低。
  9. 错误答案进缓存会被反复命中放大成批量错误,写入前要过滤掉异常回答。
  10. 缓存答案会过期要设 TTL 并在知识变更时主动清理,个性化一次性问题不该缓存。

总结

回头看那次"同一个问题换种说法、模型又花钱重答一遍"的事故,以及我后来在语义缓存上接连踩的坑,最该记住的不是某一段相似度代码,而是我动手前那个想当然的判断——"加个缓存,用问题文本当 key 就行了"。这句话错在它把"缓存"这个我无比熟悉的工具,不假思索地套到了一个全新的场景上。我做过无数次缓存:缓存一个用户 ID 对应的信息、缓存一个商品编号对应的价格——在那些场景里,key 是标准化、确定的,精确匹配天经地义。可这一次,我要缓存的东西,它的"key"是一句人话。人话是发散的:同一个意思,有一百种说法。用"字面是否相等"去给人话做 key,从一开始就了。语义缓存想清楚的,正是这件事:当缓存的对象是"语义"时,key 也必须是"语义"——而 embedding,就是那个能把"语义"变得可计算、可比较的东西。

所以做语义缓存,真正的工程量不在"查缓存、调模型"那个一目了然的主流程上。那个流程,和普通缓存几乎一模一样。真正的工程量,在那条相似度阈值线上,以及它两边的取舍:阈值高一档,你少命中很多,省的钱变少;阈值低一档,你开始张冠李戴,把"怎么取消"答成"怎么开通"。它在写入那一下:这个答案合格吗,值得被后面成百上千次地命中吗?它带个人信息吗,该被别人命中吗?它还在时效这件事上:这条缓存,会不会哪天政策一改,就悄悄变成了一个错误答案?这篇文章的几节,其实就是顺着这条思路展开的:先想清楚 LLM 调用为什么值得缓存,再看清精确匹配为什么不行、语义缓存的本质是什么,然后是 embedding、相似度检索、写入流程这三段主干,最后是阈值、污染、时效这几个把语义缓存真正做对的工程细节。

你会发现,语义缓存的思路,和一个经验丰富的老客服的工作方式,惊人地相似。一个新客服,会把每一个问题都当成全新的,逐字逐句地重新思考——这是 ask_naive。而一个干了五年的老客服,你话才说一半,他就听懂了:"你这个问题,其实就是在问退货流程吧?"——他不在意你用的是"退货"还是"退款"、是"怎么弄"还是"如何操作",他直接抓住了你这句话的"意思",然后从脑子里调出那个早已烂熟的标准答案。他甚至有分寸:碰到"我那个具体订单到哪了"这种问题,他知道这个没有标准答案,得现查。语义缓存,就是想让你的系统,从那个逐字重想的新人,变成这个听音知意的老手

最后想说,语义缓存做没做扎实,差距永远不会在 Demo 里暴露——Demo 里你自己问几个问题,问题不重复,有没有缓存,跑起来一模一样,你只会觉得"调模型嘛,挺好"。它只在真实的、海量的、措辞千奇百怪的用户流量面前才显形。那时候它会从两个方向给你结账:做不好,你要么白白烧着本可以省下的大把 API 费用、让用户次次干等几秒,要么——更糟——因为阈值太松,有用户问"怎么取消自动续费",你的系统语义命中了"怎么开通"的答案,理直气壮地教他怎么开通,然后是投诉、是差评。而做了,它会安安静静地、不被任何人注意地,替你挡掉大半的模型调用,让高频问题瞬间得到回答,让你的账单降下来一大截。所以别等账单和投诉一起找上门,在你给大模型接口加缓存的第一下就该想清楚:我的 key,是字面还是意思?我的阈值,会不会把反义的问题答串?我存进去的答案,合格吗、会过期吗?这几个问题都有了答案,你的语义缓存才不只是 Demo 里那个有它没它都一样的摆设,而是一个既能实实在在省下成本和延迟、又绝不会把用户的问题张冠李戴答错的可靠系统。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

数据库索引完全指南:从一次"明明加了索引、查询还是慢得要命"看懂索引失效

2026-5-21 20:36:18

技术教程

分布式锁完全指南:从一次"加了锁还超卖、库存被扣成负数"看懂分布式锁

2026-5-21 20:47:34

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索