@dejavucoder: 现在很多人在讨论提示缓存和推理优化。我曾经写过一篇很长的博客…

X AI KOLs Timeline 工具

摘要

本文解释了提示缓存如何在LLM推理中使用分页注意力和前缀缓存工作,并提供了开发者优化性能的实用技巧。

现在很多人在讨论提示缓存和推理优化。我曾经写过一篇很长的博客,描述了如何更频繁地命中提示缓存以及其底层工作原理(通过分页注意力方法) https://t.co/0zi6sBCvU2
查看原文
查看缓存全文

缓存时间: 2026/08/23 13:40

现在很多人都在讨论提示缓存和推理优化。我曾经写过一篇长篇博文,讲解如何更多地命中提示缓存以及其底层原理(通过分页注意力机制实现)。

https://t.co/0zi6sBCvU2


提示缓存如何工作——分页注意力与自动前缀缓存(附实用技巧)

来源:https://sankalp.bearblog.dev/how-prompt-caching-works/ 2025年11月30日

目录

  1. 引言背景与动机 (https://sankalp.bearblog.dev/how-prompt-caching-works/#intro-lore) – 唠叨一下写这篇文章的原因,并简要介绍我们将要探索的领域
  2. 提高提示缓存命中率的技巧 (https://sankalp.bearblog.dev/how-prompt-caching-works/#tips-to-hit-prompt-cache-more-consistently) – 为什么提示缓存很重要以及如何提高缓存命中率
  3. LLM 推理基础 (https://sankalp.bearblog.dev/how-prompt-caching-works/#llm-inference-basics) – 预填充、解码和 KV 缓存基础
  4. 内存问题 (https://sankalp.bearblog.dev/how-prompt-caching-works/#the-memory-problem) – 传统 KV 缓存分配的挑战及其无法扩展的原因
  5. 分页注意力 (https://sankalp.bearblog.dev/how-prompt-caching-works/#paged-attention) – vLLM 借鉴操作系统灵感设计的解决方案:块与块表
  6. 前缀缓存 (https://sankalp.bearblog.dev/how-prompt-caching-works/#prefix-caching) – 块哈希、最长缓存命中及完整图景

预备知识:第 2 节之后的内容假设你熟悉解码器 Transformer 中的自注意力机制。可参考 nanoGPT (https://www.youtube.com/watch?v=kCc8FmEb1nY&t=905s) 或 3blue1brown (https://www.youtube.com/watch?v=eMlx5fFNoYc&t=269s) 的讲解。


引言背景与动机

最近工作中,我需要在一个紧迫的截止日期前构建一个功能。它涉及聊天和工具调用组件。由于当时只想尽快发布 v0 版本,我没有太多考虑提示缓存。

接下来的一周,我开始进行优化,并意识到在压力下犯的一些低级错误。我最终在系统提示的末尾添加了长长的用户特定数据,当时以为只需要为单个对话/消息数组保持最长前缀稳定即可。

一个消息数组可能看起来像这样:

0. [系统提示 + 工具定义]
1. 用户:       怎么了。请为我构建这个功能
2. 助手:       你能告诉我要在哪里找吗?这是一个很大的代码库
3. 用户:       看看 kv_caching 文件夹
4. 助手:       你说得完全正确!我会去那里看看
5. 工具输出:*搜索* *阅读*
6. 助手:       LLM 获取到观察结果输出
7. 用户:       ...
8. 助手:       ...

我的预期是,在这个会话中,缓存能从第 4 步开始命中——没错,因为第 0-3 步是重复的。但我忽略了更大的图景:缓存命中可以从不同用户的第 0 步开始。你的系统提示可以被来自你 API 密钥组织的所有对话共享。

我的思维模型是错误的。我将推理想象成一个同步引擎——一个为单个用户服务的单个阻塞进程,就像在本地托管模型一样。第一个提示 → 模型进行预填充 → 生成 KV 缓存 → 响应。第二个提示 → 命中缓存 → 快速响应。

但这并不是 OpenAI 和 Anthropic 等提供商大规模部署模型的方式。他们需要处理并发的用户请求。他们通过异步分布式(多 GPU、多节点)系统来实现。当“异步”这个词出现时,你应该在脑海中浮现出调度器和消息队列的画面。

这些引擎整合了多种技术来优化 LLM 推理,如 KV 缓存复用、连续批处理、分块预填充、推测解码等等。KV 缓存复用使得提示缓存成为可能。

为了理解提示缓存的工作原理,我们还需要了解推理引擎(如 vLLM)的基础知识,以及 KV 缓存复用是如何实现的。

为什么存在这篇文章

我找到了关于提示缓存的很棒技巧,但无法找到一个全面解释其底层工作原理的资源。所以,我在这里承担起这个责任,费力写下这篇文章。遵循“成为你想在世界中看到的改变”等理念。当有人搜索“提示缓存到底是如何工作的”时,我希望这篇文章能出现,让他们对提示缓存的工作原理有个好概念,并额外学到大规模推理是如何运作的。

过去几天,为了写这篇文章,我花了很多时间弄懂 vLLM 引擎和推理技术。这是我几天前的样子:

本·阿弗莱克抽烟梗图 字面就是我

很长一段时间以来,我以为提示缓存是因为 KV 缓存才起作用的,这在某种程度上是对的。然而,它实际上是通过不同的技术(如分页注意力和基数注意力)复用 KV 缓存才起作用的。在这篇文章中,我将重点关注分页注意力。为此,我们需要了解 vLLM 引擎是如何工作的。这篇文章的目标是“真正理解提示缓存”,因此我将专注于 vLLM 引擎中与分页注意力和前缀缓存超级相关的部分。

在我们深入内部原理之前,让我先从一些提高提示缓存命中率的技巧开始。正是这些技巧让我足够好奇,去弄清楚底层的一切是如何工作的。

提高提示缓存命中率的技巧

提示缓存是指 LLM 提供商为相同的提示前缀复用先前计算的键值张量,跳过冗余计算。当你命中缓存时,你付出的成本更少,响应也更快。

提示缓存基础及为何要关注它

如果你使用 Codex/Claude Code/Cursor 并检查 API 使用情况,你会注意到很多 token 是“已缓存”的。幸运的是,代码是有结构的,多个查询可以关注相同的上下文/前缀来回答问题,因此有很多缓存命中。这正是控制账单的原因。

代码生成代理就是一个很好的例子,其上下文增长非常快,输入 token 与输出 token 的比例可能非常大(这意味着预填充/解码比例会很大——这些是我将在接下来几节中介绍的关键概念)。

Codex 显示已缓存 token Codex 在会话结束时显示已缓存的 token

这就是提示缓存能为你省钱的地方。当缓存命中时,输入 token 最多可节省 10 倍成本。你还能获得更快的响应。如下图所示,Sonnet 4.5 在缓存命中时,输入 token 的成本是 1/10。

anthropic 真是贪婪。如果你试图使用提示缓存,他们实际上会收取更高的基础价格,哈哈 pic.twitter.com/kXVOw5SNGx (https://t.co/kXVOw5SNGx) — sankalp (@dejavucoder) 2025年11月17日 (https://twitter.com/dejavucoder/status/1990513414926749881?ref_src=twsrc%5Etfw)

OpenAI 提示缓存定价 OpenAI 提示缓存定价——注意存储 token 到缓存没有额外费用

我之前说 Anthropic 贪婪,是因为他们对缓存写入收费更高(而 Sonnet/Opus 本身已经很贵了)。相比之下,OpenAI 不收取额外费用。这是从消费者角度看的。从基于工程师的角度看,将键值张量存储在 GPU 显存或 GPU 本地存储中有成本——这就解释了额外收费,我们将在本文后面看到。

半相关的是,OpenAI 最近还为 GPT-5.1 系列和 GPT-4.1 模型推出了 24 小时缓存保留策略 (https://platform.openai.com/docs/guides/prompt-caching#extended-prompt-cache-retention)。默认情况下,缓存的前缀在 GPU 显存中保留 5-10 分钟不活动就会被清除。延长的 24 小时保留策略会在空闲时将 KV 张量卸载到 GPU 本地存储(连接到 GPU 节点的 SSD),并在缓存命中时将其加载回显存。

以下是一些不同的 LLM 调用模式示例,其中缓存可能非常有用。

不同 LLM 用例下的 KV 缓存共享模式 KV 缓存共享示例。蓝色框是可共享的提示部分,绿色框是不可共享部分,黄色框是不可共享的模型输出。来源:SGLang 博客 (https://lmsys.org/blog/2024-01-17-sglang/#backend-automatic-kv-cache-reuse-with-radixattention)

提高缓存命中率的技巧

OpenAI 和 Anthropic 在他们的文档中提供了一些技巧。核心思想是保持尽可能长且稳定的前缀。

展示如何构建提示以获得最佳缓存命中率的图表 OpenAI 建议的实践。来源:OpenAI 文档 (https://platform.openai.com/docs/guides/prompt-caching#best-practices)

我觉得这些技巧不够细致,有很多犯错的空间。我在 Manus 非常有帮助的博客 (https://manus.im/blog/Context-Engineering-for-AI-Agents-Lessons-from-Building-Manus) 中找到了更好的指南,特别是第一部分。

Manus 博客上下文工程技巧 Manus 上下文工程技巧。来源:Manus 博客 (https://manus.im/blog/Context-Engineering-for-AI-Agents-Lessons-from-Building-Manus)

阅读了这篇博客和其他一些资源后,我最终在我开头提到的工作问题上做了一些改动。

使前缀保持稳定

我最终从系统提示中移除了所有用户特定或动态内容。这使得其他用户即使对于系统提示消息也能命中提示缓存,因为它将在“KV 缓存块”(稍后详细介绍)中成为公共前缀。

保持上下文仅追加

在我构建的功能中,可能有多个工具调用,其适度长的输出被存储在消息数组中。我以为随着对话变长,由于上下文腐化,这可能会降低性能,所以当对话变长时,我会截断消息数组中的工具调用输出。

实际上,我这样做破坏了前缀,因此我决定停止截断,因为我更看重成本和延迟的收益。现在我的上下文是仅追加的。

我猜测 Claude Code 的压缩很可能也是一个仅追加的过程。

使用确定性序列化

Manus 博客提到要使用确定性序列化。我最终在序列化工具调用输出中的 JSON 时使用了 sort_keys=True。即使两个对象在语义上相同,不同的键顺序会产生不同的字符串——这意味着不同的缓存键和缓存未命中。我知道前两点,但没想到这一点。

展示使用提示缓存成本差异的基准测试 在 json.dumps() 中使用 sort_keys=True 以确保一致的键顺序。这篇博客对提示缓存的成本差异进行了基准测试。来源:Ankit 的博客 (https://ankitbko.github.io/blog/2025/08/prompt-engineering-kv-cache/)

不要动态更改工具调用定义

Manus 提到了另一件需要注意的重要事情——工具调用定义通常存储在系统提示之前或之后。请参考此处 (https://rlhfbook.com/c/14.5-tools#interweaving-tool-calls-in-generation)。这意味着更改或删除某些工具调用定义将破坏此后整个前缀。

Anthropic 最近推出了一个工具搜索工具,可按需搜索工具。你不需要预先提及所有工具。我曾怀疑这是否会破坏缓存,因为工具通常在内部位于系统提示的开头或结尾。后来我在文档中看到,这些工具定义是“追加”到上下文的——所以它保持了仅追加的特性。

工具调用定义位于系统提示之前或之后,所以这是个很酷的方法,尽管我好奇模型是否会在执行过程中引入新工具。这不会破坏提示缓存吗?https://t.co/zVRkU0f5dh — sankalp (@dejavucoder) 2025年11月24日 (https://twitter.com/dejavucoder/status/1993040559351738422?ref_src=twsrc%5Etfw)

prompt_cache_key 和 cache_control

对于 OpenAI,你的 API 请求需要被路由到同一台机器才能命中缓存。OpenAI 根据初始前缀(约 256 个 token)的哈希值进行路由。你可以传递 prompt_cache_key 参数,该参数会与此前缀哈希值结合,帮助你在许多请求共享长公共前缀时影响路由。请注意,这不是一个缓存断点参数——它是一个路由提示。这也是我需要进一步实验的地方。

对于 Anthropic,我认为他们没有自动前缀缓存(不完全确定),所以你需要显式标记 cache_control 断点 (https://platform.claude.com/docs/en/build-with-claude/prompt-caching) 来告诉他们在哪里缓存(如 Manus 的第 3 点所述)。从每个断点开始,Anthropic 向后检查以找到最长的缓存前缀,每个断点有 20 个块的回溯窗口。

LLM 推理基础

既然我们完成了实践部分,让我们来看看底层原理。你可能会想,除了好奇心和建立信念之外,花时间研究底层原理是否有理由。我认为,当涉及到在技术栈的任何层(尤其是应用/工程层)进行优化时,深入抽象层级可以极大地帮助。有时人们别无选择,只能研究构建模块并从中找到解决方案。

这在过去是我的经验,也是在阅读 Manus 博客后再次被提醒的。那些家伙能够优化,是因为他们知道底层原理是如何工作的。

预填充与解码

LLM 推理工作有两个阶段(或请求)——预填充(输入处理以获得第一个 token)和解码(输出生成)。

展示 LLM 推理中首个 token 时间(TTFT)的图表 首个 token 时间(TTFT)。来源:NVIDIA NIM 文档 (https://docs.nvidia.com/nim/benchmarking/llm/latest/metrics.html)

考虑一个输入提示 → “法国的首都” 即 “预填充”模式。

在预填充期间,模型处理整个提示。每个 token 通过因果自注意力关注之前的 token,在所有 Transformer 层中计算查询、键和值张量,以产生第一个输出 token。这是一个高度并行的步骤(得益于矩阵乘法),并且严重受计算/GPU 浮点运算限制。计算步骤比内存移动步骤多得多。更多算术相关内容见此处 (https://kipp.ly/transformer-inference-arithmetic/#kv-cache:~:text=e12-,Flops%20vs%20Memory%20Boundedness,-Flops%20vs%20memory)。

相比之下,解码是内存密集型的——每一步只处理 1 个 token,但必须从 GPU 内存加载整个 KV 缓存。这种计算与内存的区分对于调度很重要:vLLM 优先处理 running 队列(解码)而不是 waiting 队列(预填充),确保延迟敏感的解码步骤不会被计算密集型的预填充饿死。分块预填充通过限制每批的预填充 token 数来扩展这一点,让解码请求无需等待即可继续进行。

# 源代码:nanoGPT (https://github.com/karpathy/nanoGPT/blob/3adf61e154c3fe3fca428ad6bc3818b27a3b8291/model.py#L29)

def forward(self, x):
    B, T, C = x.size() # 批大小、序列长度、嵌入维度 (n_embd)

    # 预填充 - 计算批次中所有头的查询、键、值,并将头移动到批次维度
    q, k, v = self.c_attn(x).split(self.n_embd, dim=2)
    k = k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)
    q = q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)
    v = v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)

预填充完成后,解码开始。我们取输出 token 并将其附加到输入序列,然后将其传递给 LLM(自回归生成)。此过程重复,直到我们得到序列结束 token。

模拟运行:

预填充

[法国的首都] → 巴黎

解码迭代 1

[法国的首都巴黎] → 哪里

解码迭代 2

[法国的首都巴黎哪里] → 有

解码迭代 3

[法国的首都巴黎哪里有] → 这

解码迭代 4

[法国的首都巴黎哪里有这] → 埃菲尔

相似文章

代理中的提示缓存

Lobsters Hottest

本文解释了提示缓存在大语言模型代理中的工作原理,涵盖 KV 缓存机制、预填充和解码阶段,以及其对延迟、成本和代理设计的影响。