☰
单卡24G显存实战:从零预训练GPT-2到领域适配全流程
2026/10/1 14:28:55 网站建设 项目流程

个人开发者想完整走一遍 LLM 从预训练到领域适配的全流程,最大的障碍从来不是算法本身,而是资源约束下的取舍。我手头只有一张 RTX 3090,24GB 显存,这个配置在大厂眼里连入门都算不上,但恰恰是个人开发者最真实的起点。这篇文章记录的就是我在这个约束下,从零训练一个 GPT-2 级别模型,再把它适配到特定领域任务的完整过程。里面涉及的每一个决策——为什么选 GPT-2 而不是更大的架构、为什么预训练阶段要用梯度累积、领域适配时 LoRA 和全量微调怎么选——我都会把背后的账算清楚。如果你也是一个人、一张卡、想搞明白 LLM 全流程到底是怎么回事,这篇内容应该能帮你省下不少试错时间。

1. 为什么个人开发者值得走一遍预训练

1.1 预训练不是大厂专属,但需要重新定义目标

很多人一听到"预训练"就觉得这是几百张 A100 才能碰的事情。这个认知对了一半:训练一个真正有通用能力的 LLM,确实需要海量算力。但预训练的本质是让模型从随机初始化状态学会语言的基本统计规律,这个过程在小规模上完全可以复现。我选择 GPT-2 架构作为起点,核心原因是它的参数量在 124M 到 1.5B 之间可调,124M 版本在单张 3090 上做预训练是可行的,虽然慢,但能跑通。

这里的关键认知转变是:个人开发者做预训练,目标不是训出一个能打的通用模型,而是理解预训练的每一个环节——数据怎么清洗、tokenizer 怎么训、loss 曲线怎么读、学习率怎么调度、梯度累积怎么配。这些经验在后续做领域适配时全部用得上。你如果跳过预训练直接拿开源模型微调,很多底层问题你根本定位不了。

我实际跑下来,124M 参数的 GPT-2 在 3090 上,batch size 设为 8、序列长度 512 的情况下,单步耗时大约 0.4 秒。这个速度意味着一天能跑大约 20 万步,处理约 8 亿 token。听起来不少,但和 GPT-2 原始训练用的 40GB WebText 相比,只是零头。所以个人预训练的数据规模要务实,我最终用的是 10GB 左右的高质量中文文本,跑了两周左右。

1.2 从零预训练和直接微调的本质区别

直接拿开源模型微调,你继承的是别人在几十 GB 数据上训出来的权重。这些权重里已经编码了丰富的语言知识,你只需要在特定任务上做小幅调整。而从零预训练,你是在教模型什么是词、什么是句法、什么是语义关联。这两条路径的体验完全不同。

我建议每个想深入 LLM 的人都至少跑一次小规模预训练,原因是它会逼你面对一些微调时被掩盖的问题。比如 tokenizer 的 vocab size 设多少合适?设小了中文会被切得很碎,设大了 embedding 层参数占比过高。再比如预训练数据的去重怎么做?重复数据会导致模型在某些模式上过拟合,loss 曲线看起来在降,但泛化能力很差。这些问题你在微调时基本不会遇到,因为开源模型的 tokenizer 和数据配比已经帮你调好了。

从成本角度看,预训练阶段我建议把序列长度控制在 512 以内。原因很直接:attention 的计算复杂度是序列长度的平方,1024 长度的显存占用和计算量是 512 的四倍左右。在 24GB 显存下,512 长度能让你用更大的 batch size,梯度累积的步数更少,训练更稳定。等模型基本收敛后,再用少量长序列数据做继续预训练来扩展上下文窗口,这是更经济的做法。

1.3 RTX 3090 上的显存账本

24GB 显存听起来不少,但预训练时消耗得很快。我以 124M 参数的 GPT-2 为例算一笔账:模型参数本身用 fp16 存储约 250MB,Adam 优化器的状态(一阶矩和二阶矩)用 fp32 存储约 1GB,梯度用 fp16 约 250MB。这些加起来不到 2GB,看起来绰绰有余。但真正的显存大户是激活值。

激活值的大小和 batch size、序列长度、层数、隐藏维度都成正比。124M 的 GPT-2 有 12 层,隐藏维度 768,序列长度 512,batch size 8 的情况下,激活值大约占用 6 到 8GB。如果再开启梯度检查点,激活值能降到 2GB 左右,但计算时间会增加约 30%。我实测下来,不开梯度检查点,batch size 8 是安全的;开了之后可以上到 16,但训练速度反而更慢,因为多出来的计算量抵消了 batch 增大的收益。

所以我的配置是:batch size 8,序列长度 512,不开梯度检查点,用梯度累积来模拟更大的 batch。梯度累积步数设为 4,等效 batch size 就是 32。这个配置下显存占用稳定在 14GB 左右,留了足够余量给数据加载和临时变量。

2. 预训练数据管线的搭建细节

2.1 数据来源与清洗策略

预训练数据的质量直接决定模型的下限。我用的数据主要来自三个渠道:公开的中文维基百科 dump、中文新闻语料、以及一些技术论坛的帖子。这三类数据的语言风格差异很大,维基百科偏正式,新闻偏书面,论坛偏口语,混在一起能让模型学到更丰富的语言模式。

清洗环节我做了四件事。第一是去除 HTML 标签和特殊符号,这个用正则表达式批量处理就行。第二是过滤掉长度过短或过长的文本,我设定的阈值是 50 到 5000 个字符,太短的没有上下文价值,太长的可能是拼接错误。第三是去重,我用的是 MinHash 加 LSH 的方案,对相似度超过 0.8 的文档做去重。这一步很关键,我第一版数据没做去重,结果模型在训练后期 loss 降得很低,但生成的内容开始重复训练数据里的原句,这就是过拟合的典型表现。

第四是语言过滤,确保数据以中文为主。我用的是一个简单的字符比例判断:中文字符占比超过 60% 的文档保留,否则丢弃。这个阈值可以根据你的目标领域调整,如果你要做中英混合的模型,可以放宽到 40%。

清洗完之后,10GB 的原始数据大概剩下 7GB 左右。这个损耗率是正常的,不要心疼。我见过有人为了保留数据量,把去重阈值设得很宽松,结果模型训出来效果很差,回头排查发现是数据里大量重复内容导致的。

2.2 Tokenizer 训练:vocab size 的取舍

GPT-2 原版的 tokenizer 是 Byte-Level BPE,vocab size 是 50257。这个 vocab 对英文很友好,但对中文来说偏大,因为中文的常用字只有几千个,很多 token 是英文子词,中文用不上。我重新训了一个中文 tokenizer,vocab size 设为 32000。

这个数字怎么来的?我做了几组对比实验。vocab size 设 16000 时,中文平均每个字被切成 1.8 个 token,序列长度膨胀明显;设 32000 时,平均每个字 1.2 个 token;设 50000 时,降到 1.1 个 token,但 embedding 层参数从 32000×768 涨到 50000×768,多了约 1400 万参数,对 124M 的模型来说占比太高了。综合下来 32000 是平衡点。

训练 tokenizer 用的是 HuggingFace 的 tokenizers 库,核心参数是vocab_size=32000、min_frequency=2、special_tokens=["<|endoftext|>", "<|pad|>", "<|unk|>"]。训练数据就是从清洗后的语料里随机采样 100MB 左右,不需要全量,tokenizer 学的是字符共现规律,采样足够代表整体分布就行。

注意:tokenizer 训练完之后一定要做 round-trip 测试,也就是 encode 再 decode,看还原度。我遇到过特殊字符在 encode 后丢失的情况,原因是 BPE 合并规则把某些字节序列吃掉了。解决办法是在训练数据里确保特殊字符有足够出现频率,或者在预处理阶段做转义。

2.3 数据打包与动态掩码

预训练的数据格式是连续的 token 序列。我把每篇文档 tokenize 后拼接成一个长序列,然后按 512 长度切块。这里有个细节:文档之间要用<|endoftext|>分隔,否则模型会把上一篇的结尾和下一篇的开头当成连续语义来学,产生错误的关联。

动态掩码是 GPT-2 预训练的标准做法,但实现上有个坑。HuggingFace 的DataCollatorForLanguageModeling默认是动态掩码,每次取 batch 时随机选 15% 的 token 做预测。但如果你用的是拼接后的长序列,掩码位置可能跨文档边界,导致模型学到跨文档的虚假关联。我的做法是在拼接时记录每篇文档的起止位置,掩码时只在这些范围内选,跨边界的 token 不参与 loss 计算。

这个实现稍微麻烦一点,但效果提升明显。我对比过,做边界感知的掩码比不做,验证集 perplexity 低了约 3 个点。对于个人开发者来说,这种细节往往是拉开差距的地方。

3. 训练循环中的关键参数与踩坑记录

3.1 学习率调度与 warmup 步数

GPT-2 预训练用的是 cosine 衰减加 warmup。我一开始照搬了原论文的配置:peak learning rate 1.5e-4,warmup 2000 步。结果训练到 5000 步左右 loss 开始震荡,排查后发现是学习率对 124M 模型来说偏高了。原论文的配置是针对 1.5B 模型的,小模型需要更小的学习率。

我最终用的是 peak learning rate 6e-4,warmup 500 步,cosine 衰减到 6e-5。这个配置下 loss 曲线很平滑,从初始的 10.8 降到 3.2 左右,验证集 perplexity 从 49000 降到 24。这里的学习率数值看起来比原论文大,是因为我用的 batch size 更小,学习率和 batch size 通常要同步缩放。

warmup 步数怎么定?经验法则是总训练步数的 1% 到 5%。我总共跑了约 15 万步,warmup 500 步大约是 0.3%,偏少但够用。warmup 的作用是让模型在训练初期不要被大梯度带偏,步数太少起不到保护作用,太多则浪费训练时间。如果你不确定,设总步数的 2% 是比较稳妥的选择。

3.2 梯度裁剪与 loss 尖峰处理

预训练过程中 loss 尖峰是常见现象,尤其是数据里混入了异常样本时。我遇到过几次 loss 从 3.5 突然跳到 8 以上的情况,如果不处理,模型可能几天都恢复不过来。解决办法是梯度裁剪,我把 max grad norm 设为 1.0,超过这个值的梯度会被等比缩放。

但梯度裁剪只能缓解,不能根治。loss 尖峰的根本原因通常是某个 batch 的数据有问题,比如全是特殊符号、或者编码错误导致的乱码。我的做法是在数据加载环节加一个过滤:如果某个样本的 token 里特殊符号占比超过 30%,直接跳过。这个过滤加上梯度裁剪之后,loss 尖峰从每周两三次降到几乎不出现。

还有一个技巧是 loss 尖峰后的恢复策略。我在训练脚本里加了检测逻辑:如果连续 3 步 loss 超过前 100 步均值的 2 倍,就自动回滚到上一个 checkpoint,并跳过当前数据批次。这个机制救了我好几次,尤其是在跑长训练的时候,不可能一直盯着。

3.3 检查点策略与训练中断恢复

个人开发者的训练环境不稳定是常态,断电、系统更新、显存溢出都可能导致训练中断。我的检查点策略是每 2000 步保存一次,同时保留最近 3 个检查点,加上一个最佳验证集 perplexity 的检查点。这样即使最新检查点损坏,也能从稍早的状态恢复。

保存检查点时要同时保存优化器状态和学习率调度器的状态,否则恢复后学习率会从头开始,导致 loss 震荡。HuggingFace 的Trainer默认会保存这些,但如果你自己写训练循环,很容易漏掉。我第一版就没保存优化器状态,恢复训练后 loss 直接涨了 2 个点,花了 3000 步才降回来。

另外建议把检查点存到和训练脚本不同的磁盘上,或者至少不同的目录。我有一次磁盘写满导致检查点文件损坏,幸好有备份。训练日志也要实时写到文件里,不要只靠终端输出,终端一关日志就没了。

4. 领域适配:从通用模型到专用模型

4.1 领域数据的收集与配比

预训练完成后,我得到一个有基本中文能力的 GPT-2。接下来要做领域适配,我的目标领域是医疗问答。领域数据我收集了三类:医疗百科条目、医患问答对话、临床指南文档。这三类的比例大概是 2:5:3,问答对话占比最高,因为最终应用场景是问答。

领域数据的清洗比预训练数据更严格。医疗领域有很多专业术语和缩写,tokenizer 可能切得很碎,我统计了一下,医疗文本的平均 token 长度是通用文本的 1.6 倍。这意味着同样的序列长度能容纳的医疗文本更少,训练时需要适当增加步数。

数据配比上,我没有完全用领域数据,而是混了 20% 的通用数据。原因是纯领域数据训练会导致模型遗忘通用语言能力,生成的内容虽然专业但语句不通顺。这个比例我试过 10%、20%、30%,20% 的效果最好,验证集上的领域 perplexity 和通用 perplexity 都表现不错。

4.2 LoRA 与全量微调的决策依据

领域适配有两种主流方案:全量微调和 LoRA。全量微调是更新所有参数,LoRA 是冻结原模型,只训练低秩矩阵。我两种都试了,说下对比。

全量微调在 3090 上跑 124M 模型,batch size 8、序列长度 512 的配置下,显存占用约 12GB,单步耗时 0.5 秒。训练 3 个 epoch 大约需要 8 小时。效果上,领域 perplexity 从 18 降到 11,提升明显。

LoRA 的配置是 rank 16、alpha 32、dropout 0.1,应用在 attention 的 query 和 value 矩阵上。显存占用降到 8GB,单步耗时 0.35 秒,训练 3 个 epoch 约 5 小时。领域 perplexity 从 18 降到 13,比全量微调差一些,但差距不大。

我的选择是:如果领域数据量超过 1GB,用全量微调;如果数据量小或者需要快速迭代多个领域版本,用 LoRA。LoRA 的另一个优势是可以同时加载多个领域的适配器,切换时不用重新加载整个模型,这在部署多领域服务时很方便。

4.3 灾难性遗忘的监测与缓解

领域适配最大的风险是灾难性遗忘,模型在领域数据上表现好了,但通用能力下降。我用的监测方法是在训练过程中定期跑一个通用测试集,看通用 perplexity 的变化。如果通用 perplexity 涨了超过 15%,就说明遗忘严重,需要调整训练策略。

缓解遗忘的手段有三个。第一是前面说的混入通用数据,这是最直接的。第二是降低学习率,领域适配的学习率通常比预训练小一个数量级,我用的是 5e-5。第三是早停,不要训练太多 epoch,我一般 2 到 3 个 epoch 就停,再多就会过拟合领域数据。

还有一个技巧是分层学习率,底层参数用更小的学习率,顶层用正常学习率。原因是底层学的是通用语言特征,不应该被领域数据大幅修改;顶层学的是任务相关特征,可以适应领域。这个在 HuggingFace 的Trainer里可以通过参数组实现,稍微麻烦一点但效果不错。

5. 模型评估与迭代方向

5.1 自动评估指标的选择与局限

预训练和领域适配阶段,我主要看两个指标:loss 和 perplexity。这两个指标计算简单,能反映模型对数据的拟合程度。但它们的局限也很明显:perplexity 低不代表生成质量好,模型可能只是记住了训练数据的模式。

所以我在关键节点会加人工评估。具体做法是从验证集里随机抽 50 个 prompt,让模型生成续写,然后我从流畅度、相关性、信息量三个维度打分。这个评估很主观,但能发现自动指标发现不了的问题。我遇到过 perplexity 很低但生成内容重复的情况,就是人工评估发现的。

对于领域适配后的模型,我还会跑一个领域问答测试集,看准确率。医疗领域的测试集是我自己标注的 200 个问答对,虽然规模小,但能反映模型在真实场景下的表现。这个测试集的构建花了大概两天时间,但值得,因为它给了我一个可靠的迭代方向。

5.2 生成质量的常见问题与调参

GPT-2 生成文本时,解码策略对质量影响很大。我试过 greedy search、beam search、top-k 采样、top-p 采样。greedy search 生成的内容最保守,但容易重复;beam search 稍好,但计算量大;top-k 和 top-p 采样生成的内容更多样,但可能跑题。

我最终用的是 top-p 采样,p 设为 0.9,temperature 设为 0.8。这个配置下生成的内容既有多样性,又不会太离谱。temperature 调低到 0.5 会让内容更确定但更保守,调到 1.2 会更有创意但可能语法错误。0.8 是我试下来比较平衡的值。

还有一个参数是 repetition penalty,我设为 1.1。这个参数能抑制模型重复相同的短语,对长文本生成很有用。但设太高会导致模型不敢用常见的表达,生成的内容变得别扭。1.1 到 1.2 是比较安全的范围。

5.3 从 124M 到更大模型的扩展路径

124M 的 GPT-2 只是一个起点。如果你跑通了全流程,下一步可以考虑扩展到 350M 或 750M。扩展时不是简单地把参数调大就行,有几个地方需要调整。

首先是学习率,更大的模型通常需要更小的学习率。我的经验是参数量翻倍,学习率降为原来的 0.7 倍左右。其次是 batch size,更大的模型需要更大的 batch 来稳定训练,但受显存限制,只能用梯度累积来模拟。最后是数据量,更大的模型需要更多数据才能充分发挥容量,如果数据不够,大模型反而比小模型更容易过拟合。

在 3090 上,350M 模型的预训练是可行的,但速度会慢很多,单步耗时大约 1.2 秒,训练周期会拉长到一个月以上。我的建议是先把 124M 的全流程跑通,包括预训练、领域适配、评估、部署,然后再考虑扩展。全流程的经验比模型大小更重要。

6. 部署与推理优化

6.1 模型导出与 ONNX 转换

训练完的模型要部署,第一步是导出。PyTorch 的 checkpoint 直接用于推理不是不行,但加载慢、依赖多。我选择转成 ONNX 格式,用 ONNX Runtime 做推理。转换用的是torch.onnx.export,核心参数是opset_version=14、input_names和output_names要指定清楚。

转换过程中遇到的最大问题是动态轴的处理。GPT-2 的输入序列长度是可变的,如果导出时固定了长度,推理时就只能处理那个长度。解决办法是在dynamic_axes参数里指定序列长度维度为动态。这个配置写起来有点绕,但官方文档里有示例,照着改就行。

ONNX 转换后,模型文件大小和 PyTorch 版本差不多,但推理速度提升了约 20%,显存占用降低了约 15%。对于个人部署来说,这个提升很实在。不过 ONNX 对某些自定义操作的支持不完善,如果你在模型里加了自定义层,可能需要额外处理。

6.2 推理服务的显存管理

部署时显存管理是重点。我的服务同时加载了预训练模型和领域适配模型,两个模型加起来约 500MB 参数,但推理时的激活值和 KV cache 会占用更多显存。KV cache 的大小和 batch size、序列长度、层数、注意力头数都有关,124M 模型在 batch size 1、序列长度 512 时,KV cache 约 100MB。

如果并发请求多,显存会迅速耗尽。我的做法是限制最大并发数为 4,超过的请求排队。同时设置 KV cache 的最大长度,超过就截断。这个策略下,单张 3090 能稳定支撑每秒 10 到 15 个请求,对于个人项目来说够用了。

还有一个优化是量化。我把模型权重从 fp16 量化到 int8,显存占用降到原来的一半,推理速度提升约 30%,但生成质量有轻微下降。对于资源紧张的场景,这个取舍是值得的。量化用的是 ONNX Runtime 的量化工具,配置好校准数据集就行,不需要改模型代码。

6.3 持续迭代的数据飞轮

模型部署不是终点,而是数据收集的起点。我在服务里加了日志记录,把用户的输入和模型的输出都存下来。这些数据经过清洗和标注后,可以用于下一轮领域适配。这就是数据飞轮的雏形。

具体流程是:每周导出一次日志,过滤掉低质量对话,人工标注 100 到 200 条,加入领域训练集,重新跑一遍 LoRA 微调。LoRA 的好处在这里体现得很明显,微调只需要 5 小时左右,可以每周迭代一次。全量微调的话,每周 8 小时也能接受,但显存占用高,不能和其他任务并行。

这个飞轮跑起来之后,模型的领域表现会持续提升。我跑了两个月,领域问答的准确率从最初的 62% 提升到 78%。这个提升不是靠换更大的模型,而是靠数据的持续积累和迭代。对于个人开发者来说,这是最务实的优化路径。

7. 个人开发者做 LLM 全流程的取舍心得

7.1 哪些环节可以省,哪些不能省

全流程走下来,我的体会是有些环节可以简化,有些绝对不能省。可以省的包括:数据规模可以小,但质量要高;模型可以小,但训练要完整;评估可以简单,但要有。不能省的包括:数据清洗、tokenizer 训练、学习率调度、检查点管理、灾难性遗忘监测。这几个环节省了,后面一定会出问题,而且排查起来很痛苦。

我见过有人为了快,直接用开源 tokenizer 不训练,结果中文 token 效率很低,训练成本翻倍。也见过人不做数据去重,模型训出来只会重复。这些坑我都踩过,所以现在宁可前期多花时间,也不在基础环节偷懒。

7.2 时间与算力的真实成本

从零到部署,我总共花了约三个月,其中预训练两周,领域适配一周,评估和调参两周,部署和优化一周,剩下的时间在数据准备和踩坑排查上。算力成本就是一张 3090 的电费,按 300W 功耗算,三个月大约 650 度电,成本可以忽略。

时间成本才是大头。如果你有全职工作,只能晚上和周末跑,周期会拉长到半年左右。我的建议是不要追求一次跑通,把流程拆成小阶段,每个阶段设定明确的验收标准。比如预训练阶段的目标是 loss 降到 3.5 以下,领域适配阶段的目标是领域 perplexity 降到 12 以下。达到标准就进入下一阶段,不要无限调优。

7.3 后续可以扩展的方向

跑通全流程之后,有几个方向可以继续深入。一是扩展模型规模,从 124M 到 350M 再到 750M,观察 scaling law 在小规模上的表现。二是尝试不同的架构,比如 RoBERTa 的预训练策略、ELECTRA 的替换 token 检测,对比它们在中文上的效果。三是把领域适配扩展到多领域,用 LoRA 做多适配器管理,一个基础模型服务多个领域。

还有一个方向是 RAG 结合。纯 LLM 的知识存在模型参数里,更新成本高。RAG 把知识放在外部检索库里,模型只负责理解和生成。我最近在试的是把领域适配后的模型和向量检索结合,检索用领域数据构建索引,生成用微调后的模型。初步效果比纯 LLM 好,尤其是对于需要精确事实的场景。

最后分享一个小技巧:训练日志一定要结构化存储,我用的是 JSON Lines 格式,每步一行,包含 step、loss、learning rate、grad norm、显存占用等字段。这样后期分析很方便,用 pandas 读进来就能画图。我靠这个日志发现了不少问题,比如显存泄漏、梯度异常、学习率调度错误。不要只靠终端输出,那些信息训练完就没了。

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

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

立即咨询