DeepSeek V4 源码剖析

第6章 YaRN RoPE 与 1M 长上下文工程

作者 杨艺韬 · 7,035 字 · 发布于 · 更新于

"rope_scaling": {"type": "yarn", "factor": 16, "original_max_position_embeddings": 65536} —— DeepSeek-V4-Pro config.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 对的旋转角度是:

θm,i=m⋅β−2i/d,i=0,1,...,d/2−1\theta_{m,i} = m \cdot \beta^{-2i/d}, \quad i = 0, 1, ..., d/2-1

其中 β\beta 是 base(V4 里有两个取值:rope_theta=10000 与 compress_rope_theta=160000,§6.5 会讲清楚哪一层用哪个),dd 是 rope_head_dim = 64。

旋转作用在 q 和 k 的最后 64 维上——把位置信息"嵌入"到向量本身。Attention 计算 qTkq^T k 时,不同位置之间的"相对位置"通过旋转角度差自然出现。

RoPE 的训练数据假设:模型只熟悉训练时见过的那些旋转角度。如果训练只到 64K,又不做频率处理,模型从来没见过 m=1M 的旋转——这时模型在 attention score 里看到的是"陌生角度",表现随之崩坏。

外推的本质问题:当 m 从 64K 推到 1M,对每个频率维度 i,θm,i\theta_{m,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 内转过的圈数 r=L/λdr = L/\lambda_d 分段,r<αr < \alpha 的维度整体除以 s,r>βr > \beta 的维度不动,中间线性过渡;论文给 Llama 系列推荐 α=1\alpha=1、β=32\beta=32,正对应 V4 的 beta_slow=1、beta_fast=32。论文里完整的 YaRN 还要再加一项注意力温度(1/t=0.1ln⁡s+1\sqrt{1/t} = 0.1\ln s + 1),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 不是"滑窗段一套、压缩段一套",而是"这一层一套"。

看清楚三件事:

  1. freqs_cis 是 Attention 的一个 buffer,整层只有一份。
  2. 这一份同时喂给好几个地方:q 的旋转、滑窗 KV 的旋转、注意力输出 o 末 64 维的逆旋转(inference/model.py:534),以及 Attention.forward 里注入给 Compressor 和 Indexer 的那两行(inference/model.py:492、inference/model.py:494)。也就是说同一层里,滑窗 KV 和压缩 KV 用的是同一组频率。
  3. 分叉发生在层与层之间:
层的类型 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 的最高频维度,旋转角度 θ=m⋅10000−2⋅0/64=1048575\theta = m \cdot 10000^{-2 \cdot 0 / 64} = 1048575(弧度)。这个数除以 2π2\pi 后取余数,是真正的"有效旋转角度"。

但 torch.outer(t, freqs) 计算 m * freq 在 float32 下,当 m 很大时,lsb 精度丢失——float32 的相对精度约 1.2×10−71.2 \times 10^{-7}(23 位尾数),数值到 220≈1062^{20} \approx 10^6 这个量级时,相邻两个可表示值相差 2−3=0.1252^{-3} = 0.125。按 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 时最低频维度是 i=31i = 31,频率 base−62/64\text{base}^{-62/64},波长 2π/f2\pi/f:

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 而不是别的数:160000=16×10000160000 = 16 \times 10000,而 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 变大,所有频率整体变低(fi=base−2i/df_i = \text{base}^{-2i/d} 中 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 论文的注意力温度(1/t=0.1ln⁡s+1\sqrt{1/t} = 0.1\ln s + 1),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 是干净的 1/d1/\sqrt{d},config.json 的 rope_scaling 里也没有 mscale / mscale_all_dim 字段。照搬 V3 那条路径,缺省值会让结果出偏差:mscale_all_dim 缺省为 0,softmax scale 那一处乘的是 1,没有影响;但 DeepseekScalingRotaryEmbedding 的 mscale 参数缺省为 1,cos/sin 会被乘上 0.1ln⁡16+1≈1.2770.1\ln 16 + 1 \approx 1.277,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 延伸阅读


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 专家路由的故事。