Compaction:保真还是速度,这是一个问题
0. 太长不看
上下文越长,出字越慢,而 compaction 不是”把前文总结一下”,是一笔用信息保真度兑换生成速度的交易。
我自己的会话里,上下文堆到 300k,瞬时速度从 200 tok/s 掉到 50 tok/s,只剩基准的四分之一;瓶颈不在算力,在显存带宽,每生成一个 token 都要把整份 KV Cache 从显存搬一遍。
DeepSeek 的 MLA 把 KV Cache 基线压低了几倍到一个数量级,同样长度下带宽压力小得多。
1. 一次真实会话里的掉速
之前一直以为上下文压缩(compaction)就是”把前面的对话总结一下,给模型一个更干净的 context”。这个理解不算错,但只对了一半。
起因是我最近把 codex 接进了 dsh。dsh 的界面会实时显示 TPS(tokens per second),盯着这个数字干活是件很有意思的事,而且也是件破除幻觉的事。
第一个 step,速度大约 200 tok/s,指哪打哪。随着任务推进、上下文堆积:
- 跑到 100k context 上下,状态栏均速只剩约 100 tok/s;
- 到 300k context 上下,整体均速骤降到 69 tok/s。
均速是把早期快的部分摊进来算的,真实瞬时速度比这更惨。粗略推断:
| 上下文长度 | 瞬时 TPS(推断) | 相对基准 |
|---|---|---|
| ~0(第一个 step) | ~200 tok/s | 基准 |
| ~100k | ~80 tok/s | -60% |
| ~300k | ~50 tok/s | -75% |
先把事实口径钉清楚,免得被杠:这是单一真实工作负载下的观察(codex 接入 dsh,具体模型版本和后端硬件都不可见),不是控制变量的 benchmark,数字请按量级理解。但也正因为来自我的真实负载,它比实验室里固定 prompt 长度的测试更接近你日常用 agent 的体感。
速度掉成这样,引起了我的兴趣和思考,思考后再看 compaction,视角就完全变了。而且同一个 harness 里用 DeepSeek V4.1 Flash 时,我几乎没注意到类似量级的减速——dsh 甚至默认 800k 才 compact。
先把这篇的立场说在前面:我只想解释这条曲线为什么长这样,不打算给操作建议。降速算不算一个问题,取决于你在干什么,如果你不赶时间,或者你的用户对于速度并不是非常敏感,它就更像一个值得搞明白、但不值得动手处理的物理现象。我把它记下来,是因为弄清楚之后再看 compaction 的各种设计取舍,会顺很多。
2. 为什么上下文越长,输出越慢
你感知的输出速度是 decode 的速度
LLM 推理分两个阶段。Prefill(预填充)会一次性并行处理你的整个输入序列,把每层需要的 Key/Value 算出来并缓存起来,同时生成第一个输出 token。这个阶段矩阵乘法很多,属于计算密集型,通常决定你等第一个字要多久。
真正决定输出速度的是 Decode(解码):模型一个一个往外生成 token。每生成一个 token,都要用当前 token 的 Query,去和每一层缓存的历史所有 token 的 Key/Value 做注意力计算。模型处理过的每个 token,在每一层都会留下一份 Key 和 Value,也就是 KV Cache,相当于边读边记笔记。之后每写一个字,都要把各层笔记里的历史 K/V 拿来算一遍。因为 Decode 必须逐 token 串行生成,所以你在流式输出时感受到的”出字速度”,主要就是 Decode 的速度。
这些 KV Cache 放在 GPU 的 HBM 显存中。上下文越长,缓存的 Key/Value 数量越多,KV Cache 大小大致随上下文长度线性增长;Decode 每一步都要读取全部历史 K/V,因此上下文越长,显存占用和 HBM 带宽压力越大,出字速度越容易下降。
瓶颈从算力换成了带宽
对 decode 的每一步来说,单步计算量并不夸张,但算术强度很低:新 token 的 Query 要和每个历史 token 的 Key 做点积,再用结果对 Value 加权求和。真正的大头,是每一步都得把历史所有 token 的 K/V 从 HBM 显存读进计算单元。上下文越长,每步要读的数据越多。当读取数据的时间超过计算时间,decode 就从算力瓶颈转为显存带宽瓶颈。
粗算一笔账(数量级示意):一个采用 GQA 的主流大模型,每个历史 token 在每层约占 4 KB KV Cache(2 × 8 个 KV 头 × 128 维 × 2 字节 fp16)。几十层叠起来,每 token 约数百 KB;到 300k 上下文时,仅 KV Cache 就有几十到上百 GB。以 2~3 TB/s 的 HBM 带宽计,在这个长度下每生成一个 token,光读取 KV Cache 就要二三十毫秒——对应每秒几十个 token 的量级上限。
(GQA 已经比标准 MHA 小了几倍:MHA 的 KV 头数和 Q 头数一样多,KV Cache 体积大得多。这里用 GQA 举例,是因为它更接近当前主流大模型的实际配置。)
这和实际观测到的吞吐量级正好吻合。
(真实服务还有很多变量:batching、并行切分、投机解码、网络……这个估算只说明瓶颈的形状,不做精确预测。)
衰减形状不是纯反比,更接近:
v(n) = 1 / (a + b·n)
其中 a 是每步固定开销,主要来自读取模型权重等,对应短上下文下的速度上限 1/a;b·n 是读取 KV Cache 的时间,随上下文长度线性增长。上下文短时 a 主导,速度接近基准,随长度下降很慢;上下文越长,b·n 占比越高,速度越接近 1/(b·n) 的反比衰减。
顺带修正一个直觉:这条曲线掉得最快的是前段——v’(n) 的绝对值随 n 单调递减,所以是先陡后缓地往下滑;300k 的冲击不在”下坠在加速”,而在累计水平:到那时瞬时速度已经只剩基准的约 1/4。
三个观测点并不严格落在同一条模型曲线上,所以不假装是三点联合拟合:拿前两个点定参——a≈5 ms(由 200 tok/s 的基准直接给出)、b≈0.075 µs/token(由 100k→80 tok/s 给出)——再用模型检查剩下的观测:300k 瞬时预测约 36 tok/s(推断值 50,模型偏悲观约三成);两条均速预测约 114 和 61(实测约 100 和 69,偏差都在 ±15% 以内,均速按上下文随生成近似线性增长折算)。量级都对得上——”HBM/显存带宽瓶颈”这个解释是自洽的,不是脑补。
(拟合仅作量级验证,不做精确预测。)
Compaction 到底是什么
先钉一下术语——这三个词经常被混用,这里按一种常见用法来分,它们大致是包含关系:compaction ⊂ 上下文压缩 ⊂ 上下文管理。
- 上下文管理是最大的圈:agent 运行中所有”上下文里放什么、留什么、何时瘦身”的决策——工具结果写不写进去、哪些放外面用 RAG 检索、何时开新会话,都算;
- 上下文压缩是其中一个子类:把已经占着上下文的信息变小,提示词压缩、滑动窗口、历史摘要都在这个圈里;
- compaction 是压缩里最贴 agent 场景的一个具体动作:gent harness 在运行中把很长的会话历史压成一段摘要,然后用这份摘要重建上下文,KV Cache 随之大幅缩小——后续 decode 每一步的搬运量骤减,速度明显回来。
本文讨论的就是最里面那个圈。
所以”compaction = 更干净的 context”漏掉了另一半。它本质上是一笔交易:牺牲历史信息的保真度,兑换生成速度。而且这笔交易不是免费的:生成摘要本身要先把旧上下文读一遍,那次 prefill 很贵,用户感知是”卡一下,然后变快”。
200k 这个业界常见的推荐阈值,大致对应主流架构下”KV Cache 还没大到把 decode 拖垮”的量级区间,不是精确的物理分界。
对 coding agent 用户来说,这事尤其相关:长任务容易把上下文堆到十几万甚至更高,正好落进衰减曲线的后半段——那里下坠已经放缓,但速度也早已明显低于基准。各家默认的 compaction 阈值,大致就落在这条曲线上速度、成本、保真度三者折中的那一点附近;把它调大,本质上就是多花一些 decode 的时间,换回一点历史信息的保真度。这笔账划不划算,取决于你多在乎那点时间。
3. 那 DeepSeek 凭什么稳?
回到开头那个对照:同一个 harness 里,DeepSeek V4.1 Flash 几乎没出现这个量级的减速,dsh 也敢把 compaction 默认阈值放到 800k。架构上差在哪?
先坦白:我对 V4.1 Flash 只有使用体感,没有系统的对比实测。但 DeepSeek 这条架构路线是公开的,足够把逻辑讲清楚。
MLA:把笔记微缩
DeepSeek 自 V2 起用 MLA(Multi-head Latent Attention,多头潜在注意力)替换了标准注意力实现,V3、V4 系列延续并迭代。它的思路和 GQA 那种”少记几份笔记”不一样:MLA 把每层的 key/value 联合压缩进一个低维的潜在向量里缓存,做注意力时再从这份压缩表示中还原出需要的 K、V(实现上靠矩阵吸收避免真的展开,逻辑上等价)——相当于把整本笔记微缩成一张胶片,用的时候现场放大。
效果有多大,得看跟谁比——对照基线不同,比值差很多:
- MLA vs MHA:DeepSeek-V2 论文的官方口径是 KV Cache 压缩 93.3%(只剩约 6.7%,即约 15×),最大生成吞吐提升至 5.76 倍,注意力质量没有损失。这是”一个数量级”的来源。
- MLA vs GQA:GQA 自己已经比 MHA 小了几倍。按单层 fp16 口径粗算,GQA-8(8 KV 头 × 128 维)约 4 KB/层/token,MLA 潜向量(512 维 + RoPE 相关维度)约 1.1 KB/层/token——比值只有 3~4 倍,不是数量级。只把 MLA 一侧换成 fp8 比,比值能拉到 6~7 倍,但那是跨精度对比,不能算成架构本身省下来的量。
到 V4.1 Flash 这代,上下文窗口标称 1M,这条有官方 config.json 背书(max_position_embeddings=1048576,YaRN 外推,见参考来源 2);公开资料里的全局常驻 KV Cache 是每 token 不到 1 KB 的量级。这类数字的统计口径不好钉死(是单层还是全模型?有没有算上所有辅助缓存?),所以不拿它和 GQA 的”每 token 数百 KB”直接比。
但有一点是确定的:MLA 路线的 KV Cache 基线比 GQA 还小几倍,带宽红线来得更晚。
回到那条带宽红线:MLA 的 KV 基线更小,同样的上下文长度下搬运量更小,红线就被推后一大截。在 1M 窗口内,KV 搬运甚至可能始终不是主导瓶颈——dsh 敢默认 800k 才 compact,侧面印证的就是这个判断。
非 MLA 路线在为 KV 搬运付税;这条路线先用 MLA 把税基砍掉了几倍到一个数量级(取决于你拿什么当基线),再用稀疏注意力和滑窗,让每步实际要交的税不再随长度增长。
但”稳”不只是 MLA 的功劳
把上一节的模型再看一眼:v(n) = 1 / (a + b·n) 里,MLA 只做了一件事——把 b 变小。b 变小意味着曲线整体右移:同样长度下速度更快、带宽红线来得更晚,但形状还是那条曲线,长度继续涨,速度照样往下掉。要解释”堆到 800k 都几乎没感觉”,光有 MLA 不够,还得有机制让每步的读取量不再随上下文长度线性增长。
V4.1 Flash 的官方 config 里,除 MLA 之外还摆着几类东西(下表按字段字面整理,官方没有逐条解释语义):
| 机制 | config 里的字段 | 对 decode 每步搬运的作用 |
|---|---|---|
| MLA 本体 | num_key_value_heads=1head_dim=512qk_rope_head_dim=64 |
每层每 token 只留 576 个值,把常量压小 |
| 分层压缩 | compress_ratios(按层给 2 / 1 / 0 的压缩档位) |
部分层的 K/V 再按 2 倍压缩 |
| 滑窗 | sliding_window=128 |
滑窗层只读最近一小段,读数与 n 无关 |
| 稀疏索引注意力 | index_topk=512index_source_layer_idsindex_n_heads=32index_head_dim=128candidate_topk_blocks=2048candidate_block_size=8 |
完整注意力只在选出来的一小撮 token 上做,主项不再随 n 增长 |
| 跨层 KV 复用 | kv_source_layer_ids=[2, 8, 14, 20]( num_hidden_layers=40) |
字面看像只有 4 层存 K/V、其余层复用,常量再小一截 |
把这些字段拼起来,一条说得通的路线是:大部分层用滑窗或压缩处理,只在少数层做全局注意力;做全局注意力的那几层,先用一个轻量 indexer 从历史里选出 top-k 个 token,完整注意力只在这些 token 上算。这样每步真正要读的完整 K/V 是一个近似恒定的量,随 n 增长的只剩那次轻量索引扫描——按 index_n_heads=32 × index_head_dim=128 算,每个源层每 token 是 4096 个索引键,index_source_layer_ids 列了 8 层;同规格 GQA 叠满 40 层的完整 K/V 是每 token 8 万个值量级,索引项只有它的四成上下。这个线性项还在,只是斜率低得多,要到很长的上下文才开始主导。
怎么把上面这些加总成一个”每 token 多少字节”,取决于字段的确切语义(比如那 4 个源层是”只有它们存 K/V”,还是只是 K/V 的来源标记),这里不硬凑总数——这也正是”每 token 不到 1 KB”那个口径对不上的原因。但结论方向是清楚的:V4.1 Flash 不是一个”纯 MLA”模型,MLA 只是这套组合里最出名、也最容易被单独复用的那一环。它负责把 KV 基线压低,稀疏、滑窗和跨层复用负责让这条线不再陡峭;1M 下还能稳住,是整套机制的结果。
4. 长上下文三角:速度、保真、成本
长上下文的问题可以归到三个顶点——速度、保真、成本。
KV Cache 这笔开销有两个维度:打在延迟上,是本文主线的 TPS 衰减;打在钱包上,是显存占用、并发能力和计费。再加上在这个过程中尽量保证上下文的真实性,三个顶点齐了。三者互相顶牛,把任何一个推到极致都会挤压另外两个,这就是常说的长上下文”不可能三角”。
但先和 CAP 那种定理级的”三选二”区分开:它不是三选二——短上下文时三者明明兼得,三角只在长上下文区间收紧。更准确的说法是同一份 KV Cache 上的三方拉扯:
-
速度:每步 decode 都要从 HBM 读取历史所有 token 的 K/V,读得越多,显存带宽压力越大,TPS 越低;
-
成本:这些 K/V 要一直占着显存,上下文越长,占用越大,能塞下的并发请求越少,计费也越高;
-
保真:压缩、摘要、滑窗都会损失历史信息,压得越狠,保真度越低。
架构给定,三者此消彼长;架构演进(比如 MLA)移动的不是三角内的选点,而是整条边界。
长上下文的工程分水岭主要在两个维度:一是检索与因果推理在超过 64k后的召回保真度(有效缓解大海捞针与中间迷失问题),二是 KV Cache 的显存开销。单纯把上下文窗口标称拉长没有技术壁垒,长文本下还能保持低时延和长程状态一致性才是真正的架构门槛。
那两个维度对上了保真和成本两个顶点,但没有把速度单独拎出来——而速度是本文主线,也是长会话用户在体验上最容易感受到的。
| 顶点 | 问题 | 一句话机制 |
|---|---|---|
| 速度 | Decode 带宽瓶颈 | 每生成一个 token 都要把整份 KV Cache 搬一遍 |
| 速度 | Prefill 平方墙 | 注意力计算随 prompt 长度平方增长,首字延迟爆炸;前缀缓存一旦失效(比如改了对话前部的内容),改动点之后全部重算——时间重等,token 重付 |
| 保真 | 召回衰减 | 64k 之后捞针成功率、中间位置信息的利用明显下滑;结构性原因之一是位置编码:多数模型的长窗口靠 RoPE 外推”撑”出来,超过训练见过的长度后,衰减并不平滑 |
| 保真 | 注意力稀释 | 捞到针不等于用得上:跨位置的多跳推理、对开头指令的遵循,随长度退化得更早,还更容易被无关内容带偏 |
| 保真 | 错误自污染 | 早期误读和失败的工具调用留在上下文里,被后续步骤反复引用放大——不压,错误累积;压了,细节丢失 |
| 成本 | 显存与并发 | KV Cache 每 token 数百 KB,直接压单卡并发和 batch size,最终转嫁成排队和单价 |
| 成本 | 缓存经济学 | 多轮对话每轮都要为整份历史付费,prefix cache 命中率决定账单大小 |
三角之外还有几个横切约束——它们不是第四个顶点,而是”这个三角为什么不能被轻易撑大”的原因:
-
训练侧:真正含长程依赖的语料稀缺,拼接长文档只教会检索,教不会跨段推理;
-
安全:窗口越大,prompt 注入和语料投毒的藏匿面积越大;
-
读写不对称:输入窗口膨胀到 1M,输出上限和长篇连贯性还在原地。
看懂这个三角,缓解方案的位置也就清楚了:它们不消除开销,只在三角里选边——compaction 用保真换速度,各种记忆方案把成本卸给外部存储,滑窗、KV 量化、稀疏注意力都是同一思路的变体。
MLA 这条路线是少有的例外:它不在三角里移动选点,而是直接把三角形撑大——KV Cache 的体积本身缩小几倍到十几倍,配套的稀疏注意力和滑窗又让每步的读取量不再随长度线性增长(§3),上一节那条带宽红线就是这样被推后 的。
我这次先撞上的是速度。但上下文一长,生成变慢、成本变高、保真变差会同时发生,速度只是最先被感受到的那个。
5. 结语
这次观察改了我两个判断。
compaction 不是”把前文清干净”的礼貌动作,是一笔交易:用历史信息的保真度换 decode 的速度。200k 那个常见阈值也不是拍脑袋定的,它大致落在主流架构的 KV Cache 还没把显存带宽吃满的位置上。
“长上下文能力”同样不能只看标称窗口。拉长窗口没有壁垒,真门槛是长文本下还守不守得住时延和状态一致性——那条平缓的曲线是 MLA、稀疏注意力、滑窗和跨层复用一起撑出来的,不是哪一项技术的功劳。
降速本来就不算一个必须解决的问题。我记下来,是因为它把”长上下文”从一个标称数字,变成了一笔算得清的账。
参考来源
- DeepSeek-V2: A Strong, Economical, and Efficient Mixture-of-Experts Language Model(MLA 首发论文):https://arxiv.org/abs/2405.04434
- DeepSeek-V4.1-Flash 官方 config.json(1M 窗口与 YaRN 外推等架构参数口径已据此核实):https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash/blob/main/config.json
- Lost in the Middle: How Language Models Use Long Contexts(召回衰减/中间迷失的代表性研究):https://arxiv.org/abs/2307.03172