有人把 2017 年的原版 Transformer 和 2026 年的 DeepSeek V4.1 Flash 并排画成了两张等轴立体图:左边 6 encoder + 6 decoder,右边 20 encoder + 20 decoder,右边那一列还标着
CSA2 · Full、CSA2 · Reindex、Engram ×2、DSpark drafts、2:1 compression这些左边完全没有的词。我一开始以为这是张科普图——名词摆得整齐,但没有一个数字。结果把官方
config.json拉下来一对,发现图上每一个标签都精确对应配置文件里的一个键,连×5、×3这种分组倍数都对得上。于是这篇文章做的事是:顺着图上的标签把配置文件读完,最后手算出官方公布的890 B/token——个位数吻合。
名词速查
| 术语 | 一句话解释 |
|---|---|
| KV cache | 注意力算过的 K/V 中间结果存下来,避免给已生成的 token 重算一遍;代价是显存随上下文线性涨(那笔账我算过) |
| prefill / decode | 推理的两个阶段:一次性读完整段输入 vs 之后逐个 token 往外吐(vLLM 里怎么调度的) |
| 潜在注意力(latent / MLA 路线) | 不存原始的 K 和 V,存一个低维向量,用时再投影回去。缓存里那一份低维向量就是全部开销 |
| MoE / 激活参数 | 每层有很多个 FFN「专家」,每个 token 只走其中几个。激活参数=这个 token 实际乘过的权重量,远小于总参数量 |
| 稀疏注意力 top-k | 不让 query 看全部历史,只挑分数最高的 k 个位置。挑的过程由一个小网络(indexer,索引器)完成 |
| SWA(滑动窗口注意力) | 只看最近 n 个 token 的注意力,n 固定,所以开销不随上下文增长 |
| 投机解码 | 用便宜的方式先猜几个 token,再让主模型一次性验证,接受了就白赚(SGLang 里的实现) |
| FP4 / FP8 与 scale | 把数值压到 4 位或 8 位存。压得狠了动态范围不够,所以每若干个通道还要额外存一个缩放系数(scale)——这个额外开销待会儿要算进账里 |
本文的数字分三类,我在每处就地标注:我手算(附脚本)、配置文件实读(config.json / inference/model.py 原文)、官方报告值我未复现(model card 的 benchmark)。
一、图上直接能读到的:变了什么
先把二手的部分快速收掉。左右对照,九年里换掉的东西:
| Original Transformer(2017) | DeepSeek V4.1 Flash(2026) | |
|---|---|---|
| 层数 | 6 + 6 | 20 + 20 |
| 编码器性质 | 双向,能看全文 | 因果(causal encoder),只能往前看 |
| 解码器怎么读编码器 | cross-attention | 直接读编码器末层投影出的全局 KV |
| FFN | 一个 dense FFN | MoE:384 routed + 1 shared,每 token 选 6 |
| 注意力 | 全连接 softmax | CSA2 三态:Full / Reindex / Reuse |
| 输入 | 单一 token 流 | Vision + Text → Projector → 多路输入流 |
| 输出 | Linear + Softmax | Output head + DSpark 草稿头 |
| 图上没有对应物的新部件 | — | Engram 条件记忆 ×2、SWA、Candidate pool |
最该注意的是第二、三行:编码器变成因果的之后,encoder-decoder 这个 2017 年的老结构被重新启用了,但 cross-attention 没回来。图上「Encoder output」那个方块直接连向解码器一侧,而不是像左图那样从编码器顶端拉出一条 K, V 接到每个解码器层的 cross-attention 上。
这个差别是整篇文章的支点。
二、图里没写的第一件事:40 层里只有 4 层在往缓存里写东西
图上的标签不是起名字,是 config 键。我把官方 config.json 拉下来做了个映射:
curl -sL "https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash/raw/main/config.json" -o config.json
| 图上标签 | config.json 里的键 | 实际值 |
|---|---|---|
| 20 encoder + 20 decoder | num_hidden_layers | 40 |
| 2:1 compression(编码器侧) | compress_ratios[2:20] | 全是 2 |
| 1:1 compression(解码器侧) | compress_ratios[20:40] | 全是 1 |
| CSA2 · Full | kv_source_layer_ids | [2, 8, 14, 20] |
| CSA2 · Reindex | index_source_layer_ids 去掉上一行 | [24, 28, 32, 36] |
| CSA2 · Reuse | 剩下的 32 层 | — |
| Engram ×2 | engram_layer_ids | [1, 14] |
| SWA | sliding_window | 128 |
| Candidate pool | candidate_source_layer_id / candidate_topk_blocks | 20 / 2048 |
| DSpark drafts | num_nextn_predict_layers / dspark_block_size | 3 / 5 |
顺着这张表能把整个 43 层的排班表还原出来(40 主干 + 3 草稿层,compress_ratios 正好 43 项):
| 层号 | 位置 | 压缩比 | CSA2 模式 | 附加部件 |
|---|---|---|---|---|
| L0–L1 | 编码器 | 0(纯 SWA) | — | L1 有 Engram |
| L2 | 编码器 | 2 | Full | |
| L3–L7 | 编码器 | 2 | Reuse ×5 | |
| L8 | 编码器 | 2 | Full | |
| L9–L13 | 编码器 | 2 | Reuse ×5 | |
| L14 | 编码器 | 2 | Full | Engram |
| L15–L19 | 编码器 | 2 | Reuse ×5 | |
| L20 | 解码器 | 1 | Full | 建候选池 |
| L21–L23 | 解码器 | 1 | Reuse ×3 | |
| L24 / L28 / L32 | 解码器 | 1 | Reindex(各自后跟 Reuse ×3) | |
| L36 | 解码器 | 1 | Reindex | |
| L37–L39 | 解码器 | 1 | Reuse ×3 | DSpark 的读取点 |
| L40–L42 | 草稿 | 0(纯 SWA) | — | DSpark 三级 |
编码器 = SWA×2 + [Full + Reuse×5] × 3,解码器 = [Full + Reuse×3] + [Reindex + Reuse×3] × 4。
图上那些 ×5、×3、×4 的分组倍数,就是这两个式子。 编码器右侧标的 ×5 和 ×3,对应「每个 Full 后面跟 5 层 Reuse」和「这个块重复 3 次」;解码器侧的 ×3 和 ×4 同理。这不是画图的人随手写的装饰。
一句话概括 CSA2:Full 存 KV 也存索引键,Reindex 借上游的索引键重算 top-k,Reuse 连算都不算,直接沿用上一层挑好的 top-k。 参考实现里的注释把这件事说得比 model card 更直白(inference/model.py:82):
layers sharing a ratio also share one compressed KV and one indexer, produced by the first
三、890 字节是怎么凑出来的(我手算,和官方数字个位数吻合)
model card 只给了结论:全局 KV 缓存 890 B/token,约为 V4-Flash 的 1/4、V1 的 1/437。这个数可以自己算出来,需要两个来自参考实现的事实:
事实一:每个「压缩位」的主 KV 占多少字节。 head_dim = 512,用 FP4(E2M1,即 1 位符号 + 2 位指数 + 1 位尾数,每个数 0.5 字节)存,每 16 个通道配一个 E4M3 格式的 scale(1 字节)——分组这么细是因为 FP4 只有 16 个可表示值,动态范围极窄,scale 稀了就会溢出。见 inference/model.py:760:
# Compressed KV uses groups of 16 with E4M3 scales; the indexer uses 32 with E8M0.
fp4_act_quant(latent, 16, True, scale_dtype=torch.float8_e4m3fn)
所以 512 × 0.5 + 512 / 16 × 1 = 288 字节。
事实二:索引器的 K 也要存。 index_head_dim = 128,同样 FP4,但每 32 通道一个 scale:128 × 0.5 + 128 / 32 × 1 = 68 字节。
一个 Full 层因此占 288 + 68 = 356 字节/压缩位。编码器的压缩比是 2(两个 token 合成一个压缩位),所以摊到原始 token 上是 178 字节;解码器压缩比 1,就是 356 字节。
L02 ratio=2 → 178 B/token
L08 ratio=2 → 178 B/token
L14 ratio=2 → 178 B/token
L20 ratio=1 → 356 B/token
─────────────────────────────
合计 890 B/token ← model card 的数字
我用一次性脚本(v41calc.py,直接读 config.json 算,不写死任何中间值)验过,match=True。
我在这里算错过一次,而错法恰好是这张图会引导你犯的错
第一遍我算出来是 1162 字节,比官方多 272。因为我按图上的标签想:CSA2 · Reindex 既然要「重新索引」,那它总得有自己的索引键吧?于是我把 index_source_layer_ids 里全部 8 层都算了一份 68 字节。
翻开参考实现,一行就把这个假设否掉了(inference/model.py:500):
# the index keys are derived from the compressor's latent, so only a layer that
# compresses its own KV can produce them; every other indexer reads them from that layer's cache
self.owns_k = layer_id in args.kv_source_layers
owns_k 为假时,那个 k_cache buffer 根本不会被创建(model.py:517-525 整段在 if self.owns_k: 里)。也就是说 Reindex 层重算的是 query 侧和打分权重,K 是借 L20 的。去掉那 4 份多算的 68 字节(272 = 4 × 68),剩下正好 890。
这是我认为这张图最容易误导人的地方:Full / Reindex / Reuse 三个标签看着像「三档强度」,实际是「存 KV + 存 K」、「都不存但重算分数」、「什么都不做」——存储上是 1:0:0,不是 3:2:1。
下面这张图就是照着这个结论画的,顺着走一遍就是上面那笔账:
flowchart TD
A["输入:最长 1M token"] --> B["L0-L1 · SWA 窗口 128<br>环形缓冲,只留最近 128 个<br>0 字节/token"]
B --> C["L2 / L8 / L14 · Full<br>各写 356 B 每压缩位<br>压缩比 2 → 178 B/token"]
C --> D["编码器其余 15 层 · Reuse<br>沿用上游 top-k<br>0 字节/token"]
D --> E["L19 编码器末层隐藏态"]
E --> F["L20 · Full<br>写 356 B/token<br>并建 16384 位候选池"]
F --> G["解码器其余 19 层<br>Reindex 4 + Reuse 15<br>0 字节/token"]
G --> H["全局 KV = 3×178 + 356<br>= 890 B/token"]
顺便算清另外三笔账
激活参数 8B / 16B 的那个 2 倍是从哪来的。 model card 说 prefill 每 token 激活 8B、decode 激活 16B。按 config.json 的形状估算单层开销:注意力约 127M(q_lora_rank=1280、o_lora_rank=1024 的低秩投影),MoE 约 248M(6 routed + 1 shared,每个 5120 × 2304 三矩阵),合计约 374M/层。于是 20 层 ≈ 7.5B、40 层 ≈ 15.0B(我估算,比官方低约 6%,因为我没算 embedding、索引器的 Q 投影和 Engram 的投影)。
数量级和那个整齐的 2 倍都对上了,于是 CED 的算力分配就清楚了:prefill 基本只跑编码器这 20 层;decode 要跑编码器 + 解码器共 40 层。 之所以能这样,是因为解码器的全局 KV 是从编码器末层隐藏态投影来的,不依赖解码器自己每层的隐藏态——所以读 prompt 时解码器不必逐层跑全长,只需在最后那个 128 宽的窗口上把 SWA 状态重建出来,好接着往下生成(model card 原文如此,我按 compress_ratios 和缓存 buffer 的形状对照后认为一致)。
读输入按半个模型收费,生成输出按整个模型收费——这正是 agentic 负载想要的偏向(输入 token 通常比输出多一到两个数量级)。
候选池让深层索引器的成本与上下文脱钩。 candidate_topk_blocks = 2048、candidate_block_size = 8 → 候选池 16,384 个位置。L20 先把全上下文筛到这 16,384 个(model.py:583 的 select_candidate_blocks),L24/28/32/36 只在池内打分。1M 上下文下这是 64 倍的差距,而且池子大小是常数——上下文从 128K 涨到 1M,深层索引器的成本不变。
注意力实际有多稀。 解码器每个 query 看 sliding_window=128 个近邻 + index_topk=512 个压缩位 = 640 个位置。1M 上下文下是 0.061%(我手算)。
SWA 为什么不用落盘。 window_kv_cache 的形状是 (batch, window_size, head_dim)(model.py:663-668)——槽位数是 128 这个常数,不是序列长度,靠环形缓冲覆写。43 层加起来约 2.8 MiB/序列,与上下文长度无关。如果照朴素做法把 SWA 的 KV 也持久化,1M 上下文要 22 GiB(我手算)。这就是 model card 说的「SWA Bounded Replay」:需要时重放最近 n_win 个 token 重建,而不是存着。
四、三个只有翻源码才知道的细节
图上有标签,但标签背后的机制和你猜的不一样。
1. 2:1 compression 不是量化,是学出来的加权平均。 我最初以为这是把激活压到一半位宽。实际是沿 token 轴 把相邻 2 个 token 池化成 1 个潜在向量,而且权重是学出来的 softmax 门控,不是取均值(model.py:475):
kv = (kv * score.softmax(dim=2)).sum(dim=2)
还有个容易被跳过的细节:压缩比 > 1 时 wkv 和 wgate 都被提升到 fp32,而压缩比 = 1 时留在 bf16——注释说得很清楚,「ratio 1 是个普通投影,没有东西要池化」(model.py:444-448)。所以图上编码器写 2:1、解码器写 1:1,这两个标签底下跑的根本不是同一段代码路径。
2. DSpark 的 5-token 草稿块是「1 个真 token + 4 个噪声占位」。 图上「DSpark drafts」只是个方块。实际 dspark_block_size = 5,而构造输入时(model.py:1131-1132):
draft_input_ids = input_ids.new_full([input_ids.size(0), self.block_size], self.noise_token_id)
draft_input_ids[:, 0] = input_ids
5 个位置里只有第 0 个是真 token,后 4 个填 dspark_noise_token_id = 128799。整块一次前向出 5 个位置——这就是 model card 里「semi-autoregressive」的含义。
但块内并非全无顺序依赖。出 logits 时有个便宜的串行修正(model.py:1149-1153):
for i in range(self.block_size):
logits_bias, markov_embed = self.markov_head(output_ids[:, i])
logits[:, i].add_(logits_bias)
output_ids[:, i + 1] = sample(logits[:, i], self.temperature)
markov_head 是个 rank-256 的小头(dspark_markov_rank = 256):查刚采样出的那个 token 的 256 维嵌入,投回全词表当 bias 加上去。等于「一次大模型并行出块 + 一个廉价 bigram 头串行纠偏」——并行的速度,加一点自回归的连贯性。我没跑过接受率,所以这里只能说机制,说不了效果。
另外三个草稿层(L40–L42)的 compress_ratios 是 0,MoE 也缩到 128 专家选 3(dspark_n_routed_experts)。草稿层完全不碰全局 KV 缓存,只用 128 窗口的 SWA。
3. 残差流是 4 份并行的,混合系数过 Sinkhorn。 图上完全看不到这个。hc_mult = 4 意味着每层的残差流是 4 份并行副本(Hyper-Connections),注意力和 FFN 各自夹在「把 4 份收成 1 份」和「再展开回 4 份」之间,而展开时的混合矩阵被 Sinkhorn 迭代 20 次(hc_sinkhorn_iters = 20)变成双随机矩阵。
「Single-Pass」省在哪,注释里有答案(model.py:914):
The coefficients a sublayer computes are used by the next one
也就是说每个子层算一次混合系数,给下一个子层用,不需要为了拿系数再过一遍流——这才是「单遍」的含义。
五、为什么只能长成 20 + 20
根本约束:在 agentic 负载里,输入 token 比输出 token 多一到两个数量级,而单机能并发多少条长会话,由 KV 缓存的字节数决定,不由算力决定。
这句话可反驳,所以先说它不成立会怎样:如果上下文只有 4K,或者显存无限,CED 就毫无意义——你不会愿意为了省缓存把模型劈成两半、还让 decode 付 2 倍的激活参数。整套设计(编码器/解码器不对称、40 层里只 4 层存 KV、FP4 存缓存、候选池、SWA 不落盘)是同一个约束的五个推论,不是五个独立优化。
崩点在哪、差几个数量级。 换成最朴素的做法——每层存自己完整的 K 和 V,就像 2017 那张图左边——按 model card 的 437 倍算,V1 一代是约 380 KB/token。1M 上下文一条会话:V4.1 Flash 要 0.87 GiB,朴素做法要 0.37 TiB(我手算,基于 model card 的 437 倍)。先崩的不是速度,是一条会话装不进一台机器,差三个数量级。890 字节和 380 KB 的区别,是「单卡能跑几十条 1M 会话」和「一条都跑不了」。
共同祖先:这不是新发明,是老路子换了共享维度。 从 MQA 到 MLA,省 KV 的思路一直是「让缓存在某个维度上共享」——MQA 在注意力头之间共享,MLA 换成共享一个低维潜在向量。CSA2 只是把共享维度从头挪到了层:40 层共用 4 份缓存。参考实现的注释自己认了祖宗(model.py:94):
Names match DeepSeek-V3.2-Exp, where this mechanism first appeared.
而 encoder-decoder 本身就是 2017 那张图左边的东西。所以这两张图并排放的真正意思不是「进化了多少」,而是一个被 decoder-only 淘汰了七年的结构,因为约束变了(从「怎么建模翻译」变成「怎么扛住百万上下文的缓存」)又被捡回来,只是 cross-attention 换成了共享全局 KV。
六、收口:三行
- 约束:长上下文 agentic 推理的瓶颈是 KV 字节数,不是算力——所以架构的每个决定都在省字节。
- 崩点:朴素 MHA 在 1M 上下文下要 0.37 TiB/会话,一条都装不下;890 B/token 让它变成 0.87 GiB。差三个数量级。
- 可带走的动作:看到架构图上的标签,去翻
config.json对一遍——那些标签基本都是配置键,而配置文件会告诉你标签隐瞒的量级差别(比如Full/Reindex/Reuse在存储上其实是 1:0:0)。
误解 → 纠正表:看这张图容易得出的五个错结论
我自己中了第 1 条和第 3 条,列在这里给下一个看图的人。
| 看图会以为 | 实际情况(出处) |
|---|---|
CSA2 · Full / Reindex / Reuse 是三档递减的强度 | 存储上是 1:0:0。只有 4 个 Full 层有缓存 buffer,Reindex 层的 k_cache 根本没被创建(model.py:500) |
2:1 compression 是把激活压到一半位宽 | 是沿 token 轴把 2 个 token 池化成 1 个,权重是学出来的 softmax 门控,还跑在 fp32(model.py:475) |
| 编码器 20 层、解码器 20 层,所以两边开销对称 | prefill 只跑编码器(约 8B 激活),decode 跑全部 40 层(约 16B)。读输入是半价 |
DSpark drafts 就是普通的多 token 预测头 | 块内 5 个位置里 4 个是噪声占位,且有个 rank-256 的 Markov 头在块内串行纠偏(model.py:1131、1149) |
| 图上画出来的就是全部结构 | 残差流是 4 份并行副本 + Sinkhorn 混合(hc_mult=4),图上完全没有对应物 |
最后一条软处说清楚:这张对照图我查不到出处,不知道是谁画的,也不知道依据哪一版资料——但它每个标签都能在官方 config.json 里落地,所以我倾向于认为作者是照着配置文件画的。另外我只读了 inference/model.py(1309 行)里跟缓存、索引器、DSpark、Block 相关的部分,engram.py、kernel.py、vision.py 我没读,所以 Engram 的 n-gram 哈希具体怎么做的、196B 那张表怎么分片落盘的,我给不出源码级的说法——只能说参数量对得上(两张 384M 行 × 256 维 = 196.6B,config.json 实读)。技术报告的 PDF 我也还没读,正文里所有 benchmark 数字都是 model card 的报告值,我未复现。
下一步我想做的是把 index_topk=512 调小,看 DeepSWE 上掉多少分——这是整套设计里唯一一个能用单卡小模型复现的旋钮。做出来了我更新到这篇。
参考来源
官方资料
- DeepSeek-V4.1-Flash model card(2026-09-10 发布,MIT 许可)
config.json— 本文所有层级/形状数字的来源inference/model.py— 参考实现,本文所有model.py:行号引用。注意它的ModelArgs默认值是能在单机跑起来的小模型,不是发布版形状(第 46-48 行自己说明了),所以形状必须读config.json,model.py只用来读逻辑- DeepSeek V4.1 技术报告 PDF(我未读)
站内相关
- KV cache 那笔账:为什么 Qwen2.5-7B 在 2048 长度下大约是 117MB — 本文 890 B/token 的算法和那篇一样,只是分母换了
- vLLM APC 六问:从 block hash 到 free/evict — prefill/decode 与前缀缓存
- SGLang 内幕:RadixAttention、超售调度与期货流水线的源码深读 — 投机解码在服务层怎么落地
- Masked Softmax 到底在 mask 什么 — 左图那个 Masked attention
- 不教,只给激励:DeepSeek-R1 如何让大模型自己「长」出推理能力 — 同一家的上一代思路