做了大半年的AI应用我发现一个特别实在的问题用户问来问去翻来覆去就是那几类问题。每次这些重复请求都要完整走一遍模型推理浪费算力不说首字延迟还高得离谱。后来我把重心放在了Prompt缓存上从最笨的精确匹配一路做到语义检索中间踩了不少坑也总结出一套比较完整的策略体系。这篇就专门聊聊Prompt缓存的四种策略从精确匹配到语义检索这条演进路径以及每种策略背后的适用场景和实现细节。适合谁看呢如果你在做AI客服、企业知识库问答、代码助手这类“问题高度重复”的AI应用或者你在优化模型服务端的成本与延迟这篇文章应该能给你一套非常落地的参考方案。1. 先搞清楚为什么需要缓存1.1 一次请求的延迟被谁吃掉在聊缓存之前得先明白一个事情大模型请求的耗时到底花在哪。很多人第一反应是“生成token慢”但实际上你测过就会发现从发起请求到吐第一个字这段时间往往占比巨大。这段被称为首token延迟里面大头是预填充阶段——模型要把你输入的整个Prompt逐字处理一遍生成对应的中间状态然后才能开始输出。这意味着什么呢如果你的Prompt有3000个token其中2500个是系统提示词加固定知识片段只有后面500个是用户当次输入。理论上前面2500个token的计算结果应该是可以复用的但如果没有缓存机制每次请求都要从头算一遍。Prompt越长这个浪费越明显。我实测过一个3000token的请求预填充部分能占到整体首token延迟的60%到70%。也就是说你等的那一秒钟里大半年其实是花在了“重复读相同的文字”上。Prompt缓存要解决的就是这个问题把重复计算的部分存下来下次遇到一样的或者相似的输入直接复用跳过预填充。收益是两方面的一是延迟下降尤其首token延迟能砍掉一半以上二是成本下降因为很多模型服务的计费是包含输入token的省掉重复的输入计算账单自然好看。1.2 缓存到底缓存什么很多人一听“Prompt缓存”直觉是“把相同的Prompt和相同的回答存起来下次直接返回”。这确实是一种缓存思路但它只是最表层的一层。深入一点你会发现可以缓存的东西其实分层最顶层是回答结果缓存Prompt完全一样时直接返回之前生成的答案完全跳过模型请求。这种缓存命中时延迟最低几乎为零但要求Prompt必须严格一致。中间层是预填充状态缓存Prompt的前缀部分一样时复用模型已经算好的KV状态只对新增的部分做预填充。这一层不需要语义理解只需要做前缀匹配命中后延迟收益也很大。底层是语义缓存Prompt文本不完全一样但意思差不多把回答结果复用过来。这层需要嵌入向量和相似度判断命中率比前两层高但有误判风险。完整的缓存设计应该同时考虑这三层。一篇文章里我会把这三种形式对应到四种策略上精确匹配对应顶层结果缓存前缀匹配对应中间层状态缓存语义缓存和混合检索对应底层结果缓存与多层分级。1.3 设计缓存前必须想清楚的三件事动手设计之前先有三个问题要回答不然后面一定返工。第一你的业务里到底有多少重复请求这个直接决定缓存值不值得做。如果是那种用户每次问法千奇百怪的开放式闲聊重复率低做精确匹配命中率可能不到10%收益很有限。但如果是客服、知识库、政务问答这类场景高频问题重复率非常高缓存的价值就非常大。我见过一个实际项目做了缓存之后模型调用量直接下降了40%。第二你能接受多大程度的“近似命中”如果只允许一模一样的请求走缓存那么实现简单但命中率会受限。如果你愿意承担“相似问题套用同一个回答”的风险命中率可以大幅提升但需要有兜底校验。这个权衡没有标准答案取决于业务对答错问题的容忍度。第三系统的延时和成本瓶颈到底在哪如果你的模型服务本来就很慢或者模型调用费用对你来说不是主要矛盾那么缓存带来的收益可能没那么明显。先明确目标再谈策略选型。2. 四种策略全景从“硬匹配”到“软匹配”的演进2.1 四种策略横向总览先把四种策略放在一起做个对比后面再逐个拆开讲。这四种策略按照匹配的“松紧程度”排列基本也对应了实现成本从低到高的顺序策略匹配粒度命中条件典型延迟收益实现难度误命中风险精确匹配整段文本Prompt完全一致极高直接返回极低无前缀匹配Token序列前缀Token序列一致高复用预填充中无语义缓存向量空间余弦相似度超阈值中返回近似结果较高有混合检索多级流水线多条件组合判定高综合最优高可控“从精确匹配到语义检索”这个演进路线本质上就是让“什么算命中”的定义越来越宽松换来的指标就是命中率越来越高但同时引入了更多需要控制的变量。每一层策略都是在前一层基础上解决一部分场景但不是替代关系。2.2 策略一精确匹配精确匹配是最基础、也最好理解的一种策略。核心思想就是计算Prompt的哈希值存一张映射表下次来一个请求算一下哈希如果在表里能找到就直接返回缓存的结果。实现起来是真的简单import hashlib def make_cache_key(prompt: str, model: str, user_id: str) - str: raw f{user_id}|{model}|{prompt} return hashlib.sha256(raw.encode(utf-8)).hexdigest()这个key里加上model和user_id是必须的。加上model是因为不同模型对同样问题的回答可能不一样不能混用缓存加上user_id是因为不同用户看到的内容权限可能不同尤其企业场景用户A问到的内部数据结果不能直接返回给用户B。精确匹配最大的优点是安全命中了一定是用户想要的原答案不存在语义理解偏差。而且实现成本几乎为零一个Redis哈希表就够了没有学习成本。但它有个致命弱点就是命中率很低。用户只要多打一个空格、换一个标点、改一个语气词哈希就变了缓存就失效了。我在一个客服项目里统计过这个数据完全不处理的原始用户消息精确匹配命中率大概在8%到12%之间。这个数字对一些高重复场景还算有价值但远远不够。要让精确匹配发挥更大价值可以做一个前置处理归一化。在算哈希之前先对Prompt做标准化处理比如统一全半角、去除多余空格、把换行符统一甚至可以把数字和日期这类动态变量替换成占位符。有一招很实用把用户昵称、当天日期这类高频变化但语义无关的内容先替换成固定标记再算哈希这样哪怕用户每天问“今天天气”里的“今天”是动态的也能稳定命中一次。但无论如何精确匹配能覆盖的范围有限。下一步自然想到的就是前缀匹配。2.3 策略二前缀匹配前缀匹配解决的是另一类场景Prompt很长但大部分内容是不变的。最典型的就是带系统提示词的应用系统提示词可能有2000个token每次请求都一样只有用户的输入在变。现代模型推理服务在处理Prompt时会把文本切成Token序列然后逐个计算。如果两次请求的前段Token序列完全一样那么理论上前面这些Token对应的计算结果可以直接复用。这个技术在推理引擎层面一般叫KV Cache复用具体做法是对Prompt做分词后与之前处理过的序列做最长公共前缀比较找到公共前缀长度只对增量部分做新的预填充。所以前缀匹配的核心不是“整个Prompt相同”而是“前缀Token序列相同”。它比精确匹配宽容得多因为它不需要后面用户输入部分一致只要前面系统提示词部分一致就行。你可以这么理解精确匹配要求整篇文章一模一样前缀匹配只要求开头几段一模一样后面随便写。实现上关键是两层。第一层是要拿到Prompt的Token ID序列而不是原始字符串。因为模型处理的是Token序列只有Token级别的一致才有复用价值。第二层是存储结构要支持快速定位到“以某个Token序列开头”的缓存记录。最简单的是一个字典树或者更实际的做法是对系统Prompt部分单独计算缓存Key。def longest_common_prefix(a, b): n 0 for x, y in zip(a, b): if x ! y: break n 1 return n # 假设 cached_prefix 是之前某个请求的 token 序列 # 新请求 prompt_tokens 与之比对拿到公共前缀长度 share_len longest_common_prefix(cached_prefix, prompt_tokens) if share_len reuse_threshold: # 可以复用前 share_len 个 token 的 KV 状态 pass我在实际使用中给前缀匹配定了一个阈值公共Token长度至少要占到系统提示词长度的90%以上才认为是有效命中。否则复用的收益太小不值得为它单独做一次查找。这个策略对自带超长系统提示词的RAG应用效果非常好。用户每次问不同的问题但系统提示词都是同样的大几千字前缀匹配命中后首token延迟基本可以降一半。它也有一个注意点如果业务里把多轮对话历史放在Prompt的前面那么每轮对话都会变化前缀匹配就没法覆盖历史部分了。所以设计Prompt模板时最好把系统提示词放到最前面把动态的对话历史放到后面这样前缀匹配能作用到最值得复用的部分。2.4 策略三语义缓存前缀匹配再往前一步就是让“相似的问题也能命中”。这个靠的是语义缓存用嵌入模型把Prompt转成一个向量然后通过向量之间的相似度判断两个Prompt是否表达同一意思。这是从“硬匹配”到“软匹配”的关键一跃。精确匹配和前缀匹配本质上都是字符串层面的比较一个字不一样就没办法。而语义缓存是把文字映射到向量空间在这个空间里“我要退货”和“怎么申请退款”这样的句子会离得很近它们的向量夹角很小余弦相似度高于是系统会认为它们语义相同直接复用缓存里的回答。理论上这个思路很完美实现也相对标准import numpy as np def embed_prompt(prompt: str): 调用开源嵌入模型返回向量 vector embedding_model.encode(prompt) return vector def cosine_similarity(a, b): return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))) def semantic_lookup(prompt: str, threshold: float): vec embed_prompt(prompt) # 在向量索引中查找相似度最高的候选 candidates vector_index.search(vec, top_k5) for cand_vec, cand_meta, score in candidates: if cosine_similarity(vec, cand_vec) threshold: return cand_meta[response] return None这里有几个细节决定成败。第一是嵌入模型的选择。不同嵌入模型对语义的捕捉能力差异很大。通用的嵌入模型并不一定擅长捕捉“问题意图的相似性”。我在做客服场景时试过用通用的嵌入模型发现“我要退货”和“我退货单填错了”这种表达相似度只有0.8达不到阈值。后来用专门在客服语料上微调过的嵌入模型这类相似度能到0.9以上。所以语义缓存的命中率很大程度上取决于嵌入模型选得好不好。第二是阈值的设定。阈值设太高命中率上不去设太低误命中率飙升。我的建议是不要按拍脑袋定而是拿一批真实历史请求标注出哪些是“应该命中”的语义对跑一遍不同阈值下的命中率和误报率取两者的平衡点。用业务数据标定的阈值比用0.8、0.9这类经验值靠谱得多。第三是必须做缓存隔离。语义缓存最危险的地方在于两个用户的问法非常相似但他们在系统中的数据权限完全不同。如果不区分用户维度做隔离就可能出现用户A问“我的工资单是多少”用户B问的是“我的工资单是多少”两者语义相似结果B拿到了A的工资信息。这是严重的合规问题。所以语义缓存的每条记录必须标注owner在召回后做一个归属校验只有同一个用户或者允许共享的公共类问题才放行。2.5 策略四混合检索到了混合检索这一步思路已经不再是“选哪种策略”而是“怎么把前三种策略组合起来”。单用精确匹配命中率太低单用语义缓存在高风险场景又不放心混合检索就是把它们按成本从低到高排列组成一条流水线。常规的流程是这样的先做精确匹配哈希直接命中就返回这是最快的一层没有命中就做前缀匹配看有没有可复用的预填充状态再没有命中就进入语义检索用向量召回几个语义接近的候选再做二次校验校验也通过了才复用缓存否则老老实实走模型推理。这就像你找一本书先在书桌上找精确匹配再去书架上看有没有同系列的书前缀匹配最后去图书馆检索同主题的书语义检索。一步比一步慢但覆盖范围越来越大。混合检索的优势在于它不是简单地把命中率指标堆上去而是把“安全”和“性能”做了分工。低风险的精确匹配直接给结果中等风险的前缀匹配只是复用计算状态高风险的语义检索后面再跟一个校验层把风险控制住。校验层怎么设计我的做法是加一个规则过滤器。语义召回之后先比较两个Prompt之间的文本相似度如果文本层面的重合度也很高说明除了语义接近字面上也足够相似这个复用就放心了很多。如果文本相似度很低纯靠语义拉近的就稍微谨慎一点尤其涉及金额、身份、联系方式这类敏感信息时宁可多一次模型调用也不冒险。还有一个更实用的做法把语义缓存限定在“非敏感”内容上。你可以在缓存写入时就标记好这条回答是否涉及敏感数据。只有标记为“通用知识”或“非敏感”的内容才允许跨用户语义命中敏感内容只允许同用户精确命中。这样就算语义误判造成的影响也有限。3. 实操搭建一套两级的Prompt缓存系统3.1 数据结构和Key的设计理论聊完了直接上实操。以我实际搭建过的一套系统为例我用的是两级结构第一级精确匹配第二级语义检索。前缀匹配依赖推理引擎内部的能力在自建服务里可以直接对KV Cache做控制但在调用外部模型时一般接触不到这层所以我把重点放在结果缓存的层级设计上。缓存系统的Key设计是第一个要注意的地方。我见过很多人只拿Prompt做Key结果不同模型、不同版本之间串了缓存。一个合理的Key应该包含以下信息用户维度标识用于隔离权限模型标识与版本号不同模型回答不同Prompt的哈希值精确匹配用可选的采样参数temperature等影响输出多样性在存储结构上精确匹配的缓存记录包含响应内容、创建时间、过期时间。语义缓存会多一个向量字段、标注文本以及一个权限标记。{ key: u123|model-x|v2|a1b2c3..., prompt: 原始用户输入, response: 模型返回的完整回答, embedding: [0.012, -0.023, ...], owner: u123, sensitive: False, created_at: 1712345678, ttl: 86400 }3.2 精确匹配的实现要点精确匹配层不需要向量计算只需要一个高性能的哈希存储。这里的关键在于“归一化”要做得足够细。我总结了一套归一化步骤按照这个顺序处理字符串统一全角半角中文场景经常遇到这个问题去除连续多余空格和首尾空白统一换行符为单一形式把年份、日期、数字统一替换为占位符把称呼性内容比如“客服小妹”“你好”这类词做轻度清洗归一化后算哈希存储和查询都非常快。这个层级的命中率在我的项目里大概在15%左右虽然不高但完全零成本属于不做白不做。3.3 语义检索的向量化流程第二级的语义检索做起来会重一些。我的流程是三个环节第一持久化存储向量与内容。做一个独立的向量索引表每条记录存向量、原文、响应、归属、时间。增删改查都用标准接口。第二写入口做异步嵌入。在每次模型返回结果后把用户的Prompt异步送去嵌入模型转成向量连同响应一起写入缓存。注意这里是异步的不能阻塞主流程否则缓存写入的耗时会影响在线请求。第三查询时做向量召回。新Prompt来的时候先嵌入成向量然后到向量索引里做最近邻搜索取TopK个候选再做阈值判断和权限过滤。整个过程里最容易被忽略的是向量索引的更新与淘汰。缓存不能无限膨胀访问频次低的旧向量要及时清理。我用的是TTL加LRU的双重策略每条记录有过期时间同时记录最近访问时间数据量超过阈值时优先淘汰最久没用的。3.4 阈值应该怎么标定阈值是语义缓存最微妙的部分。我在项目里发现直接套用经验阈值很容易踩坑因为不同业务、不同嵌入模型下相似度分布完全不一样。一次对标定过程是这样的从客服系统抽取真实用户请求人工构造了一批“等价问题对”比如“怎么退货”和“退货流程是什么”和“不等价问题对”比如“怎么退货”和“促销活动有哪些”然后跑不同嵌入模型记录每个模型的相似度分布。我最终选择的阈值是让“等价问题对”的命中率在90%以上同时“不等价问题对”的误命中率在5%以下的那个点。这个标定过程看起来很麻烦但值得做因为直接省掉了后面无数的线上问题。另外阈值不必是一个固定值。可以把阈值拆成两部分一个宽松阈值用于召回一个严格阈值用于确认。召回的阈值可以低一点多捞几个候选确认时用严格阈值保证准确。误命中率主要受严格阈值控制而召回率不受太大影响。3.5 完整流水线代码示例把两级逻辑串起来核心流程大概是这样的def get_cached_response(prompt: str, user_id: str, model_id: str, threshold: float 0.90): # 第一步归一化 精确匹配 merged_prompt normalize(prompt) cache_key f{user_id}|{model_id}|{sha256(merged_prompt)} exact_cache exact_store.get(cache_key) if exact_cache: return exact_cache # 第二步语义向量召回 if not model_id.endswith(strict): # 模型版本有更新时跳过缓存 vec embed(prompt) candidates vector_store.search(vec, top_k5) for cand in candidates: if cand.score threshold: # 权限校验不是本人且非共享内容跳过 if cand.owner ! user_id and not cand.is_public: continue # 敏感内容只允许同用户命中 if cand.sensitive and cand.owner ! user_id: continue return cand.response # 第三步未命中走真实模型 return None这一段逻辑看起来不长但实际部署要考虑的东西不少归一化函数很关键必须反复测试覆盖各种输入形式向量召回要用带截止时间的索引不然老数据永远停在里面权限校验的规则必须跑在返回结果之前哪怕多写几层if也值得。4. 常见问题与排查技巧实录4.1 语义缓存命中了错误答案这是语义缓存上线后最可能遇到也是后果最严重的问题具体表现是用户问了一个新问题系统从缓存里返回了一个看着相关、但根本不是用户要的回答。我踩过的一次坑是这样的用户问“手机的保修期是多久”而缓存里有一条“笔记本的保修期是两年”两者的向量相似度很高因为都是问保修期但产品对象完全不同于是用户拿到了错误的信息。这个问题彻底让我明白了语义相似和事实正确是两个维度的东西不能混为一谈。后来我加了一道防线召回后把缓存原Prompt和新Prompt做关键词一致性校验。如果产品型号、规格、日期这类关键实体对不上就算相似度再高也不命中。这个校验规则简单粗暴但有效它管住了“看着相似实则不同”的情况。4.2 命中率很高但用户体感没变化还有一类问题是命中率指标很好但线上延长了用户没觉得变快。出现这个现象的根源是缓存命中返回的结果虽然快但用户的对话流程依然要走业务系统自己的逻辑比如查询订单状态、调用外部接口这些步骤本身就需要时间。缓存省掉的只是模型推理那几百毫秒但整体响应里的其他耗时还在。排查时先把全链路耗时拆开看模型推理占比多少业务逻辑占比多少外部接口占比多少。如果模型推理只占三成那做缓存帮助就不大重点应该优化别的地方。这也是我一直强调的先确认瓶颈再决定投在哪一层缓存。4.3 缓存数据的权限隔离问题语义缓存最让我担心的就是权限隔离。解决思路在前面提到了核心是两条敏感内容只允许同用户精确命中非敏感内容才允许跨用户语义命中。这里还有一个隐藏问题判断“敏感”由谁来做。我的做法是在写入缓存时用一个轻量级分类模型或规则去判断回答内容里是否包含金额、手机号、地址、证件号等模式匹配到就标记为敏感。当然这不是绝对可靠的你也可以在部分场景直接一刀切所有涉及用户个人数据的请求不做语义缓存只做精确缓存。少一点命中率换一份安心。4.4 缓存要不要带过期与版本管理缓存数据有一定时效性。比如业务规则变了、商品信息变了、模型版本升级了旧缓存里的回答可能就已经过期了。我见过一个翻车案例某系统的缓存没有设置过期时间用户在政策调整后问同一个问题获得的是旧政策的回答。这个场景不说有多严重但影响体验是跑不掉的。因此我强烈建议给每条缓存记录加TTL生存时间并且把模型版本和系统提示词版本作为缓存Key的一部分。版本变更时让旧缓存自然淘汰而不是立刻清空可以避免瞬时大量回源引发模型服务雪崩。4.5 小技巧速查表问题排查要点常用解法命中率太低归一化是否够细嵌入模型是否匹配业务加强归一化、更换领域嵌入模型命中结果错误检查语义相似但关键实体不一致的案例加关键实体一致性校验缓存不生效检查缓存Key是否包含版本/用户等维度确保Key的颗粒度合理缓存雪崩没有做过期与淘汰策略加TTL、加随机过期时间、限流回源存储膨胀向量数据无上限增长引入LRU淘汰与定期清理任务5. 选型建议不同业务场景应该怎么配5.1 几类典型场景的组合方案回到最初的问题到底用哪种策略。我的建议是不要直接在四种策略里选一个而是按场景组合使用。做知识库问答的场景Prompt里往往塞了大量的参考文档用户的问题本身短且高频。这种场景下前缀匹配的收益最明显因为系统提示词和参考文档前缀大部分是固定的同时用户问题部分适合做精确加语义的组合缓存对高频问法命中很有效。我比较推荐的组合是“前缀匹配 语义缓存”前缀匹配解决固定长文本的重复预填充语义缓存解决问题表述的多样性。做企业级客服的场景问题高度同质化但安全要求又很高。这种场景最稳妥的是“精确匹配兜底 语义缓存非敏感召回”。所有涉及用户个人信息的请求关闭语义缓存只允许精确匹配通用知识类问题开放语义缓存。既能拿到足够的命中率又能把风险控制在可接受范围内。做代码助手或开发工具类场景用户输入高度多变重复度低缓存价值相对有限。这种情况下我建议只做精确匹配做轻量兜底不要轻易上语义缓存因为代码相关的Prompt语义粒度极其敏感一个变量名不同整个答案可能就完全错了语义误判的代价太大。5.2 什么时候不值得上缓存不是所有系统都适合做Prompt缓存。如果你正在做的是高度个性化的一对一交互比如角色扮演、情感陪伴类应用每次对话内容都千差万别缓存命中率可能连5%都不到做缓存就是白搭精力。另外如果你对外部模型服务的调用成本占比不高或者业务延迟瓶颈根本不在模型推理那缓存的意义也不大。我在一个内部系统里见过这种情况模型调用本身只占整个流程15%的时间剩下的都在等数据库查询和第三方接口往模型缓存上砸功夫就是典型的投资错方向。Prompt缓存这件事价值不在技术本身多炫而在于它到底砍掉了多少真实延迟和成本。选型之前先算清楚账比先选方案重要得多。结语这套缓存体系从精确匹配一步步走到混合检索我是踩了不少坑才慢慢捋清楚的。最深的体会是缓存策略没有绝对的优劣只有和业务场景匹配不匹配。精确匹配简单但表现稳定前缀匹配在固定长Prompt场景收益惊人语义缓存命中率最高却需要花大力气做安全和准确性的兜底混合检索则是在性能与风险之间找平衡。如果你正在做类似的东西我的建议是从最基础的精确匹配做起先把归一化做好看看命中率能不能接受不够用的时候再逐层加。不要一上来就全量上语义检索那会让你陷入调不完的阈值和排不完的误报。先让简单策略跑起来再根据数据说话这是最不折腾人的路线。