DeepSeek V4 源码剖析
第6章 YaRN RoPE 与 1M 长上下文工程
"rope_scaling": {"type": "yarn", "factor": 16, "original_max_position_embeddings": 65536}—— DeepSeek-V4-Proconfig.json(节选)V4 的位置编码要在 64K 的原始窗口和 1M 的目标长度之间保持数学一致——这是 YaRN 给出的工程答案。
6.1 引子:配置里的 64K 与 1M
V4 的 config.json 里有这么一段:
"max_position_embeddings": 1048576,
"rope_scaling": {
"type": "yarn",
"factor": 16,
"original_max_position_embeddings": 65536,
"beta_fast": 32,
"beta_slow": 1
}
读这段配置:
max_position_embeddings = 1048576(1M)—— 支持的最大上下文original_max_position_embeddings = 65536(64K)—— YaRN 的原始窗口,与技术报告里 64K 那一档训练长度吻合factor = 16—— 1M / 64K = 16 倍的扩展倍数
但这不等于 V4 只在 64K 上训练过:技术报告第 4.2.2 节写明,V4-Flash 与 V4-Pro 的预训练序列长度都从 4K 起步,逐步扩到 16K、64K,最后到 1M。所以 1M 不是零样本外推出来的长度。YaRN 在这里的角色是:以 64K 为原始窗口,把 RoPE 频率改成适合 1M 的形态,参考推理代码用的就是这套频率。技术报告正文没有提 YaRN,1M 阶段训练时是否用同一套频率,报告没有写明;DeepSeek-V3 的做法是在 YaRN 改过的频率上继续做长序列训练,分两阶段从 4K 扩到 32K、再到 128K(V3 技术报告第 4.3 节)。反过来,如果把 1M 长度的输入直接喂给一个只训过 64K、又没做任何频率处理的 RoPE 模型,会出现"位置编码外推塌陷"——模型在 64K 之外的位置上基本失效。
YaRN("Yet another RoPE extensioN method",arXiv:2309.00071)通过频率插值让 RoPE 在未训练区间也能稳定工作。本章拆 V4 是怎么把 YaRN 工业化的——以及 V4 在主 attention 与压缩 KV 上分别用了什么 RoPE 配置。
6.2 RoPE 复习:为什么要外推
RoPE(Rotary Position Embedding)的核心思想:把每个 head 的最后 64 维(rope_head_dim=64)拆成 32 对,每对用复指数旋转编码位置。
具体地,对位置 m,第 i 对的旋转角度是:
其中 是 base(V4 里有两个取值:rope_theta=10000 与 compress_rope_theta=160000,§6.5 会讲清楚哪一层用哪个), 是 rope_head_dim = 64。
旋转作用在 q 和 k 的最后 64 维上——把位置信息"嵌入"到向量本身。Attention 计算 时,不同位置之间的"相对位置"通过旋转角度差自然出现。
RoPE 的训练数据假设:模型只熟悉训练时见过的那些旋转角度。如果训练只到 64K,又不做频率处理,模型从来没见过 m=1M 的旋转——这时模型在 attention score 里看到的是"陌生角度",表现随之崩坏。
外推的本质问题:当 m 从 64K 推到 1M,对每个频率维度 i, 也变大 16 倍。高频维度(i 小)在 64K 内已经转过成千上万圈,所有角度都见过,问题不大;低频维度(i 大)在 64K 内连一圈都转不满,推到 1M 后会落进训练中从未出现过的角度区间,相对位置感失真。
6.3 YaRN 的核心思想:频率插值
YaRN 的解决方案:给频率分维度做插值——
- 高频维度(i 小,旋转快,对短距离敏感):保持原始频率,因为它们在原始窗口内已经转过很多圈,各种角度训练时都见过
- 低频维度(i 大,旋转慢,对长距离敏感):把频率除以 factor=16,于是位置 1M 处的旋转角恰好等于原频率下位置 64K 处的角度——仍落在原始窗口覆盖的角度范围内
中间过渡区用 linear ramp(beta_fast / beta_slow 控制)平滑插值。论文里这一步叫 "NTK-by-parts":按维度 d 在原始窗口 L 内转过的圈数 分段, 的维度整体除以 s, 的维度不动,中间线性过渡;论文给 Llama 系列推荐 、,正对应 V4 的 beta_slow=1、beta_fast=32。论文里完整的 YaRN 还要再加一项注意力温度(),V4 参考实现没有这一项,§6.12 会讲它在 vLLM 里带来的差异。
flowchart LR
subgraph 频率分布["维度 i (从高频到低频)"]
direction LR
HF[高频<br/>i 小]
Mid[过渡区<br/>beta_fast → beta_slow]
LF[低频<br/>i 大]
end
subgraph 处理["YaRN 的处理"]
HF --> Keep["保持原 freq"]
Mid --> Ramp["线性插值<br/>(1 - smooth) / factor + smooth"]
LF --> Scale["freq / factor"]
end
YaRN 的论文用实验表明,这种"分频率插值"在外推上比 NTK-aware、Linear interpolation 等先前方法更稳定。V4 用的 16 倍扩展,正是 YaRN 论文自己评测过的两档之一(s=16 与 s=32,后者把 4k 预训练长度撑到 128k,且只用 64k 上下文微调)——也就是说这个倍数不是外推到论文没验过的区间,而是落在它已验证的范围内。
6.4 V4 源码里的 precompute_freqs_cis
V4 在 inference/model.py 里实现了带 YaRN 的 RoPE 预计算函数:
@lru_cache(2)
def precompute_freqs_cis(dim, seqlen, original_seq_len, base, factor, beta_fast, beta_slow) -> torch.Tensor:
"""Precomputes complex exponentials for rotary embeddings with YaRN scaling."""
def find_correction_dim(num_rotations, dim, base, max_seq_len):
return dim * math.log(max_seq_len / (num_rotations * 2 * math.pi)) / (2 * math.log(base))
def find_correction_range(low_rot, high_rot, dim, base, max_seq_len):
low = math.floor(find_correction_dim(low_rot, dim, base, max_seq_len))
high = math.ceil(find_correction_dim(high_rot, dim, base, max_seq_len))
return max(low, 0), min(high, dim-1)
def linear_ramp_factor(min, max, dim):
if min == max:
max += 0.001
linear_func = (torch.arange(dim, dtype=torch.float32) - min) / (max - min)
ramp_func = torch.clamp(linear_func, 0, 1)
return ramp_func
freqs = 1.0 / (base ** (torch.arange(0, dim, 2, dtype=torch.float32) / dim))
if original_seq_len > 0:
low, high = find_correction_range(beta_fast, beta_slow, dim, base, original_seq_len)
smooth = 1 - linear_ramp_factor(low, high, dim // 2)
freqs = freqs / factor * (1 - smooth) + freqs * smooth
t = torch.arange(seqlen)
freqs = torch.outer(t, freqs)
freqs_cis = torch.polar(torch.ones_like(freqs), freqs)
return freqs_cis
这函数可以拆成 4 步:
步骤 1:算原始 RoPE 频率
freqs = 1.0 / (base ** (torch.arange(0, dim, 2, dtype=torch.float32) / dim))
freqs[i] = 1 / base^(2i/dim)——dim/2 个频率值,从高到低。
步骤 2:YaRN 频率插值(仅当 original_seq_len > 0)
low, high = find_correction_range(beta_fast, beta_slow, dim, base, original_seq_len)
smooth = 1 - linear_ramp_factor(low, high, dim // 2)
freqs = freqs / factor * (1 - smooth) + freqs * smooth
find_correction_range 计算"高频维度的截止"和"低频维度的截止"。smooth 在这两个截止之间做线性 ramp(高频 = 1,低频 = 0)。频率被按 smooth 插值——高频用原 freqs,低频用 freqs/factor。
步骤 3:构造完整的位置×频率矩阵
t = torch.arange(seqlen)
freqs = torch.outer(t, freqs)
freqs[m, i] = m * freqs[i] ——每个位置 m 在每个频率 i 上的旋转角度。
步骤 4:转复指数
freqs_cis = torch.polar(torch.ones_like(freqs), freqs)
torch.polar(magnitude=1, angle=θ) 返回 cos(θ) + i sin(θ) 的复数张量。
@lru_cache(2) 装饰器让这个函数最多缓存 2 个不同参数组合——V4 实际使用 2 套不同的 freqs_cis(有压缩段的层一套、纯滑窗的 MTP 层一套,§6.5 细讲)。
注意 seqlen 传进来的是 args.max_seq_len:ModelArgs 里它的默认值是 4096(inference/model.py:38),仓库自带的 inference/config.json 也没有设这个字段,所以参考实现按默认只预计算 4096 个位置;要真跑 1M,得自己把 max_seq_len 设成 1048576。下文的 1M 数字都按这个部署设定来算。
6.5 V4 的双 RoPE:主 attention 与压缩 KV 各自一套
V4 在 Attention.__init__ 里有这一段不显眼但极关键的代码:
# inference/model.py:475-482
# (同一个 __init__ 里 :466 还有一个同样条件的 if,那里建的是 Compressor /
# Indexer;这里这个才是决定 RoPE 参数的)
if self.compress_ratio:
original_seq_len, rope_theta = args.original_seq_len, args.compress_rope_theta
else:
# disable YaRN and use base rope_theta in pure sliding-window attention
original_seq_len, rope_theta = 0, args.rope_theta
freqs_cis = precompute_freqs_cis(self.rope_head_dim, args.max_seq_len, original_seq_len,
rope_theta, args.rope_factor, args.beta_fast, args.beta_slow)
self.register_buffer("freqs_cis", freqs_cis, persistent=False)
这里要先破除一个非常容易产生的误解——这两套 RoPE 不是"滑窗段一套、压缩段一套",而是"这一层一套"。
看清楚三件事:
freqs_cis是Attention的一个 buffer,整层只有一份。- 这一份同时喂给好几个地方:q 的旋转、滑窗 KV 的旋转、注意力输出 o 末 64 维的逆旋转(
inference/model.py:534),以及Attention.forward里注入给 Compressor 和 Indexer 的那两行(inference/model.py:492、inference/model.py:494)。也就是说同一层里,滑窗 KV 和压缩 KV 用的是同一组频率。 - 分叉发生在层与层之间:
| 层的类型 | original_seq_len |
base | YaRN |
|---|---|---|---|
有压缩段(ratio = 4 或 128,主模型全部 61 层) |
65536 | compress_rope_theta = 160000 |
开 |
纯滑窗(ratio = 0,只有 MTP 层) |
0 | rope_theta = 10000 |
关 |
源码里那句注释把动机写得很直白:
# disable YaRN and use base rope_theta in pure sliding-window attention
纯滑窗层根本不需要长程外推——它的注意力半径就是 window_size = 128 个 token,位置差永远在 [0, 128) 之内,落在任何 RoPE 的舒适区里。给它套 YaRN 反而是把一个为"1M 距离"设计的频率压缩强加到"128 距离"上,没有必要。
反过来,有压缩段的层要处理的位置差最大到 1M,所以两件事一起上:base 从 10000 抬到 160000(把所有频率整体调低,最低频维度的波长因此拉长,同样的 1M 位置差落在更少的圈数里),再叠 YaRN 的分频率插值。
至于压缩 KV 的位置取值,Compressor.forward 里写得很清楚,它取的是真实 token 位置而不是组索引:
if start_pos == 0:
freqs_cis = self.freqs_cis[:cutoff:ratio] # 步长 ratio 的切片 —— 仍是 token 位置
else:
freqs_cis = self.freqs_cis[start_pos + 1 - self.compress_ratio].unsqueeze(0)
[:cutoff:ratio] 是在 token 位置轴上按 ratio 步长采样——第 g 个压缩组拿到的是 token 位置 g × ratio 的那组频率。所以压缩段和滑窗段的位置坐标系天然就是同一个,不需要任何"组索引修正"。
flowchart TD
subgraph 压缩层["61 个主模型层(ratio = 4 或 128)"]
A0["precompute_freqs_cis(64, 1M,<br/>original_seq_len=65536,<br/>base=160000, factor=16)"]
A0 --> A1["q 的最后 64 维"]
A0 --> A2["滑窗 KV 的最后 64 维"]
A0 --> A3["压缩 KV:freqs_cis[:cutoff:ratio]<br/>(按 token 位置采样)"]
end
subgraph MTP层["MTP 层(ratio = 0,主模型之外)"]
B0["precompute_freqs_cis(64, 1M,<br/>original_seq_len=0,<br/>base=10000) ← YaRN 关闭"]
B0 --> B1["注意力半径只有 128,<br/>不需要外推"]
end
6.6 1M 上下文下的位置精度问题
YaRN 让 1M 外推数学上稳定,但工程上还有一个问题:位置编码的浮点精度。
考虑位置 m=1,048,575 的最高频维度,旋转角度 (弧度)。这个数除以 后取余数,是真正的"有效旋转角度"。
但 torch.outer(t, freqs) 计算 m * freq 在 float32 下,当 m 很大时,lsb 精度丢失——float32 的相对精度约 (23 位尾数),数值到 这个量级时,相邻两个可表示值相差 。按 61 个压缩层那套频率实测,1M 个位置上 float32 与 float64 算出的角度最大相差约 0.03 弧度。
V4 的 precompute_freqs_cis 全部用 dtype=torch.float32 计算(注意函数里 torch.arange(0, dim, 2, dtype=torch.float32);torch.arange(seqlen) 默认 int64,与 float32 的频率做外积时提升为 float32),最后转复指数。这套精度对 1M 大致够用,但已经在 float32 的极限附近。
如果你想跑 V4 到 16M 甚至更长,可能要改成 float64 计算——V4 当前的实现没有为这种极端情况做特殊处理。
@lru_cache(2) 装饰器的另一个作用是:把 freqs_cis 的计算结果缓存,让参数相同的各层在构造时直接复用同一张表,而不是每层各算一遍 1M 个复指数(precompute_freqs_cis 只在 Attention.__init__ 里调用,forward 不会重算)。一张表是 1M × 32 个 complex64 = 256 MiB(约 268 MB),61 层各算一份的话成本不低。
6.7 双 RoPE 的工程权衡:为什么不用单一 base
既然同一层里滑窗和压缩共用一套频率,那"两套 RoPE"的分界就只剩下层级这一条。能不能干脆统一成一个 base?两个方向都试着推一下:
方案 A:全部用 base=10000 + YaRN
对 61 个压缩层来说,base 越小,最低频维度的波长越短。rope_head_dim=64 时最低频维度是 ,频率 ,波长 :
| base | 最低频维度的波长 | 1M 位置差要绕几圈 |
|---|---|---|
| 10000 | 约 4.7 万 token | 22.3 圈 |
| 160000 | 约 69 万 token | 1.52 圈 |
差别就在这里。用 base=160000,最低频维度在原始 64K 窗口内只转约 0.09 圈,按 YaRN 的判据(在原始窗口内转不满 beta_slow=1 圈的维度整体除以 factor),第 25–31 对维度频率整体除以 16、第 16–24 对走斜坡。叠上 YaRN 后,第 25–31 对的波长都超过 1M(约 117 万到 1106 万 token),在 1M 范围内位置与角度保持单调对应。用 base=10000 的话,最低频维度在 64K 内就已转满约 1.39 圈,YaRN 会把它归进斜坡区,32 对维度里没有一对被完整除以 16;叠上 YaRN 后,最低频维度在 1M 内仍要绕约 4.6 圈(上面的数字都用 §6.4 的函数实算)。
方案 B:全部用 base=160000
对 MTP 那个纯滑窗层来说,它的注意力半径只有 128 个 token。base 抬到 160000 意味着除第 0 对(永远是每 token 1 弧度)之外的频率都被调低,在 128 这个尺度上能转满一圈的维度变少:波长短于 128 的维度对,base=10000 时有 11 对,base=160000 时只剩 9 对。高频维度存在的意义就是分辨近距离,对一个只看 128 的层,把它们调慢没有好处。源码只用一行注释交代了这个选择(disable YaRN and use base rope_theta in pure sliding-window attention),没有展开理由。
所以 V4 的分配是各取所需:
- 要看 1M 的层 → base=160000(波长盖住 1M)+ YaRN(以 64K 为原始窗口,把频率适配到 1M)
- 只看 128 的层 → base=10000(高频维度保持锐利)+ 不用 YaRN(没有需要外推的距离)
至于为什么是 160000 而不是别的数:,而 16 正是 rope_scaling.factor。技术报告与源码都没有解释这个取值;把它读成"base 放大一级、YaRN 插值再一级,两级分担扩展压力"只是本专栏的推测。两级各自的效果可以在 §6.11 的可视化里直接看到。
更细的优化方向是按 compress_ratio 给每层单独选 base(ratio=128 的层实际需要覆盖的距离与 ratio=4 的层并不相同),但 V4 选了全模型只有两个 base——工程上简单,@lru_cache(2) 这个"2"也正好对应这两套参数。
6.8 freqs_cis 的内存账本
freqs_cis 是个被频繁忽略的内存大户。让我们算一下 V4 在 1M context 下的 freqs_cis 总占用:
单份 freqs_cis:
- 形状:
[max_seq_len=1048576, rope_head_dim/2=32] - dtype:
torch.complex64(8 字节 / 元素) - 大小:
1048576 × 32 × 8 = 268 MB
模型里一共有几份?答案由 @lru_cache(2) 决定。precompute_freqs_cis 的参数在 V4 里只有两种组合:
(64, 1048576, 65536, 160000, 16, 32, 1)—— 61 个压缩层全都命中同一个缓存项(64, 1048576, 0, 10000, 16, 32, 1)—— MTP 那个纯滑窗层
所以 62 层一共只算两次、只占两份,合计约 536 MB。每层的 register_buffer("freqs_cis", freqs_cis, persistent=False) 存的是同一个 tensor 对象的引用,不额外占内存;persistent=False 则保证它不会被写进 checkpoint。
lru_cache 的容量正好是 2,与这两种参数组合一一对应——如果哪天 V4 变成"每层一个 base",这个 2 就会变成颠簸的缓存,每层都要重算 268 MB。这是"配置形态"和"实现细节"耦合的一个典型例子。
536 MB 听起来不多,但每张卡都要留一份——TP=8 部署累计 4 GB 显存只用于位置编码。如果要扩到 2M 上下文,这部分线性翻倍到 1 GB / 卡。真到那一步,合理的做法是把 freqs_cis 改成"按需生成"(只算本次 forward 用到的位置切片),而不是预生成整个 max_seq_len。
6.9 与其他外推方案的对比
YaRN 不是唯一的外推方案。把它与同期方案比一下:
| 方案 | 思路 | V4 是否使用 |
|---|---|---|
| Position Interpolation(线性插值) | 把位置 m 缩到 m / factor,等价于把所有频率统一除以 factor | 否 |
| NTK-aware | 改 base:base × factor^(d/(d-2)) | 否 |
| YaRN | 分频率插值(NTK-by-parts)+ 注意力温度调节 | ✅(参考实现只有分频率插值,没有温度项) |
| LongRoPE | 进化算法搜索每维度的最优插值因子 | 否 |
| PoSE | 训练时在短窗口内跳跃式改写位置索引,模拟长位置 | 否 |
技术报告没有解释为什么选 YaRN。从工程上看,合理的原因有:
- DeepSeek-V3 就用 YaRN 做过上下文扩展(factor=40,4K→32K→128K,V3 技术报告第 4.3 节),配置字段与代码都是现成的
- 论文公开,实现只是一段频率预计算,推理时零额外开销
6.10 章末:YaRN 在生产中的实际表现
把 YaRN + V4 在生产中的表现总结成一个对照表:
| 上下文长度 | 训练是否覆盖 | MRCR 8-needle(V4-Pro-Max,技术报告图 9) | 工程建议 |
|---|---|---|---|
| ≤ 128K | 预训练已覆盖(4K→16K→64K→1M) | 0.85–0.94,稳定 | 完全可用 |
| 128K-512K | 同上 | 256K 0.82、512K 0.66,开始下降 | 可用,关键信息建议在 prompt 里重复 |
| 512K-1M | 同上 | 1024K 0.59 | 可用,但精确检索明显变弱,重要信息别只放一次 |
| > 1M | 超出 max_position | — | 参考实现 generate.py 的断言直接报错;需要更大 max_position 的版本 |
技术报告第 5.3.2 节的原话是:128K 以内检索表现高度稳定,超过 128K 开始可见下降,但 1M 处的检索能力与闭源、开源对手相比仍然很强。128K 到 1M 这一段,正是 RAG / 仓库代码理解 / 长文档分析这类场景关心的区间。
6.11 动手实验:可视化 YaRN 频率插值
import torch
import math
import matplotlib.pyplot as plt
def find_correction_dim(num_rotations, dim, base, max_seq_len):
return dim * math.log(max_seq_len / (num_rotations * 2 * math.pi)) / (2 * math.log(base))
def linear_ramp(low, high, dim):
return torch.clamp((torch.arange(dim) - low) / (high - low), 0, 1)
dim = 64
base = 10000
factor = 16
original = 65536
beta_fast = 32
beta_slow = 1
orig_freqs = 1.0 / (base ** (torch.arange(0, dim, 2, dtype=torch.float32) / dim))
low = max(int(find_correction_dim(beta_fast, dim, base, original)), 0)
high = min(int(find_correction_dim(beta_slow, dim, base, original)) + 1, dim - 1)
smooth = 1 - linear_ramp(low, high, dim // 2)
yarn_freqs = orig_freqs / factor * (1 - smooth) + orig_freqs * smooth
plt.semilogy(orig_freqs.numpy(), label='Original RoPE')
plt.semilogy(yarn_freqs.numpy(), label='YaRN (factor=16)')
plt.xlabel('Frequency index i')
plt.ylabel('Frequency value (log scale)')
plt.title('RoPE freqs: original vs YaRN (factor=16)')
plt.legend()
plt.show()
跑这段代码,能直观看到 YaRN 的"高频保持、低频缩放"行为。把 base 改成 160000 再跑一遍,就是 61 个压缩层真正用的那条曲线——注意方向:base 变大,所有频率整体变低( 中 base 在分母的指数上),最低频维度的波长从约 4.7 万 token 拉长到约 69 万 token。这正是 §6.7 说的两级里的第一级(base 放大);YaRN 插值是第二级。
6.12 双 RoPE 与 vLLM 的接口冲突
直觉上会觉得"一个模型两套 RoPE"会把推理引擎难住。对 vLLM 来说这一条恰好不是问题,值得先把现状核清楚,免得把力气花错地方。
vLLM 里的 RoPE 对象由模型代码按层构造,attention backend 自己不决定 base。非 MLA 路径(DeepseekV2Attention)里,vllm-0.8.5/vllm/model_executor/models/deepseek_v2.py:268 的 self.rotary_emb = get_rope(...) 建好之后,在 :315 的 q_pe, k_pe = self.rotary_emb(positions, q_pe, k_pe) 处显式调用,再把 q/k 交给 attention。V3 实际走的 MLA 路径(DeepseekV2MLAAttention,:422 同样调 get_rope)则把这个对象通过 rotary_emb=self.rotary_emb 传进 Attention,由 MLA backend 在 vllm-0.8.5/vllm/attention/backends/mla/common.py:1409、:1416 调用(V1 引擎对应 vllm-0.8.5/vllm/v1/attention/backends/mla/common.py:940、:950)。两条路径里,用哪组频率都由模型代码传进来的那个对象决定。
而 get_rope(vllm-0.8.5/vllm/model_executor/layers/rotary_embedding.py:1471)的缓存 _ROPE_DICT 是按参数做键的:
key = (head_size, rotary_dim, max_position, base, is_neox_style,
rope_scaling_args, dtype)
if key in _ROPE_DICT:
return _ROPE_DICT[key]
base 与 rope_scaling 都在键里。所以"61 个压缩层传 base=160000 + yarn、MTP 层传 base=10000 + 不带 scaling"会自然拿到两个不同实例,并且各自只构造一次——与 V4 的 @lru_cache(2) 是同一个形状。V4 要落到 vLLM,这一段几乎是照抄 deepseek_v2.py 里的写法,按 compress_ratios[layer_id] 决定给 get_rope 传哪组参数即可。
真正需要留意的差异在别处:YaRN 论文的注意力温度(),vLLM 分两处实现。一处是 vllm-0.8.5/vllm/model_executor/models/deepseek_v2.py:276-279(MLA 路径为 :429-432)的 self.scaling = self.scaling * mscale * mscale,其中 mscale 由 rope_scaling 里的 mscale_all_dim 算出;另一处是 vllm-0.8.5/vllm/model_executor/layers/rotary_embedding.py:710 的 DeepseekScalingRotaryEmbedding,它把 yarn_get_mscale(factor, mscale) / yarn_get_mscale(factor, mscale_all_dim) 直接乘进 cos/sin 缓存,也就是论文里"缩放旋转嵌入"的写法。而 V4 的参考实现两处都没有——inference/model.py:464 的 self.softmax_scale = self.head_dim ** -0.5 是干净的 ,config.json 的 rope_scaling 里也没有 mscale / mscale_all_dim 字段。照搬 V3 那条路径,缺省值会让结果出偏差:mscale_all_dim 缺省为 0,softmax scale 那一处乘的是 1,没有影响;但 DeepseekScalingRotaryEmbedding 的 mscale 参数缺省为 1,cos/sin 会被乘上 ,q_pe 与 k_pe 各放大一次,rope 部分的注意力 logit 凭空大出约 1.63 倍。所以接 V4 时要么在 rope_scaling 里显式补 mscale: 0,要么绕开这个类。
关于 vLLM 的 attention backend 接口与 model runner 怎么组织每层元数据,见《vLLM 推理内核深度解析》专栏第 4 章(PagedAttention)与第 8 章(Model Runner)。
6.13 1M 上下文的真实应用场景
V4 的 1M 上下文不是营销噱头——它对应几类真实有商业价值的应用。把这些场景列出来:
场景 1:法律 / 合同分析
一份完整的并购合同 + 历史修订版本 + 相关法规可能 200K-500K token。V4 的 1M context 可以一次处理完整合同包,不需要拆段后再合并。
场景 2:仓库级代码理解
中型项目的源码 50K-300K token,加上文档 / issue 历史可能 500K-1M。V4 可以在一个 context 内"看懂整个项目"——回答如"这个 bug 涉及哪些模块"、"重构 X 类需要改哪些文件"等问题。
场景 3:长篇研究文献综述
100 篇相关论文每篇平均 5K token = 500K token。V4 可以一次性读完,做综述总结、引用关系分析、共识 / 分歧梳理。
场景 4:长视频字幕分析
一部 2 小时视频的字幕 + 元数据约 30K-50K token。V4 可以在 1M 内同时处理多部相关视频——做跨视频的主题分析。
场景 5:客服历史对话上下文
一个用户的客服对话累积 6 个月可能 100K-500K token。V4 可以把全部历史作为 context,不需要做摘要丢失细节——客服模型对这个用户的理解更深入。
场景 6:生物信息基因组分析
某些基因组分析任务需要在长序列上找模式——1M 上下文可以覆盖大型基因或多基因区域。
这些场景的共同点:短上下文模型必须做"摘要 / 检索"才能处理,但摘要 / 检索会损失细节。V4 的 1M 让"完整原始信息直接送给模型" 成为可能——质量上限被解锁。
理解这些场景让你判断"V4 是否真的适合你的产品"——如果你的应用永远是 short context,V4 的 1M 用不到,可以选更便宜的模型;如果你的应用本质是长上下文,V4 是开源模型里值得优先评估的一个。
6.14 长上下文模型的"3 个评估指标"
部署 V4 用于长上下文场景时,需要专门的评估指标——不能只看常规 benchmark。把 3 个最重要的长上下文指标解释清楚:
指标 1:Needle-in-a-Haystack (针在草垛中)
把一段不相关的"针"(如某个特殊数字 / 句子)插入长 context 的不同位置,让模型答出针的内容。这个测试评估"模型是否真的看到了所有 context"——而非只看开头结尾。
V4 在 1M 下的 Needle 测试需要在多个位置(10%、30%、50%、70%、90%)测试——每个位置应该都能高精度回答。如果某些位置回答差,一个可能的原因是 Compressor / Indexer 在该位置的稀疏选取不准。
指标 2:RULER (Ruler benchmark)
更系统的长上下文测试——多任务(针、变量追踪、问答、聚合)在不同 context 长度上的成绩。Ruler 给出一个综合分数,可以与其他长 context 模型对比。
技术报告没有给 RULER 成绩;它在 1M 长度上报的是 MRCR 与 CorpusQA(技术报告第 5.3.2 节,V4-Pro-Max 分别为 83.5 与 62.0),RULER 1M 的数字要等社区独立测评。
指标 3:长输出连贯性
不只是输入长——某些任务要求输出长。比如让 V4 写一篇 50 万字的小说、生成 100 万字的代码 / 文档。输出长度增加会累积稀疏 attention 的误差——评估输出连贯性是关键。
测试方法:让模型生成长输出后,逐段评估"是否与开头一致 / 角色名是否漂移 / 逻辑链是否断裂"。
这 3 个指标在 V4 部署时必须监控——任何一个退化都说明长上下文能力在劣化。常规 benchmark(MMLU / HumanEval)跑得再好,也不能保证长 context 表现。
6.15 V4 在 RAG 场景下的"上下文优化"
V4 的 1M 上下文让 RAG 模式发生本质变化——这部分对部署 V4 的工程师极重要。
传统 RAG 的限制:
传统 RAG 把召回的 chunks 拼成 < 32K context 喂给模型——召回数限制在 5-20 个。结果:召回不全 → 答案不完整。
V4 RAG 的新模式:
V4 的 1M 让你召回 100-1000 个 chunks 不是问题——召回率可以大幅提高。RAG 设计的重心从"精召回" 转向"全召回 + 智能排序"。
新的工程模式:
- 减少向量数据库的 top-k 限制(从 20 提到 200-500)
- 把召回结果按相关性排序(最相关放在 prompt 前后)
- 中间放低相关 chunks 作为"上下文支持"
- 让模型自己在 1M 内做精细推理
对系统设计的影响:
之前 RAG 系统需要"召回 → 重排 → 精筛 → 拼 prompt"四步,有些场景下可以简化为"召回 → 拼 prompt",一部分筛选工作交给模型内部——V4 的 Indexer 本来就在为每个 query 挑选"哪些压缩 KV 重要",不过它是注意力内部的 top-k,并不等价于一个外部重排器。
这种"把工作从外部排序转到模型内部" 的简化能明显减少 RAG 系统的工程量(这是定性判断,没有实测数据)——但前提是你愿意付 V4 的部署成本。对中小项目来说可能不值;对大项目(如企业知识库)来说极有 ROI。
6.16 延伸阅读
- YaRN 论文(arXiv:2309.00071):本章主要参考
- RoFormer 论文(arXiv:2104.09864):RoPE 的源头
- LongRoPE(arXiv:2402.13753):进化搜索找最优外推
- LongLoRA(arXiv:2309.12307):长上下文 LoRA 微调
- 本专栏第 3 章:Compressor 中的 freqs_cis 注入路径
6.17 本章小结
- V4 以 65536 为 YaRN 原始窗口,factor=16 撑到 1M;技术报告写明预训练序列长度逐步扩到了 1M,1M 不是零样本外推
- YaRN 把高频维度保持原 freq、低频维度除以 factor、中间用 linear ramp——既支持外推、又不损失短距离精度
- V4 的两套 RoPE 分在层与层之间,不是"滑窗一套压缩一套":61 个主模型层(有压缩段)用 base=160000 + YaRN,同一层里 q / 滑窗 KV / 压缩 KV 共用这一份 freqs_cis
- 只有 MTP 那个
ratio=0的纯滑窗层用 base=10000 且关掉 YaRN——它的注意力半径只有 128 个 token,没有需要外推的距离(源码注释:disable YaRN and use base rope_theta in pure sliding-window attention) - 压缩 KV 的位置取的是真实 token 位置(
freqs_cis[:cutoff:ratio]按步长采样),不是组索引,所以两段 KV 的位置坐标系天生一致 - 1M 在 float32 下接近精度极限——更长上下文可能需要 float64
- 16 倍正是 YaRN 论文评测过的倍数之一;技术报告的 MRCR 曲线显示 128K 以内稳定、之后逐步下降
第 7 章我们离开 attention 工程,进入 V4 的另一个核心子系统:MoE Gate——sqrtsoftplus、noaux_tc、384 专家路由的故事。