高中背了三年的 sin 和 cos,原来是大模型记住"谁先谁后"的秘密

2026-07-01 · Steve Chan

正弦、余弦、正切、和角公式、正弦定理……高中背了三年,考完只记得"sin30° = 0.5"。 因为老师只教你"查表算三角形",没告诉你两件事:

第一,sin 和 cos 的灵魂根本不是三角形,是"旋转"。 第二,今天每一个大模型(LLaMA、Qwen、GPT、DeepSeek)记住"哪个词在前、哪个词在后", 靠的正是给每个位置转一个角度——这套机制叫 RoPE(旋转位置编码)。

这篇文章不背一个公式、不证一个定理,全部用能跑起来的 PyTorch 代码, 把三角函数最核心的"旋转"本质,一路接到大模型的 RoPE。 并且顺手回答四个灵魂拷问:

  • sin、cos 明明是算三角形的,怎么会和"位置""顺序"扯上关系?
  • "给一个向量转一个角度"到底是什么意思?为什么要转?
  • Transformer 不是号称"并行处理"吗,它究竟怎么知道哪个词在前、哪个在后?
  • RoPE 公式里那些 sin(pos / 10000^...) 到底在干嘛?

0. 一句话主线

如果只能留一句话,那就是这句:

三角函数的灵魂是"旋转"。sin 和 cos,就是单位圆上一根转动的钟表指针的两个坐标(纵、横)。

一根指针从正右方(0°)开始转,转到角度 θ 时,它的指尖坐标正好是 (cos θ, sin θ)。 所以"sin/cos"根本不是三角形的边长比,而是"转了 θ 角之后,落在了哪里"。 大模型给第 m 个位置的词"转 m 个单位角度",就能把"它排第几"这件事,悄悄编码进向量里。

import torch, math

# 一根从正右方出发的单位指针,转动角度 theta 后的指尖坐标
for deg in [0, 30, 90, 180]:
    theta = math.radians(deg)
    tip = torch.tensor([math.cos(theta), math.sin(theta)])   # (cos, sin) = 指尖位置
    print(f"转 {deg:3d}° -> 指尖坐标 (cos, sin) = ({tip[0]:.2f}, {tip[1]:.2f})")
# 0°->(1,0)正右   30°->(0.87,0.5)   90°->(0,1)正上   180°->(-1,0)正左

记住这个画面:cos 是指针的横坐标,sin 是纵坐标,它俩一起描述"转了多少"。 下面所有东西都挂在这根转动的指针上。


1. sin、cos 不是三角形,是单位圆上的钟表指针

高中把 sin 定义成"对边比斜边",把你锁死在一个直角三角形里。 但真正有用的定义是单位圆:让一根长度为 1 的指针绕原点转,转到哪,(cos, sin) 就报出它在哪。 这个视角一换,三角函数立刻从"算三角形"升级成"描述旋转":

import torch

# 让指针转一整圈,记录轨迹 —— 它画出的正是一个圆
angles = torch.linspace(0, 2 * torch.pi, 8)
tips = torch.stack([torch.cos(angles), torch.sin(angles)], dim=1)
print(tips.round(decimals=2))
# 每一行是指针转到某个角度时的落点,8 个点均匀铺在单位圆上
print("每个落点到原点的距离:", tips.norm(dim=1).round(decimals=2))  # 全是 1.0

所有落点到原点的距离都是 1——它们严格躺在单位圆上。 这就是 sin/cos 的真身:一台"角度 → 坐标"的翻译机。喂进去一个角度,吐出来圆上的一个位置。

高中你被"对边斜边"框住了,其实只要记住:cos/sin = 转了 θ 角之后,指针尖在哪。 有了这个,下一节的"旋转"就水到渠成。


2. 旋转矩阵:把任意一个向量"转 θ 度"

指针能转,任意向量当然也能转。把一个向量转 θ 度,用的就是那个高中偷偷教过、却没说它多重要的旋转矩阵

\[R(\theta) = \begin{bmatrix} \cos\theta & -\sin\theta \\ \sin\theta & \cos\theta \end{bmatrix}\]

(如果你读过本系列《线性代数》那篇,这台"旋转机器"你已经见过。)代码转一个向量看看:

import torch, math

def rotation(theta):                                  # 造一台"转 theta 度"的机器
    c, s = math.cos(theta), math.sin(theta)
    return torch.tensor([[c, -s],
                         [s,  c]])

v = torch.tensor([1.0, 0.0])                          # 指向正右的向量
R90 = rotation(math.radians(90))
print(R90 @ v)                                        # tensor([0., 1.]) —— 被转到正上方
print((R90 @ v).norm())                               # 1.0 —— 长度不变,只改方向

旋转矩阵只改方向、不改长度。 这一点后面是关键:位置编码只能"标记位置", 绝不能把词向量本身的大小改掉,否则语义就毁了。旋转恰好满足这个要求——它温柔地只动方向。


3. 旋转的黄金性质:转 a 再转 b = 转 (a+b)

这是整篇文章的枢纽,也是 RoPE 全部魔法的来源。三角函数的"和角公式" (cos(a+b) = cos a cos b − sin a sin b 那一坨,你一定背过),翻译成人话就一句:

先转 a 度,再转 b 度,等于一口气转 (a+b) 度。角度是可以直接相加的。

用代码验证这个"角度可加"性质——它看起来平平无奇,却是位置编码能work的根基:

import torch, math

def rotation(theta):
    c, s = math.cos(theta), math.sin(theta)
    return torch.tensor([[c, -s], [s, c]])

a, b = math.radians(25), math.radians(40)
left  = rotation(a) @ rotation(b)        # 先转 b 再转 a
right = rotation(a + b)                   # 直接转 a+b
print(torch.allclose(left, right, atol=1e-6))   # True —— 转两次 = 转一次总和

角度可加,意味着"位置 5"就是"位置 3"再多转 2 步——位置之间的关系,变成了角度之间的减法。 这正是我们想要的:一个能自然表达"谁比谁靠后多少"的机制。记住这句,第 5 节它会变成主角。


🤔 疑惑点一:Transformer 不是"并行"的吗?它到底怎么知道词的先后顺序?

默认情况下,它根本不知道——纯粹的自注意力是"集合运算",把词序打乱,输出只会跟着一起打乱,模型察觉不到顺序。所以必须人工把位置信息"注入"进去。

这是最反直觉的一点。注意力机制里,每个词和所有词算相关度、再加权求和, 这个过程对词的排列是"等变"的:你把输入的词换个顺序,输出也只是换同样的顺序, 没有任何一步依赖"绝对位置"。用代码把这个"顺序失明"证出来:

import torch
import torch.nn.functional as F

def attention(X):                          # 最简自注意力,不含任何位置信息
    d = X.shape[-1]
    scores = X @ X.transpose(-2, -1) / d ** 0.5
    return F.softmax(scores, dim=-1) @ X

torch.manual_seed(0)
X = torch.randn(3, 4)                      # 3 个词,每个 4 维
perm = torch.tensor([2, 0, 1])            # 把词序打乱成 (第2, 第0, 第1)

out_then_perm = attention(X)[perm]         # 先算注意力,再打乱输出
perm_then_out = attention(X[perm])         # 先打乱输入,再算注意力
print(torch.allclose(out_then_perm, perm_then_out, atol=1e-6))   # True

结果为 True 说明:打乱输入 = 打乱输出,注意力对"顺序"完全无感。 "猫追狗"和"狗追猫"在纯注意力眼里,只是同一堆词的两种摆法,算出的关系一模一样。 这显然不行——语言里顺序就是意义。所以我们必须主动给每个位置盖一个"第几号"的戳。 RoPE 盖戳的方式,就是第 0~3 节那根转动的指针。


4. RoPE:给第 m 个位置的向量,转 m 个单位角度

思路简单到令人发指:既然旋转能优雅地标记角度,那就让第 m 个位置的词向量,转 m × θ 度。 位置 0 不转,位置 1 转 θ,位置 2 转 2θ……位置本身,就变成了"转了多少圈"。

import torch

def rope_2d(x, pos, theta=1.0):            # 把 2 维向量 x 按它的位置 pos 旋转
    ang = pos * theta                      # 第 pos 个位置转 pos*theta 度
    c, s = torch.cos(torch.tensor(ang)), torch.sin(torch.tensor(ang))
    x0, x1 = x[..., 0], x[..., 1]
    return torch.stack([x0 * c - x1 * s,   # 旋转矩阵作用在 (x0, x1) 上
                        x0 * s + x1 * c], dim=-1)

word = torch.tensor([1.0, 0.5])            # 同一个词的向量
for pos in [0, 1, 2, 3]:
    print(f"位置 {pos}: {rope_2d(word, pos).round(decimals=3)}")
# 同一个词,放在不同位置,被转到了不同方向 —— 位置被"转"进了向量里

同一个词向量 [1.0, 0.5],放在位置 0、1、2、3,被转成了四个不同方向的向量。 "它排第几"这个信息,现在藏在"它被转了多少角度"里。 位置编码完成,而且没动向量的长度(第 2 节的好处)。


5. 为什么"转角度"这么妙:两个词的相关度,只取决于它们的相对距离

这是 RoPE 最漂亮的地方,也是它吊打老式位置编码的原因。注意力要算 query · key(第 1 节讲过, 点积衡量两个向量多相关)。当 query 在位置 m、key 在位置 n,各自被 RoPE 旋转后,它们的点积会神奇地只依赖 m − n(相对距离),而与它们各自的绝对位置无关

回到第 3 节的"角度可加/可减":R(mθ)ᵀ R(nθ) = R((n−m)θ),所以旋转后的点积只剩下 (n−m) 这个相对量。空口无凭,代码验证——固定相对距离,绝对位置随便挪,点积纹丝不动:

import torch

def rope_2d(x, pos, theta=0.5):
    ang = torch.tensor(pos * theta)
    c, s = torch.cos(ang), torch.sin(ang)
    x0, x1 = x[..., 0], x[..., 1]
    return torch.stack([x0 * c - x1 * s, x0 * s + x1 * c], dim=-1)

torch.manual_seed(0)
q = torch.randn(2)          # 一个 query 向量
k = torch.randn(2)          # 一个 key 向量

# 三组 (m, n),相对距离 n - m 都等于 3,但绝对位置完全不同
for m, n in [(0, 3), (2, 5), (10, 13)]:
    score = torch.dot(rope_2d(q, m), rope_2d(k, n))
    print(f"query@{m:2d}, key@{n:2d}  (相对距离={n-m})  ->  点积 = {score:.4f}")
# 三行的点积完全相等 —— 只要相对距离一样,注意力分数就一样

三行输出的点积一模一样。这意味着:RoPE 让模型天生理解"相对位置"—— 它不在乎你这句话从第几个字开始,只在乎"这个词离那个词隔了多远"。 这正是语言的本质("形容词修饰它后面的名词"是相对关系,和整句话在文档第几行无关)。 老式的绝对位置编码做不到这一点,RoPE 靠一根转动的指针就做到了。


🤔 疑惑点二:RoPE 公式里那一堆 10000^(2i/d) 是什么鬼?

那是给向量的不同维度配上"不同的转速",像钟表的时针、分针、秒针——快针分辨相邻词,慢针记住远距离,组合起来就能覆盖从"隔壁"到"很远"的所有位置关系。

前面为了讲清楚,我们只转了一个 2 维向量、用了同一个转速 θ。真实的词向量有几百上千维, RoPE 把它们两两配对,每一对用不同的角速度去转:低维配对转得快,高维配对转得慢。

import torch

d = 8                                       # 向量维度(配成 4 对)
i = torch.arange(0, d // 2).float()
theta = 10000 ** (-2 * i / d)               # 每一对的"转速",从快到慢
print("四对维度各自的转速 theta:", theta.round(decimals=4))
# tensor([1.0000, 0.1000, 0.0100, 0.0010]) —— 第一对转最快,最后一对转最慢

pos = 5                                      # 某个位置
angles = pos * theta                         # 该位置下,每一对各自要转的角度
print(f"位置 {pos} 时各对的旋转角度:", angles.round(decimals=4))

为什么要多个转速?打个比方就懂了——这就是钟表的智慧

秒针转得飞快,用来分辨"1 秒 vs 2 秒"这种近距离差别; 时针转得极慢,用来记录"3 点 vs 9 点"这种大跨度。 只有一根针,要么分不清相邻、要么表示不了大范围。 RoPE 给向量配上一整套"快慢不同的指针",于是它既能分辨相邻词,又能记住隔了几十上百个词的远距离关系——用一组三角函数,把从近到远的所有位置尺度一网打尽。

这就是那个吓人的 10000^(2i/d) 的全部含义:它只是在给每一对维度分配一个转速,凑齐一套时针分针秒针。 (顺带一提:这里的"转速"就是高中 y=Asin(ωx+φ) 里那个角频率 ω——只是当年没人告诉你它能这么用。)


🎬 动手:一个最小 RoPE,看位置如何"转"进注意力

把上面所有画面缝进一个能跑的最小 RoPE,作用在一串词上,然后看注意力分数如何随相对位置变化:

import torch

def build_rope(seq_len, d, base=10000.0):        # 预先算好每个位置、每一对的 cos/sin
    i = torch.arange(0, d, 2).float()
    theta = base ** (-i / d)                      # 每一对的转速
    pos = torch.arange(seq_len).float()
    ang = torch.outer(pos, theta)                 # (seq_len, d/2):每个位置每对的角度
    return torch.cos(ang), torch.sin(ang)

def apply_rope(x, cos, sin):                      # 把 x 的每一对维度旋转对应角度
    x0, x1 = x[..., 0::2], x[..., 1::2]           # 偶数维、奇数维配对
    out0 = x0 * cos - x1 * sin
    out1 = x0 * sin + x1 * cos
    return torch.stack([out0, out1], dim=-1).flatten(-2)

torch.manual_seed(0)
seq_len, d = 6, 8
X = torch.randn(seq_len, d)                       # 6 个词,每个 8 维
cos, sin = build_rope(seq_len, d)
Xr = apply_rope(X, cos, sin)                      # 注入位置信息后的词向量

scores = Xr @ Xr.transpose(-2, -1) / d ** 0.5     # 加了位置的注意力相关度
print("第 0 个词对其余各词的注意力打分:")
print(scores[0].round(decimals=3))                # 现在这些分数"知道"了谁离谁多远

和"疑惑点一"里那个顺序失明的注意力对比一下:现在 scores 里已经悄悄编码了相对位置, 打乱词序结果就会变了——模型终于长出了"顺序感"。

仓库里的动画脚本把这根"转动的指针"画了出来:

python rope_rotation_visualization.py

画面里,同一个词向量被放在位置 0、1、2、3……你会看到它像钟表指针一样, 每往后挪一个位置,就多转一个固定的角度;而多频率那一版里,几对指针以不同转速同时旋转, 快的负责相邻、慢的负责远方。最后一幅图把"点积只依赖相对距离"画成一条曲线—— 无论绝对位置怎么平移,曲线的形状纹丝不动。


缝合:把所有画面接起来

回到开头,现在每个概念都有了画面和代码出处:

三角/旋转概念 高中怎么讲 这篇文章怎么看(画面) 在大模型里是什么
sin / cos 对边、邻边比斜边 单位圆指针的纵、横坐标(第 0、1 节) 位置编码的原材料
旋转矩阵 一个数表 把向量转 θ 度、只改方向(第 2 节) 给词向量盖"位置戳"
和角公式 cos(a+b)=… 一坨 转 a 再转 b = 转 a+b(第 3 节) RoPE 能表达相对位置的根基
多个频率 三角函数的周期 / 角频率 ω(y=Asin(ωx+φ) 时针分针秒针,快慢配合(疑惑点二) 10000^(2i/d) 的各维转速
点积 向量数量积 a·b,求夹角、投影 转完后只依赖相对距离(第 5 节) 注意力天生懂"相对位置"

三句话总结这篇文章:

三角函数的灵魂是"旋转"——sin/cos 是单位圆指针的坐标,不是三角形的边(第 0、1 节); 旋转能优雅地把"位置"编码成"角度",而"角度可加"让位置关系变成相对减法(第 2、3、4 节); 于是 RoPE 让注意力天生理解"谁离谁多远",这就是今天所有主流大模型记住词序的秘密(第 5 节、疑惑点二)。

当年三角函数背得那么苦,不是因为它难,是因为没人告诉你: 那根在单位圆上转啊转的 sin/cos 指针,最后转进了每一个大模型的注意力里, 成了它记住"谁先谁后"的办法。 现在把上面每段代码跑一遍,比高中刷十套三角卷子都值。


备注(选题/标题): 这篇走的是"高中知识其实是 AI 基石"的钩子(和《求根公式》那篇同一路子)。 若想并入"大学4年"主系列,标题可换成: 《大学4年没让你真正搞懂的三角函数,被一段 RoPE 代码讲透了》。