☰
从零手撸小型LLM:Transformer原理与训练全流程实战
2026/10/17 2:24:14 网站建设 项目流程

从零手撸一个LLM,到底难不难?说实话,如果目标是复现某大厂动辄几千亿参数的那种模型,那我劝你趁早放弃。但如果你只是想彻底搞懂大语言模型内部的运作原理,亲手训练一个几千万参数、能跟你对话的小型模型,这条路完全走得通,而且收获远比刷十篇论文、看一百个视频都大。

这篇不是论文复现笔记,而是我自己从环境搭建、数据处理到模型训练、效果调优全流程走下来的一份完整实操记录。内容会一直覆盖到出现"loss不降""显存爆炸""生成质量稀碎"这些典型坑时怎么排查。无论你是刚入门深度学习的学生,还是想在公司内部做内部工具的开发工程师,这篇文章都能让你少走几个月的弯路。我尽量不用教科书式语言,见到公式就解释,见到代码就说为什么这么写。

1. 整体设计思路:你的第一个LLM到底该做成什么样

1.1 先明确目标:不是复刻大模型,是复刻"原理"

很多人一听到"构建自己的LLM",脑子里想的是对标那些超大模型。这是最大的误区。真正适合从零入门的目标是:训练一个参数规模在几千万级别的decoder-only语言模型,让它能够在给定上下文的情况下,生成逻辑相对连贯、语法基本正确的文本。

我给自己定的验收标准有两条:

  • 模型能根据"今天天气真不错,我们去"这样的开头,续写出通顺的句子
  • 模型在简单的问答式任务上,能够泛化出一定的"记忆推理"能力,而不是单纯背诵训练数据

把目标定成这样,硬件、数据、代码的实现难度都会掉到一个可控的范围。整体技术路线也非常清晰:Transformer架构 + 自回归语言建模 + 亚词级分词。这个路线跟当前主流LLM完全同源,只是规模小了无数倍。

1.2 选型决策:为什么选decoder-only,为什么不从零写CUDA

做架构选型时我其实纠结过一阵子。最初的方案有三个:encoder-decoder架构、纯decoder架构、以及直接用现成大模型做微调。后来我全部推翻了。

先说为什么不做微调。微调虽然省事,但你学不到tokenizer怎么构建、attention怎么张量化、训练时的梯度问题怎么处理,这些东西才是理解LLM的关键。再说为什么选decoder-only而非encoder-decoder。语言模型生成时的核心机制是自回归,即每一步只用已经生成的token来预测下一个token,decoder架构天然契合这个逻辑,实现起来也最简单。BERT那种encoder架构适合做理解任务,不是我们要的生成式LLM。

至于不自己写CUDA算子,纯粹是性价比问题。PyTorch底层已经封装了高效的attention实现,对初学者来说,先学会用这些原语搭建模型,把注意力放到"逻辑正确"上,等真正需要极致性能了再去啃底层。毕竟我们的目标是搞懂原理,不是写一个能跑benchmark的推理引擎。

2. 环境准备与数据工程:地基不牢,后面全白干

2.1 硬件、框架与依赖清单

训练几千万参数的模型,其实不需要多吓人的硬件配置。我实际跑通的这套方案使用单张消费级显卡也能完成,显存在8GB以上就行。如果你的机器连独立显卡都没有,也可以退一步用云平台,或者干脆把模型参数量再砍一半。

我的建议配置是:

  • GPU:8GB显存起步,最好是16GB(比如消费级的中端卡就完全够跑)
  • 内存:16GB以上,因为数据预处理和词汇表构建环节挺吃内存的
  • 框架:PyTorch 2.x,配合HuggingFace生态的tokenizers库
  • 关键Python包:transformers(只用它加载tokenizer和数据集)、numpy、tqdm

提示:不要一开始就在环境上死磕。Python版本用3.9~3.11之间都行,重点是torch的CUDA版本跟你显卡驱动要匹配,否则装好了也用不上GPU。

整个环境的搭建时间控制在半天内,超过这个时间就要检查是不是驱动和版本的问题。我见过太多人在conda里折腾三天装环境,最后项目还没开始就放弃了。

2.2 语料获取与清洗的三个核心原则

训练LLM的语料,质量和数量同等重要。我第一次做的时候直接找了个开源中文语料就往里灌,结果模型的输出全是乱码和重复废话。后来反复清洗才明白,语料处理有黄金三原则:

第一,去噪比去重更重要。HTML标签、特殊符号、超链接、无意义的重复标点,这些噪音对模型的影响极大。模型会学到输出"!!!!!"这样的垃圾。清洗后的语料应该看起来就像一篇干净的网页文章,纯文本、无杂质。

第二,混入多领域数据。代码片段、对话记录、百科条目、新闻内容,不同领域的数据可以让模型的泛化能力更强。我当时混合了三个公开数据源,比例上以百科和新闻为主,这样模型生成的句子既有知识性,又贴近日常表达。

第三,长度过滤。太短的句子(比如少于20个字)说明含金量低,太长的段落(比如超过1000字)可能是文档拼接,都建议过滤掉。处理后的理想样本长度在200到800字之间。

数据预处理这一步值得多说两句。我用的是内存流式处理,先把语料全部读进内存,遍历一遍做清洗,然后按照长度切分句子,最后打乱顺序。这个流程看起来简单,但一旦数据量上来,代码里的一个低级错误(比如忘了处理空行)就能让你的训练集莫名其妙多出一堆无效样本。

3. 模型架构实现:Transformer核心模块逐行拆解

3.1 Embedding与位置编码:让模型理解词语和顺序

模型的输入是一串token id,首先要把它们映射成向量,这就是nn.Embedding做的事。这个环节有两个关键参数:vocab_size(词汇表大小)和d_model(向量维度)。对千万级参数的小模型来说,词汇表建议控制在5000到一万之间,向量维度采用256或384都可以。

映射到向量之后,模型还需要知道词语在句子里的位置。Transformer本身没有时序概念,如果不加位置信息,模型会把"猫追狗"和"狗追猫"当成完全一样的输入。早期的绝对位置编码用的是sin/cos函数,那套方案数学上很漂亮,但实际上训练起来不太友好,我建议直接用可学习的nn.Embedding来做位置编码,实现简单,效果也够用。

import torch import torch.nn as nn class GPTConfig: vocab_size = 8000 block_size = 128 # 最大上下文长度 n_layer = 6 n_head = 8 d_model = 384 dropout = 0.1 class TokenAndPositionalEmbedding(nn.Module): def __init__(self, cfg): super().__init__() self.tok_emb = nn.Embedding(cfg.vocab_size, cfg.d_model) self.pos_emb = nn.Embedding(cfg.block_size, cfg.d_model) self.dropout = nn.Dropout(cfg.dropout) def forward(self, idx): _, T = idx.shape pos = torch.arange(T, device=idx.device).unsqueeze(0) return self.dropout(self.tok_emb(idx) + self.pos_emb(pos))

注意这里有个细节:位置编码的张量形状是(1, T, d_model),在广播机制下,每一个batch里的序列都会拿到相同的位置向量。这个相加操作就是在告诉模型:"你现在处理的这个token,它所在的位置是第几个"。

3.2 多头注意力机制:核心中的核心

Attention可以说是整个LLM的发动机。它的核心思路就一句话:每个token在生成时,应该把上下文里其他token的信息按重要程度加权融合进来。

公式不复杂:

[ Attention(Q,K,V) = softmax(\frac{QK^T}{\sqrt{d_k}})V ]

这里Q、K、V分别代表查询、键、值矩阵。生活化地理解,Q像是在问"我需要什么信息",K像是在说"我手里有什么信息",两者做点乘,算出每个token跟当前token的相关性分数,经过softmax归一化变成权重,最后用权重去加权V,得到融合后的输出。除以根号d_k是为了防止点积结果过大导致softmax梯度消失。

多头机制就是把Q、K、V切分成多组,让不同组关注不同维度的关系。比如一个头负责语法关系,另一个头负责语义相似度,最后拼接在一起。实现时有一个关键易错点:切分后要把(B, T, n_head, head_dim)先转成(B, n_head, T, head_dim),让注意力计算在最后的两个维度上做矩阵乘法。

class MultiHeadAttention(nn.Module): def __init__(self, cfg): super().__init__() self.n_head = cfg.n_head self.d_model = cfg.d_model self.q = nn.Linear(cfg.d_model, cfg.d_model) self.k = nn.Linear(cfg.d_model, cfg.d_model) self.v = nn.Linear(cfg.d_model, cfg.d_model) self.out = nn.Linear(cfg.d_model, cfg.d_model) def forward(self, x): B, T, C = x.shape q = self.q(x).view(B, T, self.n_head, C // self.n_head).transpose(1, 2) k = self.k(x).view(B, T, self.n_head, C // self.n_head).transpose(1, 2) v = self.v(x).view(B, T, self.n_head, C // self.n_head).transpose(1, 2) att = q @ k.transpose(-2, -1) / (C // self.n_head) ** 0.5 att = att.softmax(dim=-1) y = (att @ v).transpose(1, 2).reshape(B, T, C) return self.out(y)

做decoder模型时,还必须加一个下三角掩码,确保每个位置只能看到自己之前的信息。这一步常被忽略,如果漏了,模型在训练时就"偷看"了未来的答案,生成时性能会暴跌。

3.3 前馈网络与层归一化:稳定训练的隐形功臣

Attention的每个输出后面,都会接一个两层的前馈网络(FFN)。这个结构用非线性变换对融合后的信息做进一步加工,通常中间维度会扩大到四倍,再加一个ReLU或GELU激活函数。没有FFN,多层attention堆叠后模型的表达能力提升非常有限。

层归一化放在每层的Attention和FFN之前,也就是Pre-LN结构。这跟最早的Transformer论文用的Post-LN不同,Pre-LN在训练时更稳定,梯度更健康,也是现在主流大模型的标配。所有归一化操作都作用在d_model维度上,而不是batch维度,因为序列长度和batch大小常常变化,基于特征维度的归一化更扛折腾。

最后一层是线性输出层,把d_model维度映射回vocab_size,再经过softmax得到每个token的概率分布。这里有个共享权重的细节:三个全连接层的权重其实是同一个张量,共享后参数量能省下不少。

4. 完整训练流程实操:从数据加载到模型收敛

4.1 Tokenizer构建与数据加载管线的落地

Tokenization是把文本切成小块,再映射成数字ID的过程。我个人强烈建议用BPE(字节对编码)方案,它既能覆盖绝大多数常用词,又没有"字母都会被切开"的低效率。直接用tokenizers库里的BPE类训练一个自己的分词器,几分钟就能搞定。

训练tokenizer时,喂进去的语料最好是原始文本本身,不要提前截断。BPE会先从字符级别开始,逐步合并出现频率最高的相邻字节对。我在一个800MB规模的中文语料上训练,设置vocab_size=8000,训练完看一眼词汇表,会发现常用汉字、"人工智能"这类高频词都成了一个独立的token。

数据加载我选择了最简单的思路:把清洗好的语料切分成固定长度(我用的序列长度是128)的样本,每64个样本打包成一个batch。这样做的好处是batch内序列长度一致,方便张量运算。加载管线大概长这样:

from torch.utils.data import Dataset, DataLoader class TextDataset(Dataset): def __init__(self, tokens, block_size): self.tokens = tokens self.block_size = block_size def __len__(self): return len(self.tokens) - self.block_size def __getitem__(self, i): x = torch.tensor(self.tokens[i:i+self.block_size], dtype=torch.long) y = torch.tensor(self.tokens[i+1:i+self.block_size+1], dtype=torch.long) return x, y

这个数据集很有意思的特征是,输入x和标签y错开了一个位置。模型看到前128个token,要预测第129个。每一步预测的都是"下一个token",这就是自回归语言建模的数据形态。

4.2 训练超参数与优化器选择

训练超参数这块,我直接给出实测下来比较靠谱的组合,你照抄就能跑:

  • 优化器:AdamW,学习率初始为3e-4
  • 学习率调度:预热(warmup) 2000步后做余弦退火
  • 训练批次:batch size为64,总共训练约2万步
  • 梯度裁剪:max_grad_norm设为1.0
  • 正则化:Dropout设为0.1,权重衰减设为0.1

AdamW最关键的设置在权重衰减上。原版Adam的权重衰减是直接在梯度里减的,效果不理想。AdamW的做法是把权重衰减跟梯度的自适应学习率解耦,直接写进参数更新的最终值里。用PyTorch内置实现即可,注意不要把bias和归一化层的参数也加了权重衰减,需要单独分出来。

关于训练时长的预期管理:在单张消费级显卡上,一个3800万参数的模型,batch size为64,序列长度128,大概每小时能跑1500步。2万步大约需要十几个小时。首次训练不用跑满,让我看一下1000步之后的loss变化趋势,来判断是否值得继续。

optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.1) def get_lr(step, warmup_steps=2000, max_steps=20000, max_lr=3e-4): if step < warmup_steps: return max_lr * (step + 1) / warmup_steps progress = (step - warmup_steps) / (max_steps - warmup_steps) return max_lr * 0.5 * (1 + (1 - progress) * progress)

学习率从零开始线性爬升到峰值,再缓慢衰减到接近零。预热阶段防止模型在初始几万步内因为过大的梯度而震荡,之后让学习率逐渐变小,模型才能精细地逼近最优解。

4.3 生成策略与采样参数:让模型的输出更接近真人

训练完成之后,你会拿到一个能预测下一个token的模型。但怎么从概率分布里挑一个token输出,里面的门道也不少。最简单的做法是贪心解码:每次选概率最高的token。这样生成的文本稳定、逻辑连贯,但是非常平庸,经常出现重复。

更好的办法是带温度的采样加top-k约束。温度T的作用是把token概率分布拉平或者压陡。T大于1时分布更平滑,罕见词也有机会被选中,输出多样性增加。top-k的做法是只从概率最高的前k个token里抽样,防止选了太离谱的词。这两个手段组合起来,是我实测中最有效的配置:

def generate(model, idx, max_new_tokens, temperature=0.8, top_k=50): for _ in range(max_new_tokens): idx_cond = idx[:, -cfg.block_size:] logits = model(idx_cond)[:, -1, :] / temperature if top_k is not None: v, _ = logits.topk(top_k) logits[logits < v[:, -1:]] = -float("Inf") probs = torch.softmax(logits, dim=-1) idx_next = torch.multinomial(probs, num_samples=1) idx = torch.cat([idx, idx_next], dim=1) return idx

初次训练完,模型生成的句子可能还是有一堆语法错误。不用气馁,这说明训练数据量或者迭代步数还不够。我记得自己第一次跑完2万步时,模型已经能生成"这本书的内容非常丰富,值得每一个人认真阅读"这种接近自然语言的句子,那一刻还是有点小兴奋的。

5. 常见问题与排查技巧实录

5.1 Loss不下降:先从这三处查起

训练到一定阶段,loss断断续续地卡住不动,这是最常见的现象。我的排查顺序是:

第一,看数据管线。先跑一次next(iter(dataloader))把样本打印出来,确认x和y确实错位了一个token。很多诡异的loss不降问题,源头都是标签和输入完全对齐了,模型学到的只是恒等映射。

第二,检查学习率。如果loss从一开始就纹丝不动,很可能是学习率太大导致模型发散,或者是太小导致学习缓慢。用一个简单方法验证:把学习率设成一个极大值(比如0.01),跑100步。如果loss直接NaN或者暴涨,说明代码本身能更新,问题在于学习率调度;如果loss仍然不变化,问题大概率出在模型前向传播上。

第三,回归验证模型初始输出。模型初始化后,如果输入的logits分布非常不平滑,softmax之后某个token的概率极大,训练一开始就陷入局部最优。这个情况可以把初始化方式换成更小方差的方案,或者把最后一层线性层的初始化scale调小一点。

5.2 显存溢出:参数量没变,为什么内存爆了

有一个我踩过很深的坑:模型参数量只有几千万,按理说显存完全够,但训练时还是OOM。原因在于显存消耗的大头是激活值(activations),不是模型参数。序列长度128还好,一旦把上下文长度加长到512,中间特征的形状会成倍膨胀,显存占用跟着飙升。

降低显存占用的优先级如下:

  • 缩小batch size,降到16或8,这是最直接的办法
  • 开启梯度累积,用小batch累积梯度再更新,效果等价于大切size
  • 参考PyTorch的torch.utils.checkpoint做激活检查点,用计算换显存
  • 实在不行,降低序列长度或减少层数

梯度累积的写法是用loss.backward()后不立即step(),等累积几步后再更新。注意loss除以累积步数,否则梯度会放大好几倍。这个细节我漏过一回,模型直接飘了。

5.3 生成内容空洞且重复的调优心得

模型能跑了,但生成的全是"是的是的下面是下面是下面"这种复读机话术,这属于典型的解码退化问题。通常不是模型不行,而是采样策略没配好。我从实践中总结出来的生效组合是:temperature=0.7、top_k=50、top_p=0.95三管齐下。

如果调整采样后还是重复,那就要考虑训练层面的问题,比如训练数据里本身有很多重复文本,或者模型容量不够。我曾经在清洗语料时偷懒没做去重,结果模型有个坏毛病:遇到通用词汇时,会反复以最高概率输出同一个token,整个句子就卡在循环上。把语料里明显重复的段落删掉,重新训练一些步数之后,这个问题缓解了很多。

还有一个容易忽略的陷阱:生成时上下文长度超过了训练时的block_size。模型看到的位置编码是它从来没见过的位置ID,输出会变得很奇怪。生成时一定要先截断输入序列,保证长度不超过训练时的窗口。

6. 项目扩展方向:你的第一个LLM还能往哪里走

模型训练完成、能流畅生成句子之后,这个项目的价值还远没有榨干。我尝试过几个扩展方向,按性价比排序供你参考:

第一个方向是指令微调(SFT)。准备几千条"问题-回答"对数据,在预训练模型基础上用较小的学习率继续训练,模型就会从"文本续写师"变成"对话助手"。这个阶段的数据质量远大于数量,我当时手工攒了3000条高质量问答,效果比直接用10万条网上爬来的数据好得多。

第二个方向是加一层LoRA低秩适配。如果你后续想针对特定领域(比如法律、医学、编程)继续优化,用LoRA只需要训练原模型参数量的2%~3%,成本极低。LoRA相当于在原有权重旁边加一个低秩的旁路矩阵,训练时只更新旁路,原始权重保持不动。

第三个方向是从工程角度打磨:把模型导出成ONNX格式,写一个简单的服务接口,部署成一个本地小工具。虽然参数量小,但应对一些固定的文本生成任务,体验其实很不错。我自己后来做了一个简单的文档自动摘要服务,运行成本和响应速度都让我很满意。

如果你想让效果再上一个台阶,可以尝试把模型的上下文长度从128逐步扩到512。这个过程并不用重新训练,只需把位置编码层重新初始化,然后用一段长文本数据做少量步数的二次训练。模型的长期依赖推理能力会明显提升,生成的文本在逻辑连贯性上的进步非常直观。

最后再分享一点个人体会:构建自己的LLM,收获最大的不是模型本身,而是你在踩坑过程中建立起来的"直觉"。什么时候该调学习率、什么时候该查数据、生成效果差到底是模型的问题还是解码策略的问题,这些判断力无法从任何教程里直接学到,非得亲手把一套流程走完。朋友圈里晒两张loss曲线很容易,真正有价值的是你能在深夜盯着一屏报错信息,冷静地说出"哦,原来是batch维度写反了"的那一刻。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询