旋转位置编码 RoPE
与其把位置信息加进词向量,不如把它旋转进注意力。相对位置由此直接进入注意力分数,也为扩展上下文长度留下空间。
正弦位置编码,留了两个隐忧
回忆「位置编码」一课的做法:给每个位置算一个向量, 加 到词嵌入上,让注意力能区分词序。这套办法能用,但留下两个隐忧:
- 它编码的是「绝对」位置 ,也就是「这个词是第 5 个」。正弦编码里相对位置并非无迹可寻,固定间隔的两个位置之间存在稳定的变换关系,模型有机会自己学出来;但没有任何机制 保证 位置信息一定以「相对距离」的形式进入注意力分数。而语言里更要紧的往往是 相对 位置,「这两个词隔了 2 个位置」比「它们各自排第几」更有意义。
- 对更长的文本力不从心 。训练时只见过有限长度的句子,真到了更长的文本上(即所谓 外推 ),表现会下降。
RoPE 换了一个思路来正面回应第一个隐忧:把「位置只以相对距离的形式影响注意力分数」从模型 可以学到 的一种可能,变成数学上 必然成立 的恒等式。
核心思想:不「加」位置,而「旋转」位置
RoPE(Rotary Position Embedding,旋转位置编码) 不再把位置信息加到词向量上,而是直接对注意力里的 Query 和 Key 向量做旋转 , 位置越靠后,旋转的角度越大 。V 不旋转,因为需要「只看相对距离」的是 Q 与 K 打分这一步,而 V 只参与之后的加权求和,不参与打分。
关键的妙处在点积这一步:
注意力本来就是靠 Q 和 K 的点积来打分的,所以位置信息「免费」搭上了这趟车,不需要额外再加一个位置向量。
施加的时机也和正弦编码不同。正弦编码在输入端加一次,此后各层不再过问位置;RoPE 则发生在 每一层注意力内部 :Q、K 切成多个头之后,每个头各自把自己的维度旋转一遍,各头共用同一套旋转角。
具体怎么旋转
把单个头的 Q(或 K)的维度 两两配对 ,每一对 看成二维平面上的一个向量。 维一共配成 对。对位置 上的第 对( ),旋转角度为
公式里的 是 每个头的维度 (即「多头注意力」里的 ,代码中写作 head_dim ),不是整个模型的 。每一对用 不同的频率 ,这和正弦位置编码的设计一脉相承: 序号靠前的对( 小)转得快,序号靠后的对( 大)转得慢 ,快慢搭配,从而同时捕捉近距离与远距离的位置关系。 是这条指数式的 底数 (与旋转角 无关,只是沿用了业界 rope_theta 的叫法),常取 10000,LLaMA3 用的是 500000。
而二维旋转,就是平面上把一个向量转过角度 的标准公式:
公式里的每个量都能画出来。下面取一个 head_dim = 8 的 Q 向量,把它的 8 个维度两两配对,得到 4 个平面向量。拖动位置滑块,选中的那一对就绕原点转过 ;换一对再拖同样的距离,转过的角度会差一个数量级。
位置每往后挪一格,这一对就多转一个 。第 0 对的 ,走 6.3 个位置就转满一圈;第 3 对的 ,要走 6283 个位置才转满一圈,在这 32 个位置里几乎看不出动静。快的那几对分辨近处的位置差异,慢的那几对留给远距离。
为什么点积「自动」得到相对位置
先看单独一对维度。设位置 的 Query 在第 对上旋转了 ,位置 的 Key 在同一对上旋转了 。两者各自旋转后再做点积,结果只依赖于 角度差 和这一对的原始内容,而 与 、 各自的绝对值无关 。
为什么?二维点积等于「两个向量的长度相乘,再乘以夹角的余弦」。旋转不改变向量的长度,长度这一项原样保留;至于夹角,两个向量各转一个角度之后,夹角相比原来只 多变了 「转角之差」 这么多,与它们各自从第几位起转无关。长度不变、夹角只多变一个 ,点积自然就只随 变化。
下面把这段话画出来。 摆在位置 、 摆在位置 ,同一对维度上的两个平面向量各自转过 与 。拖动滑块改变位置,看夹角和点积什么时候变、什么时候不变。
这条曲线只画了第 0 对( ),所以是一条规整的余弦。真正一个头有 4 对, 从 1 一路降到 0.001,各对的周期差着几个数量级;4 对的结果加起来,整体不会再是这么简单的周期起伏。近处的距离靠转得快的那几对分辨,远处的交给转得慢的那几对。
把 m 和 n 一起往后挪,两个向量都在转,夹角与点积却停在原处;只要 m − n 变了,曲线上的红点立刻滑到别处。第 8 节的代码正是这样验证的,把 (2, 5) 换成 (12, 15),绝对位置全变,分数一模一样。
再看整个向量。 与 的点积,正是各对二维点积之和。每一对的结果都只依赖 (各对的频率 不同,但都乘在同一个 上),求和之后整体自然也只依赖 。于是「相距多远」被直接编码进了注意力分数。这里讲清直觉即可,不做严格推导。
RoPE 的好处
- 相对位置天然内建 :正面回答了正弦编码的第一个隐忧;
- 不引入任何可学习参数 :旋转角度是算出来的,不用训练;
- 计算简单 :每层只对该层的 Q、K 各做一次旋转,开销很小。
关于长度外推:澄清一个常见误解
在长度扩展这件事上,RoPE 的价值在于:它的「旋转」形式,让后续一些 低成本的插值技巧 得以把上下文长度扩展开(比如把旋转角按比例缩放,把更长的位置「塞进」训练见过的角度范围)。这属于进阶话题,知道有这条路即可,点到为止。
动手:先看清二维旋转
取一个二维向量,对不同位置施加不同旋转角,看它的轨迹,建立「位置 → 旋转角」的直觉。
动手:实现 RoPE 并验证相对性
第 3 节旋转的是一对维度, head_dim = 8 则有 4 对。每一对转过的角度不同,做的却是同一个二维旋转,所以不必逐对循环,把 4 对摞在一起、一次把 cos 和 sin 乘完就行。代码分成两步, build_rope 预先算好每个位置、每一对的旋转角, apply_rope 再把某个位置的旋转施加到 Q 或 K 上。
然后验证它编码的确实是 相对 位置。构造两组「绝对位置不同、但相对距离相同」的摆放,比较旋转后的注意力点积。
距离同为 3 的两组分数一致,距离换成 7 的那组明显不同。分数不随绝对位置变、只随相对距离变,这两半合起来才说明 RoPE 确实把相对位置编码进了注意力分数(只有前一半的话,一个根本不看位置的打分函数也能通过测试)。
零件快齐了,但生成还很慢
RMSNorm、SwiGLU、RoPE,现代零件已经基本集齐。但当模型真正一个词一个词地生成长文本时,会暴露一个严重的效率问题:
怎么免掉这些重复计算,是「KV 缓存与分组查询注意力」要解决的。