news 2026/11/3 19:50:52

从零手搓LLaMA2:Happy-LLM大模型训练与微调实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零手搓LLaMA2:Happy-LLM大模型训练与微调实战

简介:《Happy-LLM:从零开始的大语言模型原理与实践教程》由 Datawhale 发起,面向已熟悉大模型基本用法、希望进一步弄清其原理与训练流程的学习者和开发者。教程从 NLP 基础概念与常用工具讲起,逐层拆解 Transformer 的 Encoder-Decoder 结构、多头自注意力机制,并对比 Encoder-Only、Encoder-Decoder、Decoder-Only 三类建模范式的差异与适用场景。实践部分给出完整路径:动手实现 LLaMA2、训练 Tokenizer、预训练小型语言模型,再到指令微调与 LoRA/QLoRA 参数高效微调,最后延伸到模型评估、RAG 检索增强与 Agent 构建等应用形态,读者可据此完成第一个自建大模型项目。资源包仅含 1 个 PDF 文件,约 19.06MB,章节编排连贯清晰,适合按模块顺序通读,也可作为动手实验时的对照手册。目前已有 558 人学习。

1. 从一份 PDF 到能跑通的 LLaMA2:Happy-LLM 到底解决了什么问题

上个月一个做后端的同事找我,说想搞懂大模型,结果翻了两周论文,注意力公式看懂了,真让他写一行训练代码还是懵的——不知道该先建 tokenizer 还是先搭模型,不知道该用 nn.Linear 还是 transformers.AutoModel,更不知道 loss 不降的时候该看哪。这种卡点不是个例,绝大多数人卡在原理懂了但代码串不起来这一步。Happy-LLM 这份从零开始的大语言模型原理与实践教程,针对的就是这段空白:它不走论文综述路线,也不做 API 调用教学,从前面的 NLP 基础概念一路讲到 Transformer 的实现细节,再落到用 PyTorch 手搓一个 LLaMA2、跑完预训练和有监督微调,最后接上 LoRA/QLoRA 微调、RAG 与 Agent 的应用层。适合两类人:有 Python 和深度学习基础、想亲手把训练链路跑一遍的开发者;以及需要给团队做技术选型、想搞清楚每个训练阶段到底在做什么的工程师。全书七章,前四章理论,后三章实战,读完你对「一个大模型从数据到出词」这条链路就不会再有黑匣子感。

2. 教程架构与阅读路线:七章内容怎么拆成一条可执行的工程链路

先给结论:这份教程的价值不在章节标题,而在它把「原理」和「能跑的代码」按同一条主线对齐了。七章分成两半,第 1~4 章是知识线,讲 NLP 基础、Transformer、三类预训练语言模型架构、LLM 的能力与训练阶段;第 5~7 章是实战线,讲基于 PyTorch 从零实现一个 LLaMA2 并完成预训练 + SFT、基于主流训练框架做工程化训练、以及 RAG 和 Agent 的应用落地。两条线是咬合的:第 2 章的注意力实现,会在第 5 章被直接搬进模型骨架;第 4 章讲的训练三阶段(预训练、指令微调、对齐),会在第 5 章的代码里变成三个可以分别跑起来的脚本。

2.1 章节依赖关系:哪几章必须按顺序读,哪几章可以跳

我的建议是分三档处理。第 1 章 NLP 基础概念属于「查漏」性质,如果你已经清楚分词、词性标注、文本分类这些任务在做什么,扫一遍术语表就行,不用逐字读;但如果你对「为什么中文要先分词」「子词切分解决了什么」没概念,这一章必须补,否则后面看到 BPE、SentencePiece 会完全是玄学。第 2 章 Transformer 和第 3 章预训练语言模型是硬依赖,跳过这两章直接看第 5 章,你会看不懂模型里为什么要搞个 RMSNorm、为什么位置编码要用旋转而不是可学习 embedding。第 4 章 LLM 概述是从 PLM 到 LLM 的过渡,讲清楚「涌现能力」「上下文学习」「指令微调」这三个概念就够了,重点看训练阶段划分那张图。

实战线的第 5 章是全书重心,也是最容易翻车的地方,它把 LLaMA2 拆成 tokenizer、模型结构、预训练循环、SFT 数据管道四块,每块都有可运行代码。第 6 章引入训练框架,本质是「当你不想手搓的时候用什么」,重点是 LoRA 和 QLoRA 的接入方式。第 7 章的 RAG 与 Agent 属于应用层,可以最后看,也可以在跑通第 5 章之后立刻看,因为 RAG 的检索部分对理解 embedding 很有帮助。

2.2 环境与依赖:一套能同时跑通训练和推理的 Python 环境

教程里的代码是 Python + PyTorch 体系,我给一套我实际用下来最稳的依赖组合,版本不用完全对齐,但大版本别跨太远。Python 建议 3.10 或 3.11,3.12 在某些编译型依赖上还会有兼容问题。

# 建一个干净环境,别和本机的其他项目混用 conda create -n happy-llm python=3.10 -y conda activate happy-llm # 先装 PyTorch,按自己的 CUDA 版本去官方命令生成器里拿对应命令 pip install torch==2.1.2 torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 # 训练与数据处理全家桶 pip install transformers==4.38.2 datasets==2.17.1 accelerate==0.27.2 \ peft==0.8.2 trl==0.7.11 sentencepiece==0.1.99 \ tensorboard==2.15.1 einops==0.7.0

这里几个包的用途要说清楚,别一股脑装上就不管了。transformers提供模型定义、tokenizer 和 Trainer 训练循环,第 6 章的工程化训练基本围绕它展开;datasets负责数据加载与 map 预处理,处理大语料时它的内存映射比 pandas 靠谱得多;peft是 LoRA/QLoRA 的实现方,第 6 章的参数高效微调离了它写不动;accelerate负责多卡和混合精度调度;sentencepiece是 LLaMA2 系 tokenizer 的底层依赖,漏装会在加载分词器时直接报错。trl里有 SFTTrainer,用来做指令微调比手写循环省事。

提示:不要图省事在 base 环境里装,训练脚本经常需要改 torch 版本,混装过一次之后排查依赖冲突能花掉一整天。

2.3 把 PDF 里的代码落地成仓库:目录怎么组织

PDF 里的代码是一段段贴的,直接复制到 notebook 里一行行跑,跑到第三章就会乱。我的做法是按「数据 / 模型 / 训练 / 配置」四层拆开,先建骨架再往里填。下面这套目录是我改过两版之后定下来的,小模型单卡训练完全够用。

# 在项目根目录执行,一次性把骨架建好 mkdir -p happy-llm/{configs,data/raw,data/processed,tokenizer,model,train,eval,scripts} # configs/ 训练超参 yaml,一个实验一份,方便回溯 # data/raw 原始语料,txt 或 jsonl,不要直接改这里 # data/processed 分词打包后的二进制,可随时重建 # tokenizer/ 训练好的分词器模型与词表 # model/ 模型结构定义,按模块拆文件 # train/ 预训练、SFT、LoRA 三个入口脚本 # eval/ 困惑度、生成样例的评估脚本 # scripts/ 启动脚本与日志重定向

这样拆的好处是,第 5 章手搓的模型和第 6 章用 transformers 加载的模型可以共用一个configs/,改超参只动 yaml,两个实现跑出来的 loss 曲线能直接对比,出问题时能快速定位是结构写错了还是训练框架用错了。数据目录分 raw 和 processed 也是血泪经验:分词一次在小语料上可能就要十几分钟,缓存下来能省掉大量重复劳动,而且 processed 目录删掉可以无损重建,不会污染原始数据。

3. Transformer 核心模块拆解:从缩放点积注意力到前馈网络

第 2 章是全书技术密度最高的一章,也是最值得逐行敲的一章。它的写法是先讲注意力机制的动机,再给多头注意力的实现,然后讲位置编码、残差与归一化,最后落到完整的 Encoder-Decoder 结构。我这里不复述公式,直接讲工程上要注意的几个点,以及代码写出来应该长什么样。

3.1 一个注意力层到底在算什么:Q、K、V 的工程含义

把注意力理解成「查字典」是最省脑子的方式:Q 是你要查的词,K 是字典里的索引,V 是索引对应的释义。计算时用 Q 和每个 K 做点积得到相关性分数,softmax 归一化成权重,再用权重对 V 加权求和。工程上真正影响结果的三个细节是:缩放因子为什么是sqrt(d_k)(防止维度大时点积方差过大导致 softmax 饱和、梯度消失)、mask 怎么加(-inf还是极小值)、以及 Q/K/V 是一次线性层切出来还是三次独立线性层。

第三点很多人不纠结,但它直接影响显存和速度。一次Linear(d_model, 3*d_model)再chunk成三份,比三个独立Linear少两次 kernel 启动,在长序列上差别肉眼可见。教程里给的是标准写法,我把它整理成可以直接放进model/attention.py的版本。

import math import torch import torch.nn as nn class MultiHeadAttention(nn.Module): def __init__(self, d_model=512, n_head=8, dropout=0.1): super().__init__() # d_model 必须能被头数整除,否则每个头的维度不是整数 assert d_model % n_head == 0, "d_model 必须能被 n_head 整除" self.d_model = d_model self.n_head = n_head self.d_k = d_model // n_head # 一次投影切出 QKV,减少 kernel 启动次数 self.w_qkv = nn.Linear(d_model, 3 * d_model, bias=False) self.w_o = nn.Linear(d_model, d_model, bias=False) self.dropout = nn.Dropout(dropout) def forward(self, x, mask=None): B, L, _ = x.shape q, k, v = self.w_qkv(x).chunk(3, dim=-1) # 各 [B, L, d_model] # 拆头:[B, L, H, d_k] -> [B, H, L, d_k],把头维提到 batch 后面 q, k, v = [t.view(B, L, self.n_head, self.d_k).transpose(1, 2) for t in (q, k, v)] # 缩放点积:除以 sqrt(d_k) 防止 logits 过大 scores = q @ k.transpose(-2, -1) / math.sqrt(self.d_k) # [B, H, L, L] if mask is not None: # 用 dtype 最小值而不是 -inf,避免整行被 mask 时 softmax 出 nan scores = scores.masked_fill(mask == 0, torch.finfo(scores.dtype).min) attn = self.dropout(scores.softmax(dim=-1)) # 合并头:[B, H, L, d_k] -> [B, L, d_model] out = (attn @ v).transpose(1, 2).contiguous().view(B, L, self.d_model) return self.w_o(out)

参数上,d_model是小模型里最该先定的量,它决定了整个模型的宽度,常见配置是 512、768、1024 三档,配合 8 或 16 个头;d_k由两者相除得出,一般控制在 64 左右比较稳,太大容易在 fp16 下溢出,太小则表达能力不足。mask的形状要和scores对得上,因果注意力传的是[L, L]的下三角矩阵,padding mask 则是[B, 1, 1, L],两者要能广播到一起,这一点在写 SFT 的时候最容易搞错。

3.2 位置编码、残差与归一化:三处最容易写错的细节

Transformer 本身对顺序不敏感,位置信息必须显式注入。教程里讲了正弦位置编码和可学习位置编码两种,前者不占参数、可外推到更长序列,后者更灵活但长度被锁死。写代码时的坑在于:正弦编码的div_term如果按arange(0, d_model, 2)生成,要注意偶数维用 sin、奇数维用 cos 的对应关系,写反了模型也能训,但收敛会明显变慢,这种不报错的 bug 最难查。

残差和归一化的位置也有讲究。原始论文是 Post-LN(先注意力再相加再归一化),后来主流实现基本都换成了 Pre-LN,把归一化放在子层输入之前,训练更稳定,学习率可以开大一点,不需要那么长的 warmup。这一点在第 5 章手搓 LLaMA2 时会体现得更明显——LLaMA2 用的是 RMSNorm + Pre-Norm,是 Pre-LN 的简化版本。

class FeedForward(nn.Module): def __init__(self, d_model=512, d_ff=2048, dropout=0.1): super().__init__() # 标准 Transformer 是两层线性,中间维度放大 4 倍 self.net = nn.Sequential( nn.Linear(d_model, d_ff), nn.GELU(), # 早期用 ReLU,现在基本 GELU 起步 nn.Dropout(dropout), nn.Linear(d_ff, d_model), ) def forward(self, x): return self.net(x) class EncoderLayer(nn.Module): def __init__(self, d_model, n_head, d_ff, dropout=0.1): super().__init__() self.attn = MultiHeadAttention(d_model, n_head, dropout) self.ffn = FeedForward(d_model, d_ff, dropout) self.norm1 = nn.LayerNorm(d_model) self.norm2 = nn.LayerNorm(d_model) self.dropout = nn.Dropout(dropout) def forward(self, x, mask=None): # Pre-LN:先归一化再进子层,残差走旁路 x = x + self.dropout(self.attn(self.norm1(x), mask)) x = x + self.dropout(self.ffn(self.norm2(x))) return x

d_ff一般是d_model的 4 倍,这是原始论文的经验值,小模型上可以降到 2~3 倍省显存。注意nn.GELU()和 LLaMA2 用的SwiGLU不一样,后者引入门控结构,参数量会多出来一截,第 5 章会单独说。

3.3 三类架构的取舍:Encoder-Only、Encoder-Decoder 与 Decoder-Only

第 3 章把预训练语言模型分成三条路线,这个划分是后面所有内容的地基,值得用一张表记住。

架构代表思路擅长任务训练目标工程上的代价
Encoder-Only双向编码分类、抽取、句向量掩码语言建模不能直接做生成,要接任务头
Encoder-Decoder编码 + 解码翻译、摘要、序列到序列去噪 / 跨度预测参数量大,推理要维护两套缓存
Decoder-Only单向左到右通用生成、对话、推理下一词预测训练简单,但推理要 KV Cache

选型结论很直接:如果你要做的是「给定一段文本判断它属于哪类」,用 Encoder-Only 性价比最高;如果你要做的是「给定输入生成输出」,而且是长度不对等的任务,Encoder-Decoder 结构更自然;但如果你要复现的是一份通用大模型教程,那必然是 Decoder-Only,因为它的训练目标统一(永远是预测下一个 token),数据不需要成对构造,扩展性最好。第 5 章之所以选 LLaMA2,就是因为它是纯 Decoder-Only 结构,七个部件拆得干净,适合当第一个手写对象。

4. 手搓 LLaMA2:Tokenizer、模型结构、预训练与 SFT 的完整链路

第 5 章是实战线的核心,也是这份教程最值得动手的部分。它把「训一个模型」拆成四段可独立验证的步骤:先训 tokenizer,再搭模型结构并验证前向能跑通,然后写预训练循环看 loss 是否下降,最后构造 SFT 数据做指令微调。这四步的顺序不能反,尤其是 tokenizer 必须先于模型定下来,因为词表大小决定了 embedding 的参数量。

4.1 Tokenizer 先行:BPE 与 SentencePiece 到底在做什么

tokenizer 做的事是把字符串映射成整数 id 序列。LLaMA2 系用的是 BPE 的变体,本质是从字符级开始,反复把出现频率最高的相邻对合并成新词元,直到词表达到目标大小。工程上的三个关键参数:词表大小(小模型 8k~32k 都合理,越大 embedding 越占参数)、character_coverage(中文语料建议 0.9995 以上,低了会把生僻字全丢成 UNK)、max_sentence_length(设小了长文本会被截断在训练语料里,造成静默数据丢失)。

# 用 sentencepiece 训练一个最小可用的分词器 import sentencepiece as spm spm.SentencePieceTrainer.train( input="data/raw/corpus.txt", # 一行一句的纯文本 model_prefix="tokenizer/tokenizer", # 产出 tokenizer.model / tokenizer.vocab vocab_size=32000, # 小模型可以降到 8000 model_type="bpe", # LLaMA2 系用的就是 bpe character_coverage=0.9995, # 中文语料别低于这个值 max_sentence_length=8192, # 防止长句被截断 pad_id=0, unk_id=1, bos_id=2, eos_id=3, add_dummy_prefix=False, # 中文不加前导空格,避免多出 token )

add_dummy_prefix这个参数在中文上坑过不少人:默认是 True,会在每句话前面加一个空格,导致中文语料的第一个 token 永远是个多余符号,模型训出来生成的句子开头带空格。pad_id和eos_id一定要显式指定,后面 padding 和生成终止都依赖这两个 id,尤其是做 SFT 的时候,用 eos 当 pad 是个常见技巧,能减少无效位置的 loss 干扰。

训完 tokenizer 第一件事不是马上训模型,而是拿几句话 encode 再 decode 一遍,看是否原样还原。这个检查能挡掉大部分「词表和模型不匹配」的问题。

import sentencepiece as spm sp = spm.SentencePieceProcessor(model_file="tokenizer/tokenizer.model") for text in ["今天天气不错,适合训练模型。", "Hello, world!"]: ids = sp.encode(text, out_type=int) print(len(ids), ids) print(sp.decode(ids)) # 必须和原文一致,否则 tokenizer 有问题

4.2 LLaMA2 的四个结构差异:RMSNorm、RoPE、SwiGLU 与 GQA

标准 Transformer 讲到第 3 章就够了,但 LLaMA2 改了四处,改动都不复杂,影响却不小,是手搓时最值回票价的部分。

第一是归一化换成 RMSNorm。LayerNorm 要算均值和方差,RMSNorm 只算均方根,不减均值,少一次 reduce,在深层网络里累积起来的收益很明显。第二是位置编码换成 RoPE,旋转位置编码,通过对 Q/K 向量做旋转来注入位置信息,好处是相对位置信息内积形式自然成立,外推性比可学习位置编码好。第三是前馈网络换成 SwiGLU,引入门控支路。第四是部分尺寸的 LLaMA2 用了 GQA(分组查询注意力),多个 Q 头共享一组 K/V 头,减少 KV Cache 显存。

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): # 只按最后一维求均方根,不减均值 norm = x.pow(2).mean(-1, keepdim=True) + self.eps return self.weight * x * torch.rsqrt(norm) class SwiGLU(nn.Module): def __init__(self, d_model, d_ff=None): super().__init__() # SwiGLU 的中间维度通常取 2/3 * 4d,保证参数量和标准 FFN 接近 d_ff = d_ff or int(8 * d_model / 3) self.w_gate = nn.Linear(d_model, d_ff, bias=False) # 门控支路 self.w_up = nn.Linear(d_model, d_ff, bias=False) # 上投影 self.w_down = nn.Linear(d_ff, d_model, bias=False) # 下投影 def forward(self, x): # SiLU 门控:一条支路做激活再和另一条做逐元素相乘 return self.w_down(torch.nn.functional.silu(self.w_gate(x)) * self.w_up(x))

d_ff取8/3 d_model是为了让 SwiGLU 的三矩阵参数量和标准两层 FFN(4 倍扩张)持平,这个换算在移植时最容易被忽略,直接按 4 倍写会导致 FFN 参数凭空多出一大截。RoPE 的实现要点是预先算好每个位置的 cos/sin 表,然后对 Q/K 做「前半后半交换再取负」的 rotate_half 操作,写成函数以后在注意力里调用,注意 cos/sin 表的精度要跟模型 dtype 一致,fp16 下外推到长序列会出现位置漂移。

4.3 预训练循环:梯度累积、loss 位移与学习率调度

搭完结构要先做一次前向测试:随机输入[2, 32]的 token id,看输出形状是不是[2, 32, vocab_size]且 loss 是个合理值(约等于ln(vocab_size),32000 词表大概 10.4)。这个检查能挡掉维度写错、mask 广播失败、softmax 维度搞反这类问题,比我见过的大部分「训练三小时发现 loss 是 nan」的排查过程高效。

预训练的 loss 计算有一个必踩的坑:语言模型是预测下一个 token,所以 logits 要左移一位、labels 右移一位。变形金刚库内部做了这件事,手写的时候不做,模型会变成「预测自己」,loss 掉得飞快然后卡死在一个很低的值上,看起来像收敛其实是崩了。

import torch def train_step(model, batch, optimizer, scheduler, grad_accum=4, device="cuda"): input_ids = batch["input_ids"].to(device) # 手写时不做 shift 是最常见的错误:模型会学着复制输入 logits = model(input_ids) # [B, L, vocab] shift_logits = logits[:, :-1, :].contiguous() shift_labels = input_ids[:, 1:].contiguous() loss = torch.nn.functional.cross_entropy( shift_logits.view(-1, shift_logits.size(-1)), shift_labels.view(-1), ignore_index=0, # pad_id 不参与 loss ) (loss / grad_accum).backward() # 累积梯度等价于大 batch if (step := getattr(train_step, "step", 0)) % grad_accum == 0: torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) # 防梯度爆炸 optimizer.step() scheduler.step() # warmup + cosine 衰减 optimizer.zero_grad(set_to_none=True) # 省显存,比 zero_grad() 快 train_step.step = step + 1 return loss.item()

grad_accum是显存不够时的救命参数,单卡只能放 batch=2 时,累积 8 步等价于 batch=16,代价是训练时间线性变长。clip_grad_norm_的 1.0 是小模型常用值,训大模型时偶有调到 0.5 的,这个参数和后面 loss 是否出现尖峰强相关。ignore_index=0必须和 tokenizer 的pad_id对齐,写错会出现 loss 一直在 0.3 以下但生成质量极差的情况。

学习率调度建议 warmup 加 cosine:warmup 步数占总步数的 1%~5%,峰值学习率小模型 3e-4 到 1e-3 都能试,越大的模型峰值越低。我一般先拿 1% 的数据跑几百步,看 loss 曲线形状:warmup 段下降明显、中途平稳、偶有尖峰但能回落,基本就是健康的。

4.4 SFT 数据管道:label mask 怎么切,决定微调能不能用

预训练之后模型会「接话」但不会「答话」,SFT 的作用是让它学会按指令格式回复。这一步的技术量不大,但数据管道的细节决定了训出来的模型是不是能用。核心只有一件事:只在 answer 部分计算 loss,prompt 部分全部 mask 成 -100。

IGNORE_INDEX = -100 def build_sft_example(prompt, answer, tokenizer, max_len=1024): # 拼接格式要和推理时完全一致,否则训练和推理对不上 prompt_ids = tokenizer.encode(prompt, add_bos=True, add_eos=False) answer_ids = tokenizer.encode(answer, add_bos=False, add_eos=True) input_ids = (prompt_ids + answer_ids)[:max_len] labels = ([IGNORE_INDEX] * len(prompt_ids) + answer_ids)[:max_len] # 截断后要保证长度一致,否则 collate 时对不齐 input_ids = input_ids + [tokenizer.pad_id()] * (max_len - len(input_ids)) labels = labels + [IGNORE_INDEX] * (max_len - len(labels)) return {"input_ids": input_ids, "labels": labels}

三个要点。第一,prompt 和 answer 的拼接格式必须和推理阶段的模板一字不差,训练用「### 指令:... ### 回答:」,推理时也得这么拼,否则模型见过的是模板 A、你喂的是模板 B,效果会断崖式下跌。第二,IGNORE_INDEX用 -100 而不是 0,因为 0 是 pad_id,是真实存在于交叉熵计算里的类别,用 0 会让模型学到「padding 位置预测 0」这种噪声。第三,max_len截断时如果只截 input_ids 不截 labels,长度对不上会直接报错,两个都要截。

数据规模上,小模型做 SFT 一两万条高质量样本就能看到明显效果,比起堆到十万条低质数据,不如把样本清洗和模板统一做好。评估方式也简单:拿十条没见过的指令让它生成,看格式对不对、内容有没有跑偏,比困惑度直观得多。

5. 避坑与排查:训练链路上最容易翻车的五个地方

这一章是我自己复现过程中踩出来的清单,按「现象 → 原因 → 解决」写,每条都是真实会卡住半天的问题。

loss 前几十步就是 nan。现象是 loss 输出直接变成nan,之后所有权重都是 nan。原因通常有三类:学习率开太大导致梯度爆炸、fp16 下 softmax 或 layernorm 溢出、mask 里用了-inf且整行都被 mask。解决顺序是先关掉混合精度用 fp32 跑十步,看是否恢复;恢复说明是精度问题,改用 bf16 或给 softmax 加torch.finfo(dtype).min替代-inf;不恢复就检查学习率,把峰值降到 1e-4 再试。

loss 卡在 10.4 附近不动。现象是三千步之后 loss 还是ln(vocab_size)左右,生成结果是随机词。原因基本可以锁定为「预训练没做 shift」或者「labels 全是 ignore_index」。前者是模型在学复制输入,后者是 mask 写反了。解决方法是打印一个 batch 的labels看看非 -100 的比例,正常应该在 50% 以上;再检查 logits 和 labels 的切片偏移。

显存明明够却 OOM。现象是 24G 卡跑 1B 模型,batch 设 4 就炸。原因是 AdamW 的优化器状态占了参数量的两倍(fp32 下每参数约 16 字节),加上激活值,1B 模型实际占用接近 20G。解决办法按性价比排序:开梯度检查点(省激活但慢 20%)、换 8-bit 优化器、降max_len、上 LoRA 冻结主干。实在不够就老老实实降 batch 加梯度累积。

模型生成时停不下来,一直重复。现象是回答末尾反复输出同一个短语,直到max_new_tokens耗尽。两个原因:tokenizer 的eos_id和模型配置里的eos_token_id不一致,模型压根不知道哪个 token 是结束符;或者 padding 方向弄反了,batch 推理时左侧 padding 才对得上位置。解决是先打印model.config.eos_token_id和tokenizer.eos_token_id比对,不一致就手动设;batch 生成统一用tokenizer.padding_side = "left"。

SFT 之后模型反而不听话了。现象是微调后模型回答变短、格式统一但内容空洞,甚至丢掉了原本的通用能力。原因是学习率沿用预训练量级、训练轮数过多导致过拟合,或者 SFT 数据模板单一让模型只会一种回答方式。解决是把学习率降到 1e-5 量级、epoch 控制在 2~3 轮、数据里混入 10% 的通用指令做正则,同时每轮都在固定测试集上跑一次生成观察退化情况。

6. 二次开发改造:把这份教程变成自己的实验平台

跑通之后真正有价值的是改造。我一般的做法是从最小改动开始,每次只动一个变量,保证能回滚。

第一层改造是换数据。把data/raw/corpus.txt换成自己领域的语料,重训 tokenizer 后必须同步改模型配置里的vocab_size,否则加载权重会维度不匹配。这里有个省事的对照表:

改造项必改参数容易漏的地方
换领域语料vocab_size、pad_id、eos_id旧 checkpoint 不能直接加载
缩小模型d_model、n_layer、n_headd_ff要跟着按 8/3 重算
加长上下文RoPE base、max_lencos/sin 表要重新预计算
上 LoRAr、lora_alpha、target_modules只训 adapter,主干要冻住

第二层是接工程化框架。手搓版本的目的是理解,真要长时间训练还是换成 transformers + peft。LoRA 的接入只需要几行,关键是target_modules选对,选错了效果等于没训。

from peft import LoraConfig, get_peft_model, TaskType cfg = LoraConfig( task_type=TaskType.CAUSAL_LM, r=8, # 秩,8 起步,数据多可以上 16/32 lora_alpha=16, # 缩放系数,通常取 2r lora_dropout=0.05, target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], # 注意力四矩阵 ) model = get_peft_model(model, cfg) model.print_trainable_parameters() # 可训练参数应只占总量的 1% 以内

print_trainable_parameters()这一步别省,输出如果显示可训练参数占比超过 5%,说明target_modules匹配到了别的层,或者主干没冻结,显存和预期会完全对不上。

第三层是接应用层,也就是教程第 7 章的 RAG 与 Agent。我的验证方法是先不训模型,拿现成的 base 模型加检索做一遍问答,确认检索召回的相关性没问题,再回头决定要不要微调。很多时候问题出在检索而不是生成,盲目微调只会浪费算力。

从那以后我每次动训练代码,都强制走一遍「十步 sanity check」:打印一个 batch 的 input_ids 和 labels、确认 loss 初值接近ln(vocab_size)、跑十步看 loss 有没有下降、保存一次 checkpoint 再重新加载验证。这四件事加起来不到五分钟,但挡掉的是后面几小时甚至一整天的无效训练。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/3 19:50:37

反激电源RCD钳位二极管选型:正向/反向恢复、功耗与EMI实测

简介:围绕RCD钳位电路中二极管D选型这一常见设计难点,这份DOC文档整理了电源网论坛资深工程师的精华讨论与实测经验,面向开关电源设计者、反激电源调试人员,以及希望补齐钳位电路认知的工程师。内容从RCD公式计算电阻与实际参数偏…

作者头像 李华
网站建设 2026/11/3 19:49:41

AI SSE流式输出性能测试工具解析与应用

1. 项目背景与核心价值 7DGroup开源的AI SSE流式输出性能测试工具填补了当前AI应用开发领域的关键空白。在实时AI交互场景爆发式增长的当下,从智能客服到内容生成,服务端推送(Server-Sent Events, SSE)技术因其轻量级、低延迟的特性,已成为大…

作者头像 李华
网站建设 2026/11/3 19:47:52

Markdown图片增强:Typora与VLOOK™实战指南

1. 为什么我们需要Markdown图片增强功能作为一名长期使用Markdown写作的技术博主,我深刻体会到原生Markdown在图片处理方面的局限性。标准的Markdown语法只支持最基本的图片插入功能,而实际写作中我们经常需要对图片进行旋转、反转、添加滤镜等操作。传统…

作者头像 李华
网站建设 2026/11/3 19:45:56

SpringBoot+微信小程序构建高并发志愿者管理系统

1. 项目概述:SpringBoot微信小程序的志愿者服务管理系统 这个基于SpringBoot后端和微信小程序前端的志愿者服务管理系统,是我在参与某大型公益活动技术支援时开发的实战项目。它解决了传统志愿者管理中的三大痛点:信息孤岛(各部门…

作者头像 李华
网站建设 2026/11/3 19:45:41

光谱仪原理、分类与典型应用场景解析

## 1. 光谱仪究竟是个什么设备?第一次接触光谱仪这个词是在大学物理实验课上。当时教授拿着一个鞋盒大小的金属盒子,说它能"看见光里的彩虹"。这个看似简单的描述背后,其实藏着光与物质相互作用的奥秘。光谱仪本质上是一种将复合光…

作者头像 李华
网站建设 2026/11/3 19:45:34

电商商品模块设计:SPU/SKU架构与高并发库存管理

1. 电商商品模块的核心定位 商品模块是电商系统的核心中枢,就像超市的货架管理直接影响销售业绩一样。我经手过的几个电商平台重构项目中,商品模块的设计缺陷往往会导致30%以上的运营效率损失。这个模块不仅要承载基础的商品信息管理,更需要考…

作者头像 李华