同一个 token不管它出现在上下文中的任何位置查出来的向量完全一样如下所示# model.py — NovaModel self.token_emb nn.Embedding(config.vocab_size, config.d_model) # 16000 行 × 384 列的查找表索引是 token ID不是位置 x self.token_emb(input_ids) # [batch, seq_len, 384]这带来了一个非常致命的问题。我喜欢你和你喜欢我包含完全相同的三个 token只是顺序不同但 token_embedding 查出来的向量集合完全一样。后续多头自注意力中的 QK 点积做的是集合运算对输入顺序天然不敏感。把 token 打乱顺序关联度矩阵也只是行列重新排列数值本身不变。为什么因为 Q 和 K 都由输入向量 x 线性投影而来如下所示# model.py — MultiHeadAttention.forward q self.w_q(x) # Q W_Q · x k self.w_k(x) # K W_K · xx 来自 token_embedding 查表只取决于 token_id和位置无关。我不管在位置 0 还是位置 2W_Q · x 的结果完全一样。说白了QK 点积矩阵里只包含语义关联度语义相似的向量在空间中天然靠近但完全不包含距离关联度。没有位置编码的 Decoder-Only本质上就是一个词袋模型Bag of Words只知道句子里有哪些词但不知道先后顺序。然而自然语言是强顺序依赖的。相同的 token 出现在上下文的不同位置天然就承载着不同的语义猫吃鱼和鱼吃猫的含义截然不同。一个不知道顺序的模型根本无法理解语言所以 Decoder-Only 必须通过某种方式把位置信息注入进去这就是位置编码Positional Encoding要解决的最核心的问题。02 绝对位置编码既然 token_embedding 只编码了语义信息那我们还需要另一种机制来告诉模型这个 token 出现在上下文中的具体第几个位置上。业界目前主流的位置编码方式有两种绝对位置编码和相对位置编码。先从绝对位置编码讲起。Nova 最初的做法是单独建一张 position_embedding 位置查找表每个位置0, 1, 2, …, max_seq_len-1对应一组 d_model 维的向量然后把 position_embedding 和 token_embedding 直接逐元素相加作为进入 Block 层的最初输入向量。这也是 GPT-2 采用的可学习绝对位置嵌入Learned Absolute Position Embedding方案Nova 的实现如下所示# 改造前的 model.pyGPT-2 方案 self.token_emb nn.Embedding(config.vocab_size, config.d_model) # 字义表16000 行 × 384 列 self.pos_emb nn.Embedding(config.max_seq_len, config.d_model) # 位置表128 行 × 384 列 # forward x self.token_emb(input_ids) self.pos_emb(positions) # positions [0, 1, 2, ..., seq_len-1]这张位置表和 token_embedding 一样是可学习的表里的数字初始化为随机值训练过程中通过反向传播由 AdamW 优化器持续更新。训练完成后每个位置都有一组学到的 384 维向量用来告诉模型该 token 在上下文中的具体位置。从实现上看这套方案非常简单直观无非就是多查一张表、多做一次加法就能给模型注入位置感知能力而且在训练上下文长度范围内的表现也确实不错。但绝对位置编码有三个绕不开的问题。首先问题是语义污染。token_embedding 查出来的向量每个维度都编码了这个 token 的某种语义特征而 pos_emb 是另一组完全不同含义的数值。两者直接相加后原始的语义特征就被位置数值污染了如下所示token_emb[你] [0.3, 0.7, -0.1, 0.5] ← 原始语义特征 pos_emb[2] [0.9, 0.1, 0.0, -0.2] ← 位置信息 相加后的输入 [1.2, 0.8, -0.1, 0.3] ← 语义 位置 混在一起了 ↑ ↑ 0.3→1.2 0.5→0.3原来第 0 维是 0.3加完变成了 1.2第 3 维从 0.5 变成了 0.3。后续的 W_Q、W_K 矩阵拿到的已经是一个语义和位置混杂在一起的向量模型需要自己学着把两者分开这一定程度上给模型学习增加了不必要的负担。其次是向量长度被篡改。在向量空间中一个向量的长度L2 范数代表这个特征有多强值越大特征越显著。两个向量相加后新向量的长度几乎不可能等于原来的长度如下所示原始语义向量长度 √(0.3² 0.7² 0.1² 0.5²) √(0.84) 0.917 位置向量长度 √(0.9² 0.1² 0.0² 0.2²) √(0.86) 0.927 相加后向量长度 √(1.2² 0.8² 0.1² 0.3²) √(2.18) 1.476向量长度从 0.917 膨胀到了 1.476涨了 61%。更糟糕的是不同位置的 pos_emb 值不同相加后向量长度的变化幅度也不同位置 0 可能涨 20%位置 5 可能涨 80%。说白了改变了长度就等于把向量特征的强弱篡改了给后续的归一化和注意力计算凭空增加了噪音如图 1 所示。图1 几何视角下的绝对位置编码如果说前面两个问题还算小问题的话最后这个问题才是真正的硬伤在此大家需要注意position_embedding 表有上限训练多长推理就只能多长。 位置表是一张固定大小的查找表有多少行就只能表示多少个位置如下所示self.pos_emb nn.Embedding(config.max_seq_len, config.d_model) # max_seq_len 128 → 表只有 128 行位置 0 到 127 # 推理时如果输入了 200 个 token位置 128~199 没有对应的行可以查直接越界报错为了避免越界推理时必须强制截断上下文只保留最近的 max_seq_len 个 token如下所示# 改造前的 chat.py — 推理截断 ids_cond ids[:, -model.pos_emb.weight.shape[0]:] # 超出 128 就截掉前面的 token这意味着训练时用多长的上下文推理时就只能用多长。位置表 n 行推理上下文就只有 n 个 token。想要更长的上下文则只能重新启用更大的 max_seq_len 训练而且表里新增的行都是从零开始学的需要大量数据和训练时间。对于 Nova 这种千万参数的微型 LLM 来说为了延长上下文而重新训练所带来的成本会变得相当昂贵。03 相对位置编码既然绝对位置编码的上限问题绕不过去那不妨换一个角度来思考这件事模型真正需要的究竟是这个 token 在第几个位置还是两个 token 之间隔了多远举个例子。句子 A 是今天天气真好啊天气在位置 1“好在位置 3距离 2句子 B 是我觉得今天天气真好啊”天气跑到了位置 3好跑到了位置 5但它们之间的距离依然是 2。从语义理解的角度看天气和好之间的语法关系主语与形容词之间的修饰关系并没有因为前面多了几个词就发生改变。模型需要学到的是距离为 2 的 token 之间的关系模式而不是第 1 个位置和第 3 个位置的关系模式。这就是相对位置编码Relative Positional Encoding的核心思想不给每个 token 打固定位置标签而是让模型在计算注意力分数时能够感知两个 token 之间的距离差 (m - n)。RoPERotary Position Embedding旋转位置编码是当前最主流的相对位置编码实现方案LLaMA、DeepSeek、Qwen、Gemma 等几乎所有现代大模型都在用它。它的做法从根本上不同于绝对位置编码不往输入向量上叠加位置信息而是在 Attention 内部对 Q 和 K 向量按当前 token 的位置做旋转操作。图2 绝对位置编码和RoPE在模型数据流上的差异为什么偏偏选旋转因为旋转这个几何操作天然具备三个数学性质刚好对应解决了绝对位置编码的三个硬伤。首先旋转只改变向量的方向不改变向量的长度所以原始语义特征的强度完好无损地保留了下来不会像加法那样把量级篡改掉。其次位置 m 的 Q 被旋转了 mθ 度位置 n 的 K 被旋转了 nθ 度当 QK 做点积时结果里会自然出现 cos((m-n)θ)这不是人为设计的而是三角恒等式 cosA·cosB sinA·sinB cos(A-B) 的数学性质决定的(m-n) 就是两个 token 的相对距离模型通过点积大小间接感知到了这个距离。最后旋转角度用公式角度 位置 × 单位旋转角度计算不需要查表任何位置都能算出旋转角度不存在表不够长的问题再配合位置插值Position Interpolation技术甚至可以在推理时把上下文长度扩展到训练长度的数倍。加法、乘法、拼接都做不到这三点这也是 RoPE 被称为优雅的真正原因。如图2所示。对应到 Nova 的代码改造前后的关键差异如下所示# 改造前绝对位置编码 # model.py — NovaModel.__init__ self.token_emb nn.Embedding(config.vocab_size, config.d_model) self.pos_emb nn.Embedding(config.max_seq_len, config.d_model) # ← 128行位置表 # model.py — NovaModel.forward x self.token_emb(input_ids) self.pos_emb(positions) # ← 加法注入位置 # model.py — MultiHeadAttention.forward attn_output scaled_dot_product_attention(q, k, v, ...) # ← Q、K 直接点积不做旋转 # 改造后RoPE # model.py — NovaModel.__init__第 413~434 行 self.token_emb nn.Embedding(config.vocab_size, config.d_model) freqs_cis precompute_rope_freqs(head_dim, config.max_seq_len, ...) self.register_buffer(freqs_cis, freqs_cis) # ← 旋转系数表不参与训练 # model.py — NovaModel.forward第 486~498 行 x self.token_emb(input_ids) # ← 只查字义表不做加法 freqs_cis self.freqs_cis[:seq_len] # ← 截取当前长度的旋转系数 for block in self.blocks: x block(x, freqs_cis) # ← 旋转系数透传到每层 Block # model.py — MultiHeadAttention.forward第 305~306 行 q, k apply_rotary_emb(q, k, freqs_cis) # ← 点积之前先对 Q 和 K 做旋转 attn_output scaled_dot_product_attention(q, k, v, ...) # ← 然后才做点积总而言之绝对位置编码通过加法给每个 token 打上固定位置标签简单但有上限相对位置编码RoPE通过旋转让模型感知 token 之间的距离差既不破坏内容又没有长度限制。这也是为什么当今主流大模型几乎全部转向了 RoPE的主要原因它是目前唯一一种既不破坏原始语义、又能让点积自动感知相对距离、同时支持上下文扩展的位置编码方案。04 RoPE的几何直觉上一节我们知道了 RoPE 通过旋转来编码位置也提到了它的三个天然优势。但旋转到底是什么它在向量空间里是怎么操作的为什么旋转就能编码位置这一节我会把 RoPE 的几何直觉彻底拆解。在 Nova 模型中每个自注意力头负责 head_dim 64 维的向量空间。RoPE 的第一步是把这 64 个维度两两配对分成 32 组如图3所示。图3 head_dim/2组单位旋转角度如图4所示每一组就是两个数字比如第 0 组是 (q0, q1)。把这两个数字当成二维平面上一个点的坐标q0 是横坐标q1 是纵坐标就是数学课上画的那个十字坐标系。从原点 (0, 0) 到点 (0.6, 0.8) 画一条线这条线就是一个向量。每个向量有两个属性分别是长度和方向其中长度到原点的距离代表这对维度的语义特征有多强方向跟横轴的夹角 θ代表这对维度的语义特征的指向。图4 二维平面上的点(向量)所谓旋转就是把这个点沿着以原点为圆心的圆弧滑动一个角度。转完之后点到原点的距离没变还在同一个圆上但方向变了如图5所示。图5 RoPE旋转位置编码RoPE 做的事就是给 token 的 Q/K 向量中的 32 组配对各自在对应的二维平面上旋转一个角度。旋转的角度由 token 的位置决定位置越靠后旋转的角度越大。位置 0 的 token 不旋转角度 0位置 1 的 token 转一小段位置 100 的 token 转了很多。这样一来不同位置的 token 即使内容完全相同旋转后的向量方向也不同位置信息就被编码进去了。这里有一个关键细节步长固定为 2每 2 个维度组成一组这不是一个可以调的超参数而是旋转数学本身的要求旋转是一个平面操作需要 (x, y) 两个坐标才能定义一个平面上的点一个数字没有方向的概念无法旋转。所有使用 RoPE 的主流大模型LLaMA、DeepSeek、Qwen、Gemma步长全部都是 2没有例外。接下来展开说说上一节提到的三个优势为什么旋转能刚好解决绝对位置编码的三个硬伤。第一个优势是不破坏内容。上一节我们看到绝对位置编码的加法会把向量长度从 0.917 膨胀到 1.476而向量长度代表的是语义特征的强度。旋转操作天然保持向量长度不变这不是人为设计的而是旋转的数学定义决定的证明过程如下原始向量 (a, b)长度² a² b² 旋转 θ 度后的新向量 new_a a×cosθ - b×sinθ new_b a×sinθ b×cosθ 新向量的长度² (a×cosθ - b×sinθ)² (a×sinθ b×cosθ)² a²cos²θ - 2ab·cosθsinθ b²sin²θ a²sin²θ 2ab·sinθcosθ b²cos²θ ↑ 这两项正好抵消 ↑ a²(cos²θ sin²θ) b²(sin²θ cos²θ) a² × 1 b² × 1 ← cos²θ sin²θ 1三角恒等式 a² b² 新长度² 原始长度²所以长度不变。对任意角度 θ 都成立。拿前面的例子验证一下(0.6, 0.8) 旋转 90° 后变成 (-0.8, 0.6)旋转前长度 √(0.36 0.64) 1.0旋转后长度 √(0.64 0.36) 1.0完全一致。说白了旋转后 token 的语义特征强度原封不动地保留了下来位置信息纯粹通过方向的改变来编码。就像时钟的秒针针的长度不变但指向不断旋转不管指向 12 点还是 3 点秒针还是那根秒针长度/内容不变你通过它指的方向就知道时间位置信息。图6 点积时自动编码相对位置第二个优势是点积时自动编码相对距离。这是 RoPE 最精髓的性质。两个向量的点积有一个几何含义A · B |A| × |B| × cos(A 和 B 之间的夹角)。也就是说点积的大小取决于两件事向量的长度代表内容和它们之间的夹角代表方向差异。现在假设位置 m 的 Q 和位置 n 的 K 分别被旋转了 mθ 和 nθ 度旋转不改变长度但改变了方向。那么旋转后 Q 和 K 之间的夹角是旋转前Q 的方向 α K 的方向 β 夹角 α - β纯内容的方向差 旋转后Q 的方向 α mθK 的方向 β nθ 旋转后的夹角 (α mθ) - (β nθ) (α - β) (m - n)θ ↑ ↑ 内容方向差 位置距离 × 单位旋转角度旋转后的夹角由两部分组成内容方向差 (α - β) 和位置距离贡献 (m-n)θ。代入点积公式就是Q_旋转 · K_旋转 |Q| × |K| × cos((α - β) (m - n)θ)。关键在最后一项 (m - n)θ不管 m 和 n 各自是多少点积只取决于它们的差 (m-n)。位置 3 和位置 1 的距离是 2位置 103 和位置 101 的距离也是 2它们对点积的贡献完全一样。这就是 RoPE 的核心旋转编码的是绝对位置每个 token 各转各的但点积只看角度差所以最终效果是编码了相对距离。这不是人为设计的是旋转几何的天然性质。在数值上也很直观位置差越小夹角越小cos 越接近 1点积越大注意力越高位置差越大夹角越大cos 越接近 0点积越小注意力越低。这完美符合自然语言中临近词通常更相关的先验知识。第三个优势是无长度上限。绝对位置编码的 nn.Embedding(128, 384) 有 128 行位置 128 以后没有行可查。而 RoPE 的旋转角度用公式计算角度 位置 × 单位旋转角度。不管位置是 0、128 还是 100000代入公式都能算出一个角度不需要查表自然没有上限。当然“能算出角度不等于效果一定好”模型的 W_Q、W_K 等权重只在训练长度范围内优化过直接用超出训练范围的位置可能导致注意力模式崩掉。但 RoPE 至少提供了一个扩展的可能性配合位置插值技术后续章节会详解可以把旋转角度压缩回训练范围从而在推理时把上下文长度扩展到训练长度的 2~16 倍。这是绝对位置编码完全做不到的事。理解了旋转的几何直觉之后接下来看看它在代码层面到底是怎么算的。旋转听起来很抽象但翻译成最底层的计算操作就是两次乘法和一次加减法没有任何神秘的东西。忘掉旋转这个词忘掉复数看最底层到底发生了什么你有一个 Q 向量中的一组配对q0 0.6, q1 0.8 你有一张预计算好的旋转系数表这一组对应的系数是cos值 0.540, sin值 0.841 旋转就是这两行算术 new_q0 q0 × cos值 - q1 × sin值 0.6 × 0.540 - 0.8 × 0.841 0.324 - 0.673 -0.349 new_q1 q0 × sin值 q1 × cos值 0.6 × 0.841 0.8 × 0.540 0.505 0.432 0.937 就这样。每个数字经过 2 次乘法、1 次加或减得到一个新数字。结束了。写成通用形式就是new_q0 q0 × cosθ - q1 × sinθnew_q1 q0 × sinθ q1 × cosθ其中 θ 位置 × 单位旋转角度。这不是什么新发明就是初中解析几何的平面旋转公式。上面的例子中cos 值 0.540、sin 值 0.841 对应的角度是 1.0 弧度约 57.3°即这个 token 在这组维度上旋转了 1.0 弧度。验证一下旋转后长度有没有变旋转前 √(0.6² 0.8²) 1.0旋转后 √(0.349² 0.937²) 1.0确实没变。接下来我们来看下完整的旋转过程。假设 head_dim 4为了简单实际是 64分成 2 组位置 2 的某个 token原始 Q 向量 [0.6, 0.8, 0.3, -0.5] ↑ ↑ ↑ ↑ 组0的a 组0的b 组1的a 组1的b 旋转系数表位置 2 对应的系数 组0cos值 0.540, sin值 0.841 ← 快速组单位旋转角度大每步转很多度 组1cos值 0.980, sin值 0.200 ← 慢速组单位旋转角度小每步只转一点 旋转操作每组独立做互不影响 组0(0.6, 0.8) → 代入旋转公式 new_q0 0.6×0.540 - 0.8×0.841 0.324 - 0.673 -0.349 new_q1 0.6×0.841 0.8×0.540 0.505 0.432 0.937 组1(0.3, -0.5) → 代入旋转公式 new_q2 0.3×0.980 - (-0.5)×0.200 0.294 0.100 0.394 new_q3 0.3×0.200 (-0.5)×0.980 0.060 - 0.490 -0.430 旋转后的 Q 向量 [-0.349, 0.937, 0.394, -0.430] 验证长度没变每组独立验证 组0旋转前√(0.6² 0.8²) √1.000 1.000 组0旋转后√(0.349² 0.937²) √1.000 1.000 ✓ 组1旋转前√(0.3² 0.5²) √0.340 0.583 组1旋转后√(0.394² 0.430²) √0.340 0.583 ✓这里有一个重要细节组 0 和组 1 用的旋转系数是不同的。组 0 是快速组每步转很多度相邻 token 的方向差别大擅长捕捉近距离关系组 1 是慢速组每步只转一点点要隔很远才能看出方向差别擅长捕捉远距离关系。实际的 Nova 模型有 32 组从最快的 θ₀ 1.0 到最慢的 θ₃₁ 0.00013232 组分工协作覆盖从 1 步到几千步的距离感知范围。推广到实际的 head_dim 64 维向量旋转操作的完整过程如下对于 head_dim 64 的向量分成 32 组每组 2 个数字 [q0,q1], [q2,q3], [q4,q5], ..., [q62,q63] ↓ ↓ ↓ ↓ 2次乘加 2次乘加 2次乘加 ... 2次乘加 ← 每组用各自的 (cos值, sin值) ↓ ↓ ↓ ↓ [q0,q1], [q2,q3], [q4,q5], ..., [q62,q63] 每组独立做互不干扰。32 组共做 32 × 4 128 次乘法 64 次加减法。 输入形状 [batch, n_heads, seq_len, 64]输出形状 [batch, n_heads, seq_len, 64] —— 和输入完全一样下游的点积、softmax、加权求和完全无感知。到这里还有一个问题绕不过去之前我们一直在说角度但角度是一个几何概念你没法拿1.0 弧度这个数字直接去跟 Q 向量做矩阵运算GPU 也不认识旋转 57.3°这种指令。要让旋转真正落地到计算上必须把角度转换成 cos 和 sin 这两个实数系数才能代入旋转公式 new_q0 q0×cosθ - q1×sinθ 跟 Q/K 做乘加运算。这一步在 Nova 的代码里对应的就是torch.polar把每个位置的每组角度预先算好对应的 cos 和 sin打包成一张旋转系数表 freqs_cis。没有这一步Q 和 K 就无法完成旋转如下所示# model.py — precompute_rope_freqs第 191~202 行 # 把角度转为 cos/sin 系数用复数形式打包e^(i×angle) cos(angle) i×sin(angle) # 例如 angle 1.0 弧度 → torch.polar(1.0, 1.0) 0.540 0.841i # ↑cos ↑sin freqs_cis torch.polar(torch.ones_like(angles), angles) # freqs_cis 的形状 [max_seq_len, 32]每个元素是一个复数打包了该位置该组的 cos 和 sin这里用复数格式 cos sin·i 来存储 (cos, sin) 这一对系数不是因为旋转跟复数有什么神秘关系纯粹是一个工程上的打包技巧。因为 PyTorch 的复数乘法 (a bi) × (c di) (ac - bd) (ad bc)i其中实部 ac - bd 跟旋转公式 new_q0 q0×cosθ - q1×sinθ 的结构一模一样虚部 ad bc 跟 new_q1 q0×sinθ q1×cosθ 也一模一样。换句话说只要把 (q0, q1) 打包成复数 q0 q1·i把 (cosθ, sinθ) 打包成复数 cosθ sinθ·i一次复数乘法就等价于一次旋转32 组的旋转用一行向量化操作就能全部搞定比手写 for 循环快得多。Nova 中实际执行旋转的代码如下所示# model.py — apply_rotary_emb第 206~233 行 # 1. 把 Q 向量的 head_dim64 维两两配对塞进复数容器 # [batch, n_heads, seq_len, 64] → [batch, n_heads, seq_len, 32]每个元素是复数 q_complex torch.view_as_complex(q.float().reshape(*q.shape[:-1], -1, 2)) k_complex torch.view_as_complex(k.float().reshape(*k.shape[:-1], -1, 2)) # 2. 逐元素复数乘法 32 组同时完成旋转 q_rotated torch.view_as_real(q_complex * freqs_cis).flatten(-2) k_rotated torch.view_as_real(k_complex * freqs_cis).flatten(-2) # 拆回实数后 flatten 恢复为 [batch, n_heads, seq_len, 64]形状和输入完全一样所以整条执行链路可以概括为先用外积算出每个位置的旋转角度几何量不能直接计算再用 torch.polar 把角度转成 cos/sin 系数实数可以计算最后用复数乘法把 cos/sin 系数一次性作用到 Q 和 K 上完成旋转。说到底旋转翻译成计算语言就是把 head_dim 维的向量分成 32 对每对用各自的 (cosθ, sinθ) 做两次乘加本质和矩阵乘法一样就是普通的乘加运算只不过它恰好具备不改长度、只改方向的几何性质让位置信息可以优雅地编码进 Q 和 K 向量中。05 RoPE的工程实现上一节从几何直觉和计算公式两个层面把旋转讲清楚了这一节沿着 Nova 的源码完整走一遍 RoPE 从预计算到实际旋转的工程实现链路。对应的核心函数是 model.py 中的precompute_rope_freqs第 133~202 行它在模型初始化时只调用一次算好一张旋转系数表注册为 buffer后续每次前向传播直接查表使用不参与反向传播不消耗训练显存也不影响训练速度。我们首先要算出 32 组单位旋转角度。所谓单位旋转角度就是每往后走一个位置这一组维度对要转多少度。32 组的单位角度各不相同从 1.0每步转很多指数递减到 0.000132每步几乎不转目的是让不同组分工协作大步长的组擅长捕捉近距离关系小步长的组擅长捕捉远距离关系。计算公式是 freqs 1 / (theta ^ 比例)其中 theta 是基数业界标准值 10000比例是 0~1 之间的等差序列。具体分三步算如下所示# model.py — precompute_rope_freqs第 158 行 freqs 1.0 / (theta ** (torch.arange(0, head_dim, 2).float() / head_dim)) 拆解这一行代码的计算过程 第1步算比例64 个维度两两配对分成 32 组组编号 [0,2,4,...,62] 除以 64得到 0~1 之间的比例 [0/64, 2/64, 4/64, ..., 62/64] [0.0, 0.03125, 0.0625, ..., 0.96875] 第2步算基数的幂10000 ^ 比例比例越大结果越大 10000^0.0 1 → 10000^0.03 1.318 → ... → 10000^0.5 100 → ... → 10000^0.97 7586 第3步取倒数得到单位旋转角度 1/1 1.0大步长近距离敏感→ 1/100 0.01 → 1/7586 0.000132小步长远距离敏感 最终 32 个单位角度从 1.0 指数递减到 0.000132 freqs [1.0, 0.759, 0.575, ..., 0.01, ..., 0.000132]为什么要用指数递减而不是线性递减因为自然语言中近距离关系主谓、动宾等出现频率远高于远距离关系跨段落指代等指数分布可以让更多的组集中服务于近距离感知同时保留少数慢速组覆盖远距离这和傅里叶变换中高频到低频的分布逻辑是类似的。有了单位角度之后接下来要算每个位置的真实旋转角度。单位角度表示的是每走一步转多少度真实角度则是这个位置实际要转多少度。关系很简单真实角度 位置 × 单位角度。用 torch.outer外积一次性算出所有位置 × 所有组的角度矩阵如下所示# model.py — precompute_rope_freqs第 168~189 行 # 生成位置编号序列 t torch.arange(max_seq_len, dtypetorch.float32) # [0, 1, 2, ..., 127] # 外积每个位置 × 每个单位角度 真实旋转角度 angles torch.outer(t, freqs) # [128, 32] angles 是一张 128 行 × 32 列的角度表展开来看 组0(快1.0) 组1(0.759) ... 组31(慢0.000132) 位置 0 的角度 0×1.00 0×0.7590 ... 0×0.0001320 位置 1 的角度 1×1.01.0 1×0.7590.759 ... 1×0.0001320.000132 位置 2 的角度 2×1.02.0 2×0.7591.518 ... 2×0.0001320.000264 ... 位置127的角度 127×1.0127 127×0.75996.4 ... 127×0.0001320.01676 竖着看每一列同一组内位置越靠后角度越大转得越多 横着看每一行同一位置上组0 转得最多组31 转得最少角度表算好之后还不能直接拿去用上一节讲过角度是几何量不能直接跟 Q/K 做矩阵运算必须先转成 cos/sin 系数。torch.polar 做的就是这件事把每个角度变成一个复数 cos(angle) sin(angle)·i打包存储 (cos, sin) 这对系数如下所示# model.py — precompute_rope_freqs第 200 行 freqs_cis torch.polar(torch.ones_like(angles), angles) # 形状 [128, 32]每个元素是一个复数包含了该位置该组的 cos 和 sin 系数整张 freqs_cis 表在模型初始化时算好注册为 buffer 跟随模型保存和加载。前向传播时只需按当前序列长度截取对应的行即可如下所示# model.py — NovaModel.__init__第 425~434 行 freqs_cis precompute_rope_freqs(head_dim, config.max_seq_len, thetaconfig.rope_theta, ...) self.register_buffer(freqs_cis, freqs_cis) # 注册为 buffer不参与训练 # model.py — NovaModel.forward第 494 行 freqs_cis self.freqs_cis[:seq_len] # 截取当前长度推理时 seq_len 可能小于 max_seq_len最后一步就是实际应用旋转。拿到 freqs_cis 之后在多头自注意力的 QK 点积之前对 Q 和 K 分别施加旋转。注意V 不旋转——因为 V 是实际提供的内容位置信息只需要编码进 Q我在找什么和 K我能提供什么中通过 QK 点积来影响注意力权重就够了。旋转后的 Q 和 K 进入正常的点积 → 缩放 → 因果掩码 → softmax → 加权求和流程下游完全无感知如下所示# model.py — MultiHeadAttention.forward第 305~306 行 q, k apply_rotary_emb(q, k, freqs_cis) # Q 和 K 各自按位置旋转V 不旋转 attn_output F.scaled_dot_product_attention(q, k, v, ...) # 然后才做 QK 点积最后把整条链路串起来算 32 组单位旋转角度freqs→ 外积算每个位置的真实角度angles→ 转成 cos/sin 系数表freqs_cis→ 在 Attention 内部旋转 Q 和 Kapply_rotary_emb。预计算只做一次每次前向传播只查表和做一轮复数乘法计算开销几乎可以忽略。06 QK点积展开三角恒等公式前面几节反复提到旋转后的 QK 点积里会自然出现 (m-n)但一直没有真正展开算过这个自然出现到底是怎么回事。这一节用一组完整的数值推导把这件事彻底剖析清楚你会看到(m-n) 这个减法从未被显式计算过它完全藏在乘法展开后的三角恒等式里。假设只看一对维度2 个数字两个 token 的原始 Q 和 K 如下token A 在位置 3原始 Q (0.6, 0.8) token B 在位置 1原始 K (0.5, 0.3) 单位旋转角度 θ 30°每走1个位置转30°为了好算先分别旋转 Q 和 K。Q 在位置 3转 3×30° 90°K 在位置 1转 1×30° 30°Q_rot (0.6×cos90° - 0.8×sin90°, 0.6×sin90° 0.8×cos90°) (0.6×0 - 0.8×1, 0.6×1 0.8×0) (-0.800, 0.600) K_rot (0.5×cos30° - 0.3×sin30°, 0.5×sin30° 0.3×cos30°) (0.5×0.866 - 0.3×0.5, 0.5×0.5 0.3×0.866) (0.283, 0.510)然后做点积。到这一步为止算法和普通的 QK 点积没有任何区别——还是逐元素相乘再求和Q_rot · K_rot (-0.800) × 0.283 0.600 × 0.510 -0.226 0.306 0.080数字算出来了但 (m-n) 藏在哪里现在把这两项用原始数据完全展开不跳任何一步。第一项 (-0.800) × 0.283还原成旋转前的变量 (q0×cos90° - q1×sin90°) × (k0×cos30° - k1×sin30°) q0×k0×cos90°cos30° - q0×k1×cos90°sin30° - q1×k0×sin90°cos30° q1×k1×sin90°sin30°第二项 0.600 × 0.510 (q0×sin90° q1×cos90°) × (k0×sin30° k1×cos30°) q0×k0×sin90°sin30° q0×k1×sin90°cos30° q1×k0×cos90°sin30° q1×k1×cos90°cos30°两项加在一起后按 q0k0、q1k1、q0k1、q1k0 四组系数归并含 q0×k0 的项cos90°cos30° sin90°sin30° 含 q1×k1 的项sin90°sin30° cos90°cos30° 含 q0×k1 的项-cos90°sin30° sin90°cos30° 含 q1×k0 的项-sin90°cos30° cos90°sin30°关键来了。cos90°cos30° sin90°sin30° 这个式子恰好就是三角恒等式 cosA×cosB sinA×sinB cos(A-B)即 cos(90°-30°) cos(60°)。而 -cos90°sin30° sin90°cos30° sin(90°-30°) sin(60°)。所有项里的角度全部坍缩成了 (90°-30°) 60°而 90° m×θ30° n×θ所以 60° (m-n)×θ (3-1)×30° 位置差 × 单位旋转角度。最终整个点积可以写成Q_rot · K_rot (q0×k0 q1×k1) × cos((m-n)θ) (q0×k1 - q1×k0) × sin((m-n)θ) ↑ 原始内容的点积 ↑ 原始内容的交叉项 0.54 × cos(60°) (-0.22) × sin(60°) 0.54 × 0.5 (-0.22) × 0.866 0.270 - 0.190 0.080 ✓和直接算的结果一致减法就在这里。cos(90°-30°) cos(60°) 里的减号不是代码里写的减法运算而是三角恒等式 cos(A-B) cosA×cosB sinA×sinB 在乘法展开后自动做的。(m-n) 从头到尾没有被显式计算过代码里只有旋转乘法和点积乘法加法但乘法展开后三角恒等式替你完成了那个减法。这就是相对距离从旋转和点积中自然涌现的真正含义。为了验证这个结论不是碰巧再做两组对比实验。同样的 Q(0.6,0.8) 和 K(0.5,0.3)同样的距离差 2但换到位置 100 和 98(m-n)θ 依然 2×30° 60°代入公式 0.54×cos(60°) (-0.22)×sin(60°) 0.080和位置 3、1 时的结果完全一样。不管在句子的什么位置只要两个 token 的距离差相同点积中的位置贡献就相同。这就是对绝对位置无感、只对相对距离有感。图7 两组对比实验的结论再换一个距离同样的 Q 和 K但改为位置 4 和 1距离差 3(m-n)θ 3×30° 90°代入公式 0.54×cos(90°) (-0.22)×sin(90°) 0.54×0 (-0.22)×1 -0.220和距离差 2 时的 0.080 完全不同。同样的内容、不同的距离点积结果截然不同。模型正是通过点积大小的差异间接感知到了两个 token 之间的距离远近。如图7所示。07 位置插值扩展上下文长度前面讲过RoPE 的旋转角度用公式计算理论上任何位置都能算出角度不存在表不够长的问题。但能算出角度不等于效果一定好。模型的 W_Q、W_K、FFN 等权重都是在训练长度范围内优化出来的它们只见过 [0, max_seq_len) 范围内的旋转角度。如果推理时直接灌入一个超出训练范围的位置比如训练时 max_seq_len 128推理时来了个位置 200这个位置对应的旋转角度是模型从未见过的注意力模式大概率会崩掉。位置插值Position Interpolation解决的就是这个问题。它的思路极其简单既然模型只认识 [0, 128) 范围内的角度那就把更长的位置序列压缩回这个范围让所有角度都落在模型见过的区间内。假设训练时 max_seq_len 128推理时想支持 256 个 token2 倍扩展做法就是把位置编号除以缩放因子 scale_factor 2原始位置索引: [0, 1, 2, 3, ..., 255] 间距 1.0 插值后位置索引: [0, 0.5, 1.0, 1.5, ..., 127.5] 间距 0.5 ↑ 256 个位置被压缩回 [0, 128) 范围对应到代码里就是 precompute_rope_freqs 中的一行除法如下所示# model.py — precompute_rope_freqs第 180~182 行 if scale_factor is not None: t t / scale_factor # ← 位置插值的全部代码就这一行256 个位置共享了原来 128 个位置的角度空间每个位置的真实旋转角度都被等比压缩了。这意味着所有角度仍然落在模型训练时见过的范围内W_Q 和 W_K 不需要做任何修改就能正常工作。但压缩是有代价的。原来相邻两个位置的角度差是 θ单位旋转角度压缩后变成了 θ / scale_factor。以步长最大的组θ₀ 1.0为例scale_factor 1 → 相邻位置的角度差 1.0 弧度 → 差异巨大轻松区分 scale_factor 2 → 相邻位置的角度差 0.5 弧度 → 差异缩小一半还能分清 scale_factor 4 → 相邻位置的角度差 0.25 弧度 → 差异只剩 1/4开始吃力 scale_factor 8 → 相邻位置的角度差 0.125 弧度 → 差异很小模型难以分辨相邻 token而步长最小的组θ₃₁ ≈ 0.000132情况更糟scale_factor 8 时角度差只有 0.0000165 弧度几乎重叠位置信号基本消失。说白了scale_factor 越大相邻 token 在旋转后的方向差异就越小模型越难区分谁在前谁在后这就是位置插值的精度代价。Meta 在 2023 年的位置插值论文中基于 LLaMA-7B70 亿参数训练长度 2048给出了一组实验数据2 倍扩展基本无损4 倍扩展轻微下降但可接受8 倍需要短时续训约 1000 步才能恢复效果16 倍续训后也只是勉强可用。需要注意的是这些数据是基于 70 亿参数大模型得出的大模型本身的泛化能力更强。对于 Nova 这种 22M 参数的微型模型泛化能力更弱实践中建议保守估计 2~4 倍为安全范围。一句话概括位置插值的本质用一行除法t t / scale_factor把更长的位置序列压缩回训练范围让模型在推理时能处理比训练更长的上下文。代价是相邻 token 的角度差缩小、位置分辨率下降收益是不需要重新训练就能扩展上下文长度。这个 trade-off 在 2~4 倍的范围内通常是划算的。08 总结回顾整篇文章从token 为什么需要位置编码一路讲到一行代码扩展上下文长度核心其实就是两件事。第一件事是 RoPE 怎么把位置信息编进去的。它没有像绝对位置编码那样给每个 token 加一个固定的位置标签而是在 Attention 内部对 Q 和 K 做旋转每个 token 按自己的位置转一个角度位置越靠后转得越多。旋转只改方向不改长度所以原始语义特征的强度完好无损而当旋转后的 Q 和 K 做点积时乘法展开后三角恒等式 cosA×cosB sinA×sinB cos(A-B) 会自动把两个绝对角度的差 (m-n)θ 提取出来(m-n) 就是两个 token 的相对距离从未被显式计算过却藏在了每一次乘加运算的结果里。于是QK 点积的结果天然同时包含了两层信息语义关联度两个向量内容有多相似和距离关联度两个 token 隔了多远。第二件事是怎么在推理时扩展上下文长度。RoPE 的旋转角度用公式计算不受位置表行数的限制但模型的权重只在训练长度范围内优化过。位置插值的做法是把更长的位置序列等比压缩回训练范围代码上就是一行t t / scale_factor让所有角度落在模型见过的区间内。代价是相邻 token 的角度差缩小、位置分辨率下降但在 2~4 倍的扩展范围内这个 trade-off 通常是划算的。这也是为什么 RoPE 能成为 LLaMA、DeepSeek、Qwen、Gemma 等几乎所有现代大模型的标配方案它用一套统一的旋转机制同时解决了不破坏内容、自动感知相对距离和支持上下文扩展三个工程需求而整个实现只需要一个预计算函数和一行复数乘法。学AI大模型的正确顺序千万不要搞错了2026年AI风口已来各行各业的AI渗透肉眼可见超多公司要么转型做AI相关产品要么高薪挖AI技术人才机遇直接摆在眼前有往AI方向发展或者本身有后端编程基础的朋友直接冲AI大模型应用开发转岗超合适就算暂时不打算转岗了解大模型、RAG、Prompt、Agent这些热门概念能上手做简单项目也绝对是求职加分王给大家整理了超全最新的AI大模型应用开发学习清单和资料手把手帮你快速入门学习路线:✅大模型基础认知—大模型核心原理、发展历程、主流模型GPT、文心一言等特点解析✅核心技术模块—RAG检索增强生成、Prompt工程实战、Agent智能体开发逻辑✅开发基础能力—Python进阶、API接口调用、大模型开发框架LangChain等实操✅应用场景开发—智能问答系统、企业知识库、AIGC内容生成工具、行业定制化大模型应用✅项目落地流程—需求拆解、技术选型、模型调优、测试上线、运维迭代✅面试求职冲刺—岗位JD解析、简历AI项目包装、高频面试题汇总、模拟面经以上6大模块看似清晰好上手实则每个部分都有扎实的核心内容需要吃透我把大模型的学习全流程已经整理好了抓住AI时代风口轻松解锁职业新可能希望大家都能把握机遇实现薪资/职业跃迁这份完整版的大模型 AI 学习资料已经上传CSDN朋友们如果需要可以微信扫描下方CSDN官方认证二维码免费领取【保证100%免费】