做AI应用这几年,我踩过最不值的一个坑,就是让大模型一遍又一遍去读同一页书。
很多团队在做LLM落地时,眼睛只盯着提示词怎么写、输出质量怎么调,很少回头去看请求日志里那些重复出现的输入Token。我做过一个实际项目,用户每次提问前,整个调用链都会把一套近千Token的系统指令原封不动传给模型,一天几千次请求,光这部分重复输入的Token就占到总消耗的近三成。账单上的数字,一大半其实是在为重复的东西付费。
Prompt缓存就是解决这个问题的:把已经算过、已经答过的东西按一定规则存下来,下次遇到相同或相似的输入,直接复用结果,少算、少等、少花钱。我把目前主流的做法整理成了四种策略,从最严格的全量精确匹配,到最灵活但风险也最高的语义检索,逐个讲清楚原理、实现、适用边界和坑。这篇文章适合正在搭LLM应用、管AI成本,或者准备做统一API网关的开发者参考。
1. 缓存的是什么?先看清Prompt开销的本质
1.1 大模型账单里最容易被忽视的“重复税”
用过主流大模型API的人都知道,计费按输入Token和输出Token双向计算。输出Token单价通常更高,大家也都习惯盯着生成长度做优化,却忽略了一个更隐蔽的事实:业务里的输入Token总量,往往远远大于输出Token总量。原因很简单,一次请求的Prompt可能几百上千Token,而输出可能只有几十Token,除非你在做长文生成,否则输入才是账单的大头。
而输入Token里又存在大量重复。我翻过不少项目的日志,重复率常见在20%到40%,最夸张的接近一半。这些重复来自几个地方:多轮对话里每轮都要重新加载系统提示词、同一个标准问题被不同用户反复问、前端页面刷新或用户重试时重复提交、还有我们自己在编排链路里重复拼接的固定指令块。这些都是典型的“重复税”,特点是每次单看都不贵,累积起来非常吓人。
1.2 从计算层面看:为什么复用前缀能省钱
要理解缓存为什么有效,得先明白生成式模型的一次请求在计算上做了什么。Transformer在生成每个新Token时,都要做一次Self-Attention,而Self-Attention需要用到当前序列里所有历史Token的Key和Value。为了避免每次都重算这些历史Token的K/V,推理框架会把已经算过的Key和Value缓存下来,这就是常说的KV Cache。
Prompt输入阶段(也叫prefill阶段)需要逐个计算输入Token的KV,这部分计算量和Prompt长度基本成正比。也就是说,Prompt越长,单次请求的计算开销越大。那么问题来了:如果新请求的开头若干Token和之前某个请求完全一致,理论上服务端可以直接复用那部分KV,只需从头继续计算新增Token的部分。这就是前缀缓存的底层逻辑。
对开发者来说,这个原理引出一条关键准则:把稳定的东西放在Prompt开头,把变化的东西放在Prompt后面。因为Token是顺序处理的,只有开头的Token序列完全一致,前缀缓存才能命中。平台侧是否有公开的前缀缓存机制另说,但这条编排准则永远成立。
1.3 一条主线:从“字符级一致”到“语义级相似”
四种缓存策略,本质上是复用资格判定尺度的递进。全量精确匹配要求整个字符串完全一致,前缀匹配要求开头一段Token序列一致,模板插槽化要求结构一致且槽位值一致,语义检索则放宽到“意思相近”。缓存尺度越宽,覆盖的请求就越多,命中率也就越高,但风险同时也在累积——你怎么知道两个“意思相近”的问题一定需要同一个答案?
所以选缓存策略,本质上是在选一个“容忍度”。你能承担多大的误复用风险,就去放宽多少匹配尺度。这篇文章的后面四节,就是沿着这条线一层层展开的。
2. 策略一:全量精确匹配——最省心的方案,也是最脆的方案
2.1 实现:哈希键加全串比对
全量精确匹配最好写,拿来就能用。核心思路是把完整Prompt做归一化,然后算一个哈希值作为缓存键,命中就直接返回之前的响应。
import hashlib import json import redis cache = redis.Redis(host="localhost", port=6379, decode_responses=True) def normalize_prompt(prompt: str) -> str: # 统一换行符,去掉首尾空白,做Unicode归一化 return " ".join(prompt.strip().split("\n")).strip() def prompt_cache_key(prompt: str, model: str, temperature: float) -> str: raw = json.dumps({ "prompt": normalize_prompt(prompt), "model": model, "temperature": temperature }, ensure_ascii=False, sort_keys=True) return f"prompt_cache:{hashlib.sha256(raw.encode()).hexdigest()}" def get_cached_response(prompt: str, model: str, temperature: float): key = prompt_cache_key(prompt, model, temperature) return cache.get(key) def set_cached_response(prompt: str, model: str, temperature: float, response: str, ttl: int = 3600): key = prompt_cache_key(prompt, model, temperature) cache.set(key, response, ex=ttl)这里有几个容易忽略的细节。第一,缓存键里必须带模型名和采样参数,换模型或者改了temperature,原来缓存的响应就不能直接用,否则你会看到同一句Prompt在不同配置下返回一模一样的结果,这会影响线上效果验证。第二,归一化只做“安全”级别的处理,比如统一换行、去首尾空格、Unicode归一化,千万不要做同义词替换之类的语义改写,否则你等于把语义判定的风险引入了精确匹配环节,得不偿失。
2.2 残酷现实:一个标点、一个空格都会失配
精确匹配的最大问题,是它对真实世界用户的“不整齐输入”完全没有弹性。你以为用户会规规矩矩提交一模一样的文本,实际上重试时浏览器可能多带一个空格,前端拼接时多了一个换行,用户改了一个同义词,整条请求就变成了另一个字符串。
我做过的项目里,在不做任何预处理的阶段,精确匹配命中率普遍低于5%。做了归一化之后能提升一些,但也就到10%上下。真正能跑到30%以上的场景非常少,一般是请求内容由程序自动生成的场景,比如固定表单提交、定时任务触发、工具调用的重复入参。
这类场景反而把精确匹配的优势发挥得最充分:完全一致才命中,正确性零风险,实现成本极低,几乎没有副作用。但一旦你的用户是“真人自由输入”,精确匹配就撑不起缓存体系的大梁。
2.3 精确匹配并没有被淘汰
那是不是可以说精确匹配没用?不是。我的建议是把它当作整个缓存链路的第一道关卡,也就是守门员。任何请求进来,先查一遍精确匹配缓存,命中就直接返回;没命中再往下走前缀、模板或语义策略。
理由很简单,这道关卡几乎没有成本,也不引入任何正确性风险。就算命中率只有10%,那10%的重复请求也是白捡回来的Token节省。先把最简单的东西做好,再谈复杂的策略,这是做缓存工程最稳的路线。
3. 策略二:前缀KV Cache复用——把公共开头变成可复用资产
3.1 推理层面发生了什么
前缀缓存和前文说的KV Cache是一回事,但它不一定是你在代码里自己写的缓存,而是推理框架或平台侧提供的能力。它的工作模式是:当新请求的开头一段Token序列和之前某次请求的Token序列完全一致时,那部分KV直接复用,模型只需要计算新增Token部分的prefill开销。
几个平台约束我建议提前摸清楚。一是公共前缀的最小长度要求,有的平台要求公共部分达到512甚至1024个Token才启用缓存,太短的前缀它根本不屑于缓存。二是缓存有效期,常见在几分钟到几十分钟,超时要重新计算。三是是否跨请求复用,同一个前缀第一个请求算完之后,后续请求才能享受复用。
一个常见的误解是,平台前缀缓存会缓存“最终回答”,下次遇到相同Prompt直接返回结果。实际上多数平台做的是KV Cache复用,而不是响应缓存。也就是说,命中前缀缓存后模型还是要继续生成后面的Token,只是前缀部分的计算成本省掉了。省掉的部分依然可观,但它和“直接返回已生成答案”是两件事。
3.2 开发者能做什么:把公共前缀“喂”给缓存
既然前缀缓存依赖“开头Token完全一致”,开发者的核心任务就是让公共前缀尽量长、尽量稳定。做法有几条:
第一,稳定内容前置。系统提示词、知识库背景、技能定义、少数固定示例放在Prompt头部,把变化最大的用户输入整体推后。第二,头部的稳定内容里不要插入动态字段。我见过有人在系统提示词里放当前时间戳、随机示例、会话ID,结果每个请求的开头都不一样,前缀缓存彻底失效。第三,Few-shot示例尽量用固定的,不要每次从候选池里随机抽。
我自己习惯把Prompt编排分成三段:头部是“系统固定指令”,中间是“业务上下文”,尾部是“用户本次输入或问题”。这样的结构天然有利于前缀缓存。多轮会话也类似,稳定的角色设定在最前面,历史消息在中间,最新用户消息在最后,能前置的稳定信息绝不放后面。
3.3 一个典型收益数据
用我之前一个模拟项目X来举例。系统提示词加固定技能定义约800 Token,用户输入平均300 Token,原来一次完整请求计费1100 Token。调整编排顺序并确认平台前缀缓存可复用后,增量部分通常只有300多Token,也就是说每次请求能省下近七成的输入Token计算量。这个比例相当可观。
但也要看到前缀策略的局限:它只解决“开头一致”,用户的动态输入只要一变,动态部分和之后的内容还是全量计算。如果你的用户输入本身很长,比如单次提问就有几千Token,前缀命中省下的比例就会明显下降。这时候就需要模板策略和语义策略出场了。
4. 策略三:模板插槽化——把系统提示词与动态内容分离
4.1 模板不是新概念,但它是缓存之友
模板化Prompt大家都不陌生,把一段Prompt拆成固定指令块和可变槽位块,拼接时把槽位值填进去。但如果只是把它当字符串拼接工具,那就浪费了它在缓存层面的价值。
模板策略的核心贡献,是让“变化点”最小化。把Prompt拆成固定指令和动态槽位之后,固定指令部分可以独立维护版本号,动态槽位单独参与缓存键计算。模板版本不变意味着固定部分没变,所有使用这个模板的请求都共享同一个公共前缀,前缀缓存的命中概率大幅提升。
我在代码里一般这样组织:
class PromptTemplate: def __init__(self, template_id: str, version: str): self.template_id = template_id self.version = version def render(self, slot_values: dict) -> str: # 固定指令块在前,动态槽位值渲染在后 return f"{self._fixed_system_block()}\n\n{self._render_slots(slot_values)}" def cache_key(self, slot_values: dict, model: str, temperature: float) -> str: slot_hash = hashlib.sha256( json.dumps(slot_values, sort_keys=True).encode() ).hexdigest() return f"template_cache:{self.template_id}:{self.version}:{slot_hash}:{model}:{temperature}"固定指令块算好版本号之后,只要模板没改,所有请求的头部Token完全一致,平台侧的前缀缓存就能稳定工作。而业务侧也可以直接用模板ID加槽位哈希作为缓存键,存住“同一模板同一槽位值”对应的响应。这种命中属于确定性的结构化复用,正确性是有保证的。
4.2 组合缓存键与请求编排
模板策略落地时有两个问题要注意。一是缓存键粒度,槽位值可能是一大段文本,直接把整个槽位值哈希进缓存键没问题,但这意味着某个用户问了一个新问题,这次请求的缓存键就和以前完全不一致,精确命中就没了。那模板策略还剩什么用?答案是它还保证了“动态值最小化”——因为固定指令块不再参与变化,真正参与变化的只有槽位值那一段,这会让前缀缓存和精确缓存的命中半径更大。
二是拼接顺序要服务前缀缓存。固定指令块必须在最前面,动态槽位放在后面。如果固定指令块和用户内容交替渲染,前缀缓存会束手无策。我知道有些团队习惯把用户昵称、会话信息放在Prompt第一行,这种编排和缓存是天然冲突的。真觉得需要个性化信息,把它放到尾部,让它成为增量计算的一部分,而不是毁掉整个公共前缀。
4.3 命中率管理:模板策略解决的核心问题
模板策略真正的价值不在于它创造了新的命中方式,而在于它管理和放大了其他策略的命中面积。多租户SaaS场景尤其明显,所有租户共用一套系统指令,只是用户输入不同。模板统一之后,公共前缀被拉得很长,平台前缀缓存的收益极其稳定。
模板改版时缓存要按版本隔离,这也是我特别想提醒的地方。很多人改了一句系统提示词,没有升版本号,结果旧缓存还在,新指令没生效,线上表现变成“改了但没完全改”。我在项目里强制要求模板每次修改都必须递增版本号,缓存键里带上版本,这样既能保证新模板生效,又能让旧模板的缓存自然过期,互不干扰。
5. 策略四:语义检索缓存——从“完全相同”到“意思相近”
5.1 基本链路:向量化、召回与判定
语义检索缓存是四种策略里覆盖能力最强、风险也最高的一种。它不再要求文本相同,而是要求语义相近。实现链路一般是这样:先把历史请求里的用户问题做Embedding,存到向量索引里;线上新请求进来,同样做Embedding,然后和库里的向量做相似度计算,通常用余弦相似度;相似度超过阈值就认为是同一类问题,直接返回对应的历史答案。
阈值怎么定要靠经验。短问题我一般要求余弦相似度在0.95以上才敢直接复用,低于这个阈值很容易出幺蛾子。参考长文本问题时可以适当放宽,因为长文本本身在向量空间里分布更稀疏,相似度普遍偏低。超参要拿自己业务的数据实测,不要照抄别人的数值。
向量库的选择看请求量。请求量小,几千条历史记录,直接用暴力计算逐条比对也没问题;请求量大,才需要上近似最近邻索引,但那套东西有召回率损失的问题,索引参数调不好反而会让该命中的没命中。我自己的习惯是先用精确计算跑通链路,量上来再换索引。
5.2 两种处理:直接复用与引导生成
语义缓存命中之后的处理方式有两种。第一种最简单,直接复用之前生成的答案。适用于标准FAQ、知识库问答这类答案稳定的场景。第二种是引导生成,当相似度够高但还没到直接复用的水平时,可以把历史答案作为参考上下文注入当前请求,让模型基于参考生成新回答。
我实践下来更偏向后一种,因为它不会把“语义相似但答案有差异”的情况彻底判死。模型拿到参考内容后还能结合当前上下文做修正,虽然成本比直接缓存高,但比全量生成还是便宜不少。直接复用适合做绝对稳定的那一层,引导生成适合做中间层兜底。
5.3 这里藏着最大的坑
语义近似和信息一致之间,隔着一道鸿沟。举一个很经典的例子:用户问“退款到原路要多久”,另一个用户问“退款到银行卡要多久”。这两个问题的整体语义非常接近,但答案可能完全不同——原路退款可能两小时到账,退到银行卡可能要两三个工作日。如果你因为向量相似度高就把第一个问题的答案返回给第二个用户,就是一次实打实的线上事故。
时效性问题也同样致命。汇率、库存、价格、新闻资讯这类内容,答案每隔几小时甚至几分钟都会变,昨天缓存下来的语义相似答案今天就是错的。我的处理原则是:对涉及时效性或个性化信息的请求,语义缓存一律不启用;只对只读类、事实类、知识稳定的请求开放语义缓存,并且命中后还要做一次业务参数的一致性校验。缓存是拿来省钱的,不是拿来替决策背锅的。
另外别忘了语义检索本身有成本。Embedding要花钱,向量比对要花时间,如果业务本身重复率不高,语义缓存省下的Token可能还没Embedding花出去的多。这就要在真实数据上一笔一笔算清楚。
6. 落地工程三件事:缓存键设计、TTL策略与命中率观测
6.1 缓存键怎么设计才不误伤
缓存键设计是决定缓存体系安全性的第一道闸门。我见过的最常见事故,是缓存键只放了Prompt文本,漏掉了模型版本、采样参数等关键信息。换了个模型版本后,同样的Prompt还返回旧版模型的答案,线上效果直接失真。
正确的做法,是把影响输出语义的所有参数都纳入缓存键:模型名、模型版本、temperature、max_tokens、response_format,以及必要时的模板版本。涉及用户隐私或强个性化的响应,缓存键里必须带上用户标识,不要跨用户复用。规范化处理照前文说的做,统一换行、去首尾空白、Unicode归一化,但绝不做语义改写。这些规则听起来琐碎,缺一个都可能让缓存帮你“制造”线上故障。
6.2 TTL策略:不同内容不同寿命
缓存不能全部一视同仁地设置过期时间,不同内容对时效的敏感度差别太大了。我的经验值可以参考下面这个表:
| 内容类型 | 建议TTL | 原因 |
|---|---|---|
| 事实型知识库问答 | 数小时到一天 | 答案稳定,修改频次低 |
| 时效型业务数据(价格、库存、汇率) | 30秒到5分钟 | 数据随时可能变化 |
| 个性化生成(写作、翻译、情感类) | 不缓存或极短TTL | 个体差异大,复用价值低 |
| 固定表单/工具调用类请求 | 数小时 | Prompt由程序生成,结构稳定 |
TTL太短,缓存形同虚设;TTL太长,用户问到一个已经变化的时效性问题时收到旧答案。这里没有万能的默认值,需要结合业务能容忍的错误率来定。拿不准时从短TTL开始,命中率跑出来之后再慢慢拉长。
6.3 命中率指标的拆解
评价缓存效果,不能只看“命中率”一个数。至少有两个指标要分开看:请求命中率,也就是命中的请求数占总请求数的比例,反映的是体验优化;Token节省率,也就是命中的输入Token量占总输入Token量的比例,反映的是成本优化。这两个指标经常不一致。比如一个大请求没命中,消耗的Token可能顶得上几十个小请求的命中节省。
所以上线后要按入口、按模板ID、按用户群拆分看指标。如果某个模块的Token节省率极低,说明它的问题不是重复率高,而是Prompt本身太长,引流方向错了。还有一个容易被忽略的点是冷启动,新模板上线时缓存是空的,前缀缓存也要从第一次请求开始“预热”。大版本上线前,先跑一遍预热请求,避免上线高峰期一大波请求全部打在冷缓存上。
7. 四种策略怎么选:从账单到延迟的全景对比
7.1 一次性看清四种策略
聊到这里,我把四种策略放在一个表里做个总览,大家在选型时可以直接对着看:
| 策略 | 实现成本 | 命中率潜力 | 正确性风险 | 适合场景 |
|---|---|---|---|---|
| 全量精确匹配 | 极低 | 低(5%~15%) | 无 | 固定流程、防抖重试、程序化请求 |
| 前缀KV Cache复用 | 中 | 中(20%~40%) | 无(前缀一致即安全) | 统一系统提示词的Agent应用 |
| 模板插槽化 | 中 | 中高(30%~50%) | 低(需保证拼接结果一致) | 多租户SaaS、流程化问答 |
| 语义检索缓存 | 高 | 高(40%~70%) | 中高(相似不等于信息一致) | 知识库问答、客服FAQ |
实现成本和命中率基本成正比,正确性风险也随之上升。这里没有“哪个策略最好”的标准答案,只有“哪个策略最适合当前业务”的取舍。前缀策略几乎适合所有场景,是底座;模板策略帮助你优化前缀的命中率;语义策略是重武器,用来打最后的重复率,但代价是风险提高。
7.2 组合使用才是常态
现实项目里很少只用一种策略。标准做法是分层组合:第一层精确匹配做守门员,命中就回;第二层靠平台侧前缀缓存和编排优化,稳定降低公共前缀成本;第三层用模板结构管理变化点,让前缀和精确缓存更有效;第四层语义检索做兜底,但只覆盖知识稳定、正确性可校验的只读类请求。
预算有限的情况下,我建议先做好前两层。绝大多数应用只要把Prompt编排理顺,让系统提示词稳定前置,就能拿到不小的成本收益。语义缓存可以先影子运行,在日志里记录“如果命中能省多少Token”,跑一两周确认收益和风险再上线。不要一上来就为了炫技把语义缓存直接开进生产环境,我见过太多因为误复用导致线上答案错误的事故。
7.3 我的经验与给刚上手的人的建议
最后分享一段个人体会。做缓存之前,一定要先把请求日志拉出来数一数重复率。如果重复率本身就低,说明你真正的问题是Prompt过长或调用次数过多,而不是缺缓存系统。日志分析这一步做完,方案自己就浮出来了。
缓存只是性能手段,不是业务逻辑。任何缓存策略都不应该改变答案的语义正确性,这是底线。我在实际项目里最常做的一件事,是上线缓存后做回归对比:同一输入在缓存开关前后分别拿到什么结果、业务侧错误率有没有变化、用户反馈有没有异常。缓存优化是一件越做越细的事,先把日志和指标看清楚,再让策略一层层叠加,路径远比凭感觉猜测要可靠。