大语言模型(LLM)核心技术原理

困难 🔴AI 学习
21 个标签
预计阅读时间:98 分钟
LLMTransformer注意力机制预训练微调LoRAKV缓存RLHFMoE量化TokenizationBPEScaling Law采样策略GPTQAWQ长上下文RoPE幻觉Prompt思维链

大语言模型(LLM)核心技术原理

大语言模型(Large Language Models, LLMs)是现代人工智能领域的核心技术之一,它们通过深度学习和大规模数据训练,实现了对自然语言的深刻理解和生成能力。本文将深入探讨 LLM 的核心技术原理、发展历程和实现机制,并配以大量可运行的代码示例、真实工程案例和对比数据,帮助你从"会用"走向"理解本质"。

在正式展开之前,先建立一个整体直觉:一个 LLM 本质上只做一件事——根据前面出现过的所有 token,预测下一个 token 的概率分布。这个看似简单的目标,配合 Transformer 架构、海量数据和巨量参数,最终催生出了语言理解、常识推理、代码生成等一系列令人惊讶的能力。理解这条主线,后面的所有细节都会变得清晰。

🧠 语言模型的发展历程

语言模型的发展经历了几个重要阶段,每一个阶段都在解决前一阶段的核心瓶颈。

1. 统计语言模型

早期的语言模型基于统计方法:

N-gram 模型:基于马尔可夫假设,使用前 N-1 个词预测下一个词。比如 2-gram(bigram)假设"下一个词只依赖前一个词"。
平滑技术:解决零概率问题,如加一平滑(Laplace Smoothing)、Good-Turing 平滑、Kneser-Ney 平滑。
局限性:无法捕捉长距离依赖,参数空间随 N 指数级爆炸,且完全无法泛化到未见过的词组合。

一个最小的 bigram 语言模型可以这样实现,帮助你直观理解"统计"到底统计的是什么:

pythonCode
from collections import defaultdict, Counter
import random

class BigramLanguageModel:
    """最简单的二元语言模型:只根据前一个词预测下一个词"""
    def __init__(self):
        # bigram_counts[w1][w2] 表示 w1 后面出现 w2 的次数
        self.bigram_counts = defaultdict(Counter)

    def train(self, sentences):
        for tokens in sentences:
            tokens = ['<s>'] + tokens + ['</s>']  # 加上句子起止标记
            for w1, w2 in zip(tokens[:-1], tokens[1:]):
                self.bigram_counts[w1][w2] += 1

    def predict_next(self, prev_word):
        counter = self.bigram_counts[prev_word]
        total = sum(counter.values())
        if total == 0:
            return None
        # 返回归一化后的概率分布
        return {w: c / total for w, c in counter.items()}

    def generate(self, max_len=20):
        word, result = '<s>', []
        for _ in range(max_len):
            probs = self.predict_next(word)
            if not probs:
                break
            word = random.choices(list(probs), weights=list(probs.values()))[0]
            if word == '</s>':
                break
            result.append(word)
        return result

# 训练与生成示例
corpus = [['我', '喜欢', '机器', '学习'], ['我', '喜欢', '深度', '学习']]
model = BigramLanguageModel()
model.train(corpus)
print(model.predict_next('我'))     # {'喜欢': 1.0}
print(model.generate())              # 随机生成一句话

从这段代码可以看出统计语言模型的致命弱点:只要"喜欢"后面从没见过某个词,模型就永远给它 0 概率,而且它完全不理解词与词之间的语义相似性。

2. 神经网络语言模型

神经网络的引入带来了突破:

词嵌入(Word Embedding):将词汇映射到连续向量空间,让"猫"和"狗"这样语义相近的词在向量空间中距离更近,解决了 N-gram 无法泛化的问题。
循环神经网络(RNN):处理序列数据,理论上可以捕捉任意长度的上下文信息。
长短期记忆网络(LSTM/GRU):通过门控机制缓解梯度消失问题,能处理更长的序列,但仍然是"串行"计算,无法并行,长距离依赖依然衰减严重。

3. Transformer 时代

2017 年 Transformer 架构彻底改变了语言模型:

注意力机制:并行处理整个序列,任意两个位置之间都可以"直接对话",捕捉全局依赖。
自注意力(Self-Attention):同一序列内元素间的相互关注,让模型动态决定"看哪里"。
位置编码:因为注意力本身不含顺序信息,需要显式注入位置。

下面用一张表对比三代语言模型的关键差异:

| 维度 | N-gram 统计模型 | RNN/LSTM | Transformer |

| --- | --- | --- | --- |

| 长距离依赖 | 几乎不能 | 弱(梯度衰减) | 强(任意位置直连) |

| 并行训练 | 不适用 | 不能(串行) | 能(整序列并行) |

| 参数规模 | 随 N 爆炸 | 百万级 | 百万到万亿级 |

| 语义泛化 | 无 | 有(靠嵌入) | 强 |

| 典型代表 | Kneser-Ney | ELMo | GPT、BERT、LLaMA |

🔬 Transformer 架构详解

Transformer 是现代 LLM 的基础架构,由 Vaswani 等人在论文《Attention Is All You Need》中提出。它由堆叠的编码器/解码器层组成,每一层的核心是"多头自注意力 + 前馈网络 + 残差连接 + 层归一化"。

1. 自注意力机制(Self-Attention)

自注意力机制是 Transformer 的核心。直观理解:对每个 token,模型都生成三个向量——查询(Query)、键(Key)、值(Value)。用当前 token 的 Query 去和所有 token 的 Key 做点积,得到"我应该关注谁"的权重,再用这个权重对所有 Value 加权求和,得到融合了上下文的新表示。可以类比成"在图书馆查资料":Query 是你的问题,Key 是每本书的标签,Value 是书的内容,你根据问题和标签的匹配度,决定从每本书里各抄多少内容。

pythonCode
import torch
import torch.nn.functional as F
import math

def scaled_dot_product_attention(Q, K, V, mask=None):
    """
    计算缩放点积注意力
    Q: 查询矩阵 (batch_size, num_heads, seq_len, d_k)
    K: 键矩阵 (batch_size, num_heads, seq_len, d_k)
    V: 值矩阵 (batch_size, num_heads, seq_len, d_v)
    """
    d_k = Q.size(-1)
    # 计算注意力分数,除以 sqrt(d_k) 防止点积过大导致 softmax 梯度消失
    scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(d_k)

    # 应用掩码(如因果掩码,禁止看到未来的 token)
    if mask is not None:
        scores = scores.masked_fill(mask == 0, float('-inf'))

    # 计算注意力权重(每一行归一化为概率分布)
    attention_weights = F.softmax(scores, dim=-1)

    # 用权重对 Value 加权求和
    output = torch.matmul(attention_weights, V)
    return output, attention_weights

为什么要除以 sqrt(d_k)? 当维度 d_k 很大时,Q 和 K 的点积数值会变得很大,softmax 会进入饱和区,梯度接近 0,训练无法进行。除以 sqrt(d_k) 把方差重新拉回 1 附近,保证梯度健康。这是一个经常被面试问到的细节。

多头注意力(Multi-Head Attention):与其用一组巨大的 Q/K/V,不如把它们切成 h 个"头",每个头在低维子空间里独立计算注意力,最后拼接。这样不同的头可以捕捉不同类型的关系——有的头关注语法主谓,有的头关注指代消解,有的头关注邻近词。

pythonCode
import torch.nn as nn

class MultiHeadAttention(nn.Module):
    def __init__(self, d_model, num_heads, dropout=0.1):
        super().__init__()
        assert d_model % num_heads == 0
        self.d_model = d_model
        self.num_heads = num_heads
        self.d_k = d_model // num_heads

        self.W_q = nn.Linear(d_model, d_model)
        self.W_k = nn.Linear(d_model, d_model)
        self.W_v = nn.Linear(d_model, d_model)
        self.W_o = nn.Linear(d_model, d_model)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x, mask=None):
        batch_size, seq_len, _ = x.size()

        # 线性映射后拆成多个头:(batch, heads, seq_len, d_k)
        def split_heads(t):
            return t.view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2)

        Q = split_heads(self.W_q(x))
        K = split_heads(self.W_k(x))
        V = split_heads(self.W_v(x))

        scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k)
        if mask is not None:
            scores = scores.masked_fill(mask == 0, float('-inf'))
        attn = self.dropout(F.softmax(scores, dim=-1))
        out = torch.matmul(attn, V)

        # 合并多头 (batch, seq_len, d_model)
        out = out.transpose(1, 2).contiguous().view(batch_size, seq_len, self.d_model)
        return self.W_o(out)

2. 位置编码(Positional Encoding)

由于自注意力对输入顺序"无感"(打乱输入,输出只是对应打乱,语义不变),必须显式注入位置信息。原始 Transformer 用正弦位置编码:

pythonCode
def positional_encoding(seq_len, d_model):
    """经典正弦/余弦位置编码"""
    pe = torch.zeros(seq_len, d_model)
    position = torch.arange(0, seq_len, dtype=torch.float).unsqueeze(1)
    div_term = torch.exp(torch.arange(0, d_model, 2).float() *
                         -(math.log(10000.0) / d_model))
    pe[:, 0::2] = torch.sin(position * div_term)
    pe[:, 1::2] = torch.cos(position * div_term)
    return pe.unsqueeze(0)

现代 LLM 更多使用旋转位置编码(RoPE, Rotary Position Embedding),它把位置信息通过旋转矩阵直接作用在 Q、K 上,天然具备"相对位置"感知能力,并且外推到更长上下文时表现更好。LLaMA、GPT-NeoX、Qwen 等主流开源模型都采用 RoPE:

pythonCode
def apply_rotary_emb(x, freqs_cos, freqs_sin):
    """
    RoPE 简化实现:把成对的维度看作复数,用位置相关的角度旋转
    x: (batch, seq_len, num_heads, head_dim)
    """
    x1, x2 = x[..., 0::2], x[..., 1::2]   # 偶数维、奇数维
    # 复数旋转:(x1 + i*x2) * (cos + i*sin)
    rotated_x1 = x1 * freqs_cos - x2 * freqs_sin
    rotated_x2 = x1 * freqs_sin + x2 * freqs_cos
    return torch.stack([rotated_x1, rotated_x2], dim=-1).flatten(-2)

RoPE 的最大工程价值是长上下文外推:通过对旋转基频做插值(NTK-aware scaling、YaRN 等),可以把训练时只见过 4K 上下文的模型扩展到 32K、128K 甚至更长,这正是很多"长文档问答"产品背后的关键技术。

3. 前馈神经网络(Feed-Forward Network)

每个位置独立应用相同的前馈网络,负责在注意力"混合信息"之后做非线性变换与特征提炼。它通常把维度先放大 4 倍再压回:

pythonCode
class PositionWiseFeedForward(nn.Module):
    def __init__(self, d_model, d_ff, dropout=0.1):
        super().__init__()
        self.linear1 = nn.Linear(d_model, d_ff)
        self.linear2 = nn.Linear(d_ff, d_model)
        self.dropout = nn.Dropout(dropout)
        self.activation = nn.GELU()   # 现代模型多用 GELU/SwiGLU 而非 ReLU

    def forward(self, x):
        return self.linear2(self.dropout(self.activation(self.linear1(x))))

LLaMA 系列进一步用 SwiGLU 替代普通 FFN,用门控机制提升表达力,在同等参数下效果更好。

4. 把它们组装成一个 Transformer 层

有了注意力和前馈网络,再加上残差连接(缓解深层网络退化)和层归一化(稳定训练),就能拼出一个完整的解码器层。现代 LLM 普遍使用 Pre-LN(先归一化再进子层),比原始的 Post-LN 训练更稳定:

pythonCode
class TransformerBlock(nn.Module):
    def __init__(self, d_model, num_heads, d_ff, dropout=0.1):
        super().__init__()
        self.attn = MultiHeadAttention(d_model, num_heads, dropout)
        self.ffn = PositionWiseFeedForward(d_model, d_ff, dropout)
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)

    def forward(self, x, mask=None):
        # Pre-LN:残差 + 子层
        x = x + self.attn(self.norm1(x), mask)
        x = x + self.ffn(self.norm2(x))
        return x

class MiniGPT(nn.Module):
    """一个可运行的迷你 GPT,展示完整数据流"""
    def __init__(self, vocab_size, d_model=256, num_layers=6, num_heads=8, max_len=512):
        super().__init__()
        self.token_emb = nn.Embedding(vocab_size, d_model)
        self.pos_emb = nn.Parameter(torch.zeros(1, max_len, d_model))
        self.blocks = nn.ModuleList([
            TransformerBlock(d_model, num_heads, d_model * 4) for _ in range(num_layers)
        ])
        self.norm = nn.LayerNorm(d_model)
        self.head = nn.Linear(d_model, vocab_size, bias=False)

    def forward(self, idx):
        b, t = idx.size()
        x = self.token_emb(idx) + self.pos_emb[:, :t, :]
        # 因果掩码:下三角为 1,保证只能看到自己和之前的 token
        mask = torch.tril(torch.ones(t, t)).view(1, 1, t, t)
        for block in self.blocks:
            x = block(x, mask)
        return self.head(self.norm(x))   # 输出每个位置对整个词表的 logits

这段代码虽然精简,但已经涵盖了 GPT 类模型的全部核心结构。真实的 GPT-3 无非是把 d_model、层数、头数、词表放大若干个数量级:GPT-3 有 96 层、d_model=12288、96 个注意力头、总计 175B 参数。

🏗️ LLM 的预训练范式

1. 预训练任务

LLM 通常使用以下预训练任务:

自回归语言建模(Autoregressive LM)

GPT 系列、LLaMA、Qwen 等采用此方法。
从左到右预测下一个词,天然适用于生成任务。
训练时用因果掩码,让每个位置只能看到之前的 token。

去噪自编码(Denoising Autoencoding / MLM)

BERT 系列采用此方法。
随机遮蔽(Mask)15% 的 token 让模型预测,天然适用于理解类任务(分类、抽取)。
缺点是不擅长生成,且预训练与下游存在 [MASK] 标记的分布差异。

前缀语言建模(Prefix LM)与 Span Corruption

T5、GLM 等结合双向理解和单向生成。
前半段双向可见,后半段自回归生成。

下面对比三种预训练目标:

| 预训练目标 | 代表模型 | 上下文可见性 | 擅长任务 |

| --- | --- | --- | --- |

| 自回归 LM | GPT、LLaMA | 单向(因果) | 文本生成、对话、代码 |

| 掩码 LM | BERT、RoBERTa | 双向 | 分类、命名实体、句子对匹配 |

| 前缀/Span | T5、GLM | 混合 | 生成+理解统一 |

2. 预训练数据

高质量、大规模的训练数据是 LLM 成功的关键。

数据来源:网页爬取(Common Crawl、C4、RefinedWeb)、书籍、代码仓库(GitHub、Stack Overflow)、学术论文(arXiv、PubMed)、百科、论坛问答。

数据处理流水线

文本抽取与语言识别
精确去重与近似去重(MinHash / SimHash),去重能显著提升效果并防止记忆泄露
质量过滤(用分类器判断"是否像高质量文本")
有害内容、隐私(PII)过滤
数据配比(code/web/book 的比例直接影响模型能力侧重)

一个真实数据对比:LLaMA-1 用了约 1.4T token,LLaMA-2 用 2T,LLaMA-3 一举提升到 15T token,数据量的持续扩大是 LLaMA-3 能力大幅提升的关键原因之一。

3. 训练策略

混合精度训练:使用 FP16 或 BF16 减少显存、加速计算,BF16 因数值范围大更受大模型青睐。
课程学习:从简单到复杂逐步引入数据。
学习率调度:Warmup(线性预热)+ Cosine 衰减是标配。

🎯 模型微调技术

预训练得到的是通才 base model,要让它在特定任务或对话场景表现好,需要微调。

1. 全量微调(Full Fine-tuning)

更新所有参数,性能上限最高但成本极高:微调一个 70B 模型的全部参数,即使用 AdamW 优化器,仅优化器状态就要存 2 份 FP32 副本,显存需求是模型本身的 3-4 倍,动辄需要数十张 80G 显存的 A100/H100。

2. 参数高效微调(PEFT)

LoRA(Low-Rank Adaptation) 是目前最流行的方案:冻结原模型,只在权重旁边训练两个小的低秩矩阵 A、B,用 B·A 的乘积来近似权重更新量。因为秩 r 很小(通常 8~64),可训练参数量往往只有原模型的 0.1%~1%。

pythonCode
class LoRALayer(nn.Module):
    def __init__(self, in_features, out_features, rank=8, alpha=16):
        super().__init__()
        self.lora_A = nn.Parameter(torch.zeros(rank, in_features))
        self.lora_B = nn.Parameter(torch.zeros(out_features, rank))
        self.scaling = alpha / rank
        # A 用 Kaiming 初始化,B 初始化为 0,保证训练开始时 LoRA 不改变原输出
        nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
        nn.init.zeros_(self.lora_B)

    def forward(self, x):
        # 只学习"增量",原始权重保持冻结
        return self.scaling * (x @ self.lora_A.T @ self.lora_B.T)

class LinearWithLoRA(nn.Module):
    """把已有的线性层包一层 LoRA,原权重冻结"""
    def __init__(self, linear: nn.Linear, rank=8, alpha=16):
        super().__init__()
        self.linear = linear
        for p in self.linear.parameters():
            p.requires_grad = False
        self.lora = LoRALayer(linear.in_features, linear.out_features, rank, alpha)

    def forward(self, x):
        return self.linear(x) + self.lora(x)

QLoRA 更进一步:把冻结的基座模型量化到 4-bit(NF4 格式)存储,再在其上训练 LoRA。这让在单张 24G 消费级显卡上微调 33B 模型成为可能——这是很多个人开发者和小团队能做定制模型的关键突破。

Adapter、Prefix-Tuning、Prompt-Tuning 是其他 PEFT 路线,思路都是"冻结大部分、只训练极少量新增参数"。

对比一下主流微调方式的成本(以微调 7B 模型为例,数字为量级参考):

| 方法 | 可训练参数占比 | 显存需求 | 效果损失 | 适用场景 |

| --- | --- | --- | --- | --- |

| 全量微调 | 100% | 极高(80G+) | 无(上限) | 资源充足、追求极致 |

| LoRA | 约 0.1%~1% | 中(16~24G) | 很小 | 大多数定制场景 |

| QLoRA | 约 0.1%~1% | 低(12~24G) | 较小 | 单卡、消费级显卡 |

| Prompt-Tuning | 约 0.01% | 极低 | 较大 | 任务简单、多任务复用 |

3. 指令微调(Instruction Tuning)

用大量"指令-响应"对训练,让模型学会遵循指令而不是单纯续写。代表工作包括 InstructGPT、FLAN、Alpaca(用 5.2 万条 GPT 生成的指令数据微调 LLaMA-7B,成本仅约 600 美元,却显著提升了指令遵循能力,是"低成本对齐"的经典案例)。

🧪 推理优化技术

训练一次,推理无数次。生产环境中,推理成本往往远高于训练成本,优化推理至关重要。

1. KV 缓存(KV Cache)

自回归生成时,每生成一个新 token 都要对之前所有 token 做注意力。如果每步都重新计算所有历史 token 的 K、V,会有大量重复计算。KV 缓存把历史 token 的 K、V 存起来,每步只计算新 token 的 K、V 并追加,把每步复杂度从 O(n²) 降到 O(n):

pythonCode
class KVCacheAttention(nn.Module):
    def __init__(self, d_model, num_heads):
        super().__init__()
        self.num_heads = num_heads
        self.d_k = d_model // num_heads
        self.W_q = nn.Linear(d_model, d_model)
        self.W_k = nn.Linear(d_model, d_model)
        self.W_v = nn.Linear(d_model, d_model)
        self.W_o = nn.Linear(d_model, d_model)
        self.cache_k, self.cache_v = None, None

    def forward(self, x, use_cache=True):
        b, t, _ = x.size()
        q = self.W_q(x).view(b, t, self.num_heads, self.d_k).transpose(1, 2)
        k = self.W_k(x).view(b, t, self.num_heads, self.d_k).transpose(1, 2)
        v = self.W_v(x).view(b, t, self.num_heads, self.d_k).transpose(1, 2)

        if use_cache and self.cache_k is not None:
            # 把历史 K、V 拼接到当前,避免重复计算
            k = torch.cat([self.cache_k, k], dim=2)
            v = torch.cat([self.cache_v, v], dim=2)
        self.cache_k, self.cache_v = k, v

        scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.d_k)
        attn = F.softmax(scores, dim=-1)
        out = torch.matmul(attn, v).transpose(1, 2).contiguous().view(b, t, -1)
        return self.W_o(out)

KV 缓存是空间换时间:它极大提升了生成速度,但缓存本身会占用大量显存。一个 70B、128K 上下文的请求,KV 缓存可能吃掉几十 GB 显存。这催生了 MQA(Multi-Query Attention)GQA(Grouped-Query Attention):让多个 Query 头共享同一组 K、V 头,把 KV 缓存缩小几倍。LLaMA-2 70B、Mistral 都采用 GQA。

2. 投机采样(Speculative Decoding)

用一个小的"草稿模型"一次性猜测多个 token,再让大模型并行验证一次,接受匹配的部分。因为大模型的一次前向可以并行验证多个位置,实测能带来 2~3 倍的加速,且输出分布与原模型完全一致(无损加速)。

3. 内存与吞吐优化

FlashAttention:通过分块计算和减少对显存的读写(IO-aware),在不改变数学结果的前提下大幅加速注意力、降低显存。FlashAttention-2 在 A100 上可达到理论峰值算力的 70% 以上。
PagedAttention(vLLM):借鉴操作系统虚拟内存分页管理 KV 缓存,大幅减少显存碎片,把服务吞吐提升数倍,是当前推理服务的事实标准之一。
连续批处理(Continuous Batching):请求动态拼批,GPU 利用率显著提升。
梯度检查点(Gradient Checkpointing):训练时用计算换显存,支持更大 batch 或更大模型。

📊 模型评估指标

1. 传统指标

困惑度(Perplexity, PPL):语言模型对测试文本的"意外程度",越低越好,是预训练质量的核心内部指标。
BLEU / ROUGE:评估生成与参考答案的 n-gram 重合度,多用于翻译、摘要。
准确率 / 精确率 / 召回率 / F1:分类抽取任务。
pythonCode
import math

def perplexity(model_log_probs):
    """给定模型对每个真实 token 的对数概率,计算困惑度"""
    n = len(model_log_probs)
    avg_neg_log_prob = -sum(model_log_probs) / n
    return math.exp(avg_neg_log_prob)

# 假设模型对 5 个 token 的对数概率如下(越接近 0 说明越自信且正确)
log_probs = [-0.1, -0.5, -0.2, -1.0, -0.3]
print(f"困惑度 PPL = {perplexity(log_probs):.3f}")

2. LLM 特有的综合评测

MMLU:57 个学科的多选题,衡量广博知识。
GSM8K / MATH:小学到竞赛级数学,衡量多步推理。
HumanEval / MBPP:代码生成,用 pass@k 衡量能否通过单测。
Chatbot Arena(Elo 评分):真人盲测两两对比投票,是当前最贴近真实体验的排行方式。

下面给出几个代表模型在公开基准上的量级对比(数值为公开报告的近似值,随版本变化):

| 模型 | 参数量 | MMLU | HumanEval | 说明 |

| --- | --- | --- | --- | --- |

| GPT-3 (2020) | 175B | 约 43% | 较低 | few-shot 时代开创者 |

| LLaMA-2 70B | 70B | 约 68% | 约 30% | 开源里程碑 |

| GPT-4 | 未公开 | 约 86% | 约 67% | 长期综合第一梯队 |

| LLaMA-3 70B | 70B | 约 82% | 约 82% | 15T 数据带来的飞跃 |

🔒 安全性和对齐

1. RLHF(人类反馈强化学习)

RLHF 是把 base model 变成"听话、有用、无害"助手的关键。它通常分三步:SFT(监督微调)→ 训练奖励模型 → 用 PPO 优化策略。核心是让模型学会"人类更偏好什么样的回答"。下面用伪代码勾勒奖励模型和 PPO 阶段的核心逻辑:

pythonCode
# 奖励模型训练:让"更好的回答"得分高于"更差的回答"
def reward_model_loss(reward_model, prompt, chosen, rejected):
    r_chosen = reward_model(prompt, chosen)      # 人类更喜欢的回答
    r_rejected = reward_model(prompt, rejected)  # 人类不喜欢的回答
    # 排序损失:让 chosen 的分数尽量高于 rejected
    return -F.logsigmoid(r_chosen - r_rejected).mean()

# PPO 阶段的奖励设计:奖励分 - KL 惩罚,防止跑偏
def ppo_reward(reward_model, policy_logprob, ref_logprob, prompt, response, kl_coef=0.1):
    score = reward_model(prompt, response)
    # KL 惩罚:不能离原始 SFT 模型太远,避免 reward hacking
    kl_penalty = kl_coef * (policy_logprob - ref_logprob)
    return score - kl_penalty

2. DPO:更简单的对齐路线

DPO(Direct Preference Optimization) 跳过显式奖励模型和强化学习采样,直接用偏好数据优化策略,工程复杂度大幅降低,效果接近甚至超过 PPO,近年被广泛采用:

pythonCode
def dpo_loss(policy_chosen_logp, policy_rejected_logp,
             ref_chosen_logp, ref_rejected_logp, beta=0.1):
    """
    直接偏好优化损失:拉大 chosen 与 rejected 的相对对数概率差
    logp 为模型对整段回答的对数概率之和
    """
    pi_logratio = policy_chosen_logp - policy_rejected_logp
    ref_logratio = ref_chosen_logp - ref_rejected_logp
    return -F.logsigmoid(beta * (pi_logratio - ref_logratio)).mean()

3. 安全措施

内容过滤:输入/输出双向过滤,检测有害、越权、隐私内容。
红队测试(Red Teaming):专门设计攻击性 prompt 寻找漏洞。
越狱防护:应对 prompt 注入、角色扮演绕过等攻击。
模型编辑:定向修正事实错误与偏见(如 ROME、MEMIT 技术)。

🚀 模型架构演进

1. GPT 系列

GPT-1(2018,1.17 亿参数):首次展示"预训练+微调"范式在语言建模上的成功。
GPT-2(2019,最大 15 亿参数):展示 zero-shot 多任务能力,因"太危险"而分阶段开源。
GPT-3(2020,175B):few-shot / in-context learning 惊艳世界。
GPT-3.5 / ChatGPT(2022):RLHF 加持,引爆全民 AI 浪潮。
GPT-4(2023):多模态、更强推理,据传采用 MoE 架构。

2. 开源与其他重要架构

LLaMA 系列(Meta):开源社区的地基,RoPE + SwiGLU + RMSNorm + GQA 的组合成为事实标准。
Mistral / Mixtral:Mixtral 8x7B 用稀疏 MoE,激活参数远小于总参数却达到接近 70B 稠密模型的效果。
DeepSeek 系列:以 MoE + 高效训练著称,DeepSeek-V3 用约 671B 总参数、37B 激活参数,训练成本显著低于同级别稠密模型。
Chinchilla(DeepMind):提出"计算最优"缩放法则,指出多数早期模型"参数过多、数据不足",应在给定算力下让参数与数据按约 1:20 的 token/参数比同步扩大。

3. 混合专家(MoE)

MoE 用一个路由器把每个 token 只送到少数几个"专家" FFN,实现"总参数很大、单次激活很小",在同等推理成本下容纳更多知识。核心是稀疏门控:

pythonCode
class TopKMoE(nn.Module):
    def __init__(self, d_model, num_experts=8, top_k=2, d_ff=None):
        super().__init__()
        d_ff = d_ff or d_model * 4
        self.top_k = top_k
        self.gate = nn.Linear(d_model, num_experts)
        self.experts = nn.ModuleList([
            PositionWiseFeedForward(d_model, d_ff) for _ in range(num_experts)
        ])

    def forward(self, x):
        # 路由:为每个 token 选出得分最高的 top_k 个专家
        logits = self.gate(x)                       # (b, t, num_experts)
        weights, idx = torch.topk(logits.softmax(-1), self.top_k, dim=-1)
        out = torch.zeros_like(x)
        for i in range(self.top_k):
            for e, expert in enumerate(self.experts):
                mask = (idx[..., i] == e)
                if mask.any():
                    out[mask] += weights[..., i][mask].unsqueeze(-1) * expert(x[mask])
        return out

📈 训练基础设施与分布式并行

训练千亿模型无法在单卡完成,必须多种并行策略组合:

数据并行(DP / DDP):每张卡放完整模型副本,处理不同数据批次,AllReduce 同步梯度。
张量并行(Tensor Parallel):把单个大矩阵按行/列切到多卡协同计算,适合层内切分(Megatron-LM)。
流水线并行(Pipeline Parallel):把不同层分到不同卡,像流水线一样传递激活值。
ZeRO / FSDP:把优化器状态、梯度、参数分片到多卡,显存占用随卡数线性下降,是训练超大模型的关键。
3D 并行:数据 + 张量 + 流水线三者叠加,训练万亿模型的标准配方。

训练优化的其他要点:学习率 warmup + cosine 衰减、梯度裁剪防爆炸、权重衰减、loss spike 的检测与回滚(大规模训练中损失突然飙升是常见故障,需要 checkpoint 回滚重训)。

🛠️ 实际应用与部署

1. 模型量化

量化把 FP16 权重压缩到 INT8/INT4,大幅降低显存与带宽需求:

GPTQ / AWQ:训练后量化(PTQ),4-bit 量化 70B 模型可从 140G 显存降到约 40G。
量化感知训练(QAT):训练时模拟量化,精度损失更小。

一个量化收益的量级对比:

| 精度 | 每参数字节 | 70B 模型权重显存 | 相对精度损失 |

| --- | --- | --- | --- |

| FP16 | 2 | 约 140 GB | 基准 |

| INT8 | 1 | 约 70 GB | 很小 |

| INT4 (GPTQ/AWQ) | 0.5 | 约 40 GB | 小到中等 |

2. 服务架构与成本

批处理与请求队列:连续批处理 + 优先级调度。
负载均衡与自动扩缩容:按 QPS 动态调度 GPU。
成本直觉:推理成本主要由"输出 token 数 × 单价"决定,输出比输入贵(自回归逐 token 解码)。精简 prompt、缓存系统提示、用小模型做初筛都是常见省钱手段。

一个真实工程决策案例:某客服系统最初全量调用 GPT-4,月成本高企。改造后用"小模型路由 + 大模型兜底":80% 的简单问题由微调后的 7B 小模型回答,只有 20% 复杂问题升级到大模型,配合 prompt 缓存,整体推理成本下降约 60%,而用户满意度基本不变。这类"模型分级"是生产环境降本的典型最佳实践。

⚠️ 常见坑与最佳实践

结合工程实践,总结几个高频踩坑点:

| 常见坑 | 后果 | 应对最佳实践 |

| --- | --- | --- |

| 微调数据质量差、有噪声 | 模型学坏、幻觉增多 | 数据清洗优先于加大数据量,宁缺毋滥 |

| LoRA 秩设太小 | 复杂任务欠拟合 | 复杂任务提高 rank,简单任务保持 8~16 |

| 上下文超出训练长度 | 输出崩坏、乱码 | 用 RoPE 插值扩展或做检索召回 |

| 温度设太高做事实问答 | 幻觉、不稳定 | 事实类任务用低温度甚至贪心解码 |

| 忽视 KV 缓存显存 | 长上下文 OOM | 用 GQA、量化 KV、PagedAttention |

| 直接全量微调大模型 | 成本爆炸、易灾难遗忘 | 优先 LoRA/QLoRA,混入通用数据防遗忘 |

| 只看 benchmark 不做真人评测 | 线上体验与榜单脱节 | 结合 Arena 式盲测和业务指标 |

其他最佳实践:先用检索增强(RAG)解决知识时效性再考虑微调;评测集要和训练集严格隔离防数据泄露;生产上做好流式输出、超时与降级;对齐阶段务必加 KL 约束防止 reward hacking。

🌟 未来发展方向

架构创新:稀疏激活(MoE 常态化)、状态空间模型(Mamba 等线性复杂度架构挑战 Transformer)、记忆增强与超长上下文。
训练方法:更高效的数据配比与合成数据、持续学习、过程奖励(PRM)驱动的强推理(如 o1 类"慢思考"范式)。
应用拓展:多模态原生统一、Agent 工具使用与规划、垂直领域专业模型。

🔤 Tokenization:模型看到的不是字,而是 token

在把文本喂给模型之前,必须先把字符串切成一个个 token 并映射为整数 ID。这一步看似不起眼,却直接决定了词表大小、序列长度、稀有词与多语言表现,甚至影响模型能不能正确做算术。理解 tokenization,是理解"为什么 LLM 会在数数、拼写、罕见词上犯低级错误"的关键。

1. 为什么不用字符级或整词级

最朴素的两种方案各有致命缺点:

整词(word-level):词表会爆炸(英文几十万、中文更甚),且遇到训练时没见过的词(OOV)只能标 [UNK],信息全丢。
字符级(char-level):词表极小(英文几十个字符),但序列变得极长,一句话动辄上百个 token,注意力的 O(n²) 成本难以承受,且模型要从头学"字符如何组成词"。

现代 LLM 采用子词(subword)折中方案:高频词保持完整,低频词拆成有意义的片段。比如 `unhappiness` 可能被切成 `un` + `happi` + `ness`,既控制了词表规模,又避免了 OOV。

2. BPE(Byte-Pair Encoding)原理与实现

BPE 最初是数据压缩算法,被 GPT 系列采用。核心思想极其简单:从字符开始,反复统计相邻符号对的出现频率,把最高频的一对合并成一个新符号,直到词表达到目标大小。下面是一个可运行的最小 BPE 训练器:

pythonCode
from collections import Counter, defaultdict

def get_pair_stats(vocab):
    """统计所有相邻符号对的频率,vocab 形如 {'l o w </w>': 5}"""
    pairs = defaultdict(int)
    for word, freq in vocab.items():
        symbols = word.split()
        for i in range(len(symbols) - 1):
            pairs[(symbols[i], symbols[i + 1])] += freq
    return pairs

def merge_pair(pair, vocab):
    """把词表里所有出现的 pair 合并成一个符号"""
    new_vocab = {}
    bigram = ' '.join(pair)
    replacement = ''.join(pair)
    for word, freq in vocab.items():
        new_word = word.replace(bigram, replacement)
        new_vocab[new_word] = freq
    return new_vocab

# 初始语料:词后加 </w> 标记词尾,字符间用空格分隔
corpus = {'l o w </w>': 5, 'l o w e r </w>': 2,
          'n e w e s t </w>': 6, 'w i d e s t </w>': 3}

num_merges = 10
merges = []
for i in range(num_merges):
    stats = get_pair_stats(corpus)
    if not stats:
        break
    best = max(stats, key=stats.get)   # 选出最高频的相邻对
    corpus = merge_pair(best, corpus)
    merges.append(best)
    print(f"第 {i+1} 次合并: {best} (频率 {stats[best]})")

# 前几次合并通常是 ('e','s') -> 'es',('es','t') -> 'est',('l','o') -> 'lo' ...
print("学到的合并规则:", merges)

推理时的编码过程正好相反:把新词拆成字符,然后按训练时学到的合并顺序逐步应用合并规则,直到无法再合并。合并规则的先后顺序至关重要——它决定了同一个词唯一的切分方式。

3. Byte-level BPE:GPT-2/GPT-3/GPT-4 的选择

原始 BPE 在字符层面工作,遇到 emoji、生僻 Unicode 仍会 OOV。GPT-2 起改用 byte-level BPE(BBPE):先把文本编码成 UTF-8 字节序列(任何文本都能表示为 0~255 的字节),再在字节上做 BPE。这样永远不会 OOV,任何语言、符号、二进制片段都能被编码。代价是:一个中文汉字通常占 3 个 UTF-8 字节,若没被合并成一个 token,就会消耗 2~3 个 token,这也是为什么早期模型处理中文比英文"更费 token、更贵"。

pythonCode
# byte-level 的核心:先转字节,天然无 OOV
text = "你好, AI!"
byte_seq = list(text.encode('utf-8'))
print(byte_seq)          # [228, 189, 160, 230, 156, 189, 44, 32, 65, 73, 33]
print(len(byte_seq))     # 11 个字节:中文每字 3 字节,ASCII 每字符 1 字节
# BBPE 会在这些字节上继续合并高频对,把常见字/词组压成单个 token

4. WordPiece 与 SentencePiece

WordPiece(BERT 采用):与 BPE 类似,但合并准则不是"频率最高",而是"合并后语言模型似然提升最大",即选择使 `score = freq(pair) / (freq(a) * freq(b))` 最大的对。它用 `##` 前缀标记非词首子词,如 `playing -> play + ##ing`。

SentencePiece(LLaMA、T5、多语言模型采用):把整个句子(含空格)当作原始输入,用 `▁`(U+2581)表示空格,因此不依赖预分词、语言无关,特别适合中文、日文这类没有空格分词的语言。它支持 BPE 和 Unigram 两种算法,其中 Unigram 从一个大词表出发,反复剔除"删掉后似然损失最小"的 token,逐步收敛到目标大小。

下表对比主流分词方案:

| 方案 | 代表模型 | 基本单位 | OOV 处理 | 空格/多语言 |

| --- | --- | --- | --- | --- |

| BPE | GPT-2 早期 | 字符 | 可能 OOV | 依赖预分词 |

| Byte-level BPE | GPT-3/4、RoBERTa | UTF-8 字节 | 永不 OOV | 字节级通吃 |

| WordPiece | BERT | 字符/子词 | [UNK] | 依赖预分词 |

| SentencePiece | LLaMA、T5、mT5 | 原始文本 | 永不 OOV | 语言无关最佳 |

5. Tokenization 的工程陷阱

分词是无数"诡异 bug"的根源,实战中要格外警惕:

数字被切碎:`12345` 可能被切成 `123` + `45`,导致模型做算术困难。有的模型(如 LLaMA)刻意把每个数字单独成 token 来缓解。
拼写/字母任务差:因为模型看到的是 token 而非字母,问它"strawberry 里有几个 r"容易出错——它根本"看不到"单个字母。
同一个词多种 token 数:带前导空格的 ` hello` 和 `hello` 往往是不同 token,拼接 prompt 时容易踩坑。
词表大小的权衡:词表越大,序列越短、推理越快,但嵌入矩阵和输出层参数越多。GPT-2 约 5 万词表,LLaMA 约 3.2 万,GPT-4 的 cl100k 约 10 万,多语言模型常用 25 万以上。
pythonCode
# 用 tiktoken 直观感受 token 数(GPT 系列官方分词器)
import tiktoken

enc = tiktoken.get_encoding("cl100k_base")   # GPT-4 使用的编码
for text in ["Hello world", "你好世界", "1234567890"]:
    ids = enc.encode(text)
    print(f"{text!r:20} -> {len(ids)} tokens: {ids}")
# 英文短语常 2~3 token,等长中文往往 token 更多,纯数字容易被切成多段

一条实用经验:英文约 1 token ≈ 0.75 个单词,中文约 1 个汉字 ≈ 0.6~1.5 token。做上下文预算、成本估算时,用这个比例快速换算比逐字数准得多。

📐 Scaling Law:用算力预测能力

Scaling Law(缩放法则)回答了一个价值千金的问题:在投入更多参数、数据、算力时,模型的损失会如何下降? 它让团队在真正花几百万美元训练之前,就能用小规模实验外推出大模型的表现,从而做出理性的资源分配。

1. Kaplan 定律与幂律关系

OpenAI 在 2020 年发现:测试损失 L 与模型参数量 N、数据量 D、计算量 C 之间,在很大范围内呈幂律(power law)关系。当其他因素不成瓶颈时,损失近似为:

textCode
L(N) ≈ (Nc / N)^αN      # 只受参数量限制时
L(D) ≈ (Dc / D)^αD      # 只受数据量限制时
其中经验指数 αN ≈ 0.076,αD ≈ 0.095(Kaplan et al. 2020)

关键洞见是:损失-算力曲线在双对数坐标下近似为一条直线,没有明显的"撞墙"平台——这正是过去几年不断堆规模的底气所在。

pythonCode
import numpy as np

def predicted_loss(N, Nc=8.8e13, alpha_N=0.076):
    """根据参数量 N 预测损失(Kaplan 幂律,仅示意)"""
    return (Nc / N) ** alpha_N

for N in [1e8, 1e9, 1e10, 1e11, 1.75e11]:   # 0.1B 到 175B
    print(f"参数量 {N:.0e} -> 预测损失 {predicted_loss(N):.3f}")
# 每放大 10 倍参数,损失以固定比例下降,呈平滑幂律

2. Chinchilla:计算最优的参数-数据配比

DeepMind 2022 年的 Chinchilla 工作修正了早期"重参数、轻数据"的偏差。在固定算力预算下,它推导出参数量 N 与训练 token 数 D 应当同比例增长,最优配比约为 D ≈ 20 × N(每个参数配约 20 个 token)。

据此,700 亿参数的模型"计算最优"训练数据约为 1.4T token。Chinchilla 用 70B 参数 + 1.4T token,在很多基准上打败了 280B 参数但数据不足的 Gopher——在相同算力下,把一部分参数预算换成数据,反而更优

pythonCode
def chinchilla_optimal(compute_flops):
    """给定算力预算(FLOPs),估算计算最优的参数量与数据量"""
    # 经验近似:C ≈ 6 * N * D,且最优时 D ≈ 20 * N
    # => C ≈ 6 * N * 20N = 120 * N^2 => N = sqrt(C / 120)
    N_opt = (compute_flops / 120) ** 0.5
    D_opt = 20 * N_opt
    return N_opt, D_opt

# 假设有 1e24 FLOPs 的预算
N, D = chinchilla_optimal(1e24)
print(f"最优参数量 ≈ {N:.2e},最优训练 token ≈ {D:.2e}")

3. 训练最优 vs 推理最优

Chinchilla 只考虑了"训练成本最低"。但对要服务海量用户的产品,推理成本会被摊薄放大无数倍,因此近年趋势是故意"过度训练"小模型:用远超 20:1 的 token/参数比(LLaMA-3 8B 用了 15T token,比例高达约 1875:1),换取一个更小、推理更便宜、部署更容易的模型。这解释了为什么如今 7B~8B 的小模型能力越来越强——它们被喂了远超"计算最优"的数据。

| 目标 | token/参数比 | 代表 | 权衡 |

| --- | --- | --- | --- |

| 训练计算最优 | 约 20:1 | Chinchilla 70B | 训练省,但模型偏大、推理贵 |

| 推理友好 | 远大于 20:1 | LLaMA-3 8B (~1875:1) | 训练更贵,但模型小、推理便宜 |

4. 涌现能力的争议

一个重要现象:某些能力(多步算术、复杂推理)在模型规模较小时几乎为 0,越过某个规模阈值后突然大幅提升,看似"涌现"。但也有研究指出,这种"陡峭跃升"部分是评估指标不连续造成的错觉——用连续指标(如 token 级对数概率)衡量时,能力其实是平滑增长的。无论如何,Scaling Law 加"能力随规模增长"是过去几年 LLM 军备竞赛的理论基石。

🎲 推理采样策略:从 logits 到文字

模型每步输出的是整个词表上的 logits(未归一化分数),如何据此选出下一个 token,直接决定了生成文本的质量、多样性与稳定性。采样策略是把"概率分布"变成"具体文字"的最后一公里,也是产品调参最常动的旋钮。

1. 贪心解码与温度

贪心解码(greedy):每步都选概率最高的 token。确定性强,但容易陷入重复、单调,缺乏创造力。

温度(temperature):在 softmax 前把 logits 除以温度 T。T<1 让分布更尖锐(更保守、更确定),T>1 让分布更平坦(更随机、更有创意),T→0 退化为贪心。

pythonCode
import numpy as np

def softmax_with_temperature(logits, T=1.0):
    logits = np.array(logits, dtype=np.float64) / T
    logits -= logits.max()             # 数值稳定:减去最大值防溢出
    exp = np.exp(logits)
    return exp / exp.sum()

logits = [2.0, 1.0, 0.5, 0.1]
for T in [0.5, 1.0, 2.0]:
    p = softmax_with_temperature(logits, T)
    print(f"T={T}: {np.round(p, 3)}")
# T=0.5 分布更尖锐(第一个 token 概率更高)
# T=2.0 分布更平坦(各 token 概率更接近)

经验值:事实问答、代码、抽取用 T=0~0.3;日常对话用 T=0.7 左右;诗歌、头脑风暴用 T=0.9~1.2。

2. Top-k 与 Top-p(核采样)

只靠温度有个问题:即使概率很低的"垃圾 token"仍有微小机会被采到,偶尔冒出离谱内容。截断采样解决这个问题:

Top-k:只在概率最高的 k 个 token 里采样,其余置零。简单但 k 固定不够灵活——分布很尖时 k 太大会引入噪声,分布很平时 k 太小又限制多样性。
Top-p(nucleus sampling,核采样):从高到低累加概率,只保留累积概率达到 p(如 0.9)的最小 token 集合。动态适应分布形状,是目前最常用的策略。
pythonCode
def top_k_top_p_filter(probs, top_k=0, top_p=0.0):
    """对概率分布做 top-k / top-p 截断,返回重新归一化后的分布"""
    probs = np.array(probs, dtype=np.float64)
    idx_sorted = np.argsort(probs)[::-1]          # 概率从高到低的索引
    sorted_probs = probs[idx_sorted]

    keep = np.ones(len(probs), dtype=bool)
    if top_k > 0:
        keep_sorted = np.zeros(len(probs), dtype=bool)
        keep_sorted[:top_k] = True
    else:
        keep_sorted = np.ones(len(probs), dtype=bool)

    if top_p > 0.0:
        cumulative = np.cumsum(sorted_probs)
        # 保留累积概率首次超过 top_p 时(含该 token)的前缀
        cutoff = np.searchsorted(cumulative, top_p) + 1
        keep_sorted[cutoff:] = False

    filtered = np.zeros_like(sorted_probs)
    filtered[keep_sorted] = sorted_probs[keep_sorted]
    # 还原回原始顺序并归一化
    result = np.zeros_like(probs)
    result[idx_sorted] = filtered
    return result / result.sum()

probs = softmax_with_temperature([3.0, 2.0, 1.5, 1.0, 0.2, -1.0])
print("top_p=0.9:", np.round(top_k_top_p_filter(probs, top_p=0.9), 3))
print("top_k=3 :", np.round(top_k_top_p_filter(probs, top_k=3), 3))

3. 重复惩罚与 n-gram 屏蔽

生成长文本时模型容易"复读机"式重复。常见对策:

重复惩罚(repetition penalty):对已出现过的 token 的 logits 除以一个惩罚系数(如 1.1~1.3),降低其再次被选中的概率。
presence/frequency penalty:按 token 是否出现过、出现次数施加惩罚(OpenAI API 的经典参数)。
no_repeat_ngram_size:直接禁止生成已出现过的 n-gram(如设为 3,则任何 3-gram 不能重复)。
pythonCode
def apply_repetition_penalty(logits, generated_ids, penalty=1.2):
    logits = np.array(logits, dtype=np.float64)
    for tid in set(generated_ids):
        if logits[tid] > 0:
            logits[tid] /= penalty     # 正 logit 除以惩罚,变小
        else:
            logits[tid] *= penalty     # 负 logit 乘以惩罚,更负
    return logits

4. Beam Search:面向"唯一正确答案"的搜索

贪心只保留 1 条路径,beam search 同时保留 beam_size 条最可能的候选序列,每步扩展所有候选并保留总对数概率最高的 beam_size 条。它在机器翻译、摘要等"存在标准答案"的任务上优于采样,但对开放式对话会显得死板、乏味,因此现代对话模型反而少用。

pythonCode
import heapq

def beam_search(step_fn, start_token, eos_token, beam_size=3, max_len=20):
    """
    step_fn(seq) -> 返回下一步每个 token 的对数概率数组
    维护 (累计对数概率, 序列) 的 beam
    """
    beams = [(0.0, [start_token])]
    for _ in range(max_len):
        candidates = []
        for score, seq in beams:
            if seq[-1] == eos_token:
                candidates.append((score, seq))    # 已结束,直接保留
                continue
            log_probs = step_fn(seq)
            # 只看每条 beam 的 top 候选以控制分支数
            top_ids = np.argsort(log_probs)[::-1][:beam_size]
            for tid in top_ids:
                candidates.append((score + log_probs[tid], seq + [int(tid)]))
        # 保留总分最高的 beam_size 条
        beams = heapq.nlargest(beam_size, candidates, key=lambda x: x[0])
        if all(seq[-1] == eos_token for _, seq in beams):
            break
    return max(beams, key=lambda x: x[0])[1]

注意 beam search 倾向生成更短的序列(累加对数概率随长度单调下降),实践中常配合长度归一化(把总分除以长度的 α 次方)来纠偏。

下表总结采样策略的选择:

| 策略 | 确定性 | 多样性 | 适用场景 |

| --- | --- | --- | --- |

| 贪心 | 完全确定 | 无 | 需要可复现的简单任务 |

| 温度采样 | 可调 | 可调 | 通用生成 |

| Top-p 核采样 | 中 | 好 | 对话、创作(最常用) |

| Beam search | 确定 | 差 | 翻译、摘要等有标准答案 |

🔢 量化技术深入:把大模型塞进小显存

前文提到量化能大幅降显存,这里深入其数学原理与主流算法。量化的本质是用更少的比特表示原本 16/32 位的浮点数,核心挑战是"如何在压缩的同时尽量保留信息"。

1. 对称量化与非对称量化

最基础的 INT8 量化把浮点区间线性映射到 [-127, 127](对称)或 [0, 255](非对称):

pythonCode
import numpy as np

def absmax_quantize(x, bits=8):
    """对称量化:以绝对值最大值为界,线性映射到 [-(2^(b-1)-1), 2^(b-1)-1]"""
    q_max = 2 ** (bits - 1) - 1           # INT8 -> 127
    scale = np.abs(x).max() / q_max       # 缩放因子
    q = np.round(x / scale).astype(np.int8)
    return q, scale

def dequantize(q, scale):
    return q.astype(np.float32) * scale

w = np.array([0.5, -1.2, 3.4, -0.1, 2.0], dtype=np.float32)
q, scale = absmax_quantize(w)
recovered = dequantize(q, scale)
print("量化后 INT8:", q)
print("缩放因子:", round(float(scale), 4))
print("反量化误差:", np.round(recovered - w, 3))

离群值(outlier)问题:LLM 的激活值中常有极少数数值极大的维度,用单一 scale 会让绝大多数正常值被压到只剩几个量化级,精度崩坏。LLM.int8() 的解法是混合精度分解:把离群维度单独用 FP16 计算,其余走 INT8,兼顾速度与精度。

2. GPTQ:基于二阶信息的逐层量化

GPTQ 是主流的 4-bit 训练后量化(PTQ)方法。它逐层量化权重,每量化一列就用 Hessian(二阶导)信息补偿量化误差到尚未量化的权重上,使整层输出的误差最小。它无需重新训练,量化 70B 模型只需几小时和一小批校准数据,4-bit 下精度损失通常很小。

3. AWQ:激活感知的权重量化

AWQ 的洞见是:不是所有权重同等重要。对应大激活值的那部分权重(约 1%)对输出影响最大,量化它们的损失最伤精度。AWQ 通过一个"每通道缩放"把重要权重放大后再量化,等效地保护它们,无需混合精度即可在 4-bit 下保持接近 FP16 的效果,且推理更快、更硬件友好。

4. 量化格式与选择

NF4(4-bit NormalFloat):QLoRA 使用,专为服从正态分布的权重设计的信息论最优 4-bit 数据类型。
GGUF:llama.cpp 生态的量化格式,支持 Q4_K_M、Q5_K_M、Q8_0 等多种档位,方便在 CPU/消费级设备跑。
权重 vs 激活 vs KV 量化:可以分别量化,KV 缓存量化对长上下文省显存尤其明显。
pythonCode
def estimate_model_memory(num_params_b, bits):
    """估算纯权重显存占用(GB),num_params_b 单位为十亿"""
    bytes_per_param = bits / 8
    gb = num_params_b * 1e9 * bytes_per_param / (1024 ** 3)
    return gb

for bits in [16, 8, 4]:
    print(f"70B 模型 @ {bits}-bit ≈ {estimate_model_memory(70, bits):.1f} GB")
# 16-bit ≈ 130 GB, 8-bit ≈ 65 GB, 4-bit ≈ 33 GB(另需 KV 缓存与激活的额外开销)

| 方法 | 类型 | 典型比特 | 是否需校准/训练 | 特点 |

| --- | --- | --- | --- | --- |

| LLM.int8() | PTQ | 8 | 否 | 离群值走 FP16,几乎无损 |

| GPTQ | PTQ | 4 | 需少量校准数据 | 二阶误差补偿,精度高 |

| AWQ | PTQ | 4 | 需少量校准数据 | 激活感知,推理快 |

| NF4 (QLoRA) | 量化+微调 | 4 | 配合 LoRA 训练 | 单卡微调大模型 |

| QAT | 训练时量化 | 4~8 | 需完整训练 | 精度损失最小、成本最高 |

📏 上下文窗口与长文本处理

上下文窗口(context window)是模型一次能"看见"的最大 token 数。它从 GPT-3 的 2K,一路扩展到如今的 128K、200K 乃至百万级。但更长的窗口不是免费的午餐,背后是一整套工程与算法。

1. 长上下文为什么难

计算成本:标准注意力是 O(n²) 的时间与显存复杂度。序列翻倍,注意力成本变成 4 倍。
KV 缓存爆炸:如前所述,长上下文的 KV 缓存能吃掉几十 GB 显存。
位置外推:模型在训练时只见过某个长度,直接推理更长序列会因位置编码"越界"而崩坏。
有效利用:即使塞得进去,模型也常出现"lost in the middle"——对开头和结尾的信息记得清楚,中间部分容易被忽略。

2. RoPE 插值与 YaRN

扩展 RoPE 模型上下文的核心思路是降低旋转频率、把新位置"挤"进模型见过的角度范围

位置插值(PI):把位置索引整体缩放,例如要把 4K 扩到 16K,就把所有位置除以 4,让模型仿佛还在 4K 范围内。简单但需要少量微调。
NTK-aware scaling:不均匀地调整不同频率维度,高频少改、低频多改,无需微调即可获得不错的外推。
YaRN:结合 NTK 与注意力温度校正,是目前效果与效率俱佳的长上下文扩展方案,被许多开源长上下文模型采用。
pythonCode
import numpy as np

def get_rope_freqs(head_dim, base=10000, scaling=1.0):
    """计算 RoPE 各维度的旋转频率;scaling>1 表示位置插值扩展上下文"""
    # 频率随维度指数衰减;除以 scaling 相当于把位置"压缩"
    freqs = 1.0 / (base ** (np.arange(0, head_dim, 2) / head_dim))
    return freqs / scaling

base_freqs = get_rope_freqs(128)
extended = get_rope_freqs(128, scaling=4.0)   # 把上下文扩到 4 倍
print("原始最低频率:", round(float(base_freqs[-1]), 8))
print("插值后最低频率:", round(float(extended[-1]), 8))

3. 稀疏与滑动窗口注意力

与其让每个 token 关注所有历史,不如只关注一部分,把 O(n²) 降到近似 O(n):

滑动窗口注意力(Sliding Window):每个 token 只看最近 W 个 token(Mistral 用 W=4096)。通过层层堆叠,信息仍能间接传递到更远,等效感受野随层数线性扩大。
注意力汇聚点(Attention Sink / StreamingLLM):保留最初几个 token(它们像"锚点"吸收大量注意力)加上最近窗口,就能在几乎不掉点的情况下处理无限长流式输入。
稀疏注意力:Longformer、BigBird 等用"局部窗口 + 少量全局 token + 随机连接"近似全注意力。

4. 长文本的工程替代方案

不是所有长文本问题都要靠超长上下文硬扛。更经济的路线往往是:

RAG(检索增强):先检索出最相关的几段,只把它们放进上下文,比塞入整本书更省钱也更准。
分块摘要 + 合并(map-reduce):把长文档分块各自摘要,再对摘要做二次摘要。
上下文压缩:用小模型先压缩、剔除无关内容,再交给大模型。

一条实践经验:上下文越长,注意力越"稀释",关键信息越容易被淹没。把最重要的指令放在 prompt 开头或结尾、用检索精准召回,往往比单纯堆长度效果更好、成本更低。

✨ 涌现能力与幻觉:LLM 的双刃剑

1. 涌现能力(Emergent Abilities)

涌现能力指小模型不具备、大模型突然表现出的能力,典型包括:多步算术、上下文学习(in-context learning)、按指令泛化、思维链推理、多语言迁移等。它是"规模带来质变"的直观体现,也是过去几年不断堆规模的动力之一。前文提过,这种"突变"部分与评估指标的选择有关,但大模型综合能力显著更强是公认事实。

一个最能体现涌现的能力是 in-context learning(上下文学习):不更新任何参数,仅在 prompt 里给几个示例,模型就能学会新任务。这在 GPT-3 上首次大规模展现,彻底改变了 NLP 的使用范式——从"为每个任务微调一个模型"变成"写好 prompt 即可"。

2. 幻觉(Hallucination)的成因

幻觉指模型自信地生成看似合理但实际错误或虚构的内容。理解成因才能对症下药:

训练目标本身:模型被训练成"预测最可能的下一个 token",而非"只说真话"。当它不确定时,仍会流畅地"编"下去,因为流畅的错误答案在训练分布里比"我不知道"更常见。
知识边界模糊:模型不知道自己不知道什么,缺乏对置信度的可靠自我评估。
过时或稀疏知识:训练数据里罕见或过时的事实,模型记不牢就容易瞎编。
对齐副作用:RLHF 让模型倾向给出"有帮助、肯定"的回答,可能强化了"宁可编也不拒答"的倾向(谄媚,sycophancy)。

3. 缓解幻觉的手段

| 手段 | 原理 | 适用 |

| --- | --- | --- |

| RAG 检索增强 | 用外部知识兜底,让模型基于检索到的事实作答 | 知识问答、时效性强的场景 |

| 引用与溯源 | 要求模型给出出处,便于核查 | 需要可信度的场景 |

| 降低温度 | 减少随机性,事实任务更稳 | 事实抽取、问答 |

| 思维链 + 自我校验 | 让模型分步推理并复核 | 推理、计算 |

| 让模型可以说"不知道" | 训练/提示时鼓励拒答 | 高风险领域 |

| 工具调用 | 算术交给计算器、事实交给搜索 | Agent 场景 |

一条重要认知:幻觉无法被彻底消除,只能被抑制和管理。生产系统必须在架构层面(检索、校验、人审)设防,而不能指望模型"完全不编"。

🧾 评估基准深入:如何科学地衡量 LLM

前文列举了 MMLU、HumanEval 等基准,这里深入其评测方法与陷阱。

1. 主流基准与它们测什么

| 基准 | 测什么 | 形式 | 指标 |

| --- | --- | --- | --- |

| MMLU | 57 学科广博知识 | 4 选 1 多选 | 准确率 |

| GSM8K | 小学数学多步推理 | 应用题 | 准确率(需正确最终答案) |

| MATH | 竞赛级数学 | 解答题 | 准确率 |

| HumanEval | Python 代码生成 | 函数补全 | pass@k |

| MBPP | 入门级编程 | 函数生成 | pass@k |

| HellaSwag | 常识推理 | 续写选择 | 准确率 |

| TruthfulQA | 抗幻觉/真实性 | 问答 | 真实且有信息率 |

| MT-Bench | 多轮对话质量 | 开放问答 | GPT-4 打分 |

| Chatbot Arena | 真人偏好 | 两两盲测 | Elo 评分 |

2. pass@k:代码评测的核心指标

代码题不看"文本像不像",而看生成的代码能否通过单元测试。pass@k 表示"生成 k 个样本,至少有一个通过"的概率。直接采 k 个估计方差大,标准做法是生成 n 个(n>k)、数出通过数 c,用无偏公式估计:

pythonCode
from math import comb

def pass_at_k(n, c, k):
    """
    n: 生成的样本总数
    c: 其中通过测试的样本数
    k: pass@k 的 k
    返回 pass@k 的无偏估计
    """
    if n - c < k:
        return 1.0                      # 通过的太多,k 个里必有通过
    # 1 - (从 n-c 个失败样本里全抽中 k 个的概率)
    return 1.0 - comb(n - c, k) / comb(n, k)

# 例:生成 20 个,8 个通过
for k in [1, 5, 10]:
    print(f"pass@{k} = {pass_at_k(20, 8, k):.3f}")
# k 越大,pass@k 越高:多试几次总更可能碰到对的

3. 评测的陷阱

数据污染(contamination):如果测试题目泄漏进了训练数据,分数会虚高。判断模型真实水平时要警惕"刷榜"。规避方法包括用最新题目、构造私有测试集、检查 n-gram 重叠。
提示敏感性:同一模型,换个 prompt 模板、few-shot 示例数、选项顺序,MMLU 分数能差好几个点。对比模型时必须固定评测协议。
指标与体验脱节:榜单高不代表用户体验好。这正是 Chatbot Arena 这类真人盲测越来越受重视的原因。
LLM-as-judge 的偏差:用 GPT-4 当裁判打分省事,但它有位置偏好(偏向第一个答案)、长度偏好(偏向更长回答)、自我偏好(偏向同家族模型),需要交换顺序、控制长度来缓解。

💡 Prompt 基础技巧:不改参数也能大幅提升效果

同一个模型,好的 prompt 和坏的 prompt 效果可能天差地别。掌握基础 prompt 技巧,是用好 LLM 的第一生产力。

1. Zero-shot、Few-shot 与示例的力量

Zero-shot:直接下指令,不给例子。适合模型已熟悉的常见任务。
Few-shot:在 prompt 里放几个"输入→输出"示例,让模型照葫芦画瓢。对格式要求严格、任务较新颖时效果显著。
textCode
# Few-shot 示例:教模型做情感分类
评论:这家餐厅太棒了,还会再来! -> 正面
评论:等了一个小时菜还没上,气死了。 -> 负面
评论:环境一般,味道还行。 -> 中性
评论:服务员态度非常好,菜品超出预期! ->
(模型会据前三个示例的格式,输出:正面)

2. 思维链(Chain-of-Thought, CoT)

对需要推理的任务,加一句"让我们一步步思考"或给出分步示例,让模型先写推理过程再给答案,能显著提升数学、逻辑题的正确率。原因是:把复杂问题分解成小步,每步都在更简单的分布上预测,错误更少。

textCode
问题:小明有 5 个苹果,给了小红 2 个,又买了 3 个,现在有几个?

# 不加 CoT,容易直接蒙一个数
# 加 CoT:
让我们一步步思考:
1. 小明开始有 5 个。
2. 给了小红 2 个,剩 5 - 2 = 3 个。
3. 又买了 3 个,共 3 + 3 = 6 个。
所以答案是 6 个。

进阶变体:自洽性(self-consistency)——采样多条推理路径,对最终答案投票,取多数,进一步提升可靠性。

3. 结构化与角色设定

明确角色:"你是一位资深的 Python 工程师"能激活相关能力与语气。
明确格式:要求输出 JSON、表格、指定字段,配合 few-shot 示例,便于程序解析。
拆解任务:把复杂请求拆成清晰的步骤或子问题。
给约束与边界:说明字数、语言、禁止事项,减少跑偏。
pythonCode
# 用 system + user 分工构造 prompt,是工程实践的标配
messages = [
    {"role": "system", "content": "你是严谨的法律助手,只依据用户提供的合同原文回答,"
                                   "无法确定时明确说明,不得编造条款。"},
    {"role": "user", "content": "根据以下合同,违约金比例是多少?\n<合同原文...>"},
]
# 关键点:把"角色、约束、防幻觉要求"放进 system,把具体任务与数据放进 user

4. Prompt 常见误区

| 误区 | 问题 | 改进 |

| --- | --- | --- |

| 指令含糊 | 模型自由发挥、跑题 | 明确任务、格式、约束 |

| 一次问太多 | 顾此失彼、质量下降 | 拆分成多步或多轮 |

| 不给示例做新任务 | 格式/风格不达标 | 加 2~3 个 few-shot 示例 |

| 推理题不用 CoT | 直接蒙、错误率高 | 要求分步思考 |

| 事实题用高温度 | 幻觉增多 | 降到 0~0.3 |

| 把关键指令埋在超长上下文中间 | 被"稀释"忽略 | 放在开头或结尾 |

🔧 从零训练一个字符级语言模型

把前面的知识串起来,下面是一个可运行的完整训练循环,展示从数据到损失下降的全过程。它复用了本文前面定义的 `MiniGPT`:

pythonCode
import torch
import torch.nn as nn

# 1. 准备数据:字符级"分词"
text = "hello world " * 200          # 玩具语料
chars = sorted(set(text))
vocab_size = len(chars)
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
data = torch.tensor([stoi[c] for c in text], dtype=torch.long)

# 2. 构造训练批次:输入 x 与右移一位的目标 y
block_size = 32
def get_batch(batch_size=16):
    ix = torch.randint(len(data) - block_size - 1, (batch_size,))
    x = torch.stack([data[i:i + block_size] for i in ix])
    y = torch.stack([data[i + 1:i + 1 + block_size] for i in ix])
    return x, y

# 3. 初始化模型与优化器
model = MiniGPT(vocab_size, d_model=128, num_layers=4, num_heads=4, max_len=block_size)
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)  # 3e-4 是 Transformer 常用起始学习率
criterion = nn.CrossEntropyLoss()

# 4. 训练循环
model.train()
for step in range(500):
    x, y = get_batch()
    logits = model(x)                                # (B, T, vocab_size)
    loss = criterion(logits.view(-1, vocab_size), y.view(-1))
    optimizer.zero_grad()
    loss.backward()
    torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)  # 梯度裁剪防爆炸
    optimizer.step()
    if step % 100 == 0:
        # 初始损失约等于 ln(vocab_size)(随机猜测),随训练稳步下降
        print(f"step {step}: loss = {loss.item():.4f}")

训练开始时,损失应约等于 `ln(vocab_size)`(模型在随机猜测),随后稳步下降。这个"初始损失 ≈ ln(词表大小)"是排查训练是否正常的第一个 sanity check——如果初始损失远高于此,多半是初始化或数据有问题。

配套的自回归生成函数,把前面讲的采样策略用上:

pythonCode
@torch.no_grad()
def generate(model, start_str, max_new_tokens=100, temperature=0.8, top_k=10):
    model.eval()
    idx = torch.tensor([[stoi[c] for c in start_str]], dtype=torch.long)
    for _ in range(max_new_tokens):
        idx_cond = idx[:, -block_size:]              # 只取最近 block_size 个
        logits = model(idx_cond)[:, -1, :] / temperature   # 取最后一个位置
        if top_k is not None:
            v, _ = torch.topk(logits, top_k)
            logits[logits < v[:, [-1]]] = float('-inf')     # top-k 截断
        probs = torch.softmax(logits, dim=-1)
        next_id = torch.multinomial(probs, num_samples=1)   # 按概率采样
        idx = torch.cat([idx, next_id], dim=1)
    return ''.join(itos[i] for i in idx[0].tolist())

# print(generate(model, "hello"))   # 训练充分后能续写出 "hello world hello world ..."

🧮 算力与显存估算:训练前必做的功课

在启动训练前,用几个经验公式估算算力与显存,能避免"跑到一半 OOM"或"预算严重超支"。

1. 训练算力:6ND 法则

训练一个 N 参数的稠密模型、处理 D 个 token,总浮点运算量约为 C ≈ 6 × N × D FLOPs。系数 6 来自:前向约 2ND(每个参数一次乘一次加),反向约 4ND(梯度计算约是前向的两倍)。

pythonCode
def training_flops(N_params, D_tokens):
    return 6 * N_params * D_tokens

def gpu_days(flops, gpu_tflops=312, mfu=0.4):
    """gpu_tflops: 单卡峰值(A100 BF16 约 312 TFLOPS);mfu: 实际利用率约 0.3~0.5"""
    effective = gpu_tflops * 1e12 * mfu
    seconds = flops / effective
    return seconds / 86400

flops = training_flops(7e9, 1e12)          # 7B 模型,1T token
print(f"总算力 ≈ {flops:.2e} FLOPs")
print(f"单卡 A100 需 ≈ {gpu_days(flops):.0f} GPU-天")
print(f"1024 卡集群 ≈ {gpu_days(flops) / 1024:.2f} 天")

2. 训练显存构成

全参数训练(AdamW + 混合精度)时,每个参数的显存开销大致是:

| 组成 | 精度 | 每参数字节 |

| --- | --- | --- |

| 模型权重 | BF16 | 2 |

| 梯度 | BF16 | 2 |

| 优化器动量 m | FP32 | 4 |

| 优化器方差 v | FP32 | 4 |

| FP32 权重主副本 | FP32 | 4 |

合计约 16 字节/参数,即一个 7B 模型光是这些静态状态就要约 112 GB——远超单卡,这正是必须用 ZeRO/FSDP 分片或 LoRA 只训增量的根本原因。此外还要加上激活值显存(随 batch 和序列长度增长,可用梯度检查点换取)。

pythonCode
def full_finetune_memory_gb(N_params_b):
    """全参数 AdamW 混合精度训练的静态显存估算(不含激活)"""
    return N_params_b * 1e9 * 16 / (1024 ** 3)

for n in [0.5, 7, 13, 70]:
    print(f"{n}B 模型全参训练静态显存 ≈ {full_finetune_memory_gb(n):.0f} GB")
# 0.5B≈7GB, 7B≈104GB, 13B≈194GB, 70B≈1043GB(需多卡分片)

对比之下,LoRA/QLoRA 因为冻结基座、只为极少量低秩矩阵维护优化器状态,把可训练参数从 100% 压到不足 1%,显存需求随之骤降——这就是它们能在单卡上微调大模型的数学根源。

🧬 词嵌入与表示学习

在 token 变成整数 ID 之后,还要经过嵌入层(Embedding)映射成稠密向量,模型才能对其做数学运算。这一层是"离散符号"与"连续计算"之间的桥梁。

1. 嵌入矩阵的本质

嵌入层就是一张可学习的查找表:形状为 `(vocab_size, d_model)` 的矩阵,第 i 行就是 token i 的向量。它占的参数量不容小觑——词表 5 万、d_model 4096 的嵌入矩阵就有约 2 亿参数。

pythonCode
import torch.nn as nn

vocab_size, d_model = 50000, 4096
emb = nn.Embedding(vocab_size, d_model)
print(f"嵌入矩阵参数量: {vocab_size * d_model / 1e6:.0f}M")   # 205M
ids = torch.tensor([[10, 250, 7]])
print(emb(ids).shape)     # torch.Size([1, 3, 4096]):每个 token 查出一个 4096 维向量

2. 权重绑定(Weight Tying)

许多 LLM 让输入嵌入矩阵与输出投影层(把隐状态映射回词表 logits)共享同一份权重。这既省下一大块参数(对小模型尤其显著),又因为"编码"和"解码"用同一套语义空间而略微提升效果。前文的 `MiniGPT` 里若把 `self.head.weight = self.token_emb.weight` 即实现权重绑定。

3. 向量空间里的语义

训练充分后,嵌入空间会浮现出有意义的几何结构:语义相近的词彼此靠近,甚至出现经典的"国王 - 男人 + 女人 ≈ 女王"这类线性类比。可以用余弦相似度直观感受:

pythonCode
import torch.nn.functional as F

def cosine_sim(a, b):
    return F.cosine_similarity(a, b, dim=-1)

# 假设从训练好的模型取出词向量(此处用随机张量示意接口)
vec_cat, vec_dog, vec_car = torch.randn(4096), torch.randn(4096), torch.randn(4096)
print("cat vs dog:", cosine_sim(vec_cat, vec_dog))   # 语义相近的词相似度应更高
print("cat vs car:", cosine_sim(vec_cat, vec_car))

这也是嵌入模型(embedding model)的基础:把整句话编码成一个向量,用向量相似度做语义检索,正是 RAG 系统召回阶段的核心。

⚙️ 归一化与激活函数:稳定训练的隐形功臣

深层 Transformer 能训得动、训得稳,很大程度靠归一化和精心选择的激活函数。这些"配件"看似不起眼,却是从原始 Transformer 演进到现代 LLM 的关键改动。

1. LayerNorm 与 RMSNorm

LayerNorm 对每个 token 的特征向量做归一化(减均值、除标准差,再缩放平移),让每层输入分布稳定。RMSNorm 是 LLaMA 采用的简化版:省去减均值,只用均方根(RMS)缩放,计算更省、效果相当,如今几乎成为开源大模型标配。

pythonCode
import torch
import torch.nn as nn

class RMSNorm(nn.Module):
    def __init__(self, dim, eps=1e-6):
        super().__init__()
        self.eps = eps
        self.weight = nn.Parameter(torch.ones(dim))

    def forward(self, x):
        # 只按均方根缩放,不做中心化,比 LayerNorm 少一次求均值
        rms = x.pow(2).mean(dim=-1, keepdim=True).sqrt()
        return self.weight * x / (rms + self.eps)

x = torch.randn(2, 8, 512)
print(RMSNorm(512)(x).shape)   # torch.Size([2, 8, 512])

2. Pre-LN vs Post-LN

前文提过,现代 LLM 普遍用 Pre-LN(归一化放在子层之前)。原因是 Post-LN 在深层网络里梯度传播不稳,训练初期极易发散,需要小心的 warmup;Pre-LN 让残差路径"干净",梯度直通,训练更稳,是几十上百层模型能收敛的重要保证。

3. 激活函数的演进

ReLU:原始 Transformer 使用,简单但在负区间完全截断,可能损失信息。
GELU:BERT、GPT-2 采用,平滑版 ReLU,负区间有微小梯度,效果更好。
SwiGLU:LLaMA 采用,用门控机制(一路做激活、一路做门控相乘),在同等参数下效果最佳,代价是 FFN 需要三个线性层而非两个。
pythonCode
import torch.nn.functional as F

class SwiGLU(nn.Module):
    """LLaMA 风格的门控 FFN:一路 SiLU 激活,一路作为门控相乘"""
    def __init__(self, dim, hidden_dim):
        super().__init__()
        self.w_gate = nn.Linear(dim, hidden_dim, bias=False)
        self.w_up = nn.Linear(dim, hidden_dim, bias=False)
        self.w_down = nn.Linear(hidden_dim, dim, bias=False)

    def forward(self, x):
        # SiLU(x) = x * sigmoid(x),门控路乘以变换路
        return self.w_down(F.silu(self.w_gate(x)) * self.w_up(x))

print(SwiGLU(512, 1376)(torch.randn(2, 8, 512)).shape)
# 注意:为保持总参数量相当,SwiGLU 的 hidden_dim 通常取约 2/3 * 4 * dim

🛰️ 分布式训练进阶:显存墙的突破

前文介绍了并行策略,这里补充 ZeRO 分级与通信开销的直觉,帮助理解千卡训练的取舍。

1. ZeRO 三个阶段

ZeRO(Zero Redundancy Optimizer)的核心是消除数据并行中"每张卡都存一份完整状态"的冗余,分三级递进:

| 阶段 | 分片对象 | 显存节省 | 额外通信 |

| --- | --- | --- | --- |

| ZeRO-1 | 优化器状态 | 约 4 倍 | 少 |

| ZeRO-2 | + 梯度 | 约 8 倍 | 中 |

| ZeRO-3 (FSDP) | + 模型参数 | 随卡数线性 | 多(前向/反向需 all-gather 参数) |

ZeRO-3 / FSDP 能把 70B 模型的静态状态均摊到几十张卡上,是训练超大模型的主力方案,代价是每次前向都要临时把分片的参数聚合回来,通信量更大。

2. 通信与计算的平衡

分布式训练的性能瓶颈常在通信而非计算。几条经验:

张量并行通信频繁、量大,适合放在单机内(走 NVLink 高带宽),跨机会被网络拖垮。
流水线并行通信量小,适合跨机,但有"气泡"(流水线填充/排空时部分卡闲置),需用 micro-batch 摊薄。
数据并行只在反向后同步一次梯度,通信最友好,是最外层的默认选择。

万亿模型的标准配方 3D 并行 正是把三者按"张量并行在机内、流水线在机间、数据并行在最外层"分层组合。

pythonCode
def estimate_bubble_ratio(num_stages, num_microbatches):
    """流水线并行的气泡占比:stage 越多、microbatch 越少,闲置越严重"""
    return (num_stages - 1) / (num_stages - 1 + num_microbatches)

print(f"8 段流水、8 microbatch:  气泡 {estimate_bubble_ratio(8, 8):.1%}")
print(f"8 段流水、32 microbatch: 气泡 {estimate_bubble_ratio(8, 32):.1%}")
# 增大 microbatch 数能显著降低流水线气泡、提升利用率

🤖 从 LLM 到 Agent:工具调用基础

原始 LLM 只能"输出文字",无法查实时信息、做精确计算、执行操作。工具调用(Function Calling / Tool Use)让模型能输出结构化的调用请求,由外部程序执行后把结果回填,是构建 Agent 的基石。

1. 工具调用的基本循环

流程是一个"思考-行动-观察"的循环:模型判断需要什么工具并输出调用参数 → 程序执行工具 → 把结果作为新的上下文喂回 → 模型据此继续,直到得出最终答案。

pythonCode
# 一个极简的工具调用循环示意(不依赖具体 SDK)
def calculator(expr):
    return eval(expr)     # 生产中务必用安全的表达式求值,切勿裸 eval

tools = {"calculator": calculator}

def agent_loop(llm_call, user_query, max_steps=5):
    messages = [{"role": "user", "content": user_query}]
    for _ in range(max_steps):
        resp = llm_call(messages)        # 模型可能返回普通文本或工具调用请求
        if resp.get("tool_call"):
            name = resp["tool_call"]["name"]
            args = resp["tool_call"]["arguments"]
            result = tools[name](**args)      # 执行工具
            # 把工具结果作为观察回填,进入下一轮
            messages.append({"role": "tool", "name": name, "content": str(result)})
        else:
            return resp["content"]           # 模型给出最终答案,结束
    return "达到最大步数,未得出结论"

2. 为什么工具调用能治幻觉

工具调用把"模型不擅长的事"外包出去:算术交给计算器、实时信息交给搜索、精确查询交给数据库。模型只负责"判断该用什么工具、怎么组织参数、如何解读结果",从而大幅减少在这些环节的幻觉。这也是 ReAct、Plan-and-Execute 等 Agent 范式的核心思想。

3. 结构化输出的约束

要让程序可靠解析模型输出,常用手段包括:给出严格的 JSON schema、用 few-shot 示范格式、在解码层做受约束解码(constrained decoding)强制输出合法 JSON。后者通过在每步屏蔽不合语法的 token,保证输出 100% 可解析,是生产级结构化输出的可靠做法。

🖼️ 多模态 LLM 基础

现代 LLM 正从纯文本走向多模态(图像、音频、视频)。其主流做法不是从头重训,而是把其他模态"翻译"成 LLM 能理解的 token

1. 视觉编码器 + 投影 + LLM

典型的视觉语言模型(如 LLaVA 范式)分三段:

视觉编码器(如 CLIP ViT)把图像切成 patch,编码成一串图像特征向量。
投影层(一个小 MLP)把图像特征映射到 LLM 的词嵌入空间,变成"视觉 token"。
LLM 主干把视觉 token 和文本 token 拼在一起,像处理普通序列一样统一处理。
pythonCode
class VisionProjector(nn.Module):
    """把视觉编码器输出映射到 LLM 词嵌入空间,成为可与文本拼接的视觉 token"""
    def __init__(self, vision_dim=1024, llm_dim=4096):
        super().__init__()
        self.proj = nn.Sequential(
            nn.Linear(vision_dim, llm_dim),
            nn.GELU(),
            nn.Linear(llm_dim, llm_dim),
        )

    def forward(self, image_features):
        # image_features: (batch, num_patches, vision_dim)
        return self.proj(image_features)   # -> (batch, num_patches, llm_dim)

# 之后把这些视觉 token 与文本 token 在序列维度拼接,一起送入 LLM

2. 训练与对齐

多模态训练通常分阶段:先冻结 LLM 和视觉编码器,只训投影层做"模态对齐";再解冻部分参数做指令微调。这样能以较小成本让一个强文本 LLM 获得看图能力,是当前主流的高效路线。

📝 总结

大语言模型的核心,是用 Transformer 的自注意力机制,在海量数据上通过"预测下一个 token"这一自监督目标习得语言与知识,再经由 SFT、RLHF/DPO 对齐成可用助手,并借助 KV 缓存、量化、MoE、分布式并行等工程手段解决训练与推理的规模难题。下表把全文的关键概念浓缩为一张速查表:

| 环节 | 核心技术 | 解决的问题 | 关键点 |

| --- | --- | --- | --- |

| 基础架构 | 自注意力、多头、RoPE | 建模长距离依赖并可并行 | 除以 sqrt(d_k)、因果掩码 |

| 预训练 | 自回归语言建模 | 从数据中学语言与知识 | 数据质量、去重、配比 |

| 微调 | LoRA、QLoRA、指令微调 | 低成本适配任务 | 冻结基座、只训增量 |

| 对齐 | RLHF、DPO | 让模型有用无害 | 偏好数据、KL 约束 |

| 推理优化 | KV 缓存、GQA、FlashAttention、vLLM | 降本增速 | 空间换时间、IO 感知 |

| 部署 | 量化、连续批处理、模型分级 | 生产可用与省钱 | INT4/INT8、路由分级 |

| 缩放 | Scaling Laws、Chinchilla、MoE | 用算力换能力 | 参数与数据同步扩大 |

理解这些基础原理,是掌握和应用 LLM 技术的前提。随着架构、训练方法和工程能力的持续演进,大语言模型仍将在更多领域释放价值,推动人工智能的普及与深化。