两张架构图里藏着 890 字节:把 DeepSeek V4.1 Flash 的 43 层排班表算出来

2017 原版 Transformer 与 DeepSeek V4.1 Flash 的对照图上,每个标签都对应 config.json 里的一个键。顺着这条线可以手算出官方那个 890 B/token,并发现 40 层里只有 4 层真的在往缓存里写东西。

有人把 2017 年的原版 Transformer 和 2026 年的 DeepSeek V4.1 Flash 并排画成了两张等轴立体图:左边 6 encoder + 6 decoder,右边 20 encoder + 20 decoder,右边那一列还标着 CSA2 · FullCSA2 · ReindexEngram ×2DSpark drafts2: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 + 620 + 20
编码器性质双向,能看全文因果(causal encoder),只能往前看
解码器怎么读编码器cross-attention直接读编码器末层投影出的全局 KV
FFN一个 dense FFNMoE:384 routed + 1 shared,每 token 选 6
注意力全连接 softmaxCSA2 三态:Full / Reindex / Reuse
输入单一 token 流Vision + Text → Projector → 多路输入流
输出Linear + SoftmaxOutput 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 decodernum_hidden_layers40
2:1 compression(编码器侧)compress_ratios[2:20]全是 2
1:1 compression(解码器侧)compress_ratios[20:40]全是 1
CSA2 · Fullkv_source_layer_ids[2, 8, 14, 20]
CSA2 · Reindexindex_source_layer_ids 去掉上一行[24, 28, 32, 36]
CSA2 · Reuse剩下的 32 层
Engram ×2engram_layer_ids[1, 14]
SWAsliding_window128
Candidate poolcandidate_source_layer_id / candidate_topk_blocks20 / 2048
DSpark draftsnum_nextn_predict_layers / dspark_block_size3 / 5

顺着这张表能把整个 43 层的排班表还原出来(40 主干 + 3 草稿层,compress_ratios 正好 43 项):

层号位置压缩比CSA2 模式附加部件
L0–L1编码器0(纯 SWA)L1 有 Engram
L2编码器2Full
L3–L7编码器2Reuse ×5
L8编码器2Full
L9–L13编码器2Reuse ×5
L14编码器2FullEngram
L15–L19编码器2Reuse ×5
L20解码器1Full建候选池
L21–L23解码器1Reuse ×3
L24 / L28 / L32解码器1Reindex(各自后跟 Reuse ×3)
L36解码器1Reindex
L37–L39解码器1Reuse ×3DSpark 的读取点
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=1280o_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 = 2048candidate_block_size = 8 → 候选池 16,384 个位置。L20 先把全上下文筛到这 16,384 个(model.py:583select_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 时 wkvwgate 都被提升到 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

六、收口:三行

  1. 约束:长上下文 agentic 推理的瓶颈是 KV 字节数,不是算力——所以架构的每个决定都在省字节。
  2. 崩点:朴素 MHA 在 1M 上下文下要 0.37 TiB/会话,一条都装不下;890 B/token 让它变成 0.87 GiB。差三个数量级。
  3. 可带走的动作:看到架构图上的标签,去翻 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:11311149
图上画出来的就是全部结构残差流是 4 份并行副本 + Sinkhorn 混合(hc_mult=4),图上完全没有对应物

最后一条软处说清楚:这张对照图我查不到出处,不知道是谁画的,也不知道依据哪一版资料——但它每个标签都能在官方 config.json 里落地,所以我倾向于认为作者是照着配置文件画的。另外我只读了 inference/model.py(1309 行)里跟缓存、索引器、DSpark、Block 相关的部分,engram.pykernel.pyvision.py 我没读,所以 Engram 的 n-gram 哈希具体怎么做的、196B 那张表怎么分片落盘的,我给不出源码级的说法——只能说参数量对得上(两张 384M 行 × 256 维 = 196.6B,config.json 实读)。技术报告的 PDF 我也还没读,正文里所有 benchmark 数字都是 model card 的报告值,我未复现。

下一步我想做的是把 index_topk=512 调小,看 DeepSWE 上掉多少分——这是整套设计里唯一一个能用单卡小模型复现的旋钮。做出来了我更新到这篇。

参考来源

官方资料

站内相关