2024 年我做一个 AI 客服系统,每接到一个用户问题,我都要给大模型发一个很长的 prompt——里面有几千 token 的系统提示词(角色设定、回答规范、禁止事项),有十几个固定的 few-shot 示例,还有从知识库里检索出来的产品文档。把 prompt 拼好、发出去、拿回答案这件事,我压根没多想。第一版我做得很省事:调用大模型,不就是把这些东西拼成一大段文本、发给 API?本地开发时——真不错:我问一个问题,几秒钟答案就回来了,几行代码搞定。我心里很踏实:"调模型嘛,不就是拼好 prompt 发过去?"可等这个系统真正上线、每天扛起成千上万次问答,我一看账单和延迟,一串问题冒了出来。第一种最先把我打懵:我发现供应商有一个叫"prompt 缓存"的功能,说能大幅省钱省时间,我照着文档把它开了——可一个月下来,账单一分没省、延迟也没降。第二种最隐蔽:我后来定位到,是因为我在 prompt 的最前面,拼了一句"当前时间:2024-06-01 12:00:03"——就这一句每次都在变的话,让它后面那几千 token 雷打不动的系统提示词,一个字节都没能被缓存。第三种最难缠:我把那句时间戳挪走了,命中率上去了一点,可还是忽高忽低——原来是我检索出来的那几篇文档,每次拼进 prompt 的顺序都不一样,顺序一变,前缀就又对不上了。第四种最莫名其妙:有些低频用户,隔了十几分钟才来问下一句,这种调用永远命不中缓存、永远按全价算——原来缓存是有寿命的,放一会儿就没了。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"调用大模型,就是把 prompt 拼成一大段文本、发过去"。这句话把 prompt 当成了一团无差别的、扁平的文本——它默认 prompt 里放什么、按什么顺序放,跟性能和花钱毫无关系,只要内容对就行。可它不是。一个 prompt 绝不是一团无差别的文本。它是有"结构"的:里面一部分是稳定的——系统提示词、few-shot 示例、检索来的长文档,这些在很多次调用之间是一模一样、逐字不变的;另一部分是易变的——用户这一次具体问了什么,每次都不同。而大模型供应商提供的"prompt 缓存",缓存的不是整个 prompt,而是它"从第一个 token 起、逐字节完全相同的那一段最长前缀"。第一次带着这段前缀调用,供应商把它对应的内部计算结果存下来;后续的调用只要前缀逐字节一致,就能直接复用这份结果,这段前缀的价格和处理延迟都大幅下降。这里的关键,也是我第一版栽的根:缓存只认"从头开始、连续不断、一个字节都不差"的前缀。我在 prompt 顶部塞的那句时间戳,每次都变,意味着 prompt 的"第一个 token"就对不上了,于是后面跟着的几千 token 稳定内容,无论多么一字不差,都被这个开头的小变动彻底带废,一个字节都缓存不了。我检索文档顺序每次不同,是同样的道理:前缀在文档那一段就断了。我第一版所有的麻烦,根上都是同一件事:我把 prompt 当成"内容对就行的一团文本",而没把它当成"一个前缀必须被刻意保持稳定的、有结构的东西"。真正做对 prompt 缓存,核心不是"把 prompt 拼好发过去",而是把 prompt 看成"稳定前缀 + 易变后缀"的结构:让稳定的内容逐字节不变地、全部集中到最前面,让易变的内容一律排到最后,在该缓存的地方显式标好断点,再盯着命中率和缓存寿命去调。这篇文章就把 prompt 缓存梳理一遍:为什么"拼好发过去就行"是错的、前缀缓存到底缓存了什么、prompt 结构该怎么排、怎么让前缀真正稳定、缓存的寿命怎么管,以及命中率监控、预热、动态内容剥离这些把缓存真正用出效果要避开的坑。
问题背景
先把那串问题的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一套"把 prompt 拼好发过去就行"的大模型调用,在每天扛起成千上万次问答后冒出一串问题:照着文档开了 prompt 缓存,账单却一分没省;定位发现是 prompt 顶部那句每次都变的时间戳,把后面几千 token 的稳定内容全带废了;挪走时间戳后命中率还是忽高忽低,因为检索文档每次顺序不同;低频用户隔一会儿再问,调用永远命不中缓存。
我当时的错误认知:"调用大模型,就是把系统提示、示例、文档、用户问题拼成一大段文本,发给 API。"
真相:这个认知错在它把 prompt 想象成了一团"扁平、无差别"的文本。在我脑子里,prompt 就像一桶水——里面装了什么内容是要紧的,可这桶水本身没有形状、没有结构,我从哪儿开始倒、内容怎么排,都无所谓。可一个 prompt 恰恰是有结构的。它内部清清楚楚地分成两类东西:一类是稳定的——系统提示词、few-shot 示例、检索文档,这些在成千上万次调用里逐字不变;一类是易变的——用户这次问什么,次次不同。而 prompt 缓存这件事,整个就建立在"prompt 有结构"之上:它缓存的是 prompt 那段逐字节不变的最长前缀。开头那四个问题,根上全是"把有结构的 prompt 当成了无结构的一桶水":账单没省、时间戳带废全文,是因为我没意识到"放在最前面的东西,决定了整个前缀能不能被缓存";命中率忽高忽低,是因为我没意识到"前缀必须逐字节稳定,文档顺序也算数";低频调用永不命中,是因为我没意识到"缓存是有寿命的,不是存进去就永远在"。问题的根子清楚了:这不是"缓存功能没开对"的小毛病,而是要换一个根本的认知——prompt 是一个有"稳定前缀 + 易变后缀"结构的东西,做对缓存,就是要刻意地把这个结构经营好。
要把 prompt 缓存做对,需要几块认知:
- 为什么"拼好发过去就行"是错的——prompt 是有结构的,不是一团无差别文本;
- 前缀缓存的原理——缓存的是"逐字节不变的最长开头",一个字节都不能差;
- prompt 结构——稳定的内容全排到前面,易变的内容一律排到后面;
- 让前缀真正稳定——文档要定序,每次都变的字段绝不能混进前缀;
- 缓存的寿命——缓存有 TTL 会过期,要盯着命中率和成本;
- 命中率监控、预热、动态内容剥离这些工程坑怎么处理。
一、为什么"把 prompt 拼好发过去就行"是错的
先把这件最根本的事钉死:"拼好发过去就行"错在它脑子里有一幅错误的图景——它以为一次大模型调用,模型面对的就是一团文本,只要文本的"内容"对,放在前面还是后面、这次和上次排得一不一样,统统无所谓。这幅图景,在"只看一次调用的结果对不对"时确实成立——内容对,模型就答得对。可一旦你开始关心"成千上万次调用的总成本和总延迟",它就彻底不成立了。原因是:大模型处理一个 prompt,是从第一个 token 到最后一个 token 顺序地"读"进去、并为每个 token 做一大堆内部计算的;这个计算量,和 prompt 的长度成正比。你那几千 token 的系统提示词,每一次调用,模型都要从头把它重新计算一遍——一千次调用,就把同一段一字不差的文字,重新算了一千遍。prompt 缓存要解决的,正是这个巨大的浪费:既然这段前缀的内容逐次都一样,它对应的内部计算结果当然也一样,那就把第一次算出来的结果存起来,后面直接复用。但"复用"有一个铁一样的前提——供应商凭什么知道"这次的前缀和上次是同一段"?它不可能去理解语义,它只能做一件最机械的事:逐字节比对。从第一个字节起,一个一个比,比到第一个不一样的字节为止,前面那一段就是"可复用的前缀"。这个机制一旦想透,你就明白:prompt 里内容的"排列顺序",从此不再是无所谓的事——它直接决定了那段宝贵的前缀有多长、能不能被复用。
下面这段代码,就是我那个"本地跑着没问题、上线缓存却一分没省"的第一版:
# 反面教材:把 prompt 当成一团无差别文本,顶部还塞了每次都变的东西
from datetime import datetime
def build_prompt(user_question, docs):
now = datetime.now().isoformat() # 破绽源头:一个每次都变的时间戳
parts = [
f"当前时间:{now}", # 破绽 1:每次都变的内容,被放在了最前面
SYSTEM_PROMPT, # 几千 token 的固定系统提示,本该被缓存
FEW_SHOT_EXAMPLES, # 十几个固定示例,本该被缓存
"\n\n".join(docs), # 破绽 2:检索文档,顺序每次都可能不同
f"用户问题:{user_question}",
]
return "\n\n".join(parts) # 拼成一大段文本,直接发出去
这段代码在本地开发时表现不错,因为本地我只关心"答案对不对"——把这一团文本发过去,模型照样能读懂、照样答得对,你看不出任何破绽。它的问题不在某一行语法上——字符串拼接、join,语法都对,功能也对——而在它对 prompt 的"结构"完全无知:它把一个每次都变的时间戳,放在了整个 prompt 的最前面(破绽 1),于是从第一个 token 起,这次和上次就对不上了,后面几千 token 的稳定内容全被这个开头废掉;它又把顺序不稳定的检索文档,直接 join 进来(破绽 2),让前缀哪怕躲过了时间戳,也会在文档这一段断掉。本地只看一次结果,这两个破绽毫无影响;一上线、一开缓存,它们让缓存彻底失效。问题的根子清楚了:做对 prompt 缓存,第一步不是去开那个缓存开关,而是承认"prompt 是有结构的",然后按照"稳定的在前、易变的在后"去重新经营这个结构。下面五节,就是这件事怎么落地。
二、前缀缓存的原理:缓存的是逐字节不变的开头
既然 prompt 有结构,先把缓存到底缓存了什么说透。供应商的 prompt 缓存,缓存的是 prompt 从第一个 token 起、逐字节完全一致的那段最长前缀。要让它生效,你得显式告诉供应商"缓存到这里为止"——这个标记,在 Anthropic 的接口里叫 cache_control:
# 把 system 拆成几块,在稳定内容的末尾打上 cache_control —— 这就是"缓存断点"
resp = client.messages.create(
model="claude-3-5-sonnet",
system=[
{"type": "text", "text": SYSTEM_PROMPT}, # 稳定:系统提示词
{"type": "text", "text": FEW_SHOT_EXAMPLES,
"cache_control": {"type": "ephemeral"}}, # 断点:到这里为止的前缀都缓存
],
messages=[{"role": "user", "content": user_question}], # 易变:用户问题,排在最后
)
# 第一次调用:这段前缀被"写入缓存";之后只要前缀逐字节一致,就"命中缓存"
这里的认知要点是:理解前缀缓存,要先理解"前缀"这两个字有多严格。它不是"差不多的开头",而是"从第 1 个字节起、连续不断、一个字节都不差的开头"。这意味着两件事。第一件:缓存的复用是"全有或全无"地卡在某个位置上的。供应商逐字节比对你这次的 prompt 和缓存里的内容,比到第一个不同的字节,复用就到此为止——这个位置之前的全部算命中,之后的全部算未命中。所以"一个字节的改动"造成的损失,不是"损失这一个字节",而是"损失从这个字节一直到 prompt 结尾的所有内容"。我在顶部放时间戳之所以是致命的,就在这里:时间戳在第 1 个字节附近就变了,于是"复用到此为止"的那条线,被划在了整个 prompt 的最开头,后面几千 token 不管多稳定,全在线的后面,全部未命中。第二件:既然缓存只认前缀,那"什么内容该放在前面"就成了一个必须由你主动设计的决定。越靠前、越稳定的内容,被缓存覆盖的几率越大、价值越高;越靠后的内容,即便稳定也沾不到光。再说 cache_control 这个"断点"是干什么的:它不是"打开缓存的开关",而是"告诉供应商,从 prompt 开头到我打这个标记的地方,这一整段请你当作一个可缓存的前缀存起来"。你把断点打在 few-shot 示例的末尾,意思就是"系统提示 + 示例"这一整段要被缓存。它必须打在稳定内容的末尾、易变内容的前面——打到易变内容里面去,缓存就形同虚设。一句话:缓存认的是逐字节不变的最长前缀,而 cache_control 是你亲手划定"这段前缀请缓存"的那条线。原理清楚了,接下来就是按这个原理,把 prompt 的结构重新排一遍。
三、prompt 结构:稳定的全排前面,易变的全排后面
原理落到操作上,就是一条简单到近乎机械的规矩:把 prompt 里所有稳定的内容,全部集中到最前面;把所有易变的内容,全部排到最后。第一版那个 build_prompt 要按这条规矩重写——明确地把它拆成稳定前缀和易变后缀两段:
def build_prompt_v2(user_question, docs):
"""把 prompt 显式拆成两段:稳定前缀逐字不变排前面,易变后缀排最后。"""
# 稳定前缀:成千上万次调用里逐字不变的内容,全部集中到最前面
stable_prefix = SYSTEM_PROMPT + "\n\n" + FEW_SHOT_EXAMPLES
# 易变后缀:每次调用都不同的内容,一律排到最后
dynamic_suffix = stable_docs(docs) + "\n\n" + f"用户问题:{user_question}"
return stable_prefix, dynamic_suffix # 分开返回,调用处各自摆到正确位置
注意上面用到了一个 stable_docs——检索文档本身也是稳定内容(同一批文档,内容不变),理应排进前缀靠前的位置;但它有个顺序不稳定的毛病,要先治好(下一节细讲)。结构排好后,真正发起调用时,就把这两段对号入座:
def call(client, user_question, docs):
"""稳定前缀进 system 并打缓存断点,易变后缀进 messages 排最后。"""
stable_prefix, dynamic_suffix = build_prompt_v2(user_question, docs)
return client.messages.create(
model="claude-3-5-sonnet",
system=[
{"type": "text", "text": stable_prefix,
"cache_control": {"type": "ephemeral"}}, # 稳定前缀,标记成可缓存
],
messages=[{"role": "user", "content": dynamic_suffix}], # 易变后缀,排最后
)
这里的认知要点是:"稳定的排前面、易变的排后面"——这条规矩之所以有效,是因为它和前缀缓存的机制严丝合缝地咬在了一起。缓存只能从头开始、连续地往后覆盖一段前缀;那么你能被缓存的内容有多少,就完全取决于"从开头数起,有多长的一段是逐字节稳定的"。把所有稳定内容堆到最前面,就是让这段"逐字节稳定的开头"尽可能地长;把任何一点易变内容混进前面,就是亲手把这段开头截短。这里要破除一个很自然、却很有害的直觉:很多人写 prompt,会习惯性地把"用户当前的问题"放在很靠前的位置,觉得"这是最重要的、最该让模型先看到的"。从"让模型理解任务"的角度,这个排法没错;但从"缓存"的角度,这是灾难——用户问题是整个 prompt 里最易变的东西,把它放前面,等于在前缀的最前段就埋了一个"每次都不同"的雷,后面的系统提示、示例再稳定也全废了。正确的做法是把这个直觉反过来:不管某段内容你觉得多重要,只要它"每次都变",它就必须排到最后去。模型读 prompt 不挑顺序,它读到最后照样理解;但缓存挑顺序,挑得非常死。还有一点要明确:这个"稳定 / 易变"的二分,不是模糊的感觉,而是要你逐块去判定的——系统提示词,稳定;few-shot 示例,稳定;一整批检索文档,内容稳定;用户问题,易变;当前时间、请求 ID、用户名,易变。判定清楚了,排列就是机械的:稳定的按"最稳定、最不可能变"的在最前,挨个往后排;所有易变的,一股脑丢到队尾。一句话:让 prompt 的物理顺序,服从于"前缀要尽量长地保持稳定"这一个目标。结构排对了,可还有个隐患没除——那段排进前缀的检索文档,顺序还是飘的。
四、让前缀真正稳定:文档定序,剔除易变字段
第三节把检索文档归进了稳定前缀,可它有个致命的小毛病:检索系统每次返回文档的顺序可能不一样(相关度评分有浮动、并发返回有先后)。内容一样、顺序一变,拼出来的字节流就变了,前缀照样断。所以拼接前必须按一个稳定的 key 把文档强制定序:
def stable_docs(docs):
"""检索文档要按稳定的 key 排序后再拼接 —— 内容一样但顺序一变,前缀就断了。"""
# 按 doc_id 排序:只要这批文档不变,拼出来的字节流就永远逐字节一致
ordered = sorted(docs, key=lambda d: d["doc_id"])
return "\n\n".join(d["text"] for d in ordered)
还有一类更隐蔽的"易变字段"——用户名、请求 ID、会话 ID 这些,很容易被无意识地拼进系统提示里。它们必须被识别出来、挡在前缀之外:
# 哪些字段对所有调用都一样(可进前缀),哪些每次都变(必须排后面)
VOLATILE_FIELDS = {"timestamp", "request_id", "user_name", "session_id"}
def split_context(ctx):
"""把上下文字典拆成"稳定的"和"易变的" —— 易变字段绝不能混进缓存前缀。"""
stable = {k: v for k, v in ctx.items() if k not in VOLATILE_FIELDS}
volatile = {k: v for k, v in ctx.items() if k in VOLATILE_FIELDS}
return stable, volatile # stable 进前缀,volatile 进后缀
下面这张图,把一次调用是怎么和缓存里的前缀做匹配的画出来:
这里的认知要点是:这一节要刻进脑子的,是"稳定"这个词的标准有多苛刻——它的标准是"逐字节",不是"看起来差不多"。两批检索文档,哪怕是完全相同的几篇,只要拼接时的先后顺序变了,生成的字节流就是两条不同的字节流,在缓存看来它们毫无关系。所以"内容稳定"是不够的,你要的是"序列化出来的字节稳定"。这就引出一个普遍的工程原则:任何要进入缓存前缀的、由多个部分组装而成的内容,组装过程本身必须是确定性的——同样的输入,永远拼出逐字节相同的输出。检索文档要 sorted 一下再拼,就是为了把"顺序"这个不确定因素消掉;如果你的前缀里还拼了字典、集合这类本身无序的结构,也同样要先排好序再序列化。第二个要警惕的,是"易变字段的无意识泄漏"。时间戳那种放在最顶上的,你定位过一次就长记性了;真正阴险的是 user_name、request_id、session_id 这类——它们看起来"信息量很小、就几个字符",于是你很自然地、顺手地把它们拼进了系统提示词里,比如写一句"你正在为用户 张三 服务"。可对缓存而言,字符多少根本不重要,重要的是"变不变":一个用户一个名字,这个名字就让每个用户的前缀各不相同,缓存的复用范围从"所有用户共享"瞬间塌缩成"单个用户自己跟自己共享"。split_context 那个 VOLATILE_FIELDS 集合,就是把这件事从"靠自觉"变成"靠机制"——你显式地列一张"易变字段黑名单",凡在名单上的,一律不许进前缀。一句话:进前缀的内容,不仅要内容稳定,还要序列化确定、且严防易变小字段的混入。前缀真正稳定了,缓存能命中了,可还有最后一个变量——缓存能在那儿待多久。
五、缓存的寿命:TTL、命中率与监控
前缀稳定,只解决了"能不能命中";还有一个变量是"缓存能存活多久"。供应商的 prompt 缓存有一个 TTL(存活时间),常见是几分钟量级——一段前缀写进缓存后,若在 TTL 内没有再被用到,它就会被清掉。所以你必须能看见每次调用到底命中了没有,这要从响应的 usage 里读:
def read_cache_usage(resp):
"""从响应的 usage 里读出这次调用的缓存命中情况 —— 看不见,就谈不上优化。"""
u = resp.usage
return {
"cache_create": u.cache_creation_input_tokens, # 这次"写入缓存"的 token 数
"cache_read": u.cache_read_input_tokens, # 这次"命中缓存"的 token 数
"uncached": u.input_tokens, # 没走缓存、按全价算的 token 数
}
读到了命中情况,就能算清这次调用到底花了多少钱——缓存读、缓存写、全价输入,三种 token 的单价完全不同:
def estimate_cost(usage, price_in, price_cache_write, price_cache_read):
"""三种 token 单价不同:命中缓存(cache_read)通常只要全价的一小部分。"""
cost = usage["uncached"] / 1000 * price_in # 全价输入
cost += usage["cache_create"] / 1000 * price_cache_write # 写缓存,略贵于全价
cost += usage["cache_read"] / 1000 * price_cache_read # 读缓存,便宜得多
return cost
# 典型情况下 price_cache_read 约为 price_in 的十分之一 —— 命中率越高,省得越多
这里的认知要点是:这一节要建立的,是"缓存不是一劳永逸,而是一个需要持续盯着的活的东西"这个观念。先说 TTL。prompt 缓存里那段前缀,不是写进去就永远在——它有一个寿命,常见是几分钟,每次被命中会刷新这个寿命,但只要在寿命内没有任何调用再用到它,它就被回收了。这就解释了开头第四个问题:高频场景下,调用一个接一个,缓存被反复命中、寿命被反复刷新,它一直活着;可低频场景下,两次调用之间隔了十几分钟,远超 TTL,第二次调用来的时候缓存早凉了,只能重新写入、重新按全价算——它甚至比不开缓存还略亏,因为"写入缓存"这个动作本身比普通全价输入还略贵一点点。所以缓存的收益,本质上取决于你的调用够不够"密"。第二件事:你必须能"看见"。缓存这东西最危险的地方,是它失效时悄无声息——你的代码照常跑、答案照常对,只有账单在月底告诉你"一分没省"。我第一版栽了整整一个月,就是因为我根本没去读 usage,我对"到底命中了没有"一无所知。read_cache_usage 做的就是把这件事从黑箱里拽出来:响应的 usage 里清清楚楚分了三种 token——这次新写进缓存的、这次从缓存命中读出来的、这次完全没走缓存按全价算的。这三个数,就是你判断缓存有没有真正起作用的唯一硬证据。第三件事:把这三种 token 的成本算清楚,你才知道优化的方向。缓存读的单价通常只有全价输入的十分之一左右,而缓存写比全价还略高——这意味着 prompt 缓存有一个"盈亏平衡点":一段前缀写进去(付了略高的写入价),得至少被命中一两次以上,把省下的钱赚回来,才算真正划算。命中率,就是这一切的总开关。一句话:缓存是有寿命、要花钱写入、靠命中回本的东西,你必须用 usage 把它的命中率持续监控起来。主干都齐了,最后是几个把 prompt 缓存真正用出效果才会撞见的工程坑。
六、工程坑:命中率监控、缓存预热、动态内容剥离
主干之外,还有几个工程坑,不处理就会让你的 prompt 缓存看着开了、其实没省。坑 1:命中率必须落到监控里,而不是靠估。缓存失效是无声的——代码照跑、答案照对,只有钱在悄悄漏。每次调用都该记一笔命中情况,把命中率当成一个一等的监控指标盯着:
def log_cache_metrics(resp, logger):
"""每次调用都记一笔缓存命中 —— 命中率是 prompt 缓存最该盯的指标。"""
u = resp.usage
hit = u.cache_read_input_tokens
miss = u.input_tokens + u.cache_creation_input_tokens
rate = hit / (hit + miss) if (hit + miss) else 0
logger.info("cache_hit_rate=%.2f hit=%d miss=%d", rate, hit, miss)
坑 2:低频场景可以主动"预热"保活。承接第五节的 TTL——如果你的业务调用很稀疏,可以用一个极轻量的调用(带着同一段稳定前缀、只要 1 个 token 的输出)定期去"戳一下"缓存,把它的寿命续上:
import time
def keep_cache_warm(client, stable_system, interval=240):
"""低频场景下,用一个轻量调用定期给缓存"续命",别让它凉掉。"""
while True:
client.messages.create(
model="claude-3-5-sonnet",
system=stable_system, # 带着同一段稳定前缀,刷新它的寿命
messages=[{"role": "user", "content": "ping"}],
max_tokens=1, # 输出只要 1 个 token,几乎不花钱
)
time.sleep(interval) # 间隔要短于缓存的 TTL,才接得上
坑 3:把缓存友好的结构封装成统一入口。别让"稳定前缀在前、断点打对、易变后缀在后"这套规矩,散落在每一处调用代码里全靠自觉——把它收进一个统一的函数,让所有调用都自动是缓存友好的:
def call_with_cache(client, docs, user_question):
"""统一入口:稳定前缀在前并打断点,易变后缀排最后 —— 调用方无需操心缓存。"""
system = [
{"type": "text", "text": SYSTEM_PROMPT},
{"type": "text", "text": FEW_SHOT_EXAMPLES,
"cache_control": {"type": "ephemeral"}}, # 断点:系统提示 + 示例
{"type": "text", "text": stable_docs(docs),
"cache_control": {"type": "ephemeral"}}, # 断点:再加上检索文档
]
return client.messages.create(
model="claude-3-5-sonnet",
system=system,
messages=[{"role": "user", "content": user_question}], # 易变内容,排最后
)
坑 4:多个缓存断点,服务不同稳定程度的内容。稳定内容内部也有"稳定梯度":系统提示词几乎永不变,检索文档则可能随知识库更新而变。把断点分别打在它们末尾(像 call_with_cache 那样),文档变了时,前面"系统提示 + 示例"那段更短的前缀仍能继续命中。坑 5:缓存有最小长度门槛。供应商的 prompt 缓存通常要求被缓存的前缀达到一定的最小 token 数(比如一两千 token),太短的前缀开了也不缓存。所以缓存适合"系统提示长、示例多、带大段文档"的场景;prompt 本来就很短,缓存没多大意义。坑 6:prompt 模板一改,全部缓存立即作废。缓存认的是逐字节一致——你哪怕只是给系统提示词改了一个标点、调了一个空格,旧的前缀就和新的对不上了,所有缓存当场失效、需要重新写入。所以 prompt 模板的变更要有意识地集中、批量地做,别零敲碎打、天天改。坑 7:缓存是为了省钱省延迟,不是为了改变答案。开不开缓存、命不命中,模型给出的回答应该是一样的——缓存复用的是"前缀的内部计算结果",不是"上次的回答"。别把 prompt 缓存和"把相同问题的答案存起来直接返回"那种语义缓存搞混,它们是两回事。坑 8:对话类应用,历史消息也能缓存。多轮对话里,前面已经发生的历史轮次是不变的——可以把缓存断点打在"最新一轮用户消息之前",让越来越长的对话历史也吃上缓存,这对长对话省得尤其多。
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| prompt 是有结构的 | 分稳定部分与易变部分,不是一团无差别文本 |
| 拼好发过去就行的错 | 无视结构,把易变内容混进前缀,缓存全废 |
| 前缀缓存 | 缓存逐字节不变的最长开头,一个字节都不能差 |
| 顶部时间戳的灾难 | 开头一变,后面几千 token 稳定内容全被带废 |
| 稳定前缀 + 易变后缀 | 稳定内容全排前面,易变内容一律排到最后 |
| cache_control 断点 | 显式划定"从开头到此处的前缀请缓存" |
| 文档要定序 | 检索文档按稳定 key 排序再拼,内容稳序列化也要稳 |
| 易变字段剥离 | 用户名、请求 ID 等小字段绝不能混进前缀 |
| 缓存有 TTL | 前缀有寿命,低频不命中,可用轻量调用预热保活 |
| 命中率监控 | 从 usage 读三种 token,命中率是该盯的核心指标 |
避坑清单
- 把 prompt 当成"稳定前缀 + 易变后缀"的结构,不是一团无差别文本。
- 缓存只认逐字节不变的最长开头,一个字节的改动会带废其后全部。
- 绝不在 prompt 顶部放时间戳、随机 ID 等每次都变的内容。
- 稳定内容(系统提示、示例、文档)全排前面,用户问题排到最后。
- 检索文档拼接前按稳定 key 排序,内容稳还要序列化出来也稳。
- 用户名、请求 ID、会话 ID 等易变小字段,严防混进缓存前缀。
- 用 cache_control 把断点显式打在稳定内容末尾、易变内容之前。
- 从响应 usage 读 cache_read / cache_create,把命中率纳入监控。
- 缓存有 TTL 会过期,低频场景用轻量调用定期预热保活。
- 把缓存友好的结构收进统一入口,prompt 模板变更集中批量地做。
总结
回头看那串"开了缓存账单一分没省、时间戳带废全文、文档顺序飘命中率忽高忽低、低频调用永不命中"的问题,以及我后来在 prompt 缓存上接连踩的坑,最该记住的不是某一个接口参数的写法,而是我动手前那个想当然的判断——"调用大模型,就是把 prompt 拼成一大段文本、发过去"。这句话错在它把 prompt 当成了一团扁平、无差别的文本。我以为把系统提示、示例、文档、问题拼到一起发出去,这件事就办成了。可我忽略了一件最要紧的事:一个 prompt 是有结构的——它内部分成"成千上万次调用里逐字不变的稳定部分"和"每次都不同的易变部分";而 prompt 缓存,整个建立在这个结构之上,它缓存的是 prompt 那段从第一个字节起、逐字节完全一致的最长前缀。我第一版的错,是对这个结构完全无知:我随手把每次都变的时间戳放在了最前面,把顺序会飘的文档直接拼了进去——我用"内容对就行"的方式排 prompt,而缓存要的是"前缀逐字节稳定"。这个错配,本地开发时根本看不出来——本地我只问"答案对不对",而一团乱序的 prompt 模型照样读得懂、答得对;它只会在真正上线、每天成千上万次调用、你开始关心总成本和总延迟时,以"缓存开了却一分没省"的方式爆出来。
所以做对 prompt 缓存,真正的功夫不在"打开那个缓存开关"那一下上。开关本身好开。真正的功夫,在于你要从一开始就承认"prompt 是一个有稳定前缀的、有结构的东西",然后刻意地、处处地去经营这个前缀:你不能让易变内容污染开头,就把稳定的内容全排到前面、易变的全排到后面;你不能让前缀在某处悄悄断掉,就给检索文档定序、把易变小字段挡在前缀之外;你不能对缓存有没有生效一无所知,就从 usage 里读出命中情况、把命中率纳入监控;你不能让低频调用白白错过缓存,就用轻量调用定期预热保活;而到了多断点、最小长度、模板变更这些边角上,你还要处处守住,别让缓存又在某个角落悄悄失效。这篇文章的几节,其实就是顺着这套规矩展开的:先想清楚"拼好发过去就行"为什么错,再讲前缀缓存的原理、prompt 结构怎么排、怎么让前缀真正稳定、缓存的寿命怎么管,最后是命中率监控、预热、动态剥离这几个把缓存守扎实的工程细节。
你会发现,prompt 缓存这件事,和现实里"一个厨房怎么备菜出餐"完全相通。一个不靠谱的厨子会怎么做?每来一桌客人点单,他都从最最开头做起——现熬一锅高汤、现调一份基础酱料、现把那几样几乎每道菜都要用的料重新切配一遍,然后才轮到这桌客人具体点的那道菜。那锅高汤、那份酱料,桌桌都一样、本可以提前一次备好,他却桌桌从头来过,灶上忙得团团转、出餐慢得要命。更糟的是,就算他偶尔想提前备一点,他还在每一盆备好的料上都贴了张纸条,写上备料的精确时间——于是这一桌的料和那一桌的料,哪怕内容一模一样,也因为那个时间不同而被当成两份不相干的东西,谁也没法复用谁。而一个靠谱的厨子怎么做?他把那些桌桌都要用、内容不变的东西——高汤、酱料、切配好的常用料——在开市前一次性备齐、整整齐齐码在手边(这就是把稳定内容集中成可缓存的前缀);每来一桌,他只做最后那一小段"这桌专属"的现炒和摆盘(这就是只让易变后缀走全价);他绝不在备好的料上贴会变的时间戳,好让同一份料能被一桌又一桌地复用(这就是不让易变内容污染前缀);备料放久了会坏,他还心里有数,会照着它能放多久来安排备多少、隔多久补一次(这就是缓存的 TTL 与预热)。同样是出一桌菜,不靠谱的厨子把每一桌都当成"从零开始的一团活儿",靠谱的厨子则看清了"这团活儿里,一大半是桌桌不变、可以提前备好反复复用的"——差别不在"炒这道菜本身难不难",只在厨子心里有没有"这桌活儿是有结构的、稳定的那部分该提前备好"这根弦。
最后想说,prompt 缓存做没做对,差距永远不会在"本地开发、自己问一句测一句"时暴露——本地你只关心"模型答得对不对",而一个把时间戳放在最前面、文档顺序乱飘的 prompt,模型照样读得懂、照样答得准,你那段"拼好发过去"的代码看起来一点毛病没有,你自然觉得"调模型嘛,拼好发过去"一点问题都没有。它只在真实的、每天成千上万次调用、你开始为账单和延迟负责的生产环境里才显形。那时候它会用最难堪的方式给你结账:做不好,你会明明开了缓存,却因为顶部一句时间戳,眼睁睁看着一个月的账单一分没省,会因为检索文档顺序飘忽,让命中率忽高忽低、省钱全凭运气,会因为不读 usage,对缓存到底有没有生效整整一个月一无所知;而做对了,你的每一次调用,那几千 token 的稳定前缀都稳稳地命中缓存,价格和延迟都大幅下降,命中率清清楚楚地躺在监控面板上,低频的调用也被预热接住。所以别等"月底账单一分没省"那一刻找上门,在你写下拼接 prompt 的第一行代码时就该想清楚:这里哪些是稳定的、哪些是易变的,稳定的排前面了吗,易变的有没有污染开头,文档定序了吗,命中率我盯着吗,这一道道关口,我是不是都替这个有结构的 prompt 守住了?这些问题有了答案,你交付的才不只是一套"本地问着对"的代码,而是一个无论调用量涨到多大,每一次都把该省的钱、该省的延迟实实在在省下来的、让人放心的系统。
—— 别看了 · 2026