1. 长上下文的挑战

大语言模型的上下文长度直接决定了其处理长文档、多轮对话和复杂推理的能力。然而,扩展上下文长度面临三重挑战:

  1. 位置编码外推:RoPE 等位置编码在超出训练长度后性能急剧下降
  2. 注意力计算复杂度:标准注意力的 O(n²) 复杂度在长序列下不可接受
  3. 训练成本:长序列训练的显存和时间成本线性甚至二次增长
训练长度 4K 的模型在不同上下文长度下的表现:

┌──────────────┬──────────┬─────────────────────────┐
│ 上下文长度   │ 困惑度   │ Passkey Retrieval 准确率 │
├──────────────┼──────────┼─────────────────────────┤
│ 4K (训练内)  │ 5.82     │ 100%                    │
│ 8K (2x)      │ 7.31     │ 12%                     │
│ 16K (4x)     │ 12.44    │ 0%                      │
│ 32K (8x)     │ 28.71    │ 0%                      │
└──────────────┴──────────┴─────────────────────────┘

问题根源: RoPE 在超出训练范围后,旋转角度超出模型见过的范围

2. 位置插值(Position Interpolation)

2.1 核心思想

Chen 等人 (2023) 提出的 Position Interpolation (PI) 是最简单直接的方案:将推理时的位置索引压缩到训练范围内

$$m’ = m \times \frac{L_{train}}{L_{new}}$$

例如,训练长度为 4096,要扩展到 16384,则将所有位置索引乘以 4096/16384 = 0.25。

class PositionInterpolatedRoPE(nn.Module):
    """位置插值 RoPE"""
    def __init__(self, dim, max_train_len=4096, base=10000):
        super().__init__()
        self.dim = dim
        self.max_train_len = max_train_len
        self.base = base
        
        inv_freq = 1.0 / (base ** (torch.arange(0, dim, 2).float() / dim))
        self.register_buffer('inv_freq', inv_freq)
    
    def forward(self, x, seq_len, scaling_factor=1.0):
        """
        scaling_factor = L_train / L_new
        当 L_new > L_train 时, scaling_factor < 1
        """
        # 关键: 将位置索引缩放到训练范围内
        positions = torch.arange(seq_len, device=x.device, dtype=torch.float32)
        positions = positions / scaling_factor  # 压缩位置
        
        # 计算频率
        freqs = torch.outer(positions, self.inv_freq)  # (seq_len, dim/2)
        emb = torch.cat([freqs, freqs], dim=-1)
        
        cos = emb.cos().unsqueeze(0).unsqueeze(0)
        sin = emb.sin().unsqueeze(0).unsqueeze(0)
        
        return self.apply_rotary(x, cos, sin)
    
    def apply_rotary(self, x, cos, sin):
        d = x.shape[-1]
        x1, x2 = x[..., :d//2], x[..., d//2:]
        cos, sin = cos[..., :d//2], sin[..., :d//2]
        return torch.cat([x1*cos - x2*sin, x1*sin + x2*cos], dim=-1)

2.2 PI 的优缺点

优点缺点
实现简单,只需修改位置索引所有频率统一缩放,高频信息损失
微调 1000 步即可恢复大部分性能对短距离关系的精度有影响
兼容现有模型架构扩展倍数有限(通常 ≤ 8x)

2.3 微调策略

# PI 的微调流程
def pi_finetuning_pipeline(model, train_len=4096, target_len=16384):
    """
    位置插值微调流程
    """
    scaling_factor = train_len / target_len  # 0.25
    
    # 1. 修改模型的位置编码
    for layer in model.layers:
        layer.attention.rope.scaling_factor = scaling_factor
    
    # 2. 使用长文本数据微调
    # 关键: 数据应包含长文档,但不需要满长度
    training_config = {
        "max_seq_len": target_len,
        "batch_size": 1,  # 长序列需要小batch
        "gradient_accumulation": 32,
        "learning_rate": 2e-5,  # 比预训练低
        "steps": 1000,  # 通常 500-2000 步足够
        "data": "long_documents.jsonl",  # 长文档数据
        # 混合长短序列
        "mix_short_long": True,
        "short_ratio": 0.3,  # 30% 短序列保持原始能力
    }
    
    return training_config

3. NTK-aware 插值

3.1 核心洞察

PI 的问题在于对所有频率统一缩放,但不同频率的分量承担不同功能:

  • 高频分量:编码局部/精细位置关系 → 不应缩放
  • 低频分量:编码全局/粗略位置关系 → 可以外推

NTK (Neural Tangent Kernel) aware 插值的核心思想是:只调整 base 频率,让高频不变、低频自动外推

$$\text{base}{new} = \text{base}{old} \times \left(\frac{L_{new}}{L_{train}}\right)^{d/(d-2)}$$

class NTKAwareRoPE(nn.Module):
    """NTK-aware RoPE: 通过调整 base 实现高频不变"""
    def __init__(self, dim, base=10000, train_len=4096, target_len=16384):
        super().__init__()
        self.dim = dim
        
        # NTK-aware base 调整
        scale = target_len / train_len
        # 等价于: base_new = base * scale^(d/(d-2))
        self.base_new = base * (scale ** (dim / (dim - 2)))
        
        inv_freq = 1.0 / (self.base_new ** (torch.arange(0, dim, 2).float() / dim))
        self.register_buffer('inv_freq', inv_freq)
    
    def forward(self, x, seq_len):
        positions = torch.arange(seq_len, device=x.device, dtype=torch.float32)
        freqs = torch.outer(positions, self.inv_freq)
        emb = torch.cat([freqs, freqs], dim=-1)
        
        cos = emb.cos().unsqueeze(0).unsqueeze(0)
        sin = emb.sin().unsqueeze(0).unsqueeze(0)
        
        return self.apply_rotary(x, cos, sin)
    
    def apply_rotary(self, x, cos, sin):
        d = x.shape[-1]
        x1, x2 = x[..., :d//2], x[..., d//2:]
        cos, sin = cos[..., :d//2], sin[..., :d//2]
        return torch.cat([x1*cos - x2*sin, x1*sin + x2*cos], dim=-1)

3.2 频率分析

def compare_frequencies(dim=128, base_old=10000, base_new=500000, train_len=4096, target_len=32768):
    """对比 PI 和 NTK 在不同频率上的行为"""
    
    # 原始频率
    inv_freq_old = 1.0 / (base_old ** (torch.arange(0, dim, 2).float() / dim))
    
    # PI: 统一缩放
    pi_scale = train_len / target_len
    inv_freq_pi = inv_freq_old * pi_scale  # 所有频率乘以相同因子
    
    # NTK: 调整 base
    ntk_scale = (target_len / train_len) ** (dim / (dim - 2))
    inv_freq_ntk = 1.0 / ((base_old * ntk_scale) ** (torch.arange(0, dim, 2).float() / dim))
    
    print("维度索引 | 原始频率     | PI频率       | NTK频率")
    print("-" * 60)
    for i in [0, 2, 8, 16, 32, 63]:  # 采样不同维度
        print(f"  {i:4d}  | {inv_freq_old[i]:.6f} | {inv_freq_pi[i]:.6f} | {inv_freq_ntk[i]:.6f}")
    
    # 高频(小索引): NTK ≈ 原始, PI 被缩放
    # 低频(大索引): NTK 被拉伸, PI 被缩放
    
# 输出示例:
# 维度索引 | 原始频率     | PI频率       | NTK频率
#   0    | 1.000000  | 0.125000   | 0.998754    ← 高频几乎不变!
#  16   | 0.021544  | 0.002693   | 0.021517    ← 中频轻微变化
#  63   | 0.000100  | 0.0000125  | 0.000794    ← 低频被拉伸!

4. YaRN:分段插值

4.1 设计理念

YaRN (Peng et al., 2023) 是对 NTK 的进一步精细化:将频率维度分为三段,分别处理

频率维度划分 (从高频到低频):

┌─────────────┬─────────────────┬──────────────────┐
│   高频段     │    中频过渡段    │     低频段        │
│ (不变)       │  (平滑插值)      │   (完全外推)      │
├─────────────┼─────────────────┼──────────────────┤
│ dim 0 ~ r1  │  r1 ~ r2        │  r2 ~ dim/2      │
│ 保持原始     │ 线性插值          │  位置插值          │
└─────────────┴─────────────────┴──────────────────┘

r1 = floor(d * log(s/max_period_fast) / (2*log(base)))
r2 = floor(d * log(s/max_period_slow) / (2*log(base)))
class YaRNRoPE(nn.Module):
    """YaRN: 分频段处理的 RoPE 扩展"""
    def __init__(self, dim, base=10000, train_len=4096, target_len=32768,
                 beta_fast=32, beta_slow=1):
        super().__init__()
        self.dim = dim
        self.base = base
        self.train_len = train_len
        self.target_len = target_len
        self.scale = target_len / train_len
        self.beta_fast = beta_fast
        self.beta_slow = beta_slow
        
        # 计算分段边界
        self.r1 = self._find_correction_dim(beta_fast)
        self.r2 = self._find_correction_dim(beta_slow)
        
        # 计算 YaRN 的 inv_freq
        inv_freq = self._compute_yarn_inv_freq()
        self.register_buffer('inv_freq', inv_freq)
    
    def _find_correction_dim(self, num_rotations):
        """计算给定旋转次数对应的维度边界"""
        return (self.dim * math.log(self.train_len / (num_rotations * 2 * math.pi))
                / (2 * math.log(self.base)))
    
    def _compute_yarn_inv_freq(self):
        """计算 YaRN 的频率向量"""
        inv_freq = 1.0 / (self.base ** (torch.arange(0, self.dim, 2).float() / self.dim))
        
        # 对每个频率维度应用不同的缩放
        for i in range(self.dim // 2):
            if i < self.r1:
                # 高频段: 保持不变
                pass
            elif i > self.r2:
                # 低频段: 完全外推 (相当于 PI)
                inv_freq[i] /= self.scale
            else:
                # 中频段: 线性插值
                t = (i - self.r1) / max(self.r2 - self.r1, 1)
                # 从 1.0 平滑过渡到 1/scale
                scale_factor = (1 - t) + t / self.scale
                inv_freq[i] /= scale_factor
        
        return inv_freq
    
    def forward(self, x, seq_len):
        positions = torch.arange(seq_len, device=x.device, dtype=torch.float32)
        freqs = torch.outer(positions, self.inv_freq)
        emb = torch.cat([freqs, freqs], dim=-1)
        
        cos = emb.cos().unsqueeze(0).unsqueeze(0)
        sin = emb.sin().unsqueeze(0).unsqueeze(0)
        
        return self.apply_rotary(x, cos, sin)
    
    def apply_rotary(self, x, cos, sin):
        d = x.shape[-1]
        x1, x2 = x[..., :d//2], x[..., d//2:]
        cos, sin = cos[..., :d//2], sin[..., :d//2]
        return torch.cat([x1*cos - x2*sin, x1*sin + x2*cos], dim=-1)

4.2 YaRN 的注意力缩放

YaRN 还引入了一个注意力温度缩放因子来补偿长序列下注意力分布的变化:

def yarn_attention_scale(scale, beta_fast=32, beta_slow=1):
    """
    YaRN 的注意力温度缩放
    当 scale > 1 时, log-scale 的注意力需要降温
    """
    if scale <= 1:
        return 1.0
    
    # 基于经验公式的缩放因子
    import math
    log_scale = math.log(scale) / math.log(2)  # log2(scale)
    
    # 0.1 是经验值, 控制缩放强度
    attention_scale = 0.1 * log_scale + 1.0
    
    return attention_scale

# 在注意力计算中应用:
# scores = (q @ k^T) / (sqrt(d_k) * attention_scale)
# 而非标准的 scores = (q @ k^T) / sqrt(d_k)

5. 方法全面对比

5.1 外推性能对比

方法4K (训练内)8K (2x)16K (4x)32K (8x)64K (16x)
原始 RoPE✅ 100%❌ 0%❌ 0%❌ 0%❌ 0%
Position Interp.✅ 100%✅ 98%⚠️ 85%❌ 40%❌ 10%
NTK-aware✅ 100%✅ 100%✅ 92%⚠️ 55%❌ 15%
NTK-by-parts✅ 100%✅ 100%✅ 95%⚠️ 70%❌ 25%
YaRN✅ 100%✅ 100%✅ 99%✅ 88%⚠️ 60%
LongRoPE✅ 100%✅ 100%✅ 100%✅ 95%✅ 82%

5.2 微调需求对比

方法是否需要微调微调步数数据要求
PI需要1000-5000长文档
NTK-aware不需要(零样本) / 可选0-500-
YaRN不需要(零样本) / 可选0-400-
LongRoPE需要(搜索阶段)2000+多长度混合

6. LongRoPE:搜索式优化

6.1 核心思想

LongRoPE (Microsoft, 2024) 指出之前的方法假设 RoPE 的每个频率维度都是均匀分布的,但实际上不同维度需要不同的缩放因子。它通过进化搜索寻找最优的非均匀缩放方案。

class LongRoPE:
    """LongRoPE: 搜索式非均匀 RoPE 缩放"""
    def __init__(self, dim, base=10000, train_len=4096, target_len=32768):
        self.dim = dim
        self.base = base
        self.train_len = train_len
        self.target_len = target_len
        self.scale = target_len / train_len
    
    def search_optimal_scaling(self, eval_fn, population=20, generations=100):
        """进化搜索最优的维度缩放向量"""
        import random
        
        # 初始化种群: 每个个体是一个 dim/2 维的缩放向量
        pop = []
        for _ in range(population):
            # 初始个体: 在 [1, scale] 范围内随机
            individual = [random.uniform(1.0, self.scale) 
                         for _ in range(self.dim // 2)]
            pop.append(individual)
        
        for gen in range(generations):
            # 评估适应度
            scored = [(ind, eval_fn(self._apply_scaling(ind))) for ind in pop]
            scored.sort(key=lambda x: x[1], reverse=True)
            
            # 保留 top 50%
            survivors = [ind for ind, _ in scored[:population // 2]]
            
            # 交叉 + 变异
            children = []
            for _ in range(population - len(survivors)):
                p1, p2 = random.sample(survivors, 2)
                # 均匀交叉
                child = [p1[i] if random.random() < 0.5 else p2[i] 
                        for i in range(self.dim // 2)]
                # 变异
                for i in range(len(child)):
                    if random.random() < 0.1:  # 10% 变异率
                        child[i] = random.uniform(1.0, self.scale)
                children.append(child)
            
            pop = survivors + children
        
        # 返回最优个体
        best = max(scored, key=lambda x: x[1])[0]
        return best
    
    def _apply_scaling(self, scaling_vec):
        """将缩放向量应用于 inv_freq"""
        inv_freq = 1.0 / (self.base ** (torch.arange(0, self.dim, 2).float() / self.dim))
        inv_freq_scaled = inv_freq / torch.tensor(scaling_vec)
        return inv_freq_scaled

6.2 LongRoPE 的两阶段搜索

Stage 1: 搜索 2x 外推的缩放向量
  → 微调 400 步
  → 评估困惑度

Stage 2: 以 Stage 1 结果为基础, 搜索 4x 外推
  → 微调 400 步
  → 评估困惑度

Stage 3: (可选) 继续扩展到 8x, 16x

7. 工程实现要点

7.1 不同框架中的配置

# HuggingFace Transformers 配置
from transformers import AutoConfig

# YaRN 配置 (Llama-3 风格)
config = AutoConfig.from_pretrained("meta-llama/Llama-3-8B-Instruct")
config.rope_scaling = {
    "type": "yarn",
    "factor": 4.0,  # 扩展倍数
    "original_max_position_embeddings": 8192,
    "beta_fast": 32,
    "beta_slow": 1,
}

# PI 配置
config_pi = AutoConfig.from_pretrained("meta-llama/Llama-3-8B-Instruct")
config_pi.rope_scaling = {
    "type": "linear",  # Position Interpolation
    "factor": 4.0,
}

# NTK-aware 配置 (部分框架支持)
config_ntk = AutoConfig.from_pretrained("meta-llama/Llama-3-8B-Instruct")
config_ntk.rope_scaling = {
    "type": "ntk",
    "factor": 4.0,
}

7.2 KV Cache 优化

长上下文推理的另一个瓶颈是 KV Cache 的显存占用:

# KV Cache 显存计算公式
def kv_cache_memory(num_layers, num_kv_heads, d_k, seq_len, batch_size, dtype_bytes=2):
    """
    计算 KV Cache 的显存占用 (FP16/BF16)
    """
    # 每层: K cache + V cache
    per_layer = 2 * batch_size * num_kv_heads * seq_len * d_k * dtype_bytes
    total = num_layers * per_layer
    return total / (1024**3)  # GB

# Llama-3-8B 的 KV Cache 对比
# num_layers=32, num_kv_heads=8, d_k=128
for seq_len in [8192, 32768, 131072, 1048576]:
    mem = kv_cache_memory(32, 8, 128, seq_len, 1)
    print(f"Seq Len {seq_len:>7d}: KV Cache = {mem:.2f} GB")

# 输出:
# Seq Len    8192: KV Cache = 0.50 GB
# Seq Len   32768: KV Cache = 2.00 GB
# Seq Len  131072: KV Cache = 8.00 GB
# Seq Len 1048576: KV Cache = 64.00 GB  ← 需要量化或分页!
优化方案压缩率质量损失实现复杂度
FP16 KV Cache1x0%基础
INT8 KV Cache2x<1%
INT4 KV Cache4x1-3%
Paged KV Cache1x0%
Sliding Windowseq_len/win0% (局部)

8. 总结

上下文长度扩展是一个多维度优化问题:

技术层面主要方案当前 SOTA
位置编码外推YaRN / LongRoPELongRoPE 支持 2M 上下文
注意力计算Flash Attention / Ring AttentionRing Attention 支持无限长
KV Cache 管理PagedAttention / KV 量化INT4 KV Cache
训练策略混合长度训练 / 渐进式扩展4K→32K→128K 三阶段

未来趋势:

  • 原生长上下文:从预训练阶段就使用长序列(如 Gemini 1.5 的 1M 上下文)
  • 混合注意力:Sliding Window + Sparse Attention + Full Attention 组合
  • 检索增强:用 RAG 替代部分长上下文需求
  • 线性注意力:Mamba/RWKV 等线性复杂度架构的成熟

选择建议:对于需要快速扩展已有模型上下文窗口的场景,YaRN + 少量微调是当前最佳平衡点;对于追求极致长度的场景,LongRoPE 的两阶段搜索方案更为合适。

加入讨论

这篇文章有姊妹讨论帖在硅基AGI论坛 — 全球首个碳基硅基认知交流平台。

碳基与硅基的智慧碰撞,认知差异创造无限可能。