大语言模型(LLM)核心技术原理
大语言模型(LLM)核心技术原理
大语言模型(Large Language Models, LLMs)是现代人工智能领域的核心技术之一,它们通过深度学习和大规模数据训练,实现了对自然语言的深刻理解和生成能力。本文将深入探讨 LLM 的核心技术原理、发展历程和实现机制,并配以大量可运行的代码示例、真实工程案例和对比数据,帮助你从"会用"走向"理解本质"。
在正式展开之前,先建立一个整体直觉:一个 LLM 本质上只做一件事——根据前面出现过的所有 token,预测下一个 token 的概率分布。这个看似简单的目标,配合 Transformer 架构、海量数据和巨量参数,最终催生出了语言理解、常识推理、代码生成等一系列令人惊讶的能力。理解这条主线,后面的所有细节都会变得清晰。
🧠 语言模型的发展历程
语言模型的发展经历了几个重要阶段,每一个阶段都在解决前一阶段的核心瓶颈。
1. 统计语言模型
早期的语言模型基于统计方法:
一个最小的 bigram 语言模型可以这样实现,帮助你直观理解"统计"到底统计的是什么:
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. 神经网络语言模型
神经网络的引入带来了突破:
3. Transformer 时代
2017 年 Transformer 架构彻底改变了语言模型:
下面用一张表对比三代语言模型的关键差异:
| 维度 | 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 是书的内容,你根据问题和标签的匹配度,决定从每本书里各抄多少内容。
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 个"头",每个头在低维子空间里独立计算注意力,最后拼接。这样不同的头可以捕捉不同类型的关系——有的头关注语法主谓,有的头关注指代消解,有的头关注邻近词。
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 用正弦位置编码:
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:
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 倍再压回:
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 训练更稳定:
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):
去噪自编码(Denoising Autoencoding / MLM):
前缀语言建模(Prefix LM)与 Span Corruption:
下面对比三种预训练目标:
| 预训练目标 | 代表模型 | 上下文可见性 | 擅长任务 |
| --- | --- | --- | --- |
| 自回归 LM | GPT、LLaMA | 单向(因果) | 文本生成、对话、代码 |
| 掩码 LM | BERT、RoBERTa | 双向 | 分类、命名实体、句子对匹配 |
| 前缀/Span | T5、GLM | 混合 | 生成+理解统一 |
2. 预训练数据
高质量、大规模的训练数据是 LLM 成功的关键。
数据来源:网页爬取(Common Crawl、C4、RefinedWeb)、书籍、代码仓库(GitHub、Stack Overflow)、学术论文(arXiv、PubMed)、百科、论坛问答。
数据处理流水线:
一个真实数据对比:LLaMA-1 用了约 1.4T token,LLaMA-2 用 2T,LLaMA-3 一举提升到 15T token,数据量的持续扩大是 LLaMA-3 能力大幅提升的关键原因之一。
3. 训练策略
🎯 模型微调技术
预训练得到的是通才 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%。
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):
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. 内存与吞吐优化
📊 模型评估指标
1. 传统指标
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 | 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 阶段的核心逻辑:
# 奖励模型训练:让"更好的回答"得分高于"更差的回答"
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_penalty2. DPO:更简单的对齐路线
DPO(Direct Preference Optimization) 跳过显式奖励模型和强化学习采样,直接用偏好数据优化策略,工程复杂度大幅降低,效果接近甚至超过 PPO,近年被广泛采用:
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. 安全措施
🚀 模型架构演进
1. GPT 系列
2. 开源与其他重要架构
3. 混合专家(MoE)
MoE 用一个路由器把每个 token 只送到少数几个"专家" FFN,实现"总参数很大、单次激活很小",在同等推理成本下容纳更多知识。核心是稀疏门控:
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📈 训练基础设施与分布式并行
训练千亿模型无法在单卡完成,必须多种并行策略组合:
训练优化的其他要点:学习率 warmup + cosine 衰减、梯度裁剪防爆炸、权重衰减、loss spike 的检测与回滚(大规模训练中损失突然飙升是常见故障,需要 checkpoint 回滚重训)。
🛠️ 实际应用与部署
1. 模型量化
量化把 FP16 权重压缩到 INT8/INT4,大幅降低显存与带宽需求:
一个量化收益的量级对比:
| 精度 | 每参数字节 | 70B 模型权重显存 | 相对精度损失 |
| --- | --- | --- | --- |
| FP16 | 2 | 约 140 GB | 基准 |
| INT8 | 1 | 约 70 GB | 很小 |
| INT4 (GPTQ/AWQ) | 0.5 | 约 40 GB | 小到中等 |
2. 服务架构与成本
一个真实工程决策案例:某客服系统最初全量调用 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。
🌟 未来发展方向
🔤 Tokenization:模型看到的不是字,而是 token
在把文本喂给模型之前,必须先把字符串切成一个个 token 并映射为整数 ID。这一步看似不起眼,却直接决定了词表大小、序列长度、稀有词与多语言表现,甚至影响模型能不能正确做算术。理解 tokenization,是理解"为什么 LLM 会在数数、拼写、罕见词上犯低级错误"的关键。
1. 为什么不用字符级或整词级
最朴素的两种方案各有致命缺点:
现代 LLM 采用子词(subword)折中方案:高频词保持完整,低频词拆成有意义的片段。比如 `unhappiness` 可能被切成 `un` + `happi` + `ness`,既控制了词表规模,又避免了 OOV。
2. BPE(Byte-Pair Encoding)原理与实现
BPE 最初是数据压缩算法,被 GPT 系列采用。核心思想极其简单:从字符开始,反复统计相邻符号对的出现频率,把最高频的一对合并成一个新符号,直到词表达到目标大小。下面是一个可运行的最小 BPE 训练器:
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、更贵"。
# 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 会在这些字节上继续合并高频对,把常见字/词组压成单个 token4. 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"的根源,实战中要格外警惕:
# 用 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)关系。当其他因素不成瓶颈时,损失近似为:
L(N) ≈ (Nc / N)^αN # 只受参数量限制时
L(D) ≈ (Dc / D)^αD # 只受数据量限制时
其中经验指数 αN ≈ 0.076,αD ≈ 0.095(Kaplan et al. 2020)关键洞见是:损失-算力曲线在双对数坐标下近似为一条直线,没有明显的"撞墙"平台——这正是过去几年不断堆规模的底气所在。
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——在相同算力下,把一部分参数预算换成数据,反而更优。
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 退化为贪心。
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"仍有微小机会被采到,偶尔冒出离谱内容。截断采样解决这个问题:
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 屏蔽
生成长文本时模型容易"复读机"式重复。常见对策:
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 logits4. Beam Search:面向"唯一正确答案"的搜索
贪心只保留 1 条路径,beam search 同时保留 beam_size 条最可能的候选序列,每步扩展所有候选并保留总对数概率最高的 beam_size 条。它在机器翻译、摘要等"存在标准答案"的任务上优于采样,但对开放式对话会显得死板、乏味,因此现代对话模型反而少用。
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](非对称):
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. 量化格式与选择
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. 长上下文为什么难
2. RoPE 插值与 YaRN
扩展 RoPE 模型上下文的核心思路是降低旋转频率、把新位置"挤"进模型见过的角度范围:
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):
4. 长文本的工程替代方案
不是所有长文本问题都要靠超长上下文硬扛。更经济的路线往往是:
一条实践经验:上下文越长,注意力越"稀释",关键信息越容易被淹没。把最重要的指令放在 prompt 开头或结尾、用检索精准召回,往往比单纯堆长度效果更好、成本更低。
✨ 涌现能力与幻觉:LLM 的双刃剑
1. 涌现能力(Emergent Abilities)
涌现能力指小模型不具备、大模型突然表现出的能力,典型包括:多步算术、上下文学习(in-context learning)、按指令泛化、思维链推理、多语言迁移等。它是"规模带来质变"的直观体现,也是过去几年不断堆规模的动力之一。前文提过,这种"突变"部分与评估指标的选择有关,但大模型综合能力显著更强是公认事实。
一个最能体现涌现的能力是 in-context learning(上下文学习):不更新任何参数,仅在 prompt 里给几个示例,模型就能学会新任务。这在 GPT-3 上首次大规模展现,彻底改变了 NLP 的使用范式——从"为每个任务微调一个模型"变成"写好 prompt 即可"。
2. 幻觉(Hallucination)的成因
幻觉指模型自信地生成看似合理但实际错误或虚构的内容。理解成因才能对症下药:
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,用无偏公式估计:
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. 评测的陷阱
💡 Prompt 基础技巧:不改参数也能大幅提升效果
同一个模型,好的 prompt 和坏的 prompt 效果可能天差地别。掌握基础 prompt 技巧,是用好 LLM 的第一生产力。
1. Zero-shot、Few-shot 与示例的力量
# Few-shot 示例:教模型做情感分类
评论:这家餐厅太棒了,还会再来! -> 正面
评论:等了一个小时菜还没上,气死了。 -> 负面
评论:环境一般,味道还行。 -> 中性
评论:服务员态度非常好,菜品超出预期! ->
(模型会据前三个示例的格式,输出:正面)2. 思维链(Chain-of-Thought, CoT)
对需要推理的任务,加一句"让我们一步步思考"或给出分步示例,让模型先写推理过程再给答案,能显著提升数学、逻辑题的正确率。原因是:把复杂问题分解成小步,每步都在更简单的分布上预测,错误更少。
问题:小明有 5 个苹果,给了小红 2 个,又买了 3 个,现在有几个?
# 不加 CoT,容易直接蒙一个数
# 加 CoT:
让我们一步步思考:
1. 小明开始有 5 个。
2. 给了小红 2 个,剩 5 - 2 = 3 个。
3. 又买了 3 个,共 3 + 3 = 6 个。
所以答案是 6 个。进阶变体:自洽性(self-consistency)——采样多条推理路径,对最终答案投票,取多数,进一步提升可靠性。
3. 结构化与角色设定
# 用 system + user 分工构造 prompt,是工程实践的标配
messages = [
{"role": "system", "content": "你是严谨的法律助手,只依据用户提供的合同原文回答,"
"无法确定时明确说明,不得编造条款。"},
{"role": "user", "content": "根据以下合同,违约金比例是多少?\n<合同原文...>"},
]
# 关键点:把"角色、约束、防幻觉要求"放进 system,把具体任务与数据放进 user4. Prompt 常见误区
| 误区 | 问题 | 改进 |
| --- | --- | --- |
| 指令含糊 | 模型自由发挥、跑题 | 明确任务、格式、约束 |
| 一次问太多 | 顾此失彼、质量下降 | 拆分成多步或多轮 |
| 不给示例做新任务 | 格式/风格不达标 | 加 2~3 个 few-shot 示例 |
| 推理题不用 CoT | 直接蒙、错误率高 | 要求分步思考 |
| 事实题用高温度 | 幻觉增多 | 降到 0~0.3 |
| 把关键指令埋在超长上下文中间 | 被"稀释"忽略 | 放在开头或结尾 |
🔧 从零训练一个字符级语言模型
把前面的知识串起来,下面是一个可运行的完整训练循环,展示从数据到损失下降的全过程。它复用了本文前面定义的 `MiniGPT`:
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——如果初始损失远高于此,多半是初始化或数据有问题。
配套的自回归生成函数,把前面讲的采样策略用上:
@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(梯度计算约是前向的两倍)。
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 和序列长度增长,可用梯度检查点换取)。
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 亿参数。
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. 向量空间里的语义
训练充分后,嵌入空间会浮现出有意义的几何结构:语义相近的词彼此靠近,甚至出现经典的"国王 - 男人 + 女人 ≈ 女王"这类线性类比。可以用余弦相似度直观感受:
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)缩放,计算更省、效果相当,如今几乎成为开源大模型标配。
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. 激活函数的演进
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. 通信与计算的平衡
分布式训练的性能瓶颈常在通信而非计算。几条经验:
万亿模型的标准配方 3D 并行 正是把三者按"张量并行在机内、流水线在机间、数据并行在最外层"分层组合。
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. 工具调用的基本循环
流程是一个"思考-行动-观察"的循环:模型判断需要什么工具并输出调用参数 → 程序执行工具 → 把结果作为新的上下文喂回 → 模型据此继续,直到得出最终答案。
# 一个极简的工具调用循环示意(不依赖具体 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 范式)分三段:
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 在序列维度拼接,一起送入 LLM2. 训练与对齐
多模态训练通常分阶段:先冻结 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 技术的前提。随着架构、训练方法和工程能力的持续演进,大语言模型仍将在更多领域释放价值,推动人工智能的普及与深化。