模型能接收 32K 就真的会用 32K 吗?RoPE 位置插值、频率缩放与长上下文验证
从训练长度外的 RoPE 相位失配出发,推导位置插值与非均匀频率缩放,并用长度、证据位置和任务复杂度分桶验证有效上下文。
上一篇说明了位置函数“怎样算”:RoPE 把每层每头的 Query、Key 按全局位置旋转,使点积显式依赖相对位移。但一个危险的推论是:既然 sin 和 cos 能为任意整数位置返回数值,把模型配置里的最大长度从 4K 改成 32K,模型就自然拥有 32K 上下文。
这混淆了名义上下文长度(Claimed Context Length)和有效上下文长度(Effective Context Length)。前者只说明接口与显存允许输入多长;后者才说明模型在该长度上仍能定位、提取并组合信息。本文聚焦一件事:RoPE 模型如何把训练外的位置映回较熟悉的相位范围,以及怎样证明扩窗真的有效。
01 “能放进去”为什么不等于“能用起来”?#
设预训练最大长度为 ,目标长度为 ,每头宽度为偶数 。原始 RoPE 的第 个二维频率为:
位置 对应角度 。训练期间,模型只在相对位移 内见过这些相位怎样影响 Q–K 分数。直接输入更长序列时,代码仍能算出:
但 的相位组合位于训练分布之外。神经网络在已见区间内拟合良好,并不约束它在区间外继续平滑。
预训练:位置 0 ───────────────────── L₀-1
模型见过的相位与距离
直接扩窗:位置 0 ───────────────────── L₀-1 ───────────── L₁-1
未见相位区间
结果可能是:张量形状合法、无越界、无 OOM,但长距离注意力已失真。text所以至少要区分四层能力:
| 层级 | 要回答的问题 | 仅修改最大长度能否证明 |
|---|---|---|
| 接口容量 | tokenizer 与模型是否接受 个 token? | 能 |
| 系统容量 | 显存、prefill 延迟与吞吐是否可承受? | 不能 |
| 语言建模 | 长度增加时损失/困惑度是否稳定? | 不能 |
| 信息利用 | 能否在不同位置检索、推理与聚合? | 不能 |
02 直接外推究竟把什么送出了训练区间?#
把每对 Q/K 维度写成二维向量,RoPE 分数可以看成许多三角基函数的加权组合。内容投影学到的系数只在训练位移区间内受到损失约束。
content q_p [N,H,L,d] ─► R(p; ω) ─┐
├─ q̃k̃ᵀ / √d ─► logits [N,H,L,S]
content k_s [N,H,S,d] ─► R(s; ω) ─┘
位置部分:R(p)ᵀR(s) = R(s-p) = R(Δ)
训练只约束 |Δ| < L₀;直接扩窗要求模型在 |Δ| < L₁ 上外推。text高频维的角度变化快,善于区分局部位置,却会在长距离上经历很多次绕圈;低频维变化慢,覆盖更大的距离尺度。扩窗不是简单“多生成几行 sin/cos 表”,而是让所有频率共同进入未受训练约束的新组合。
03 位置插值怎样把长窗口压回旧范围?#
位置插值(Position Interpolation, PI)不再直接使用目标位置 ,而是令:
其中 是扩展因子。于是目标位置区间 被压到 ;相对位移也变成:
目标窗口 L₁: 0──1──2──3──4──5──6──7
│ 除以扩展因子 s=2
▼
旧相位范围 L₀:0─0.5─1─1.5─2─2.5─3─3.5text这里产生的是连续位置,不要求落在整数网格上。PI 的关键假设是:在旧区间内做插值,通常比把模型推向整个训练区间之外更稳定。它没有改变 Q/K 的形状,也没有增加可训练位置表。
04 用 4→8 token 手算“稳定”与“代价”#
令 、,所以 。只看一个角频率 rad/token。
长序列中,位置 1 的 Key 与位置 7 的 Query 相距 6:
| 方案 | 有效位移 | 旋转角度 |
|---|---|---|
| 直接外推 | rad | |
| 位置插值 | rad |
插值把长距离重新放回训练见过的 范围。但相邻 token 的位移也从 1 压成了 0.5:
若内容向量恰好使这一对维度的点积退化为 ,相邻位置会显得更相似。PI 解决了“远距离外推”,却同时牺牲了部分“局部分辨率”。这正是后续频率缩放不应一刀切的原因。
05 缩位置与缩频率为什么等价?#
RoPE 角度只由位置与频率的乘积决定:
所以线性 RoPE 缩放(Linear RoPE Scaling)可以保留整数 position_ids,改用:
这对 KV Cache 很方便:缓存逻辑位置仍是 0,1,2,...,只在生成 cos/sin 时使用缩放后的频率。
position_ids [L] ───────────────┐
├─ outer product ─► angles [L,d/2]
base b ─► inv_freq [d/2] ─► / s ┘
│
├─ cos/sin ─► rotate Q [N,H,L,d]
└─ cos/sin ─► rotate K [N,H,L,d]text注意:这里的 是扩展因子,不是前文用作 Key 位置的下标。实现中应使用 factor、key_position 等清晰名称,避免同一个字母承担两种语义。
06 为什么非均匀频率缩放能保住更多局部信息?#
线性 PI 把每个 都除以相同的 。更细的思路是:
- 高频、短波维主要解析近邻,少缩放或不缩放;
- 低频、长波维负责远距离,接近完整缩放;
- 中间频率平滑过渡,避免频谱断层。
一种有代表性的基底缩放把 RoPE base 从 改成:
于是:
当 时,最高频 不变;到最低频附近,缩放量逐渐接近 。这常被称为 NTK-aware 一类缩放。它是一种频率分配策略,不是“神经切线核已给出质量证明”。
取 、、,原频率恰为 [1, 0.1, 0.01, 0.001]:
| 维度对 | 原频率 | 线性 PI | 基底缩放(约) |
|---|---|---|---|
| 0 | 1 | 0.5 | 1 |
| 1 | 0.1 | 0.05 | 0.079 |
| 2 | 0.01 | 0.005 | 0.0063 |
| 3 | 0.001 | 0.0005 | 0.0005 |
YaRN 在此方向上进一步按波长区间混合插值与外推,并调整注意力尺度。工程上应把 linear、dynamic、yarn 等视为不同的频率契约;不能只看到相同 factor 就认为生成的角度相同。
07 用 PyTorch 写出两种可检查的频率#
下面只生成 RoPE 的逆频率 inv_freq [d/2],不隐藏缩放发生在哪里:
import math
from typing import Literal
import torch
RopeScaling = Literal["none", "linear", "base"]
def rope_inv_freq(
head_dim: int,
*,
base: float = 10000.0,
factor: float = 1.0,
scaling: RopeScaling = "none",
device: torch.device | None = None,
) -> torch.Tensor:
assert head_dim % 2 == 0
assert base > 0.0 and factor >= 1.0
if scaling == "none":
scaled_base = base
divisor = 1.0
elif scaling == "linear":
scaled_base = base
divisor = factor
elif scaling == "base":
assert head_dim > 2
scaled_base = base * factor ** (head_dim / (head_dim - 2))
divisor = 1.0
else:
raise ValueError(f"unknown scaling: {scaling}")
pair_index = torch.arange(
0, head_dim, 2, device=device, dtype=torch.float32
) # [d/2]
inv_freq = torch.exp(
-math.log(scaled_base) * pair_index / head_dim
) # [d/2]
return inv_freq / divisor
original = rope_inv_freq(8)
linear = rope_inv_freq(8, factor=2.0, scaling="linear")
base_scaled = rope_inv_freq(8, factor=2.0, scaling="base")
torch.testing.assert_close(linear, original / 2.0)
torch.testing.assert_close(base_scaled[0], original[0])
torch.testing.assert_close(base_scaled[-1], original[-1] / 2.0)python这里使用的 torch.arange ↗、torch.exp 与张量广播都是 PyTorch 2.13 当前稳定 API。频率用 float32 生成;长位置若直接用低精度计算角度,舍入误差会随位置放大。
08 把缩放频率接回 Q/K 旋转#
def apply_scaled_rope(
x: torch.Tensor, # [N,H,L,d]
position_ids: torch.Tensor, # [L] or [N,L]
*,
factor: float,
scaling: RopeScaling,
base: float = 10000.0,
) -> torch.Tensor:
n, heads, length, head_dim = x.shape
if position_ids.ndim == 1:
assert position_ids.shape == (length,)
position_ids = position_ids[None, :].expand(n, -1)
else:
assert position_ids.shape == (n, length)
inv_freq = rope_inv_freq(
head_dim,
base=base,
factor=factor,
scaling=scaling,
device=x.device,
) # [d/2]
angles = (
position_ids.to(torch.float32)[..., None]
* inv_freq[None, None, :]
) # [N,L,d/2]
cos = angles.cos().to(x.dtype)[:, None, :, :] # [N,1,L,d/2]
sin = angles.sin().to(x.dtype)[:, None, :, :] # [N,1,L,d/2]
even, odd = x[..., 0::2], x[..., 1::2]
rotated = torch.stack(
(even * cos - odd * sin, even * sin + odd * cos),
dim=-1,
).flatten(-2) # [N,H,L,d]
return rotated
x = torch.randn(2, 4, 8, 16)
positions = torch.arange(8)
y = apply_scaled_rope(
x, positions, factor=4.0, scaling="linear"
)
assert y.shape == x.shape
# 旋转只改变方向,不改变每个二维对的 L2 范数。
torch.testing.assert_close(
y.float().square().sum(dim=-1),
x.float().square().sum(dim=-1),
rtol=1e-5,
atol=1e-5,
)python函数输入是 Q 或 K,而不是 token ids;标准 RoPE 仍发生在每层每头的 Q/K 投影之后。输出形状与输入相同,所以后续仍可交给 torch.nn.functional.scaled_dot_product_attention。
09 当前 Transformers 配置表达了什么?#
Hugging Face Transformers 当前官方文档用 rope_parameters 声明 RoPE 变体。线性缩放示例为:
from transformers import LlamaConfig
config = LlamaConfig()
config.rope_parameters = {
"rope_type": "linear",
"rope_theta": 10000.0,
"factor": 8.0,
}python官方当前列出的 rope_type 包含 default、linear、dynamic、yarn、longrope 与 llama3;不同类型要求的键并不相同,缺失参数会报错。部分混合注意力模型还可按 layer type 分别配置。
10 扩窗微调时,数据流怎样变化?#
PI 类方法通常从已有 checkpoint 继续训练,而不是只在推理时改配置:
长文档 ─► tokenize / pack ─► input_ids [N,L₁]
│
├─ position_ids [N,L₁]
│ │
│ └─ RoPE scaling ─► Q̃,K̃
│
└─ causal LM labels [N,L₁]
│
▼
next-token loss [N,L₁]
│
▼
更新原 checkpointtext核心训练契约包括:
- 训练和部署使用完全相同的
rope_type、factor、rope_theta与原始最大长度。 - 长样本必须覆盖所需距离;只把许多短样本拼成一批,不会自动产生跨片段监督。
- 若 packed sequence 不允许片段互相注意,attention mask 与 position reset 必须一致。
- 除长序列外保留一部分短序列,监测扩窗是否损害原窗口质量。
- 自注意力的计算和 logits 存储通常随 增长;位置缩放只处理位置分布,不消除计算代价。
伪代码可以写成:
for batch in mixture(short_sequences, long_sequences):
ids, valid, segment = pack(batch, target_length=L₁)
pos = build_positions(valid, segment_policy)
logits = model(ids, position_ids=pos, attention_mask=valid)
loss = next_token_loss(logits, ids, valid, segment)
backward(loss)
update_parameters()text11 KV Cache 为什么也属于缩放契约?#
prefill 与逐 token decode 必须对同一全局位置生成同一角度:
# cache.key 已保存按各自位置旋转后的历史 Key。
past_length = 0 if cache is None else cache.key.size(-2)
new_positions = torch.arange(
past_length,
past_length + new_q.size(-2),
device=new_q.device,
)
rotated_q = apply_scaled_rope(
new_q, new_positions, factor=8.0, scaling="linear"
)
rotated_k = apply_scaled_rope(
new_k, new_positions, factor=8.0, scaling="linear"
)python若运行中更改 factor、base 或缩放类型,历史 Key 与新 Query 会处在不同坐标系,旧 cache 必须失效。动态缩放还可能让频率依赖当前或目标序列长度;手写实现若在长度跨阈值后改变频率,就要保证历史 Key 被一致重算,不能只旋转新 Key。
正确:整个请求固定一份 RoPE contract
prefill K(0..P-1) ─► cache ─► decode Q/K(P), Q/K(P+1), ...
错误:prefill factor=4,decode 中途改成 factor=8
旧 K 坐标系 A ────────────────┐
├─ 点积没有统一位置语义
新 Q 坐标系 B ────────────────┘text12 怎样定义“有效上下文长度”?#
不要把单个最大长度准确率当成答案。一个可执行的评估立方体至少有三条轴:
任务复杂度
单证据 / 多证据 / 多跳 / 聚合
▲
│
证据位置 开头 / 中间 / 末尾 ◄─┼─► 上下文长度 L₀ / 2L₀ / 4L₀ / L₁text对每个格子重复不同随机种子并报告置信区间。建议同时保留四类指标:
| 指标族 | 例子 | 能发现什么 |
|---|---|---|
| 语言建模 | 滑窗 loss、perplexity | 长度增加后整体分布是否崩坏 |
| 定位检索 | passkey / key-value exact match | 是否能在远处找到单条证据 |
| 组合推理 | 多 needle、多跳追踪、计数与聚合 | 是否真正联合使用多处信息 |
| 原窗口回归 | 原任务分数、短上下文 perplexity | 扩窗是否破坏已有能力 |
“在 128K 的一个末尾 passkey 上成功”最多证明一个格子通过。RULER 的动机正是:简单单针检索接近满分时,增加 needle 数量、追踪和聚合后仍可能明显下降。
13 用分桶程序避免只报一个平均数#
下面的骨架把模型调用留成显式接口,输出记录可直接聚合为长度×位置表:
from collections.abc import Callable, Iterable
from dataclasses import dataclass
@dataclass(frozen=True)
class LongContextCase:
context_length: int
evidence_fraction: float # 0.1=靠前,0.5=中间,0.9=靠后
seed: int
def evaluate_context_grid(
predict: Callable[[str], str],
make_case: Callable[[LongContextCase], tuple[str, str]],
lengths: Iterable[int],
fractions: Iterable[float] = (0.1, 0.5, 0.9),
seeds: Iterable[int] = range(10),
) -> list[dict[str, int | float | bool]]:
rows = []
for length in lengths:
for fraction in fractions:
assert 0.0 < fraction < 1.0
for seed in seeds:
case = LongContextCase(length, fraction, seed)
prompt, expected = make_case(case)
prediction = predict(prompt)
rows.append({
"length": length,
"evidence_fraction": fraction,
"seed": seed,
"correct": prediction.strip() == expected,
})
return rows
rows = evaluate_context_grid(
predict=model_generate,
make_case=make_passkey_case,
lengths=(4096, 8192, 16384, 32768),
)
# 输出 4×3×10=120 行;之后按 length 与 evidence_fraction 分组求准确率。pythonevidence_fraction 要按 token 位置而不是字符位置计算,否则中英文、代码和空格会让证据落点偏移。生成式 exact match 还应固定解码策略、最大新 token 数与答案规范化规则。
14 系统指标为什么必须与质量一起测?#
即使质量稳定,长上下文也可能因成本失去工程价值:
- 完整自注意力的关系矩阵规模从 增到 。
- KV Cache 容量随层数、KV 头数、序列长度与 head width 近似线性增长。
- prefill 处理整个输入,通常比单步 decode 更受长序列影响。
- 更长输入可能降低 batch size,引发吞吐骤降和排队延迟上升。
因此每个长度桶还应记录:
输入长度 L
├─► 质量:loss / retrieval / reasoning accuracy
├─► 延迟:time-to-first-token、inter-token latency
├─► 吞吐:tokens/s、requests/s
└─► 资源:峰值显存、KV bytes/request、可用 batch sizetext滑动窗口注意力、稀疏注意力、检索增强生成(Retrieval-Augmented Generation, RAG)与压缩记忆可以减少成本或筛掉无关文本,但它们改变了信息可见范围,不属于单纯 RoPE 缩放。
15 一条可执行的调试路径#
- 先冻结随机 Q/K 做角度测试。 比较原始、线性与基底缩放的
inv_freq,确认最高频和最低频是否按设计变化。 - 验证旋转保范数。 每个位置的 Q/K 在旋转前后 L2 范数应保持接近;否则偶奇配对或广播轴有误。
- 验证共同平移。 固定相同缩放参数,同时给 Query/Key 位置加偏移,RoPE 点积应保持不变。
- 验证完整前向与 cache。 关闭 dropout,逐位置比较 full causal forward 与 prefill+decode,而不是只看最终 token。
- 打印实际 token 长度。 tokenizer 截断可能让所谓 32K 样本仍只有 4K;检查进入模型后的
input_ids.shape。 - 按长度画 loss。 从 逐步扫到 ,寻找突然断崖,而不是只测两个端点。
- 按证据位置画热图。 开头、中央、末尾分别测,避免平均数掩盖 Lost-in-the-Middle。
- 回归短上下文。 用未改 checkpoint 的同一批短样本作基线,确认扩窗没有用明显短程退化换取长程分数。
- 记录系统曲线。 在固定硬件、batch 与解码参数下测 TTFT、吞吐和峰值显存。
16 最常见的长上下文误判#
- 只改
max_position_embeddings。 接口接受更长输入,不代表权重适配了新频率。 - 把无 OOM 当作成功。 这只验证系统容量,没验证语言建模或信息利用。
- 只测一个末尾 passkey。 模型可能在中央证据、多针或聚合任务上失败。
- 只报全局平均准确率。 U 形位置偏差会被平均数掩盖。
- 扩窗后不测短任务。 均匀插值压缩局部距离,原窗口能力可能回退。
- 训练与推理
factor不同。 Q/K 进入不同频谱,checkpoint 语义被破坏。 - 运行中更改缩放却复用 KV Cache。 历史 Key 与新 Query 坐标系不一致。
- 混淆 RoPE base 与扩展因子。
rope_theta=10000不是窗口长度,factor=8也不是角频率。 - 低精度生成超长位置角度。 float16/bfloat16 的位置舍入可能让相邻大整数无法可靠区分。
- 宣称插值消除了 。 它只改位置相位,不改变标准注意力的二次计算。
17 与相近方案的边界#
| 方案 | 改变什么 | 主要收益 | 主要边界 |
|---|---|---|---|
| 直接 RoPE 外推 | 只允许更大的 position id | 零结构改动 | 未见相位可能失效 |
| 线性 Position Interpolation | 所有位置/频率统一除以 factor | 把长距离压回旧范围 | 局部距离也被压缩 |
| NTK-aware / YaRN 类 | 按频率非均匀缩放,可能调注意力尺度 | 更好平衡局部与长程 | 方法与 checkpoint 参数必须匹配 |
| LongRoPE 类 | 搜索分维、分位置的非均匀插值 | 支持更激进扩展 | 搜索、训练与验证更复杂 |
| 滑动窗口注意力 | 限制每个 Query 可见的 Key | 控制计算与 cache 上界 | 窗口外信息不能直接访问 |
| RAG | 先检索再把相关片段放入上下文 | 减少无关 token 与成本 | 受检索召回、切块和排序影响 |
PI、YaRN 和 LongRoPE 仍属于“让 RoPE checkpoint 适配更长距离”;滑动窗口与 RAG 则改变了模型实际读取哪些 token。它们可以组合,但实验必须分别归因。
18 今天真正需要记住什么?#
- RoPE 能计算任意位置,只说明位置函数有定义;有效上下文还取决于权重是否学会使用训练外距离。
- Position Interpolation 用 把目标窗口映回旧相位范围,等价于把所有频率除以 。
- 均匀缩放会压缩局部距离;非均匀频率方案尝试保留高频局部分辨率,同时拉伸低频长程尺度。
- 缩放参数是训练、完整前向、KV Cache 与部署共同遵守的坐标契约,不能在请求中途切换。
- 有效窗口必须按长度、证据位置和任务复杂度分桶,并同时回归短上下文质量与系统成本。
19 思考题与小练习#
- 一个模型从 扩到 。分别计算原位置
0, 1024, 4096, 8191在线性 PI 下的位置;再计算原本相隔 8 token 的局部距离被压成多少。 - 修改
rope_inv_freq,打印head_dim=64、factor=8时原始、线性与基底缩放的前 3/后 3 个频率。解释为什么两种缩放不能只比较最后一个频率。 - 将
evaluate_context_grid扩成两针任务:两条证据分别放在(0.1,0.9)、(0.3,0.7)与(0.45,0.55)。除了 exact match,还应记录哪些失败类型?
相关工作#
- Chen et al. (2023), Extending Context Window of Large Language Models via Positional Interpolation ↗:提出把长位置线性压回预训练范围,并用长序列建模与 passkey 检索验证。
- Peng et al. (2024), YaRN: Efficient Context Window Extension of Large Language Models ↗:按频率区间改进 RoPE 插值,并加入注意力尺度修正。
- Ding et al. (2024), LongRoPE: Extending LLM Context Window Beyond 2 Million Tokens ↗:探索分维度、分位置的非均匀插值与渐进式扩窗。
- Liu et al. (2024), Lost in the Middle: How Language Models Use Long Contexts ↗:展示相关信息位于长输入中央时的显著位置敏感性。
- Hsieh et al. (2024), RULER: What’s the Real Context Size of Your Long-Context Language Models? ↗:将单针检索扩展到多针、追踪与聚合任务,评估真实有效窗口。
20 下一篇预告#
位置缩放解决的是“远距离坐标怎样进入注意力”,没有解决标准注意力随序列长度二次增长的问题。下一篇将从一个 token 到底能读哪些 Key 出发,比较滑动窗口、块稀疏与全局 token 如何改变可见性图、复杂度和 KV Cache。