
长上下文这四个字落到工程上从来不是“把窗口设大”那么轻巧。它真正难啃的地方是在 Attention 的计算量、KV Cache 的显存占用以及推理引擎的调度复杂度这三块骨头之间来回找平衡。我的体感是所有长上下文问题都能拆成四层底层逻辑Attention 怎么算、上下文怎么压缩、缓存怎么塞、跨页之后怎么调度。这篇文章就把这四块一次性讲清楚顺便把我踩过的一些坑一起倒出来。先说适合谁看。如果你正在做长文本推理优化、跑过 32K 以上上下文窗口、或者部署过超长文档问答一类的服务这篇内容可以直接对照排查问题。如果你是刚接触大模型工程的新人建议先把注意力机制的基本公式过一遍再来看这里面的工程取舍。1. Attention长上下文的第一道坎1.1 为什么 O(n²) 是躲不掉的标准 Self-Attention 的公式就一行Attention(Q, K, V) softmax(QK^T / sqrt(d)) · V假设序列长度是 n这个 QK^T 就有 n² 个元素要算。每一层都是这样层数越多越夸张。n 从 4K 涨到 128Kn² 直接放大约 1000 倍而这个量级的增长在内存和计算单元里都是真实的开销。很多刚接触长上下文的人以为瓶颈只在显存其实不对计算量本身就已经是第一个拦路虎。推理阶段还得拆成 prefill 和 decode 两个过程看。prefill 阶段要一次性处理 n 个 tokenn×n 的注意力分数矩阵会真实存在显存里这也是为什么长 prompt 灌进去时显存会瞬间飙升。decode 阶段每生成一个 token都要拿新 token 的 query 去和已有的 n 个 KV 做注意力计算单步是 O(n)生成 m 个 token 就累计成 O(n·m)。如果生成和上下文一样长整体就是 O(n²)。这就是长上下文生成速度越跑越慢的根源——不是模型变笨了是每一步付出的算力都在增长。1.2 为什么稀疏注意力和线性注意力没全面替代既然 O(n²) 买不起了很多人第一反应是让注意力变得稀疏。滑动窗口注意力只看相邻 token这在局部依赖强的任务里很好用但一旦问题需要跨越很远的线索就会崩。稀疏注意力比如随机注意力、跨步注意力理论上能把复杂度降下来但工程实现非常不友好GPU 上的访存模式会被打散实际速度反而不如老老实实全量算。线性注意力是另一个方向。把 softmax 的核函数近似成 φ(Q)φ(K)^T就能把复杂度拉到 O(n)。问题是 softmax 的指数特性恰恰是注意力里最有信息量的部分近似的代价往往是效果稳定但不够惊艳。更重要的是现在主流推理引擎对标准的 FlashAttention kernel 优化得足够好线性注意力如果不做深度定制很难靠内核优势扳回一程。这里必须提一下 FlashAttention。它并没有把 O(n²) 变成 O(n)而是通过 tiling 和 online softmax把中间结果留在 SRAM 里反复计算避免把 n×n 的完整矩阵写回显存。换句话说它没有减少计算量但减少了显存访问。在实际部署里这比空谈复杂度更有用。我自己的经验是跑长序列优先考虑 FlashAttention 这类“减少搬运”的算子而不是一上来就换模型架构。2. 压缩模型记不住的就让它少记2.1 不是给图片压缩是给上下文信息“做减法”这里说的“压缩”不是图片压缩或者系统内存压缩那种概念而是把已经进入模型的上下文进行信息取舍。它的目的很简单让模型只保留当前任务最需要的线索而不是把每一条 token 都原封不动塞进显存。压缩路线粗分三种。方法思路优势缺点适合场景Token 丢弃直接删掉不重要的 token 或整段窗口实现简单速度快信息不可逆可能丢关键内容日志流、流式输入Token 合并把相似 token 做均值池化或聚类保留部分统计信息合并后语义可能被平均掉了长文本摘要、冗余文本定长记忆重编码用一个额外模块把历史压成固定向量显存恒定可控性强重编码模块本身有开销对话记忆、多轮会话Token 合并里面常见做法是对相邻 token 做池化或者按向量相似度聚类后再池化。这个思路听起来合理但要注意Attention 里的位置信息往往很敏感两个相近 token 合并之后它们的相对位置关系就没了。在某些任务里影响不大在另一些任务里会直接表现为“模型开始复读”。定长记忆重编码则是把整段历史压到一个固定长度的记忆张量里再拼接到后续层的输入中。好处是显存占用不会随历史长度增长坏处是固定长度天然有容量上限长到一定程度再压就全是损耗。2.2 压缩后的信息该怎么找回来压缩最大的隐患是“压掉了刚好要用的信息”。所以真正可靠的长上下文系统通常不会只靠压缩还会配一条检索路径。这条路径在工程上的实现方式是把 KV Cache 当“文档库”用为每一段历史 tokens 建索引等当前 token 的 query 生成后去索引里检索最相关的 K、V 块再把它们临时拼到当前注意力计算中。这里有个细节值得注意拼进去的只是 K 和 Vquery 始终是当前 token 自己的。所以检索回来的历史块在计算上和连续上下文没有本质区别只是来源不同。这种按需召回的做法在长文档问答场景里往往比全量压缩更好。我做超长文档问答的实测结果是直接滑窗丢历史准确率会掉 10% 以上但滑窗加 top-k 检索召回准确率基本能和全量上下文持平显存占用却少一个量级。2.3 压缩位置和“中间遗忘”现象很多研究表明模型对长文档中间部分的信息利用效率低这就是常说的 lost in the middle。压缩策略不能无视这个规律。如果你决定压缩优先压中间段落比压开头和结尾更稳。原因并不复杂开头往往承担全局背景铺垫结尾往往藏着局部结论这两个区域对大多数任务更宝贵。所以我的建议是做总结类任务保留开头和结尾中间尽量做合并而不是直接丢做问答类任务直接上检索靠压缩兜底只适合显存实在不够的情况。3. 缓存KV Cache 的显存账本3.1 KV Cache 是怎么占显存的这里说的“缓存”不是浏览器缓存也不是 Spring 三级缓存而是 Transformer 推理里特有的 KV Cache。它的含义很直接prefill 阶段算好的 K 矩阵和 V 矩阵在 decode 阶段反复复用所以被缓存下来。代价就是显存增长得非常快。KV Cache 大小的计算可以列成一条公式KV Cache 大小 ≈ 2 × 层数 × KV头数 × 每头维度 × 序列长度 × 数据类型大小公式里的“2”是因为 K、V 各一份。如果是 MHA 架构KV头数 × 每头维度之和通常等于 hidden_size如果是 GQA这个值会变成共享的 KV 头对应的维度比 hidden_size 小很多。举个例子一个 7B 级模型32 层hidden_size 4096fp16 精度每个元素 2 字节序列长度拉满到 32K。KV Cache 大约是2 × 32 × 4096 × 32768 × 2 ≈ 16GB也就是说光 KV Cache 就能吃掉一块中高端显卡的全部显存模型权重和激活值还没算进去。这就是为什么长上下文跑起来最先爆掉的位置永远是缓存而不是权重。3.2 显存不够先动缓存的三板斧第一板斧是 GQA / MQA。让多个 query 头共用一个 key/value 头KV Cache 直接除以一个 group size。大多数新发布的模型都自带 GQA但很多推理服务默认配置没有充分利用它值得检查一下。第二板斧是 KV Cache 量化。把 fp16 降到 int8 甚至 int4KV Cache 占用直接减半甚至更多。很多人在这一步犹豫怕质量损失。我的实测体验是int8 量化在大多数长文档任务里准确率掉得很少收益却很大——省出来的显存可以开更大的 batch整体吞吐反而更高。int4 需要更谨慎针对具体数据集做校准。第三板斧是让缓存分配更合理。PagedAttention 的思路借用了操作系统分页把 KV Cache 分成小块按需分配不要求物理连续。这样能显著减少显存碎片同时为跨页调度打基础。后续第 4 节展开讲。还有一个经常被忽视的点Prefix Caching。多轮对话或者多个类似请求如果共享同一段 prompt 前缀可以把这段前缀的 KV Cache 缓存起来复用而不是每次都从头 prefill。常见做法是给 prompt 前缀算哈希命中就直接加载缓存。这个优化在 agent 类应用里收益极其明显因为 system prompt 往往很长且固定。3.3 缓存失效修改上下文的代价和所有缓存系统一样KV Cache 也有失效问题。在服务端推理里如果上下文的顺序被修改过——比如 agent 工具调用返回后你在中间插入了一段新消息——那么从插入位置往后的 KV 全部失效只能重新 prefill一次多轮对话的开销可能翻好几倍。这个问题没有银弹工程上需要从 prompt 设计层面规避。我的经验是尽量保持上下文是 append-only 结构新信息一律追加到末尾如果一定要做插入修改那就做好整段重新 prefill 的心理准备和性能预算。多轮对话服务建议显式支持 prefix cache这样即使中间修改也能至少复用前面的公共前缀。4. 跨页推理显存放不下时的最后一根稻草4.1 跨页推理到底在解决什么问题“跨页”这个词在学术论文里不常见它更像工程术语。推理引擎为了高效管理 KV Cache会把它切成固定大小的块通常叫 block 或者 page每块可能装 16 个或 32 个 token 的 KV。跨页推理指的就是上下文的 KV 被分散到多个内存页甚至分散在 GPU 显存、CPU 内存、远端存储之间当计算需要某个 token 的 KV 时通过页表和调度逻辑把它换回计算设备。为什么需要跨页因为单张卡哪怕有 80GB 显存也顶不住非常长的上下文加高并发。把一部分 KV 放到 CPU 内存或远端缓存里能突破单设备物理限制。代价是数据搬运延迟。这条路不是最快的路线但它是“能跑”和“不能跑”的区别。4.2 落地跨页推理的三个关键点第一页表与块状态管理。每个页要能记录自己存储在哪个设备、是否已被换出、最近一次访问时间。类似操作系统的页表只是换页的单位从 4KB 变成了包含若干 token 的 KV 块。第二预取。这是跨页推理效率高低的核心。不能等计算真正访问到某一页时才去搬那一步只能是 page fault等待时间会完全暴露在生成延迟里。正确做法是根据当前 query 和历史访问规律预测下一步可能用到哪些页提前搬进 GPU。不要小看这一步预取命中率直接决定整个系统的性能。第三异步重叠。把数据搬运放到独立的 CUDA stream 上和计算重叠执行。如果搬运和计算是串行的跨页方案几乎救不回来如果异步并行延迟可以被大部分掩盖。还有一个在工程上很好用的思路选择性加载。不是每次 attention 都读全部页而是挑选“最近窗口”加“top-k 检索”的页参与计算。这个方案在长文档问答里非常稳因为真正被用到的历史其实很少。4.3 一组直观的数量级感受用 PCIe 4.0 x16 作参考峰值带宽大约 32GB/s。假设你要把 1GB 的 KV 从 CPU 搬到 GPU理论耗时至少 30 毫秒实际还要打折。如果每一步 decode 都搬这么多生成一个 token 慢十倍不止。但如果每步只搬少量页比如一个页 1MB几十个页也就几十 MB搬到 GPU 的成本可以在毫秒级。再配合预取和异步重叠用户基本感知不到。所以跨页推理要比“整块 offload”精细得多。我测试时对比过全量 CPU offload 加串行搬运单 token 延迟好几秒改成“滑窗 top-k 检索 异步预取”之后单 token 延迟压到了几十毫秒量级完全不一样。5. 长上下文优化的常见问题与工程建议5.1 先判断你处理的是哪类长上下文任务不同任务对长上下文的使用方式完全不同优化策略必须分开看。任务类型典型场景推荐优化方向全量长文档处理长文档总结、全文分析全量 attention重点上 FlashAttention 和高效显存管理长会话/多轮对话Agent、客服机器人前缀缓存、GQA、KV 量化长文档问答知识库问答、论文问答检索 稀疏读取显存压力最小大规模并发长上下文高并发 API 服务PagedAttention、跨页调度、远端缓存我的建议是先把任务定性再做优化不要盲目上全套机制。比如你只是做长文档问答压缩和检索的性价比远高于把整条上下文硬塞进显存。5.2 实测里的几个反直觉经验第一KV Cache 量化到 int8 带来的准确率下降通常比想象中小。省出来的显存如果用来扩大 batch整体吞吐收益非常可观。甚至在一些任务里量化后的结果还更稳定猜测是去掉了部分过拟合噪声。第二“丢中间”确实比“丢开头和结尾”稳。这和 lost in the middle 的观察是一致的中间本来就是利用率偏低的区域压缩它对最终任务的伤害相对有限。第三不是所有场景都适合用最新的 FlashAttention 内核。如果序列不够长kernel launch 开销、尾数问题、甚至算子版本的数值差异都可能拖后腿。优化前一定要先 profiling找准瓶颈是计算、显存还是数据传输。5.3 常见问题速查表现象可能原因排查方向解决方案prefill 阶段显存爆掉QK^T 中间矩阵过大看显存峰值出现在哪个阶段用 FlashAttention按 chunk 切分 prefill降 batchdecode 越跑越慢每步都遍历全部 KV Cache看单 token 延迟随长度曲线上稀疏注意力或检索升级 kernelCPU offload 后速度极慢每步都搬整块 KV且串行等待确认搬运量和耗时页级预取只搬需要的页搬运与计算异步多轮对话每次都重新 prefill没启用 prefix cache看重复 prompt 前缀的耗时开 prefix cache前端固定 system prompt 顺序压缩后答案质量骤降压掉的位置恰好是关键信息对比不同压缩位置的准确率保留开头和结尾改用“滑窗 检索”再分享一个小技巧调试长上下文服务时不要只盯显存占用要把“每 token 延迟”和“显存峰值出现阶段”两个指标一起打出来。很多问题不是显存不够而是某个阶段触发了大量中间分配或数据搬运表象是 OOM底子是调度不合理。我个人在实际操作中的体会是长上下文优化没有银弹但有一条主线可以抓让每一层注意力只访问它真正需要的那一小部分历史。无论是压缩、缓存还是跨页最终都在为这个目标服务。能把不需要的注意力关掉就已经赢过大多数人。