4897 字
24 分钟
Prompt Caching:被忽视的非对称性

一个被忽视的非对称性#

写过 LLM 应用的人多半都遇到过这个困惑:你的 system prompt 写了 5000 个 token——里面包括角色定义、规则约束、few-shot 示例、工具描述——每次调用都一字不差,但 API 每次都按 5000 token 计费。用户输入只有 50 个 token,模型回复只有 200 个 token,可账单上 95% 的成本花在了那段你早就写好、不会变的开头上。

这件事看起来违反直觉。一段不变的内容,为什么要被反复处理?

要回答这个问题,得先承认 LLM 推理成本是高度非对称的。同样是一千个 token,“读 prompt”和”生成回复”是两件完全不同的事——前者是一次性、并行、O(n²) 的注意力计算(叫 prefill 阶段),后者是逐 token、串行、O(n) 的解码(叫 decode 阶段)。当 prompt 长达数千 token 而回复只有几百 token 时,prefill 才是真正的成本中心,而 decode 只是收尾。

Prompt Caching 击中的正是这个非对称性的尖端:把那段每次都重复的、最贵的、最容易被识别的部分剥离出来缓存。它不是优化技巧,是对推理过程物理本质的诚实回应。

KV Cache:transformer 推理的隐藏脚手架#

要讲清楚 Prompt Caching,得先讲清楚 KV Cache。这两个概念经常被混为一谈,但它们处在不同的层次。

自回归 transformer 生成一段文本的过程,本质上是一个不断追加 token 的循环:每生成一个新 token,模型都需要让它”看到”之前所有的 token。看的方式是注意力机制——新 token 的 query 向量去和所有历史 token 的 key 向量做点积,再用结果加权聚合所有历史 token 的 value 向量。如果按字面理解这个过程,每生成一个 token 都要把前面所有 token 的 key 和 value 重新算一遍,复杂度是 O(n²),长度一上去推理就会窒息。

但有一个关键观察拯救了这个机制:因果注意力下,历史 token 的 K 和 V 一旦算出,就再也不会改变。第 100 个 token 的 K 向量不会因为第 101 个 token 的存在而改变——它的计算只依赖第 100 个 token 自己的输入。既然如此,为什么不把它们存起来?

这就是 KV Cache 的物理基础。每生成一个新 token,模型只需要为这个新 token 算一次 K 和 V,把它们追加到缓存里,然后做一次注意力查询就行。复杂度从 O(n²) 降到 O(n)。今天所有的 LLM 推理引擎——vLLM、TensorRT-LLM、SGLang——都把 KV Cache 当作底层基础设施,没有它,长上下文推理在工程上是不可能的。

KV Cache 是单次推理内部的优化:一次请求里,token 越生成越多,缓存越长越大,结束时整块释放。它解决的是”生成下一个 token 时不要重新看历史”的问题。

从 KV Cache 到 Prompt Cache:一次概念的扩展#

Prompt Caching 的核心洞察,是把上面那个”不要重新算”的逻辑从一次请求内部,扩展到了跨请求之间

如果两次请求的 prompt 有相同的前缀,那么这个前缀在两次请求中算出来的 K/V 矩阵应该是完全一致的——因为 transformer 的注意力是确定性的,相同的输入必然产生相同的中间状态。既然一致,为什么不在第一次算完后保留下来,第二次直接复用?

这就是 Prompt Caching。它不是在缓存文本,而是在缓存注意力计算的中间状态——具体来说,是 prompt 前缀对应的 K/V 矩阵。当一个新请求到达时,系统先检查它的前缀是否命中缓存,如果命中,prefill 阶段就只需要处理”前缀之后的新内容”,前面那一大段——不管是 5000 token 还是 50000 token——直接跳过。

这里要强调一个非常严格的约束:前缀必须严格一致,差一个 token 都不行。原因是 K/V 矩阵的计算依赖于该 token 在序列中的绝对位置以及它前面所有 token 的累积状态——一旦中间任何一个 token 变了,从那个位置开始往后所有的 K/V 都会改变。所以缓存的本质是”前缀哈希匹配”,而不是”语义近似匹配”。

物理意义是什么?prefill 从 O(n²) 降到了 O(1)(如果完全命中)。这不是百分比的优化,是数量级的跳变。在 100K token 的长上下文场景下,一次完整的 prefill 可能需要几秒钟,cache 命中后只需要几十毫秒。

模型只区分”读”和”写”#

理解了 prefill 和 decode 的边界之后,还有一个容易被忽略的事实值得单独拿出来说:从模型的视角看,所有进入它的 token 都是 input,无一例外地走 prefill;它只有在生成自己的回复时才进入 decode。

这个边界干净得令人意外。模型不关心这些 token 来自哪里——system prompt、tool definitions、历史用户消息、模型上一轮自己生成的回复、工具执行结果、中间件注入的上下文——对它而言全是同一件事:一段需要被”读”进去的序列。你可以把 prefill 理解为”读”,把 decode 理解为”写”。读是并行的、O(n²) 的;写是串行的、逐 token 的。两者之间的边界不会因为 token 的来源不同而移动。

这件事对 agent 系统有非常具体的工程含义。一个 agent 在执行过程中会调用工具——读文件、搜索、跑代码——工具返回的结果被塞回对话历史,供模型下一轮推理使用。这些工具结果在下一轮推理时就是货真价实的 input tokens,走 prefill,走 O(n²) 的注意力计算。一次工具调用返回了 2000 token 的文件内容,在后续每一轮推理中都会参与 prefill——除非被 prompt cache 覆盖。

更有趣的是,一段 token 在它的生命周期里会经历身份的转变。模型生成一个 tool call 时,这些 token 是 output(按 output 价格计费,通常更贵)。但到了下一轮推理,这段 tool call 文本连同工具返回的结果一起,变成了 input 的一部分——此时它可以被 prompt cache 覆盖到,按 cache read 的 0.1x 价格计费。同一段 token,在生成它的那一刻是”写”(贵),在后续所有轮次中变成了”读”(便宜,且可缓存)。

这就是为什么 prompt caching 在 agent 场景下的价值比普通对话高得多。一个 coding agent 做了五次工具调用,到第五轮时,prompt 里已经积累了前面四次的 tool call + tool result——可能上万个 token。没有 caching,每一轮都要重新 prefill 这些越来越长的历史;有了 caching,前几轮的 K/V 状态直接复用,只有最新一次工具结果需要真正计算。Claude Code、Cursor 这类产品能在一次会话中做几十次工具调用而不把延迟拖到不可接受的地步,prompt caching 不是可选项,是生死线。

三家厂商的设计哲学#

工程上要落地 Prompt Caching,有三个绕不开的问题:缓存什么?缓存多久?谁来决定缓存哪部分?三家主流厂商给出了三种截然不同的答案——不是 feature 的差异,是设计哲学的差异。

Anthropic 选择了显式控制。开发者在请求里用 cache_control 字段手动标记缓存断点,最多四个,分别可以放在 tools、system、messages 的不同位置。每个断点都是一次”声明”——告诉系统”在这里写一次缓存,这之前的内容请记住”。TTL 默认 5 分钟(免费),可以付费升到 1 小时。写入比基础输入贵 25%(5 分钟)或贵 100%(1 小时),但读取只要基础输入的 10%。

这个设计的代价是认知负担——你必须理解前缀哈希、断点回退(每个断点最多向后查 20 块)、TTL 嵌套规则(长 TTL 必须在短 TTL 上游)、最小 token 阈值(Sonnet 是 1024,Opus 是 4096)。但回报是控制力:你可以精确决定哪些部分值得缓存,可以分层缓存(tools 缓存 1 小时,system 缓存 5 分钟),可以用 max_tokens=0 预热缓存。Anthropic 的哲学是”把权力交给开发者”——它假设你的应用足够复杂,值得为这种控制力付出学习成本。

OpenAI 选择了完全自动。你什么都不用做,发请求就行。系统在底层做前缀哈希匹配,命中即享受 50% 的折扣(不同模型略有差异)。最小 token 1024,命中粒度按 128 token 对齐,TTL 在 5 到 60 分钟之间浮动(取决于负载)。没有 cache_control,没有断点,没有需要学习的概念。

代价是控制力的丧失。你不知道命中了多少,不知道缓存什么时候过期,不知道为什么有时候命中有时候没命中(高峰期缓存可能被驱逐)。回报是简单——大多数应用根本不需要那么精细的控制,把 system prompt 写在前面、用户输入放后面,自动就有效果。OpenAI 的哲学是”把简单留给开发者”——它假设大多数人的需求是 80 分的优化,不是 99 分的极致。

Google Gemini 走了第三条路。它同时提供 implicit caching(自动,类似 OpenAI)和 explicit caching(显式,类似 Anthropic)。implicit 模式下命中可以享受到原价 10% 的折扣,但显式模式需要为缓存的存储时长付费——你显式创建了一个 cache 对象,存多久就付多久的存储费,按 token-hour 计算。

这个设计很有意思。它把缓存从一个”附加优化”变成了一个”显式资源”——像数据库连接、像 GPU 显存——你需要管理它的生命周期。哲学是”让你选,但要承担选择的后果”。代价是费用模型的复杂性:implicit 永远不亏,explicit 在高频复用下能省更多,但低频场景下存储费可能比省下来的钱还多。

三种哲学没有谁优谁劣,只是对”开发者承担多少认知负担”的不同判断。Anthropic 适合做 agent 这类有复杂结构的应用,OpenAI 适合做轻量级的对话应用,Gemini 适合做有明确长上下文复用模式的应用(比如长文档问答)。

工程实践的几个原则#

理解了原理之后,提示工程的几条实践原则其实是自然推导出来的,不是经验法则。

静态在前,动态在后。这是缓存语义的必然推论:前缀必须严格一致才能命中,所以你必须把不变的内容放在最前面,变化的内容放在最后面。具体的顺序是:tool definitions → system prompt → 历史消息 → 当前用户输入。任何把动态内容混进前缀的做法——比如在 system prompt 里塞当前时间戳、在 tools 描述里塞 user_id——都会让所有后续缓存失效。

多轮对话要”追加”,不要”重组”。多轮对话的缓存策略是:每一轮都把新的消息追加到 messages 数组末尾,不要重新组织历史。如果你的某个中间件每轮都重新拼接 prompt(比如重新编号、重新加时间戳、删掉某些消息再传),缓存命中率会断崖式下跌。Anthropic 的 5 分钟 TTL 在这种场景下是友好的——只要用户在 5 分钟内连续对话,每轮都能命中前一轮的缓存。

Tool definitions 是天然缓存友好。工具描述往往很长(几千 token 的 JSON Schema),但在一个应用的生命周期里几乎不变。把 tools 放在最前面,给它一个独立的缓存断点(如果你用 Anthropic),命中率接近 100%。Coding agent 这类高频调用工具的场景,tools 缓存几乎是经济可行性的前提条件。

RAG 检索结果是个两难。RAG 的本质是”动态检索 + 静态生成”——每次检索结果不同,但生成模板相同。如果你把检索结果插在 system prompt 中间,会破坏后面所有内容的缓存。正确的做法是把检索结果放在用户消息附近(前缀的最后部分),让 system prompt 和 tools 保持稳定。某些场景下,可以反过来思考:把高频检索结果(比如热门文档片段)也缓存起来,作为一个独立的 cached prefix。

警惕隐性的缓存污染。最容易踩的坑是那些你以为不变、其实在变的内容。比如:日志里的请求 ID、用户的 session token、A/B 测试的 variant ID、随机抽样的 few-shot 示例顺序。这些东西只要混进了 prompt 前缀,缓存就报废。一个好习惯是:在做缓存调优时,把同一个用户连续两次相同请求的完整 prompt 打印出来对比——如果有任何字符不同,那就是缓存失效的源头。

Pre-warming 适合”冷启动敏感”的场景。Anthropic 支持 max_tokens=0 的预热请求——只把 prompt 写进缓存,不让模型生成。在用户进入对话前,先用一次预热请求把 system prompt 灌进缓存,这样用户第一条消息的延迟就不会受到 prefill 拖累。这个技巧对实时交互产品(比如 IDE 里的 AI 助手)特别有用。

还有一个故事:Output 该不该缓存?#

讲完 prompt 缓存,还有一块容易被忽略的拼图——输出

Prompt Caching 缓存的是输入端的 K/V 状态,输出 token 永远不在缓存范围内。这是一条硬规则:所有主流厂商的 prompt caching,输出部分都按基础价计费。

但有意思的是,多轮对话里 assistant 的回复在下一轮会变成历史消息的一部分——这部分 token 在第一次产生时是按 output 计费(贵),但在下一轮被作为 input 处理时,它已经被自动写进了 prompt cache(便宜)。所以”输出”这个词的边界其实是模糊的:在它被生成的那一刻是 output,在下一轮请求里它就是 input 的一部分,参与缓存。

真正的”输出缓存”是另一个完全不同的层次的概念,它不属于 LLM 推理引擎,而属于应用层。**当两个用户问了相同(或相似)的问题,能不能直接返回上一次的答案,跳过整个 LLM 调用?**这就是输出缓存。

输出缓存有两种实现路径:

精确匹配:以输入 prompt 的哈希作为 key,输出文本作为 value。命中条件极其苛刻——一个 token 不一致就 miss。这种缓存只在很窄的场景有效,比如重复的工具调用、固定的批处理请求。

语义匹配:把 prompt 编码成 embedding 向量,做向量检索,找到 embedding 距离足够近的历史请求,返回它的输出。GPTCache 是这个方向的代表项目。语义缓存的命中率比精确匹配高得多,但代价是召回质量难以保证——“相似的问题”和”相同的问题”之间没有清晰的界限,一个微小的语义差异(“今天天气怎么样”和”今天会下雨吗”)可能需要完全不同的回答。

输出缓存和 prompt caching 是两件事,处在不同的层次,解决不同的问题:

维度Prompt CachingOutput Caching
缓存对象注意力 K/V 中间状态完整的输出文本
实现层LLM 推理引擎应用层 / API 网关
匹配方式严格前缀哈希精确哈希 / 语义相似
命中收益跳过 prefill完全跳过模型调用
风险几乎为零(保证正确性)语义匹配可能召回错误答案
适用场景所有重复前缀的请求高重复率、容错性高的查询

工程上的判断是:Prompt Caching 应该默认开启,它是几乎零风险的优化;Output Caching 应该谨慎使用,只在能容忍”相似但不同”的语义召回的场景下,配合严格的命中阈值和回退策略。客服 FAQ、知识库问答、代码片段查询是输出缓存的好场景;而需要个性化、需要实时数据、需要严格准确性的场景应该绕开它。

缓存背后的更大图景#

Prompt Caching 表面上是个性能优化,骨子里是一次思维方式的迁移。

它把”调用 LLM”这件事从一次性的、无状态的请求,变成了对一个会话状态的增量更新。这种思路在计算机系统的其他领域并不新鲜——操作系统的进程内存、数据库的连接池、CDN 的边缘缓存——它们的共同信念是:有状态优于无状态,复用优于重建

在 agent 时代,这一点尤为关键。一个 coding agent 的典型请求结构是:长 system prompt(几千 token 的角色定义和规则)+ 长 tools 定义(几千 token 的工具描述)+ 历史消息(不断累积)+ 当前用户输入。如果没有缓存,每一轮交互都要重新 prefill 上万个 token,agent 的延迟和成本会让它在工程上不可行。Claude Code、Cursor、Devin 这些产品能跑起来,prompt caching 是底层假设的一部分,不是锦上添花。

再往前看,更激进的方向已经在路上了。vLLM 的 PagedAttention 把 KV Cache 切分成块来管理,让缓存可以在不同请求之间灵活共享;disaggregated inference 把 prefill 和 decode 分到不同 GPU 上,prefill 节点专门负责跨请求的前缀匹配;跨实例的 prefix cache 共享让大型集群能在节点之间复用同一个 cache。这些方向的共同根基都是同一个观察:重复计算就是浪费算力

回到最开始那个困惑——为什么 5000 token 的 system prompt 每次都按 5000 token 计费?正确的答案不是”因为模型笨”,也不是”因为厂商坏”,而是”因为推理引擎默认不假设你会重复”。Prompt Caching 给了你一个声明的方式:你告诉系统”这部分会重复”,系统就把它当作有状态的资源来管理。

理解这一点,你会发现提示工程不只是写好 prompt,更是设计一个对缓存友好的 prompt 结构。哪些是稳定的、哪些是变化的,哪些值得缓存、哪些不该污染缓存,这些决定了你的应用在生产环境下的真实成本。

非对称性是 LLM 推理成本结构的本质特征。识别它、利用它、为它设计——这是 2026 年做 LLM 应用绕不开的基本功。

Prompt Caching:被忽视的非对称性
https://dicer-zz.github.io/posts/prompt-caching-asymmetry/
作者
Dicer
发布于
2026-05-18
许可协议
CC BY-NC 4.0