1. 为什么个人开发者也要走一遍LLM全流程
很多人一提到大模型,第一反应就是“这玩意儿得几百张卡才能玩”。我一开始也这么想,直到自己用一张RTX 3090把GPT-2从预训练一路做到领域适配,才发现个人开发者和工业级团队之间的差距,其实不在硬件规模,而在流程认知。你不需要复现一个千亿参数的模型,但你必须亲手走一遍“数据准备—预训练—微调—领域适配—推理部署”这条链路,否则你永远只能停留在调API的层面,遇到业务问题根本不知道从哪下手。
这篇内容就是把我自己踩过的坑、跑过的脚本、调过的参数完整摊开来讲。核心关键词是LLM、预训练、领域适配、GPT-2、RTX 3090,适合有一定Python和PyTorch基础、想真正搞懂大模型底层流程的个人开发者。我不会只给你一个“跑通了”的结论,而是把每一步为什么这么做、参数怎么算、显存怎么省、效果怎么评估都讲清楚。你跟着走一遍,至少能获得三个能力:第一,理解预训练语言模型到底在学什么;第二,掌握在单卡24G显存下做领域适配的实操方法;第三,知道怎么用公开榜单和评测集判断自己的模型有没有变好。
先说清楚一个前提:GPT-2虽然不是现在最强的模型,但它是个人开发者学习全流程的最佳选择。原因很简单,它的架构干净、代码成熟、参数量可控(124M到1.5B),而且预训练目标就是最经典的自回归语言建模。你把它跑通了,再去理解LLaMA、Qwen这些现代LLM的改进点,会非常轻松。RTX 3090的24G显存刚好卡在一个甜点位上:跑124M模型预训练绰绰有余,跑1.5B模型做LoRA微调也完全可行,再往上就得考虑量化或者梯度累积了。
注意:我这里说的“预训练”是指从随机初始化开始训练,不是指加载别人已经训练好的权重再做微调。很多教程把这两个概念混在一起,导致新手以为自己在做预训练,其实只是在做微调。两者的数据量、计算量、学习率策略完全不同,后面我会详细拆解。
2. 整体方案设计与硬件选型背后的逻辑
2.1 为什么选GPT-2而不是BERT或T5
预训练语言模型大致分三条路线:自回归(GPT系列)、自编码(BERT系列)、编码器-解码器(T5系列)。我选GPT-2作为全流程实践对象,核心原因是它的训练目标最直观——给定前文预测下一个token。这个目标不需要你构造复杂的掩码逻辑,也不需要处理句子对输入,数据准备成本最低。对于个人开发者来说,你手头最不缺的就是纯文本数据,而GPT-2恰好只需要纯文本。
BERT的掩码语言建模虽然在下游理解任务上表现好,但它的预训练数据构造需要动态掩码,而且不能直接用来做文本生成。T5的span corruption目标更复杂,训练效率在单卡上不如GPT-2直观。更重要的是,GPT-2的代码实现(尤其是HuggingFace的transformers版本)非常成熟,你几乎不需要改模型结构,只需要关注数据管道和训练循环。
另一个关键考量是领域适配的便利性。GPT-2的架构允许你在任何纯文本领域数据上继续做自回归训练,不需要标签,不需要重新设计输出层。比如你有大量医疗病历、法律文书、金融研报,直接扔进去做继续预训练就行。这种“无监督领域适配”的能力,对个人开发者来说极其友好,因为你大概率拿不到大规模标注数据。
2.2 RTX 3090的显存账怎么算
RTX 3090有24G显存,这个数字决定了你能跑多大的模型和多大的批次。先算模型权重的显存占用:GPT-2 124M参数,FP32精度下每个参数4字节,权重占约500MB。但训练时还有优化器状态(Adam的话是2倍权重)、梯度(1倍权重)、激活值(跟批次和序列长度相关)。粗略估算,FP32训练124M模型,总显存约等于权重×4,也就是2G左右。这还没算激活值,但124M模型激活值很小,所以24G显存跑124M预训练非常宽裕。
如果你要跑GPT-2 1.5B,FP32权重就是6G,加上优化器状态和梯度,直接冲到24G以上,单卡根本放不下。这时候有两个选择:一是用混合精度训练(AMP),把大部分计算转成FP16,显存占用直接砍半;二是用LoRA或者Adapter做参数高效微调,只训练少量额外参数。我实测下来,1.5B模型用FP16加梯度检查点,批次大小设为1、序列长度512,显存占用约18G,刚好能跑起来。但训练速度会慢很多,因为梯度检查点是用计算换显存。
提示:如果你只有一张3090,建议从124M模型开始跑通全流程,然后再尝试355M或774M。1.5B模型更适合做LoRA微调,而不是全参数预训练。别一上来就挑战最大模型,否则你会把大量时间浪费在OOM调试上。
2.3 数据管道的设计原则
预训练的数据管道比模型本身更重要。我见过太多人模型代码抄对了,但数据没处理好,训练loss不下降,最后怪模型不行。GPT-2的输入是连续的token序列,你需要把原始文本转成token id,然后拼接成固定长度的块。这里有几个关键决策:
第一,序列长度选多少。GPT-2原始预训练用的是1024,但个人开发者如果显存有限,可以降到512甚至256。序列长度直接影响显存占用和训练速度,短序列训练快但上下文信息少。我的建议是,如果你做领域适配,512足够覆盖大部分文档段落;如果你要做长文本生成,再考虑1024。
第二,要不要做文档拼接。原始GPT-2是把多篇文档拼在一起,用特殊token分隔。这样做的好处是避免padding浪费,每个批次都是满的。但缺点是跨文档的注意力会引入噪声。我的做法是,在领域适配阶段,尽量按文档边界切分,同一篇文档内的片段才拼在一起。这样模型学到的上下文更干净。
第三,词表要不要重新训练。GPT-2原始词表是50257,基于英文语料训练的。如果你的领域数据有大量中文或者专业术语,原始词表的token效率会很低。比如“心肌梗死”可能被切成好几个token。这时候你可以考虑用SentencePiece重新训练一个领域词表,但代价是模型嵌入层需要重新初始化,预训练成本增加。我的建议是,如果领域数据以中文为主,重新训练词表是值得的;如果只是英文专业领域,原始词表够用。
3. 核心细节解析与实操要点
3.1 预训练目标函数的数学本质
GPT-2的预训练目标就是最大化似然函数。给定一个token序列 ( x_1, x_2, ..., x_n ),模型要最大化:
[ \sum_{i=1}^{n} \log P(x_i | x_1, ..., x_{i-1}; \theta) ]
其中 (\theta) 是模型参数。这个公式看起来简单,但实操中有几个细节决定成败。第一,损失函数用的是交叉熵,PyTorch的CrossEntropyLoss默认会对每个token求平均。但GPT-2原始实现里,不同位置的token权重是一样的,没有做特殊处理。第二,teacher forcing机制意味着无论模型预测什么,下一个位置的输入永远是真实token。这保证了训练稳定,但也导致训练和推理时的行为不一致(推理时用的是模型自己生成的token)。这个差距就是所谓的exposure bias,个人开发者做领域适配时,如果领域数据和通用数据分布差异大,这个偏差会更明显。
第三,学习率调度非常关键。GPT-2原始论文用的是warmup加余弦退火。warmup步数通常是总步数的1%到5%,峰值学习率在1e-4到5e-4之间。我实测下来,124M模型在领域数据上继续预训练,峰值学习率设5e-5比较稳,太高容易把预训练学到的知识冲掉。如果你是从随机初始化开始预训练,峰值学习率可以设到2.5e-4。
3.2 数据预处理的具体步骤
假设你手头有一批领域文本,比如医疗问答、法律判决书、金融研报。第一步是清洗:去掉HTML标签、多余空白、乱码字符。这一步看起来简单,但如果你不做,模型会学到一堆噪声。我习惯用正则表达式先过一遍,把非文本内容干掉。
第二步是分词。用HuggingFace的GPT2TokenizerFast,它底层是BPE。这里有个坑:默认的tokenizer会把所有文本转成小写吗?不会,GPT-2的tokenizer是大小写敏感的。但如果你用的是gpt2模型,它的tokenizer对中文支持很差,一个中文字符可能被拆成多个byte token。所以如果你的数据是中文,要么换一个中文GPT-2变体(比如uer/gpt2-chinese-cluecorpussmall),要么自己训练tokenizer。
第三步是分块。把token序列按固定长度切分,比如512。如果最后一篇文档不够512,可以padding,也可以丢弃。我通常选择丢弃,因为padding会引入大量无意义的token,浪费计算。但如果你的数据量很少,那就padding,至少保证每个批次是满的。
第四步是打乱。预训练数据必须打乱,否则模型会学到文档顺序的虚假相关性。但打乱要在分块之后做,否则同一篇文档的片段会被拆到不同批次,上下文就断了。我的做法是,先按文档分块,再把所有块打乱,然后按批次取。
from transformers import GPT2TokenizerFast import torch tokenizer = GPT2TokenizerFast.from_pretrained("gpt2") tokenizer.pad_token = tokenizer.eos_token def tokenize_and_chunk(texts, block_size=512): all_ids = [] for text in texts: ids = tokenizer.encode(text, add_special_tokens=False) all_ids.extend(ids) all_ids.append(tokenizer.eos_token_id) chunks = [] for i in range(0, len(all_ids) - block_size, block_size): chunks.append(all_ids[i:i+block_size]) return torch.tensor(chunks, dtype=torch.long)这段代码的关键点是add_special_tokens=False,因为GPT-2不需要在开头加特殊token,它用eos_token作为文档分隔。另外,pad_token设成eos_token是为了避免padding时引入新的token id。
3.3 模型初始化与权重加载的取舍
如果你是从随机初始化开始预训练,直接用GPT2LMHeadModel的默认配置就行。但如果你想在通用GPT-2权重基础上做领域适配,那就加载预训练权重,然后继续训练。这里有个关键决策:要不要冻结底层参数。我的经验是,如果领域数据和通用数据差异不大(比如都是新闻类文本),可以全参数微调;如果差异很大(比如医疗和法律),建议只微调顶层几层,或者用LoRA。因为底层学的是通用语法和语义,顶层才跟具体领域相关。
另一个细节是嵌入层的处理。如果你重新训练了词表,嵌入层必须重新初始化。这时候你不能直接加载原始权重,因为维度对不上。我的做法是,保留原始嵌入层中能对应上的token,新token随机初始化。但这样做的代价是,新token的嵌入需要更多步数才能学好。所以如果领域数据不够大,重新训练词表可能得不偿失。
注意:加载预训练权重时,一定要检查模型配置里的vocab_size是否和tokenizer一致。我踩过一次坑,tokenizer加了新token但模型配置没改,结果训练时直接报维度错误。
4. 实操过程与核心环节实现
4.1 环境搭建与依赖版本锁定
个人开发者最容易忽略的就是环境版本。PyTorch、CUDA、transformers、tokenizers这几个库的版本必须匹配,否则你会遇到各种奇怪的错误。我用的组合是:PyTorch 2.0.1 + CUDA 11.8 + transformers 4.30.2 + tokenizers 0.13.3。这个组合在RTX 3090上实测稳定。
安装命令如下:
conda create -n llm_pretrain python=3.10 conda activate llm_pretrain pip install torch==2.0.1 torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118 pip install transformers==4.30.2 datasets==2.12.0 accelerate==0.20.3为什么锁定transformers 4.30.2?因为从4.31开始,Trainer的某些默认行为变了,比如梯度累积的步数计算方式。如果你跟着旧教程走,可能会发现loss曲线对不上。accelerate是用来做混合精度和分布式训练的,单卡也用得上,因为它能自动处理AMP。
4.2 预训练脚本的核心参数
我不用HuggingFace的Trainer,而是自己写训练循环。原因有两个:一是Trainer封装太深,出问题不好调试;二是自己写能更灵活地控制学习率调度和梯度累积。核心参数如下:
| 参数 | 124M模型 | 355M模型 | 说明 |
|---|---|---|---|
| batch_size | 16 | 8 | 单卡批次大小 |
| block_size | 512 | 512 | 序列长度 |
| learning_rate | 5e-5 | 3e-5 | 峰值学习率 |
| warmup_steps | 500 | 500 | 预热步数 |
| max_steps | 50000 | 30000 | 总训练步数 |
| weight_decay | 0.01 | 0.01 | 权重衰减 |
| grad_clip | 1.0 | 1.0 | 梯度裁剪 |
| fp16 | True | True | 混合精度 |
学习率调度用余弦退火,公式是:
[ lr_t = lr_{min} + 0.5 \times (lr_{max} - lr_{min}) \times (1 + \cos(\pi \times t / T)) ]
其中 ( lr_{min} ) 设为峰值学习率的0.1倍,( T ) 是总步数。这个调度能让模型在训练后期稳定收敛,不会因为学习率太大而震荡。
梯度累积是为了模拟更大的批次。如果你显存不够,batch_size只能设4,但你可以累积4步再更新一次参数,等效批次就是16。代码实现很简单:
accumulation_steps = 4 optimizer.zero_grad() for step, batch in enumerate(dataloader): outputs = model(**batch) loss = outputs.loss / accumulation_steps loss.backward() if (step + 1) % accumulation_steps == 0: torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad()注意loss要除以accumulation_steps,否则梯度会放大。梯度裁剪在累积之后做,因为这时候梯度才是完整的。
4.3 训练过程的监控与日志
训练时一定要监控loss曲线和显存占用。我习惯用tensorboard记录loss、学习率、梯度范数。梯度范数特别重要,如果它突然变大,说明训练不稳定,可能需要降低学习率或者加大梯度裁剪。显存占用用nvidia-smi实时看,如果接近24G,就要考虑减小batch_size或者开梯度检查点。
from torch.utils.tensorboard import SummaryWriter writer = SummaryWriter("logs") for step, batch in enumerate(dataloader): outputs = model(**batch) loss = outputs.loss loss.backward() grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad() if step % 100 == 0: writer.add_scalar("loss", loss.item(), step) writer.add_scalar("lr", scheduler.get_last_lr()[0], step) writer.add_scalar("grad_norm", grad_norm.item(), step)我实测下来,124M模型在医疗问答数据上继续预训练,loss从3.5左右降到2.8大概需要10000步。再往下降就很慢了,这时候如果继续训练,可能会过拟合。判断过拟合的方法是看验证集loss,如果验证集loss开始上升,就停。
4.4 领域适配的两种策略对比
领域适配有两种主流做法:继续预训练和指令微调。继续预训练就是在领域纯文本上接着做自回归训练,不需要标签。指令微调需要构造“指令-回答”对,让模型学会遵循指令。对于个人开发者,我建议先做继续预训练,再做指令微调。因为继续预训练能让模型先熟悉领域词汇和表达方式,指令微调才能更好地对齐任务。
继续预训练的数据准备和预训练一样,只是数据换成了领域文本。学习率要调低,我通常用预训练峰值学习率的十分之一,也就是5e-6。步数不用太多,10000到20000步足够。指令微调的数据格式是:
{ "instruction": "解释心肌梗死的病理机制", "input": "", "output": "心肌梗死是由于冠状动脉急性闭塞导致心肌持续缺血缺氧..." }训练时把instruction和input拼在一起作为输入,output作为标签。损失只计算output部分的token,输入部分的loss要mask掉。这个mask操作很关键,否则模型会学会复述输入。
labels = input_ids.clone() labels[:, :prompt_length] = -100 # 忽略输入部分的loss outputs = model(input_ids, labels=labels) loss = outputs.loss-100是PyTorch CrossEntropyLoss的ignore_index,表示这些位置不计算损失。
5. 常见问题与排查技巧实录
5.1 训练loss不下降的排查思路
loss不下降是最常见的问题,原因可能有很多。我按排查优先级列一下:
第一,检查数据。把tokenizer编码后的结果打印出来,看看是不是乱码。如果tokenizer对中文支持不好,编码结果会是一堆byte token,模型根本学不到东西。解决办法是换中文tokenizer或者重新训练词表。
第二,检查学习率。学习率太大,loss会震荡甚至上升;学习率太小,loss下降极慢。我建议先用一个很小的批次(比如batch_size=2)跑100步,看loss有没有下降趋势。如果没有,把学习率调大10倍再试。
第三,检查模型初始化。如果你加载了预训练权重,确认权重真的加载成功了。有时候模型配置里的vocab_size和tokenizer不一致,加载会静默失败,模型还是随机初始化。打印一下模型第一层嵌入的均值和方差,如果接近0,说明没加载成功。
第四,检查梯度。打印梯度范数,如果一直是0,说明反向传播断了。常见原因是损失函数用错了,或者labels全是-100。
5.2 显存溢出的应急处理
显存溢出(OOM)是单卡训练的家常便饭。应急处理方案按优先级排序:
| 方案 | 显存节省 | 训练速度影响 | 适用场景 |
|---|---|---|---|
| 减小batch_size | 高 | 小 | 首选 |
| 开启梯度检查点 | 高 | 大 | 模型较大时 |
| 混合精度训练 | 中 | 无(甚至更快) | 默认开启 |
| 减小序列长度 | 中 | 小 | 长文本不必须时 |
| 梯度累积 | 无(等效批次不变) | 小 | 显存不够但想要大批次 |
| 模型并行 | 高 | 大 | 单卡放不下模型时 |
梯度检查点的原理是不保存中间激活值,反向传播时重新计算。这样显存占用从O(层数)降到O(1),但计算量增加约30%。开启方法:
model.gradient_checkpointing_enable()混合精度训练用torch.cuda.amp:
scaler = torch.cuda.amp.GradScaler() with torch.cuda.amp.autocast(): outputs = model(**batch) loss = outputs.loss scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()注意scaler.step之前要先unscale梯度,否则梯度裁剪会出错。
5.3 领域适配后效果变差的可能原因
有时候你在领域数据上继续预训练,结果通用能力反而下降了。这叫灾难性遗忘。原因是模型把底层参数改得太狠,把通用知识覆盖了。解决办法有三个:一是降低学习率,让模型只做微调;二是冻结底层几层,只训练顶层;三是混合通用数据和领域数据一起训练,比例大概1:1。
另一个原因是过拟合。领域数据量太小,模型记住了训练集,但泛化能力差。判断方法是看验证集loss,如果训练集loss下降但验证集loss上升,就是过拟合。解决办法是增加数据、加dropout、或者早停。
还有一种情况是评估指标选错了。如果你只看训练loss,它当然会下降。但你要看的是下游任务的表现,比如文本生成的流畅度、问答的准确率。我习惯用公开榜单的评测集来评估,比如Open LLM Leaderboard上的任务,虽然GPT-2不在榜单上,但你可以用类似的评测逻辑,比如Perplexity、BLEU、ROUGE。
5.4 推理部署的量化与加速
训练完之后,推理部署是另一个坑。GPT-2 124M在FP32下推理速度还行,但如果你想部署到边缘设备或者降低延迟,可以考虑量化。ONNX Runtime支持动态量化,能把FP32转成INT8,模型大小缩小4倍,推理速度提升2到3倍。但量化会带来精度损失,尤其是生成任务,可能会生成重复文本。
import onnxruntime as ort from transformers import GPT2LMHeadModel, GPT2TokenizerFast model = GPT2LMHeadModel.from_pretrained("gpt2") tokenizer = GPT2TokenizerFast.from_pretrained("gpt2") # 导出ONNX dummy_input = torch.randint(0, 50257, (1, 10)) torch.onnx.export(model, dummy_input, "gpt2.onnx", opset_version=11) # 量化 from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic("gpt2.onnx", "gpt2_quant.onnx", weight_type=QuantType.QUInt8)量化后的模型用ONNX Runtime加载,推理速度确实快,但生成质量需要你自己评估。我的经验是,对于分类任务,量化影响不大;对于生成任务,最好保留FP16,别用INT8。
6. 个人开发者的经验心得与扩展方向
6.1 我踩过的三个典型坑
第一个坑是tokenizer的pad_token。GPT-2默认没有pad_token,我一开始没设置,结果padding时直接报错。后来把pad_token设成eos_token才解决。但这样做的副作用是,模型会把padding当成文档结束,生成时可能提前输出eos。所以如果你的批次里有padding,最好用attention_mask把padding位置mask掉。
第二个坑是学习率预热。我一开始没做warmup,直接上5e-5的学习率,结果前100步loss直接飙到10以上,模型差点训崩。后来加了500步warmup,loss才平稳下降。warmup的作用是让模型先适应数据分布,再逐步加大学习率,避免初期梯度太大破坏参数。
第三个坑是验证集的选择。我一开始用训练集的一部分做验证,结果验证集loss一直下降,但实际生成效果很差。后来才发现,训练集和验证集来自同一批文档,分布太接近,根本测不出过拟合。正确的做法是,验证集要来自不同的文档来源,最好时间上也隔开,这样才能真实反映泛化能力。
6.2 从GPT-2到现代LLM的迁移路径
你把GPT-2全流程跑通之后,再看现代LLM,会发现很多改进点都是针对GPT-2的不足。比如:
- 位置编码:GPT-2用的是可学习的位置嵌入,最大长度1024。现代LLM用RoPE(旋转位置编码),支持更长上下文,外推能力更强。
- 归一化:GPT-2用LayerNorm,放在注意力之后。现代LLM用RMSNorm,放在注意力之前(Pre-Norm),训练更稳定。
- 注意力:GPT-2用标准多头注意力。现代LLM用分组查询注意力(GQA)或者多头潜在注意力(MLA),减少KV Cache显存。
- 激活函数:GPT-2用GELU。现代LLM用SwiGLU,效果更好。
- 词表:GPT-2词表50257,现代LLM动辄10万以上,对多语言支持更好。
你理解了这些改进背后的动机,再去读LLaMA、Qwen的论文,会非常顺畅。而且你自己动手改GPT-2的代码,比如把LayerNorm换成RMSNorm,把位置编码换成RoPE,能更深刻地理解这些技术的实际效果。
6.3 领域适配的进阶玩法
如果你已经把基础流程跑通了,可以尝试几个进阶方向。第一,用LoRA做参数高效微调。LoRA只训练低秩矩阵,显存占用极低,1.5B模型在3090上也能跑。第二,用RAG(检索增强生成)结合领域知识库。模型本身不记住所有知识,而是从外部检索相关文档,再生成回答。第三,用GraphRAG构建知识图谱,把实体和关系结构化,提升推理能力。这些方向都需要你先有扎实的预训练和微调基础,否则调参都调不明白。
提示:LoRA的秩(rank)选择很关键。rank太小,模型学不到东西;rank太大,参数量上去了,失去高效微调的意义。我通常从rank=8开始试,如果效果不够再加到16或32。
6.4 评估与迭代的闭环
最后强调一点:没有评估就没有迭代。你训练完一个模型,必须有一套评估流程。我习惯用三个维度:一是自动指标,比如Perplexity、ROUGE;二是人工评估,找几个领域专家看生成质量;三是下游任务测试,比如问答准确率、分类F1。这三个维度结合起来,才能判断模型是真的变好了,还是只是过拟合了训练集。
评估集要固定,不能每次换。否则你无法比较不同版本的效果。我通常把评估集分成两部分:一部分是领域内测试集,看领域适配效果;一部分是通用测试集,看灾难性遗忘程度。两者兼顾,才能保证模型既懂领域,又不丢通用能力。
这个流程我反复跑了十几遍,每次都能发现新的细节问题。个人开发者做LLM全流程,最大的优势是你可以完全掌控每一个环节,不用被工程框架束缚。最大的挑战是你要自己踩完所有的坑。但只要你走通一遍,后面再做任何领域适配,都是在这个基础上改数据、调参数、换模型,效率会高很多。