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))
把 get 和 add 串起来,就是完整的"带语义缓存的提问"。它的逻辑和第二节那个精确缓存结构一样——先查、命中就返回、没命中就调模型再存——区别只在于,"查"和"存"都换成了语义的:
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,个性化一次性问题不该进语义缓存 |
避坑清单
- 每次 LLM 调用都是又慢又贵的真实推理,客服场景问题高度重复,大量是重复生成。
- 用问题字面文本做 key 的精确匹配缓存,因同一问题说法无数种,命中率几乎为 0。
- 语义缓存的本质是用意思而非字面做 key,问题换种说法也要能命中同一份缓存。
- 用 embedding 把问题转成向量,语义越近向量越靠近,命中判断用相似度而非相等。
- embedding 调用远比完整生成便宜,用一次廉价 embedding 省一次昂贵生成才划算。
- 语义缓存的 get 要遍历找相似度最高的老问题,且最高相似度须过阈值才算命中。
- 相似度阈值是生死线,太高退化成精确缓存,太低会把不同问题判成命中而答错。
- 警惕反义词陷阱,开通与取消字面相似向量也近,阈值宁可偏高也别偏低。
- 错误答案进缓存会被反复命中放大成批量错误,写入前要过滤掉异常回答。
- 缓存答案会过期要设 TTL 并在知识变更时主动清理,个性化一次性问题不该缓存。
总结
回头看那次"同一个问题换种说法、模型又花钱重答一遍"的事故,以及我后来在语义缓存上接连踩的坑,最该记住的不是某一段相似度代码,而是我动手前那个想当然的判断——"加个缓存,用问题文本当 key 就行了"。这句话错在它把"缓存"这个我无比熟悉的工具,不假思索地套到了一个全新的场景上。我做过无数次缓存:缓存一个用户 ID 对应的信息、缓存一个商品编号对应的价格——在那些场景里,key 是标准化、确定的,精确匹配天经地义。可这一次,我要缓存的东西,它的"key"是一句人话。人话是发散的:同一个意思,有一百种说法。用"字面是否相等"去给人话做 key,从一开始就错了。语义缓存想清楚的,正是这件事:当缓存的对象是"语义"时,key 也必须是"语义"——而 embedding,就是那个能把"语义"变得可计算、可比较的东西。
所以做语义缓存,真正的工程量不在"查缓存、调模型"那个一目了然的主流程上。那个流程,和普通缓存几乎一模一样。真正的工程量,在那条相似度阈值线上,以及它两边的取舍:阈值高一档,你少命中很多,省的钱变少;阈值低一档,你开始张冠李戴,把"怎么取消"答成"怎么开通"。它在写入那一下:这个答案合格吗,值得被后面成百上千次地命中吗?它带个人信息吗,该被别人命中吗?它还在时效这件事上:这条缓存,会不会哪天政策一改,就悄悄变成了一个错误答案?这篇文章的几节,其实就是顺着这条思路展开的:先想清楚 LLM 调用为什么值得缓存,再看清精确匹配为什么不行、语义缓存的本质是什么,然后是 embedding、相似度检索、写入流程这三段主干,最后是阈值、污染、时效这几个把语义缓存真正做对的工程细节。
你会发现,语义缓存的思路,和一个经验丰富的老客服的工作方式,惊人地相似。一个新客服,会把每一个问题都当成全新的,逐字逐句地重新思考——这是 ask_naive。而一个干了五年的老客服,你话才说一半,他就听懂了:"你这个问题,其实就是在问退货流程吧?"——他不在意你用的是"退货"还是"退款"、是"怎么弄"还是"如何操作",他直接抓住了你这句话的"意思",然后从脑子里调出那个早已烂熟的标准答案。他甚至有分寸:碰到"我那个具体订单到哪了"这种问题,他知道这个没有标准答案,得现查。语义缓存,就是想让你的系统,从那个逐字重想的新人,变成这个听音知意的老手。
最后想说,语义缓存做没做扎实,差距永远不会在 Demo 里暴露——Demo 里你自己问几个问题,问题不重复,有没有缓存,跑起来一模一样,你只会觉得"调模型嘛,挺好"。它只在真实的、海量的、措辞千奇百怪的用户流量面前才显形。那时候它会从两个方向给你结账:做不好,你要么白白烧着本可以省下的大把 API 费用、让用户次次干等几秒,要么——更糟——因为阈值太松,有用户问"怎么取消自动续费",你的系统语义命中了"怎么开通"的答案,理直气壮地教他怎么开通,然后是投诉、是差评。而做对了,它会安安静静地、不被任何人注意地,替你挡掉大半的模型调用,让高频问题瞬间得到回答,让你的账单降下来一大截。所以别等账单和投诉一起找上门,在你给大模型接口加缓存的第一下就该想清楚:我的 key,是字面还是意思?我的阈值,会不会把反义的问题答串?我存进去的答案,合格吗、会过期吗?这几个问题都有了答案,你的语义缓存才不只是 Demo 里那个有它没它都一样的摆设,而是一个既能实实在在省下成本和延迟、又绝不会把用户的问题张冠李戴答错的可靠系统。
—— 别看了 · 2026