
1. 三档缓存方案的业务背景与选型逻辑1.1 为什么LLM应用绕不开缓存这道坎做过LLM应用落地的朋友应该都有体会项目从Demo走向生产环境最先撞上的墙往往不是模型效果不够好而是成本和延迟这两座大山。我去年接手一个企业内部知识问答系统日均请求量在八千到一万二之间用的是LangChain做编排底层接的是商用大模型API。上线第一周账单出来的时候团队所有人都沉默了——单月token消耗折算下来接近六位数而其中相当一部分请求是高度重复或语义相近的。这不是个例。LLM应用有一个很显著的特征用户问的问题在语义层面高度聚集。比如一个客服场景用户可能用几十种不同的说法问同一个问题——“怎么退款”“退款流程是什么”“我想退钱怎么操作”“订单不想要了怎么办”这些query在字面上差异很大但在语义空间里几乎指向同一个意图。如果每一次都原封不动地打给大模型那就是在拿真金白银重复购买同一个答案。缓存就是在这个背景下被引入的。但缓存不是简单地加一个Redis就完事它有三个明显的档位每一档解决的问题不同付出的代价也不同。我把它们分别叫做无缓存档、普通缓存档和语义缓存档。这三档不是简单的“好与更好”的关系而是各有各的适用边界选错了反而会引入新的问题。1.2 三档方案的核心差异到底在哪先把三档方案的本质说清楚后面再展开细节。无缓存档顾名思义每次请求都直接打到LLM。它的优势是绝对准确、绝对新鲜不存在缓存命中错误的问题也不存在缓存一致性的维护成本。缺点是成本线性增长延迟完全取决于模型响应速度高峰期还会遇到限流。普通缓存档通常用Redis做键值存储key是用户query的精确字符串或者经过简单归一化后的字符串value是LLM的完整响应。它的逻辑是完全一样的query直接返回缓存结果。这一档能解决一部分重复请求但解决不了“换个说法问同一个问题”的情况。它的命中率高度依赖于用户提问的规范性。语义缓存档是在普通缓存的基础上引入向量相似度匹配。它不再要求query字符串完全一致而是把query转成embedding向量在向量库里检索是否存在语义相近的历史query。如果相似度超过某个阈值就认为这两个问题是同一个意图直接返回历史答案。这一档的命中率会显著提升但引入了新的问题阈值怎么定、误命中怎么处理、向量检索本身的延迟和成本。下面这张表可以先帮大家建立一个整体印象维度无缓存普通缓存语义缓存命中条件无query字符串完全一致query向量相似度超阈值典型命中率0%15%-35%45%-70%额外延迟无1-5ms20-80ms额外成本无Redis存储向量化向量检索误命中风险无极低中高需调阈值实现复杂度最低低中高适用场景强时效、强个性化高频重复问答客服、知识库、FAQ这张表里的命中率数据是我在几个实际项目中观察到的区间具体数值跟业务场景强相关。比如一个内部IT支持机器人语义缓存命中率能到65%以上而一个开放式创作助手命中率可能只有20%不到因为每个请求都是独特的。1.3 选型时最容易踩的认知误区我见过不少团队一上来就说“我们要做语义缓存”觉得这是最先进的方案。但实际落地下来有几个误区必须先破掉。第一个误区是认为缓存命中率越高越好。命中率高确实省钱但如果命中的是错误答案那省下来的钱远远抵不上业务损失。语义缓存最大的风险就是误命中——两个query向量相似度高但实际意图不同。比如“如何取消订单”和“如何取消退款”向量相似度可能很高但意图完全相反。这种误命中在客服场景里是灾难性的。第二个误区是忽略缓存失效策略。LLM的答案不是静态的知识库更新了、产品政策变了、模型版本升级了缓存里的旧答案就变成了错误答案。普通缓存的失效相对好处理key对key删掉就行语义缓存的失效很麻烦因为你不知道哪些query的向量跟被更新内容相关。这个问题后面会专门讲。第三个误区是把缓存当成万能药。缓存解决的是重复请求的问题但如果你的业务本身就是低频、长尾、高度个性化的那缓存带来的收益可能覆盖不了它引入的复杂度和维护成本。我一般建议团队先跑一周的query日志做一次聚类分析看看重复率到底有多高再决定上哪一档。2. 无缓存档基线方案的真实表现与测量方法2.1 无缓存不是“什么都不做”很多人把无缓存档理解成“不优化”其实不对。无缓存档是一个必要的基线它的价值在于给你一个准确的参照系。没有这个基线你根本无法判断缓存到底带来了多少收益。我在每个项目里都会先跑一段无缓存的生产数据至少收集三样东西平均响应延迟、P95延迟、单位请求的token成本。这三个指标是后面所有优化的锚点。具体怎么采集LangChain本身提供了callback机制可以挂一个自定义的handler在on_llm_end里记录token用量和耗时。from langchain.callbacks.base import BaseCallbackHandler import time class MetricsCallback(BaseCallbackHandler): def __init__(self): self.start_time None self.records [] def on_llm_start(self, serialized, prompts, **kwargs): self.start_time time.time() def on_llm_end(self, response, **kwargs): elapsed time.time() - self.start_time usage response.llm_output.get(token_usage, {}) self.records.append({ latency: elapsed, prompt_tokens: usage.get(prompt_tokens, 0), completion_tokens: usage.get(completion_tokens, 0), })这段代码不复杂但它是所有后续优化的基础。我建议至少采集5000条以上的真实请求样本太小会被偶发波动干扰。2.2 基线数据的解读方式拿到数据之后不要只看平均值。平均值在LLM场景里极具欺骗性因为LLM的响应时间分布是典型的长尾分布。我一般会重点看三个数P50、P95、P99。P50代表一半请求的体验P95代表大部分用户的体验上限P99代表最差情况。如果P99延迟是P50的三倍以上说明系统存在明显的抖动这时候单纯加缓存可能解决不了根本问题还得看是不是有长prompt或者模型限流的问题。成本这块我习惯把prompt token和completion token分开算因为两者的单价通常不一样而且prompt token里往往包含大量的系统提示词和上下文这部分是可以通过缓存大幅压缩的。实测下来一个典型的RAG问答场景prompt token能占到总token的70%以上这意味着缓存prompt部分的收益远大于缓存completion部分。2.3 无缓存档什么时候该保留有一种情况我会建议保留无缓存路径强时效性、强个性化的请求。比如用户问“我上个月的订单状态”这种请求的答案依赖于当前用户的具体数据缓存了也没用反而可能返回别人的数据。再比如实时行情、库存查询这类场景答案每秒都在变缓存就是给自己挖坑。所以正确的做法不是“全量走缓存”而是在请求入口做一次路由判断把请求分成“可缓存”和“不可缓存”两类。这个判断逻辑可以基于query的意图分类也可以基于业务规则。LangChain里可以用RouterChain或者自定义的RunnableBranch来实现。3. 普通缓存档Redis精确匹配的落地细节3.1 缓存键的设计是成败关键普通缓存看起来简单但key的设计直接决定了命中率。最粗暴的做法是拿用户原始query当key但这样命中率极低因为用户输入里充满了无意义的差异多余的空格、大小写、标点、语气词。我一般会做三层归一化第一层是基础清洗去除首尾空格、统一大小写、去除连续空白字符、去除常见语气词“请问”“帮我”“麻烦”这类。第二层是结构化归一化如果query里包含可提取的参数比如订单号、日期把这些参数抽出来单独处理剩下的模板部分作为key的一部分。这样“查询订单12345的状态”和“查询订单67890的状态”就能共享同一个缓存模板。第三层是哈希压缩归一化后的字符串可能很长直接当Redis key会占用大量内存。我一般用SHA256或者MD5做一次哈希取十六进制摘要作为最终key。这里要注意哈希碰撞的概率虽然极低但在高并发场景下不能完全忽略所以我会在value里存一份原始query的哈希命中后再校验一次。import hashlib import re def normalize_query(query: str) - str: query query.strip().lower() query re.sub(r\s, , query) query re.sub(r[请问|帮我|麻烦|谢谢], , query) query re.sub(r\d{5,}, NUM, query) return query def build_cache_key(query: str, model_name: str, prompt_version: str) - str: normalized normalize_query(query) raw f{normalized}|{model_name}|{prompt_version} return llm:cache: hashlib.sha256(raw.encode()).hexdigest()注意key里我加了model_name和prompt_version。这一点非常重要因为同一个query在不同模型、不同prompt模板下的答案是不一样的。如果不加这两个维度模型升级后缓存就会返回旧答案造成严重的一致性问题。3.2 Redis的数据结构与过期策略普通缓存的value我一般存JSON字符串包含三个字段answer、created_at、source_query。source_query存原始query方便排查问题时追溯。数据结构选String就够了不需要用Hash。因为我们是整体读写没有部分更新的需求。String在Redis里的内存效率最高也最简单。过期策略这块我踩过坑。一开始我设的是固定TTL比如24小时。但实际运行下来发现有些高频query的答案其实很稳定24小时过期太浪费而有些低频query的答案可能很快就过时了24小时又太长。后来我改成了分层TTL高频query命中次数超过阈值TTL设7天中频queryTTL设24小时低频queryTTL设2小时实现方式是在每次命中时对key做一次EXPIRE续期续期时长根据命中次数动态调整。这个逻辑用Redis的INCR配合EXPIRE就能实现不需要额外的存储。注意Redis的EXPIRE在key不存在时返回0不会报错但如果你在并发场景下依赖这个返回值做逻辑判断要小心竞态条件。我一般用Lua脚本把INCR和EXPIRE打包成原子操作。3.3 缓存穿透、击穿、雪崩的应对这三个经典问题在LLM缓存场景里同样存在而且因为LLM响应慢、成本高后果更严重。缓存穿透指的是查询一个根本不存在的key每次都打到LLM。在LLM场景里这通常发生在用户问了一个冷门问题缓存里没有然后LLM返回了一个“我不知道”的答案这个答案没有被缓存下次同样的query又打一遍。我的处理方式是对LLM返回的“无效答案”也做缓存但TTL设短一些比如5分钟。这样既能防止短时间内重复打LLM又不会让错误答案长期占据缓存。缓存击穿指的是某个热点key过期瞬间大量并发请求同时打到LLM。这个在LLM场景里特别危险因为LLM的并发能力有限很容易触发限流。我的处理方式是加分布式锁第一个请求发现缓存miss后先抢锁抢到锁的请求去调LLM并写缓存没抢到锁的请求等待一小段时间后重试读缓存。import redis import time r redis.Redis(hostlocalhost, port6379, decode_responsesTrue) def get_with_lock(key, ttl300, lock_timeout30): value r.get(key) if value: return value lock_key flock:{key} acquired r.set(lock_key, 1, nxTrue, exlock_timeout) if acquired: try: value call_llm_and_cache(key, ttl) return value finally: r.delete(lock_key) else: for _ in range(10): time.sleep(0.5) value r.get(key) if value: return value return call_llm_and_cache(key, ttl)缓存雪崩指的是大量key在同一时间集中过期。这个的应对方式很简单在基础TTL上叠加一个随机抖动比如基础TTL是24小时实际设置时加上0到300秒的随机值。这样过期时间就分散开了。3.4 普通缓存的命中率天花板普通缓存的命中率有一个天然上限这个上限由用户提问的规范性决定。在一个用户经过培训、提问格式统一的场景里比如内部工具命中率能到35%左右。但在面向C端的场景里命中率通常只有15%到20%。我做过一个统计在一个客服场景里把归一化做到极致之后普通缓存的命中率是22%。剩下的78%里有相当一部分是“换个说法问同一个问题”。这部分就是语义缓存要解决的。4. 语义缓存档向量相似度匹配的工程实现4.1 语义缓存的核心链路拆解语义缓存的链路比普通缓存长得多我把它拆成五步第一步query向量化。用embedding模型把用户query转成向量。这一步有成本也有延迟。embedding模型的选型很关键我一般用轻量级的模型比如bge-small或者text-embedding-3-small因为语义缓存对embedding精度的要求没有RAG那么高够用就行。第二步向量检索。在向量库里找与当前query向量最相近的历史query。向量库的选型后面单独讲。第三步相似度判定。拿到最相近的历史query和相似度分数判断是否超过阈值。这一步是语义缓存最核心、也最容易出问题的地方。第四步缓存命中处理。如果判定命中直接返回历史答案。但这里有个细节历史答案是基于历史query生成的当前query可能有细微差异直接返回是否合适我的做法是在答案里保留一定的泛化性或者在prompt设计时就要求模型生成不依赖具体措辞的答案。第五步缓存未命中处理。如果没命中走正常的LLM调用然后把当前query的向量和答案一起写入向量库。4.2 向量库选型Redis、FAISS还是专用向量库向量库的选型我试过三种方案各有优劣。Redis RediSearch这是我最推荐的方案尤其是已经在用Redis做普通缓存的团队。RediSearch支持向量索引和KNN检索延迟低运维成本低不需要引入新的中间件。缺点是向量索引的能力相比专用向量库弱一些比如不支持复杂的过滤条件组合。但对于语义缓存这个场景够用了。FAISSFacebook开源的向量检索库性能极强支持多种索引类型。缺点是它是库不是服务需要自己管理索引的持久化和加载多进程场景下比较麻烦。我一般只在单机、离线场景下用它。专用向量库如Milvus、Qdrant功能最全支持分布式、支持复杂过滤、支持多种索引。缺点是引入了新的运维组件对于语义缓存这个相对简单的场景来说有点杀鸡用牛刀。我的建议是如果已经在用Redis直接用RediSearch如果是从零开始且团队有向量库运维经验可以用Qdrant如果只是做原型验证FAISS最快。4.3 相似度阈值的确定方法阈值是语义缓存的命门。阈值太高命中率上不去阈值太低误命中率飙升。我见过团队拍脑袋定0.85结果上线后误命中一堆用户投诉不断。正确的做法是用真实数据做标注画一条ROC曲线。具体步骤第一步从历史query里采样一批人工标注哪些是“同一意图”哪些是“不同意图”。样本量至少500对。第二步对每一对query计算向量相似度。第三步以相似度为横轴以“判定为同一意图”的准确率和召回率为纵轴画曲线。第四步根据业务对误命中的容忍度选择阈值。客服场景对误命中容忍度低阈值要设高一些比如0.92知识库场景容忍度稍高可以设0.88。这里有个经验不同embedding模型的相似度分布不一样所以阈值不能跨模型复用。换了embedding模型阈值必须重新标定。4.4 误命中的兜底机制即使阈值调得再好误命中也不可能完全避免。所以必须有一套兜底机制。我的做法是在返回缓存答案之前加一道轻量级校验。校验方式有两种一种是用一个小模型比如7B级别的判断当前query和历史query是否真的同一意图另一种是用规则匹配检查两个query里是否包含相互矛盾的关键词。第一种方式更准但增加了延迟和成本。第二种方式快但覆盖不全。我一般用第二种做第一道过滤把明显矛盾的挡掉剩下的再走第一种。这样在延迟和准确率之间取一个平衡。提示兜底校验的prompt要设计得极其简洁只输出“是”或“否”不要让它生成解释否则延迟会失控。5. 三档方案的实测数据与对比分析5.1 测试环境与数据集说明为了给大家一个可参考的对比我把自己最近一个项目的实测数据整理出来。这个项目是一个企业内部知识问答系统日均请求约9000次query主要围绕产品使用、故障排查、政策咨询三类。测试环境LangChain 0.1.xRedis 7.2embedding模型用bge-small-zhLLM用某商用API。测试集是从生产日志里随机抽取的3000条真实query去重后剩余2100条。5.2 命中率与成本对比指标无缓存普通缓存语义缓存缓存命中率0%21.3%58.7%平均延迟2.8s2.2s1.3sP95延迟6.5s5.8s3.9s单请求平均token成本1.00x0.79x0.42x误命中率-0.02%1.8%这组数据里语义缓存的命中率是58.7%意味着接近六成的请求不需要调用LLM。成本降到无缓存档的42%延迟降到46%。这个收益是相当可观的。但误命中率1.8%需要特别关注。1.8%意味着每1000次请求里有18次返回了错误答案。在内部知识库场景里这个数字勉强可以接受因为用户有辨别能力。但在对外客服场景里1.8%的误命中可能就意味着投诉。5.3 延迟构成的拆解语义缓存的延迟不是零它由三部分组成embedding计算、向量检索、相似度判定。我实测下来bge-small-zh在CPU上单条embedding约15msGPU上约3msRediSearch的KNN检索在10万条向量规模下约8ms相似度判定和逻辑处理约2ms。加起来命中路径的延迟在25ms左右CPU或13msGPU。相比LLM动辄2秒以上的响应这25ms几乎可以忽略。但要注意如果embedding服务本身成为瓶颈比如并发量太大导致排队那延迟就会飙升。所以embedding服务要单独做容量规划不能和LLM共用同一套限流策略。5.4 什么场景下语义缓存不划算语义缓存不是万能的。我遇到过两种场景语义缓存的收益覆盖不了成本。第一种是query极度分散的场景。比如一个开放式创作助手用户让模型写诗、写文案、写代码每个请求都是独特的语义相似度很低。这种场景下语义缓存的命中率可能只有10%不到但每次请求都要多付embedding和向量检索的成本反而更贵。第二种是答案高度依赖上下文的场景。比如多轮对话同一个query在不同的对话历史下答案完全不同。这种场景下缓存key必须包含对话历史而对话历史的组合是爆炸性的缓存命中率极低。判断方法很简单跑一周日志做一次query聚类看最大的簇占比多少。如果最大的簇占比超过5%说明有重复语义缓存有戏如果所有簇都很小说明query分散缓存收益有限。6. 生产落地的避坑经验与运维要点6.1 缓存一致性问题的处理LLM缓存最头疼的问题是一致性。知识库更新了缓存里的旧答案怎么办我的做法是给缓存打标签。每条缓存记录除了存答案还存一个source_version字段记录生成这条答案时知识库的版本号。知识库更新时版本号递增。缓存命中时先检查source_version是否等于当前版本不等则视为未命中重新生成。这个方案的问题是知识库更新后所有旧缓存都失效了命中率会短暂跌到零然后慢慢恢复。为了缓解这个问题我会做渐进式失效不是一次性把所有旧版本缓存删掉而是让它们在命中时逐个失效并重新生成。这样命中率不会断崖式下跌。6.2 缓存预热与冷启动新系统上线或者缓存清空后会有一段冷启动期命中率极低所有请求都打到LLM。如果这时候流量很大很容易触发限流。我的做法是用历史日志做预热。上线前把过去一周的高频query提取出来批量调用LLM生成答案写入缓存。这样上线时缓存里已经有了一批热数据命中率不会从零开始。预热的query选择有讲究不要选所有query只选出现频次超过阈值的。我一般选频次Top 500到Top 1000的query这部分通常能覆盖30%到40%的流量。6.3 监控指标与告警设置缓存系统上线后必须有一套监控。我关注的指标有这几个命中率分普通缓存和语义缓存分别统计。命中率突然下跌通常意味着缓存服务异常或者知识库更新。误命中率通过用户反馈或者抽样人工评估来统计。这个指标上升说明阈值需要调整。缓存服务延迟Redis的P99延迟、向量检索的P99延迟。这两个延迟上升会拖慢整个命中路径。embedding服务可用性embedding服务挂了语义缓存就完全失效必须降级到普通缓存或无缓存。告警阈值我一般设命中率环比下跌超过20%告警缓存服务P99延迟超过100ms告警embedding服务错误率超过1%告警。6.4 降级策略的设计任何缓存组件都可能挂掉所以必须有降级策略。我的降级链路是语义缓存 - 普通缓存 - 无缓存。语义缓存挂了比如embedding服务不可用自动降级到普通缓存只做精确匹配。普通缓存也挂了比如Redis不可用降级到无缓存直接打LLM同时触发限流保护防止LLM被打爆。降级逻辑要做得足够轻量不能因为降级判断本身引入新的故障点。我一般用熔断器模式连续N次调用失败后自动熔断过一段时间再半开重试。class CircuitBreaker: def __init__(self, failure_threshold5, recovery_timeout30): self.failure_count 0 self.failure_threshold failure_threshold self.recovery_timeout recovery_timeout self.last_failure_time 0 self.state closed def call(self, func, *args, **kwargs): if self.state open: if time.time() - self.last_failure_time self.recovery_timeout: self.state half-open else: raise Exception(Circuit is open) try: result func(*args, **kwargs) if self.state half-open: self.state closed self.failure_count 0 return result except Exception as e: self.failure_count 1 self.last_failure_time time.time() if self.failure_count self.failure_threshold: self.state open raise e6.5 几个我踩过的坑第一个坑是embedding模型和LLM的版本不一致。有一次我们升级了LLM但embedding模型没动结果语义缓存的命中率突然下降。排查后发现是新LLM对同一query的答案风格变了但缓存里还是旧答案用户反馈“答案感觉不对”。后来我们规定LLM升级必须同步清空语义缓存。第二个坑是Redis内存溢出。语义缓存存的不只是答案还有向量。一个768维的float32向量就是3KB10万条就是300MB。加上答案本身内存增长很快。后来我们给Redis设了maxmemory和淘汰策略用allkeys-lru并且定期清理低频缓存。第三个坑是相似度阈值的漂移。业务运行一段时间后用户的提问风格会变化原来标定的阈值可能不再适用。我们后来加了一个自动阈值校准的机制每周用新采样的数据重新评估一次阈值如果误命中率上升就自动调高阈值。7. 不同业务场景下的方案选择建议7.1 客服与FAQ场景语义缓存优先客服和FAQ是语义缓存最典型的应用场景。这类场景的特点是query重复率高、意图集中、答案相对固定。我一般建议直接上语义缓存阈值设在0.90到0.93之间配合兜底校验。这类场景对误命中的容忍度低所以阈值要偏保守。宁可少命中一些也不能返回错误答案。同时要建立用户反馈闭环让用户可以标记“这个答案不对”这些反馈数据用来持续优化阈值。7.2 内部工具与知识库普通缓存打底语义缓存增强内部工具场景的query规范性比C端好普通缓存就能拿到不错的命中率。我一般先用普通缓存打底观察一段时间如果命中率稳定在20%以上再叠加语义缓存。这类场景对误命中的容忍度相对高因为用户是内部员工有辨别能力。阈值可以设到0.88左右追求更高的命中率。7.3 创作与开放问答谨慎使用缓存创作类场景写文案、写代码、写故事的query高度个性化缓存命中率低而且答案的“正确性”本身就很主观缓存返回的答案可能不符合当前请求的语境。这类场景我一般建议不用缓存或者只用普通缓存做极短TTL的防抖。如果一定要用语义缓存阈值要设得极高比如0.95以上只命中那些几乎完全一样的请求。7.4 多轮对话场景缓存key要包含对话状态多轮对话的缓存是最复杂的。同一个query在不同的对话历史下答案完全不同所以缓存key必须包含对话状态。但对话状态的组合是爆炸性的直接做key会导致命中率极低。我的做法是只缓存对话的最后一轮并且把对话历史做摘要后作为key的一部分。摘要用一个小模型生成成本低。这样既能捕捉对话状态又不会让key无限膨胀。8. 从零搭建语义缓存的完整实操步骤8.1 环境准备与依赖安装先把基础环境搭起来。Redis要装7.0以上版本因为RediSearch的向量功能在7.0之后才比较稳定。Python环境需要langchain、redis、sentence-transformers这几个包。pip install langchain langchain-community redis sentence-transformers numpyRedis的安装我用Docker最省事docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 redis/redis-stack:latestredis-stack镜像自带RediSearch和RedisJSON省去了单独装模块的麻烦。8001端口是RedisInsight的可视化界面调试的时候很有用。8.2 向量索引的创建RediSearch创建向量索引的命令如下。注意DIM要跟embedding模型的维度一致bge-small-zh是512维text-embedding-3-small是1536维。FT.CREATE llm_semantic_cache ON HASH PREFIX 1 semcache: SCHEMA \ query TEXT \ answer TEXT \ source_version TAG \ created_at NUMERIC \ vector VECTOR HNSW 6 TYPE FLOAT32 DIM 512 DISTANCE_METRIC COSINE这里用的是HNSW索引它是近似最近邻算法检索速度快精度也够用。DISTANCE_METRIC COSINE表示用余弦距离这是文本向量最常用的距离度量。8.3 语义缓存的读写逻辑写入逻辑query向量化后用HSET写入Rediskey用semcache:{uuid}。import numpy as np from sentence_transformers import SentenceTransformer model SentenceTransformer(BAAI/bge-small-zh-v1.5) def write_semantic_cache(query, answer, source_version): vector model.encode(query).astype(np.float32).tobytes() key fsemcache:{uuid.uuid4()} r.hset(key, mapping{ query: query, answer: answer, source_version: source_version, created_at: int(time.time()), vector: vector, }) r.expire(key, 7 * 24 * 3600)读取逻辑query向量化后用FT.SEARCH做KNN检索取最相近的一条判断相似度。def read_semantic_cache(query, threshold0.90, source_versionv1): vector model.encode(query).astype(np.float32).tobytes() q f*[KNN 1 vector $vec AS score] result r.ft(llm_semantic_cache).search( q, query_params{vec: vector}, return_fields[query, answer, source_version, score], ) if not result.docs: return None doc result.docs[0] similarity 1 - float(doc.score) if similarity threshold: return None if doc.source_version ! source_version: return None return doc.answer注意similarity 1 - score因为RediSearch返回的是余弦距离距离越小越相似。这个转换很容易搞错我第一次写的时候忘了转导致阈值判断完全反了。8.4 与LangChain的集成方式LangChain本身没有内置语义缓存但它的BaseCache接口可以扩展。我一般实现一个自定义的SemanticCache类继承BaseCache然后在初始化LLM的时候传进去。from langchain_core.caches import BaseCache from langchain_core.outputs import Generation class SemanticCache(BaseCache): def lookup(self, prompt, llm_string): answer read_semantic_cache(prompt, threshold0.90) if answer: return [Generation(textanswer)] return None def update(self, prompt, llm_string, return_val): answer return_val[0].text write_semantic_cache(prompt, answer, source_versionv1)这样LangChain在调用LLM之前会自动查缓存命中则直接返回未命中则调用LLM并写缓存。整个链路对业务代码是透明的。8.5 灰度上线与效果验证不要一次性全量上线。我的做法是按流量比例灰度先放10%的流量走语义缓存观察一周看命中率、误命中率、延迟、成本四个指标。如果指标符合预期再逐步放大到30%、50%、100%。灰度期间要特别关注误命中的用户反馈。我一般会在答案末尾加一个隐式的反馈入口用户点击“答案不对”时记录当前query和命中的缓存query这些数据是调阈值的宝贵素材。9. 缓存治理的长期维护思路9.1 定期清理与容量规划缓存不是只增不减的。我一般设一个容量上限比如Redis内存用到80%时触发清理。清理策略用LRU优先淘汰低频、低相似度命中的缓存。同时要定期做全量审计统计缓存里有多少条记录、平均TTL、命中分布。如果发现大量缓存从未被命中说明写入策略有问题可能是把不该缓存的query也缓存了。9.2 缓存质量的持续评估我每周会做一次抽样评估从缓存命中的请求里随机抽100条人工判断答案是否正确。这个评估结果用来跟踪误命中率的变化趋势。如果误命中率连续两周上升就要考虑调高阈值或者重新标定embedding模型。9.3 多版本共存与平滑迁移当embedding模型升级或者LLM升级时缓存需要迁移。我的做法是双写双读新版本缓存写入新的索引读取时先查新索引未命中再查旧索引。等新索引的命中率稳定后再下线旧索引。这样迁移过程对用户是无感的。这个方案的关键是索引命名要带版本号比如llm_semantic_cache_v2这样新旧索引可以共存不会互相干扰。9.4 成本收益的定期复盘最后一点缓存不是上了就一劳永逸的。业务在变query分布也在变。我每个季度会做一次成本收益复盘算一下缓存带来的成本节省减去缓存本身的运维成本Redis资源、embedding计算、人力看净收益是多少。如果净收益为负就要考虑是不是该调整方案了。我在实际项目里的体会是语义缓存的收益在业务早期最明显因为那时候query集中度高。随着业务发展query会越来越分散缓存收益会逐渐下降。这时候要么接受收益下降要么重新审视业务形态看是不是需要调整产品设计来引导用户提问更规范。这个内容后续还可以往多级缓存架构的方向扩展比如在语义缓存前面再加一层本地内存缓存把最热的query挡在进程内进一步降低Redis的压力。