单卡24G显存实战:从零预训练GPT-2到领域适配全流程
个人开发者想完整走一遍 LLM 从预训练到领域适配的全流程最大的障碍从来不是算法本身而是资源约束下的取舍。我手头只有一张 RTX 309024GB 显存这个配置在大厂眼里连入门都算不上但恰恰是个人开发者最真实的起点。这篇文章记录的就是我在这个约束下从零训练一个 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 存储约 250MBAdam 优化器的状态一阶矩和二阶矩用 fp32 存储约 1GB梯度用 fp16 约 250MB。这些加起来不到 2GB看起来绰绰有余。但真正的显存大户是激活值。激活值的大小和 batch size、序列长度、层数、隐藏维度都成正比。124M 的 GPT-2 有 12 层隐藏维度 768序列长度 512batch 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 BPEvocab size 是 50257。这个 vocab 对英文很友好但对中文来说偏大因为中文的常用字只有几千个很多 token 是英文子词中文用不上。我重新训了一个中文 tokenizervocab 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_size32000、min_frequency2、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-4warmup 2000 步。结果训练到 5000 步左右 loss 开始震荡排查后发现是学习率对 124M 模型来说偏高了。原论文的配置是针对 1.5B 模型的小模型需要更小的学习率。我最终用的是 peak learning rate 6e-4warmup 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.9temperature 设为 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_version14、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 读进来就能画图。我靠这个日志发现了不少问题比如显存泄漏、梯度异常、学习率调度错误。不要只靠终端输出那些信息训练完就没了。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →