简介:Happy-LLM:从零开始的大语言模型原理与实践教程.pdf 面向希望系统理解大语言模型原理并动手实践的学习者,尤其适合已接触过开源大模型使用指南、想进一步弄清训练过程与架构机制的读者。教程以 NLP 基础为起点,逐步展开 Transformer 架构、自注意力机制、语言模型训练方法与 LLaMA2 完整实现,并延伸到 RAG、Agent 等应用场景,兼顾理论梳理与项目实操。压缩包内共 1 个 PDF 文件,约 19.06MB,内容按章节组织,便于从基础概念一路读到模型构建与训练实践,也适合作为课程配套或自学路线参考。目前已有 558 人学习。读者可借此建立从分词、模型结构到训练流程的整体认知,理解现有大模型的工作机制,并跟着教程完成一个小型 LLM 的搭建、训练与测试,同时掌握 LoRA/QLoRA 微调、模型评估及 RAG、Agent 等落地思路,形成可复用的实践路径。
1. 从一份 PDF 到一次完整训练:这份教程把 LLM 的哪几块拆开了
很多人能把transformers的推理脚本跑通,模型也能吐出像模像样的句子,可一旦被追问「注意力里为什么要除以根号 d」「SFT 的 loss 为什么只算回答部分」,就卡住了。Happy-LLM 这份《从零开始的大语言模型原理与实践教程》针对的正是这个断层:它从 NLP 基础概念起步,经 Transformer 手写实现、三类预训练语言模型架构对比,一路推到 LLaMA2 的 Tokenizer 训练、预训练与 SFT 全流程,最后落到 LoRA/QLoRA 微调、RAG 与 Agent 的最小实现。适合会 Python、跑过推理但没写过训练循环的开发者,也适合要在大模型方向做课程设计或准备转岗面试的人。它不是速查手册,而是要求你跟着敲代码、把每个张量形状对上的那种教程。
2. Transformer 手写实现:把注意力拆到矩阵维度上
读开源模型代码时最常见的卡点是形状对不上:明明知道多头注意力是把 Q、K、V 拆成若干头分别算,但view之后该不该transpose、mask 该加在哪一步、softmax 沿哪个维度归一化,这些细节在框架封装里全被藏起来了。这一章的做法是反着来——先用几十行 PyTorch 把缩放点积注意力和多头拆分写出来,再去看 LLaMA2 的实现,两边一一对应,后面的训练代码才有落脚点。
2.1 先写单头注意力:四个矩阵乘法的顺序不能乱
缩放点积注意力的公式很短,但落到代码里,顺序错一步结果就完全不对。我一般会先写单头版本,把每一步的形状打印出来,确认无误再封装成多头。
import math import torch import torch.nn as nn import torch.nn.functional as F def scaled_dot_product_attention(q, k, v, mask=None): """ q: [B, H, T_q, D_h] k: [B, H, T_k, D_h] v: [B, H, T_k, D_h] mask: [B, 1, T_q, T_k] 或 [1, 1, T_q, T_k],True 表示可见 返回: [B, H, T_q, D_h] """ d_k = q.size(-1) # 1. Q 乘 K 的转置,得到 [B, H, T_q, T_k] 的相似度矩阵 scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(d_k) # 2. mask 必须在 softmax 之前加,用极小值堵住未来位置 if mask is not None: scores = scores.masked_fill(~mask, torch.finfo(scores.dtype).min) # 3. softmax 沿最后一个维度,也就是"每个 query 看所有 key"的方向 attn = F.softmax(scores, dim=-1) # 4. 用权重加权求和 value return torch.matmul(attn, v), attn四个动作对应四行代码,顺序不能调换。第一步除以sqrt(d_k)是为了防止点积随维度增大而方差爆炸,导致 softmax 进入饱和区、梯度趋近于零;这一步在早期实现里经常被漏掉,表现是训练前几百步 loss 几乎不动。第二步的 mask 用masked_fill而不是乘 0,是因为 softmax 里乘 0 之后 exp(0)=1 仍然有贡献,必须填负无穷。第三步dim=-1是最容易写错的地方:写成dim=-2会把不同 query 之间的分数归一化,模型完全学不到东西,但代码不报错,这是典型的静默失败。第四步返回注意力权重,是为了后面画热力图、做可视化排查。
参数上还有两个隐含约定。一是q和k的最后一维必须相等,否则矩阵乘法直接报错,这个错误信息很明确,反而好查;二是mask的 dtype 建议用 bool,用 0/1 的 float 去乘会在混合精度下产生意外的 0 值。我在调试阶段会固定加一行形状断言,跑通后再删掉,比事后猜要省时间。
2.2 多头拆分:三次 view 和一次 transpose
多头注意力的本质是把d_model切成n_heads份,每份独立算注意力,再拼回来。代码看起来只是几个 reshape,但transpose的位置决定了内存布局,写错了要么结果错、要么虽然结果对但慢得离谱。
class MultiHeadAttention(nn.Module): def __init__(self, d_model=512, n_heads=8, dropout=0.1): super().__init__() assert d_model % n_heads == 0, "d_model 必须能被 n_heads 整除" self.d_model = d_model self.n_heads = n_heads self.d_head = d_model // n_heads # 每个头的维度 # 一次性投影出 QKV,比三个独立 Linear 少两次 kernel 启动 self.qkv_proj = nn.Linear(d_model, 3 * d_model) self.out_proj = nn.Linear(d_model, d_model) self.dropout = nn.Dropout(dropout) def forward(self, x, mask=None): B, T, _ = x.shape # [B, T, 3*d_model] -> [B, T, 3, H, D_h] qkv = self.qkv_proj(x).view(B, T, 3, self.n_heads, self.d_head) # 拆成三份并把 head 维度提到 batch 后面 -> [3, B, H, T, D_h] q, k, v = qkv.permute(2, 0, 3, 1, 4).unbind(0) out, _ = scaled_dot_product_attention(q, k, v, mask) # 拼回 [B, T, d_model] out = out.transpose(1, 2).contiguous().view(B, T, self.d_model) return self.dropout(self.out_proj(out))view(B, T, 3, H, D_h)这一步把最后一维切成「三份投影 × 头数 × 头维度」,顺序必须是 3 在前、H 在后,因为我们希望 Q、K、V 各自连续存放。permute(2, 0, 3, 1, 4)之后unbind(0)得到三个[B, H, T, D_h],这个顺序才能让matmul把最后两维当成矩阵、前面两维当成批次。拼回来时的transpose(1, 2)如果漏了后面的.contiguous(),view会因为内存不连续直接报错——这个报错信息很直白,属于好处理的坑。
真正需要留意的是d_model和n_heads的关系。d_model=512, n_heads=8时每个头 64 维,这是经典配置;LLaMA2 用的是d_model=4096, n_heads=32,每头 128 维。头数不是越多越好,头维度太小(比如 16 维)会让每个头学到的表征容量不足,实践中很少低于 32。另一个细节是 GQA(分组查询注意力),LLaMA2 的 70B 版本里 K、V 的头数少于 Q,这是为了推理时省显存,手写实现阶段可以先按标准多头来,等能跑通了再改。
2.3 位置编码与残差:loss 不降时先查这两处
自注意力本身对位置是无感的——打乱输入顺序,输出只是跟着重排,模型不知道谁在前谁在后。所以位置编码必须显式注入,而注入方式直接决定模型能不能训起来。教程里给了正弦位置编码的手写版本,这个版本的好处是不用训练参数、可以外推到比训练长度更长的序列。
import math def sinusoidal_position_encoding(max_len, d_model, device="cpu"): """返回 [1, max_len, d_model] 的位置编码,可直接加到词嵌入上""" pe = torch.zeros(max_len, d_model, device=device) position = torch.arange(0, max_len, device=device).unsqueeze(1).float() # 不同维度用不同频率,偶数维用 sin、奇数维用 cos div_term = torch.exp( torch.arange(0, d_model, 2, device=device).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)div_term里那个10000.0是频率基数,改大会让高频分量衰减更快、长距离位置区分度下降;d_model必须能被 2 整除,否则0::2和1::2的长度不一致会报错。这套编码在序列长度超过训练长度时仍然能给出一致的相对关系,但现代 LLM 大多换成了 RoPE(旋转位置编码),原因在于 RoPE 把位置信息编码进 Q、K 的相对角度里,配合外推策略在长上下文上更稳。手写这一版的价值不在于用,而在于理解「位置信息必须显式构造」这件事。
残差结构的问题更隐蔽。Post-LN(先注意力后 LayerNorm)在深层网络里需要精心设计的 warmup,否则前几百步的梯度范数会剧烈震荡;Pre-LN(先 LayerNorm 再注意力)把归一化放在子层入口,梯度回传路径更短,训练稳定得多,这也是现在几乎所有 LLM 的选择。如果你手写的模型出现 loss 先降后爆、或者梯度范数在 10 的几次方之间跳,八成是这里的问题。改法很简单:把x = layer_norm(x + attn(x))换成x = x + attn(layer_norm(x)),同时把最终的layer_norm加在输出头之前。这个改动只有两行,但效果往往是「从训不动到能训」的差别。
3. 架构选型:Encoder-Only、Encoder-Decoder 与 Decoder-Only 的掩码差别
教程用一整章讲三类预训练语言模型,从 Encoder-Only 的 BERT 路线,到 Encoder-Decoder 的 T5 路线,再到 Decoder-Only 的 GPT/LLaMA 路线。这部分内容如果只当科普读,读完就忘;真正有价值的是把它们还原成「注意力掩码长什么样」这个具体问题——三类架构的差别,落到代码上其实就是一个 mask 矩阵的形状。
3.1 一张表看懂三种掩码
掩码决定了「谁能看到谁」。Encoder-Only 用双向可见,每个 token 都能看到整句;Encoder-Decoder 的编码器双向、解码器单向加交叉注意力;Decoder-Only 用下三角因果掩码,每个位置只能看自己和左边。这个差别在训练目标和推理方式上都会放大。
| 架构 | 可见性 | 训练目标 | 典型用途 | 掩码形状 |
|---|---|---|---|---|
| Encoder-Only | 全双向 | MLM(随机遮盖预测) | 分类、抽取、句向量 | 全 True |
| Encoder-Decoder | 编码器双向 + 解码器因果 | Span 去噪 / seq2seq | 翻译、摘要、结构化生成 | 双 mask + 交叉 mask |
| Decoder-Only | 因果下三角 | 下一 token 预测(CLM) | 对话、通用生成、Agent | 下三角 True |
表格里「掩码形状」一列是关键。Encoder-Only 的 mask 可以是None,也可以用全 True 的方阵;Decoder-Only 的 mask 必须是下三角矩阵,用torch.tril(torch.ones(T, T))生成;Encoder-Decoder 则要同时维护两份,交叉注意力那里还要额外处理 padding。三类架构的训练目标不同,导致同一个下游任务选错架构会很别扭:拿 Decoder-Only 做文本分类,得在最后位置接一个分类头,效果通常不如直接用 Encoder-Only;拿 Encoder-Only 做开放式生成,模型根本没有下一 token 预测的训练经验,生成质量会明显掉档。
这里有个常见的实操结论:当下的通用大模型几乎全是 Decoder-Only,不是因为另外两类不行,而是因为它的训练目标(下一 token 预测)对数据的要求最低——任何一段文本都能当训练样本,不需要构造配对、也不需要人工标注遮盖位置。规模上去之后,上下文学习能力就是从这个简单目标里涌现出来的。
3.2 从 PLM 到 LLM:规模、指令微调与三个阶段
教程把 LLM 的训练过程拆成三段,这个拆分方式在动手之前必须先建立起来,否则你会在「我的小模型为什么不听话」这个问题上反复浪费时间。
第一段是预训练,目标就是下一 token 预测,语料是海量无标注文本,产出的是一个会续写但不会听话的基座模型。这个阶段的计算量最大,个人开发者基本不可能从头训一个有实用价值的模型,教程里的小模型定位是跑通流程、理解数据流。
第二段是有监督微调(SFT),数据是「指令-回答」对,loss 只算回答部分。这一步把基座模型从「续写器」掰成「助手」,样本量通常只有几万到几十万条,但对格式极其敏感。
第三段是对齐(RLHF/DPO 一类),需要偏好数据。教程在实战章里以 SFT 和 LoRA 为主,对齐部分点到为止,这符合个人开发者的现实:前两段能跑通就已经能覆盖大部分工程需求。
这个三段式还有个容易忽略的推论:如果你手里的基座模型已经在海量语料上预训练过,那你只需要做后面两段,这也正是 LoRA 微调能奏效的前提。反过来,如果你从随机初始化开始训一个几千万参数的小模型,那它只能学到格式,学不到知识,这个预期要先摆正。
3.3 选型判断与三个高频误用
落到具体项目上,选型可以按「输出形态」来分:输出是标签或向量,走 Encoder-Only;输出是定长结构化文本、输入输出长度接近,可以考虑 Encoder-Decoder;输出是开放式文本或需要多轮交互,走 Decoder-Only。这个判断不需要纠结太久,因为生态和工具链的倾斜已经很明显。
误用一是拿 BERT 类模型做生成。双向注意力没有因果约束,训练时每个位置都见过后面的词,推理时无从「按顺序生成」,勉强套一个自回归解码循环,效果也远不如同规模 Decoder-Only。
误用二是把 Decoder-Only 的最后隐藏状态直接平均池化成句向量。因果掩码下,靠前位置的表示几乎不含后文信息,直接平均会让句向量偏向开头几个词。常见做法是取最后一个非 padding 位置的隐藏状态,或者用专门的对比学习目标再训一轮。
误用三是掩码写反。torch.triu得到的是上三角,用它当因果掩码会让模型只能看未来、看不到历史,训练 loss 会停在一个明显偏高的平台上不动。这个错误代码不报错,只能靠打印 mask 矩阵肉眼确认,我的习惯是第一次跑新实现时固定打印一个 8×8 的 mask 看一眼。
4. 动手搭 LLaMA2:Tokenizer、预训练、SFT 的参数怎么定
第五章是整份教程最费时间的部分:基于 PyTorch 逐层搭一个 LLaMA2 结构的小模型,训练自己的 Tokenizer,跑通预训练和有监督微调。这一章的现实目标是「在小语料上把全链路走通」,而不是训出一个能打的模型。参数设定的思路和一线的做法差别不大,只是数量级缩小了。
4.1 先训 Tokenizer:词表大小与特殊 token 的取舍
模型结构可以照着改,Tokenizer 却必须自己训。现成的词表是按通用语料切的,直接用在垂直领域语料上,一个专业术语被切成五六个子词是常事,序列长度白白膨胀。
from tokenizers import Tokenizer, models, trainers, pre_tokenizers, decoders # byte-level BPE:任何字符都能被表示为字节,理论上不存在 OOV tokenizer = Tokenizer(models.BPE(unk_token=None)) tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False) tokenizer.decoder = decoders.ByteLevel() trainer = trainers.BpeTrainer( vocab_size=32000, # 与 LLaMA2 对齐,中文为主可加到 40000~50000 min_frequency=2, # 低频子串直接丢弃,避免词表被长尾占满 special_tokens=["<pad>", "<bos>", "<eos>", "<unk>"], initial_alphabet=pre_tokenizers.ByteLevel.alphabet(), ) tokenizer.train(files=["corpus.txt"], trainer=trainer) tokenizer.save("my_tokenizer.json") # 验证:检查压缩率和特殊 token 是否正确 enc = tokenizer.encode("大语言模型的预训练过程需要大量语料。") print(len(enc.ids), enc.tokens[:12])vocab_size的选择直接权衡两件事:词表大,单条序列短、训练快,但嵌入层和输出层参数变多,小模型上这部分占比会很夸张;词表小,参数省,但序列变长,注意力计算量按平方增长。32K 是 LLaMA2 的取值,以中文为主的语料我一般会加到 40K 到 50K,能明显降低中文的字级切分比例。min_frequency=2是个经验值,设成 1 会让词表里塞满只出现一次的子串。initial_alphabet必须传 byte-level 的 256 个字节字符,否则训出来的词表在遇到罕见字符时会静默丢字符。
训完一定要做两件事:第一,看一眼压缩率(原始字符数除以 token 数),中文语料通常在 1.5 到 2.0 之间,低于 1.2 说明词表没学到东西;第二,把<bos>、<eos>、<pad>的 id 打印出来,后面写 collate 函数时会反复用到,写错一个就会导致 padding 被当成正文参与 loss。
4.2 预训练循环:batch、梯度累积与学习率的组合
个人开发者最现实的约束是显存。一张 24G 卡跑 1B 级别的模型,序列长度 1024、batch size 也就一两条,梯度累积是绕不开的手段。教程里的训练循环骨架值得照抄,但几处参数要按自己的硬件改。
import torch from torch.optim import AdamW from torch.optim.lr_scheduler import LambdaLR model.train() optimizer = AdamW(model.parameters(), lr=3e-4, betas=(0.9, 0.95), weight_decay=0.1) accum_steps = 8 # 显存放得下多少就设多少,等效 batch = micro_batch * accum * 卡数 warmup_steps = 2000 total_steps = 100000 def lr_lambda(step): if step < warmup_steps: return step / max(1, warmup_steps) # 线性 warmup # 余弦衰减到峰值的 10% progress = (step - warmup_steps) / max(1, total_steps - warmup_steps) return 0.1 + 0.9 * 0.5 * (1 + torch.cos(torch.tensor(progress * 3.1415926))) scheduler = LambdaLR(optimizer, lr_lambda) for step, batch in enumerate(loader): input_ids = batch["input_ids"].cuda() labels = input_ids.clone() # 预训练阶段输入即标签 # 模型内部做 shift:logits[:, :-1] 对 labels[:, 1:] out = model(input_ids=input_ids, labels=labels) loss = out.loss / accum_steps # 关键:除累积步数,否则等效学习率翻倍 loss.backward() if (step + 1) % accum_steps == 0: torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad(set_to_none=True) # 比 zero_grad() 省一次显存写几个参数值得单独说。betas=(0.9, 0.95)是 LLaMA 系列的常用配置,第二个 beta 比默认的 0.999 小,意味着二阶矩估计窗口更短,对梯度突变反应更快,大规模训练里更稳。weight_decay=0.1只加在权重矩阵上,LayerNorm 和 bias 不加,这一点在实现上要么用参数分组,要么手动过滤。accum_steps除 loss 是最容易漏的一步:如果忘了除,反向传播的梯度是累积 batch 的 N 倍,等效学习率被放大,表现为 loss 剧烈震荡甚至发散,而代码本身完全合法。clip_grad_norm_的 1.0 是通用起点,训小模型时可以放到 1.0,训大模型常见 0.5 到 1.0。
还有一个常被忽略的点:预训练阶段的labels要和input_ids完全一致,shift 操作在模型内部完成。如果自己手动错开一位再传进去,模型内部又 shift 一次,loss 会算在错两位的偏移上,训练能跑但效果奇差,排查时很难想到。
4.3 SFT 的 loss mask:只对回答算 loss
有监督微调的核心不在模型,在数据构造。指令部分不能让模型学,否则推理时它会热心地帮你把问题再复述一遍。做法是把指令部分的 label 置为 -100,PyTorch 的交叉熵会自动忽略这个值。
IGNORE_INDEX = -100 def build_sft_example(tokenizer, instruction, response, max_len=1024): # 指令模板要固定,推理时用同一个模板,否则分布不匹配 prompt = f"<bos>用户:{instruction}\n助手:" prompt_ids = tokenizer.encode(prompt, add_special_tokens=False).ids resp_ids = tokenizer.encode(response + "<eos>", add_special_tokens=False).ids input_ids = (prompt_ids + resp_ids)[:max_len] labels = ([IGNORE_INDEX] * len(prompt_ids) + resp_ids)[:max_len] # 截断可能把 labels 截成全部 -100,这种样本要丢掉 if all(l == IGNORE_INDEX for l in labels): return None return {"input_ids": input_ids, "labels": labels}IGNORE_INDEX = -100是 PyTorchCrossEntropyLoss的默认忽略值,别改成别的数字。add_special_tokens=False是必须的,否则每次 encode 都会重复加<bos>,一条样本里出现两个起始符,模型会学乱。截断逻辑里那个「labels 全被截掉」的检查很容易漏,漏了的后果是这些样本产生零 loss,看起来正常,实际上白白消耗算力,还会让梯度里混入全零项。模板字符串必须和推理时完全一致,多一个空格、少一个换行,模型的表现都可能掉一截,这也是很多人抱怨「微调完反而不如微调前」的常见原因。
4.4 切到 Transformers 与 LoRA:target_modules 怎么选
第六章把 PyTorch 手写版换成业界框架,用transformers加peft做 LoRA 微调。这一步的意义是把前面的理解迁移到能直接上生产的工具链上。
from transformers import AutoModelForCausalLM, AutoTokenizer from peft import LoraConfig, get_peft_model model = AutoModelForCausalLM.from_pretrained( "本地模型目录", torch_dtype="bfloat16", device_map="auto" ) lora_cfg = LoraConfig( r=16, lora_alpha=32, # 缩放系数 alpha/r = 2 target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"], lora_dropout=0.05, bias="none", task_type="CAUSAL_LM", ) model = get_peft_model(model, lora_cfg) model.print_trainable_parameters() # 通常应打印出不到 2% 的可训练参数r=16是通用起点,任务越接近预训练分布、需要的 r 越小;如果做风格迁移或垂直领域适配,可以试到 32 或 64。lora_alpha除以r得到的缩放系数决定了 LoRA 分支的相对强度,常见取 1 到 2,很多人只调r不调alpha,结果换了r之后效果忽好忽坏,其实是缩放系数被顺带改了。target_modules是最容易出问题的一处:只加q_proj、v_proj是早期论文的省钱配置,参数量少但效果打折;全量加七个投影层是现在的常见做法,参数多一点但收敛明显更稳。用print_trainable_parameters()确认比例,如果打出来是 0%,说明模块名没匹配上,模型结构里的名字可能是Wqkv或者带前缀,需要先打印model结构对一遍。
5. 训练工程避坑:显存、精度、断点与 LoRA 的五个翻车点
理论对上了、代码抄完了,真正卡人的是训练跑起来之后那些不报错但结果不对的情况。下面五条是我在按这份教程复现时踩过或者见人踩过的,按「现象、原因、解决」写清楚。
5.1 五个高频问题的排查记录
现象一:第一步就 OOM,但模型参数量明明不大。原因往往不在模型本身,而在输出层的 logits。词表 32K、序列 2048、batch 4,中间张量是[4, 2048, 32000],bfloat16 下大约 500MB,反向传播还要再存一份,加上注意力矩阵,显存瞬间翻倍。解决办法是开启梯度检查点(gradient_checkpointing_enable()),用约 30% 的速度换显存,同时把 batch 降到 1、用梯度累积补回来。另一个方向是检查有没有哪一层被误设成 float32,混合精度里只要有一层是 fp32,整条链路都会被拖回高精度。
现象二:换成 bf16 之后直接报错,提示当前设备不支持。这不是代码问题,是硬件问题。bf16 需要计算能力 8.0 以上的卡,老一些的消费级卡只支持 fp16。解决办法是回退到 fp16 并开启torch.cuda.amp.GradScaler,因为 fp16 的动态范围窄,梯度小于 6e-8 会下溢成 0,loss scaling 就是用来把梯度放大再缩回来的。这条属于没得选的硬约束,先查硬件再定精度,别在配置上反复试。
现象三:断点续训之后 loss 曲线出现台阶。原因通常是只存了模型和优化器,没存 dataloader 和 scheduler 的状态。恢复时 dataloader 从第 0 条重新开始,模型却已经在第 N 步,等于把前面的数据又学了一遍,loss 会短暂下降然后异常;scheduler 从 warmup 重启则会让学习率突然变大。解决办法是把scheduler.state_dict()、当前的 step 数、以及torch.get_rng_state()一起存进 checkpoint,恢复时逐项 load 回去。分布式训练还要存 sampler 的 epoch,让每个 rank 从正确的分片继续。
现象四:LoRA 训完,模型输出变得重复、啰嗦。先查target_modules是否只覆盖了q_proj、v_proj,覆盖不全时模型只能在一个受限的子空间里调整,容易过拟合到训练集的句式。再查学习率,LoRA 的常用区间是 1e-4 到 3e-4,比全参微调高一个数量级,如果误用了全参的 2e-5,训练会慢到看不出变化,很多人以为是配置错,实际上是步数不够。第三个要查的是数据,SFT 数据里如果有大量超短回答混着长回答,模型会学到「看情况决定说多长」,表现就是不稳定。
现象五:把多条短样本拼成定长序列后,模型开始胡言乱语。这是 packing 的经典副作用。多条样本拼成一条 2048 的序列,如果在样本边界处没有重置注意力掩码,后一条样本的 token 会attend到前一条样本的内容,模型学到跨文档的虚假关联。解决办法是用 document-level mask,在每条样本的边界处把注意力堵住;简单一点的做法是拼的时候插入<eos>并在 collate 里构造 block-diagonal 的 mask。如果嫌麻烦,最稳的方案是不 packing,用 padding 加attention_mask,代价是算力利用率低一些。
提示:训练脚本里加一条固定检查——第一次迭代后打印 loss、grad_norm、学习率三个数。loss 应该是
ln(vocab_size)附近(32K 词表约 10.4),grad_norm 在 1 附近,学习率等于 warmup 后的值。三个数有一个明显不对,说明配置层面就有问题,不用等训练跑完再回头查。
6. 把训练产物接上 RAG 与 Agent:一条能验证的最小链路
模型训完只是半成品,教程最后一章把 RAG 和 Agent 接了上去,这部分我建议不要跳过,因为它决定了你训的模型到底能不能用。RAG 的最小链路是四步:文档切块、向量化入库、按问题检索 top-k、把检索结果拼进 prompt 再生成。切块大小我一般取 300 到 500 字,重叠 50 字,太大召回不准、太小上下文断裂。检索环节真正要看的指标不是生成结果好不好看,而是 recall@k——把标准答案所在的那一块能不能进 top-5,这个指标不达标,后面怎么调 prompt 都是白费。
Agent 那部分教程给的是简化实现,核心是把工具调用表达成模型能生成的固定格式,比如输出一段 JSON 描述函数名和参数,外部解析后执行再把结果塞回上下文。这条路线的成败几乎全在格式稳定性上,模型稍微跑偏一点,JSON 就解析失败。我的做法是先用几条样本做约束解码验证,确认模型在给定模板下能稳定输出合法结构,再去接真实工具,否则会在「解析失败、重试、再失败」的循环里耗掉一整天。
验证环节给一个可操作的习惯:每次训完模型,固定跑三个检查。第一,用训练集里没出现过的十条指令看输出格式是否合规;第二,跑一个 200 条的小评测集,人工看前 20 条,剩下的看平均长度和拒绝率;第三,RAG 链路单独测 recall@5,和生成质量分开看。三项里哪项异常,问题就定位在哪一段,比笼统地说「效果不好」有用得多。
血泪经验是:我曾经跳过第一项直接上 RAG,结果检索明明命中了正确段落,生成出来的答案还是错的,排查了半天才发现是 SFT 阶段模板里的冒号用了全角、推理时用了半角,模型没见过这个前缀,直接进入自由续写模式。从那以后我每次换模板都会先把编码后的 token 打印出来肉眼对一遍,再开始训练。希望帮到你。
本文还有配套的精品资源,点击获取