ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

RTX 3090实战:个人开发者从零预训练GPT-2到领域适配全流程

RTX 3090实战:个人开发者从零预训练GPT-2到领域适配全流程 个人开发者想完整走一遍大模型的全流程最大的障碍从来不是算法本身而是资源。没有集群、没有A100、没有动辄几十万的预算只有一张消费级显卡和一堆零散的开源工具这条路到底能不能走通我花了大概三个月时间用一张RTX 3090把从预训练到领域适配的整条链路跑了一遍中间踩的坑比预想的多得多。这篇文章就是把这三个月的东西整理出来给同样想自己动手的开发者一个可复现的参考。核心关键词包括LLM、预训练、领域适配、GPT-2和RTX 3090适合有一定PyTorch基础、想从调API进阶到自己训模型的个人开发者。我不会讲太多理论推导重点放在实际怎么操作、为什么这么选、以及哪些地方容易翻车。1. 为什么个人开发者值得走一遍预训练这条路1.1 调API和训模型之间的认知鸿沟很多人用LLM的方式就是调接口写个prompt拿结果。这没什么问题但如果你的目标是真正理解LLM的行为边界光调API是不够的。你不知道tokenizer是怎么切分的不知道attention在长文本上为什么会退化不知道微调时学习率设大了会发生什么。这些认知只有在亲手训过一次模型之后才会建立起来。我自己的感受是预训练一个GPT-2级别的小模型虽然效果远不如那些千亿参数的大家伙但它能让你把整个pipeline的每个环节都摸一遍。数据怎么清洗、tokenizer怎么训、checkpoint怎么存、loss曲线怎么读、显存怎么省这些东西在调API的时候是完全接触不到的。而且GPT-2的架构足够经典理解了它再看LLaMA、Qwen这些现代架构很多设计选择就一目了然了。1.2 RTX 3090这张卡的定位RTX 3090有24GB显存这个容量在消费级卡里算是天花板了。对于GPT-2这种级别的模型124M到1.5B参数24GB足够你做一些有意思的事情。具体来说124M参数的GPT-2用fp16混合精度训练batch size可以开到32甚至64序列长度512的情况下显存占用大概在8-12GB。355M参数的版本batch size要降到16左右显存占用大概15-18GB。774M的版本就比较吃力了需要用到梯度累积和梯度检查点batch size只能开到4-8。所以我的建议是个人开发者从124M开始跑通了再往上加。不要一上来就想着训1.5B那是在跟自己过不去。1.3 全流程到底包含哪些环节所谓全流程我把它拆成这么几块数据收集与清洗、tokenizer训练、模型配置与初始化、预训练循环、训练监控与调优、领域适配微调、推理部署。每一块都有它的坑后面我会逐个展开。这里先给一个整体的时间预期数据准备大概占40%的时间预训练占30%调优和排错占20%剩下10%是部署。很多人低估了数据准备的工作量实际上这是最耗精力的一环。2. 数据管线的搭建从原始文本到训练样本2.1 数据来源的选择与取舍预训练数据从哪来个人开发者没有Common Crawl那种级别的资源但也不需要。我的做法是混合几个来源中文维基百科的dump大概几个GB质量高但领域偏百科。开源书籍数据集比如各种公版书语言风格多样。技术文档和博客的爬取内容这部分需要自己清洗。新闻语料注意版权问题个人研究用途一般没问题。关键是要保证数据的多样性和质量。我试过只用维基百科训结果模型生成的东西全是百科腔问它今天天气怎么样它给你回一段定义式的文字。后来混入了对话类和技术类语料生成风格才自然了一些。2.2 清洗流程的具体步骤清洗这块我踩了不少坑总结下来大概是这么几步去重用MinHash或者简单的SimHash做近似去重。重复数据会让模型过拟合而且浪费训练时间。我用datasketch这个库做的阈值设在0.8左右。过滤低质量文本长度太短的、符号占比太高的、重复字符过多的直接扔掉。我写了个简单的规则过滤器文本长度小于50个字符的不要非字母数字字符占比超过40%的不要。去除敏感和噪声内容这个不用多说HTML标签、乱码、特殊符号都要清掉。语言过滤如果你只要中文数据用fasttext训练一个简单的语言分类器把非中文的筛掉。注意清洗这一步不要过度。我一开始过滤太狠把很多口语化的、带标点的文本都扔了结果模型学出来的东西特别死板。后来放宽了阈值保留了一些不完美的文本生成效果反而更好。2.3 tokenizer训练的实际操作GPT-2用的是BPE tokenizer但原版的tokenizer是在英文语料上训的直接拿来处理中文效果很差。你需要自己训一个。我用的是HuggingFace的tokenizers库代码如下from tokenizers import Tokenizer from tokenizers.models import BPE from tokenizers.trainers import BpeTrainer from tokenizers.pre_tokenizers import ByteLevel tokenizer Tokenizer(BPE(unk_token|endoftext|)) tokenizer.pre_tokenizer ByteLevel() trainer BpeTrainer( vocab_size32000, special_tokens[|endoftext|, |padding|], min_frequency2 ) tokenizer.train(files[data/clean_corpus.txt], trainertrainer) tokenizer.save(tokenizer.json)vocab_size设多少我试过16000、32000、50000三个档位。32000在中文场景下比较均衡太小了会导致很多词被拆成单字太大了embedding层参数变多训练变慢。min_frequency设2是为了过滤掉只出现一次的词对减少vocab里的噪声。训完tokenizer之后一定要检查一下它的切分效果。我遇到过一个问题tokenizer把人工智能切成了人工智能但把机器学习切成了机器学习这种不一致会直接影响模型的学习效率。解决办法是增加训练语料的量或者在pre_tokenizer里加一些规则。2.4 数据格式与Dataset实现清洗完的文本要转成模型能吃的格式。GPT-2是自回归模型训练数据就是input_ids和labelslabels就是input_ids右移一位。我用的是HuggingFace的datasets库写了个简单的处理脚本from datasets import load_dataset def tokenize_function(examples): return tokenizer( examples[text], truncationTrue, max_length512, return_overflowing_tokensTrue, stride128 ) dataset load_dataset(text, data_filesdata/clean_corpus.txt) tokenized dataset.map(tokenize_function, batchedTrue, num_proc8)max_length设512是因为3090的显存限制设1024的话batch size要砍半。stride设128是为了让截断的片段之间有重叠避免句子被硬切导致语义断裂。num_proc设8是并行处理加快速度。3. 模型配置与预训练循环的实操细节3.1 GPT-2配置的调整策略HuggingFace的GPT2Config默认是124M的配置但直接拿来用不一定合适。我根据自己的数据特点调了几个参数from transformers import GPT2Config config GPT2Config( vocab_size32000, n_positions512, n_embd768, n_layer12, n_head12, resid_pdrop0.1, embd_pdrop0.1, attn_pdrop0.1, activation_functiongelu_new )n_positions设512是因为我的训练序列长度就是512设大了浪费。n_embd和n_head保持768和12这是GPT-2的标准配置改小了模型容量不够改大了显存吃不消。dropout都设0.1这是防止过拟合的基本操作。activation_function用gelu_new比原始的gelu在训练初期更稳定。3.2 训练超参数的设定逻辑超参数这块我调了好几轮最终稳定下来的配置是参数值说明learning_rate3e-4预训练用大一点微调时降到1e-5batch_size323090上124M模型的安全值gradient_accumulation_steps4等效batch size 128warmup_steps2000占总步数的5%左右weight_decay0.01标准值max_grad_norm1.0梯度裁剪防止爆炸lr_schedulercosine余弦退火比线性好learning_rate为什么是3e-4这是GPT-2论文里的推荐值我试过1e-4和5e-41e-4收敛太慢5e-4在训练中期loss会震荡。3e-4是比较稳的。gradient_accumulation_steps设4是因为单卡batch size上不去用累积来模拟大batch的效果。3.3 训练循环中的显存优化技巧3090的24GB显存说大不大说小不小。我用了几个技巧来省显存混合精度训练用torch.cuda.ampfp16前向传播fp32存master weights。这个能省大概40%的显存。梯度检查点model.gradient_checkpointing_enable()用计算换显存能再省30%左右但训练速度会慢20%。梯度累积前面说了用时间换空间。及时释放缓存每个step结束后torch.cuda.empty_cache()虽然有点影响速度但能防止显存碎片化。from torch.cuda.amp import autocast, GradScaler scaler GradScaler() for batch in dataloader: with autocast(): outputs model(**batch) loss outputs.loss / gradient_accumulation_steps scaler.scale(loss).backward() if step % gradient_accumulation_steps 0: scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) scaler.step(optimizer) scaler.update() optimizer.zero_grad()3.4 loss曲线的解读与异常处理预训练最怕的就是loss不降或者突然爆炸。我遇到过几种情况loss平稳不降大概率是学习率太小或者数据有问题。检查一下tokenizer的切分是否合理数据里有没有大量重复。loss突然飙升梯度爆炸了。检查max_grad_norm有没有生效学习率是不是太大了。loss降了又升过拟合了。增加dropout或者增加数据量。loss震荡batch size太小或者学习率太大。试试调小学习率或者增大gradient_accumulation_steps。我一般会每500步存一次checkpoint同时记录loss。用tensorboard看曲线如果连续几个checkpoint的loss都没降就说明有问题了。4. 领域适配让通用模型说行话4.1 领域适配和预训练的区别预训练是从零开始学语言领域适配是在已有模型的基础上用特定领域的数据继续训练。区别在于预训练需要大量通用语料领域适配只需要少量领域数据预训练的学习率大领域适配的学习率要小一到两个数量级预训练容易灾难性遗忘领域适配要控制好遗忘的程度。我做的领域适配是让模型适应技术文档的风格。用的数据是我自己收集的技术博客和文档大概500MB左右。相比预训练的几十GB这个量级小得多但效果很明显。4.2 领域数据的准备要点领域数据不需要像预训练数据那样大规模清洗但要注意几点格式统一技术文档有Markdown、有HTML、有纯文本要统一成纯文本。代码块要保留但要去掉多余的格式标记。领域词汇保留不要用通用的停用词表去过滤技术领域有很多专有名词过滤掉了模型就学不到。数据量控制领域数据太多会过拟合太少没效果。我的经验是领域数据量控制在预训练数据的1%到5%之间比较合适。4.3 微调策略全参数还是LoRA领域适配有两种做法全参数微调和LoRA。我两种都试过说说感受。全参数微调效果更好但显存占用大124M的模型全参数微调大概需要18GB显存3090勉强够用。LoRA显存占用小只需要8GB左右但效果会打一点折扣大概差5%到10%。如果你只是想快速验证效果用LoRA就够了。如果追求最佳效果而且显存够那就全参数微调。我最后用的是全参数微调因为3090刚好能跑。from transformers import Trainer, TrainingArguments training_args TrainingArguments( output_dir./domain_adapted, num_train_epochs3, per_device_train_batch_size8, gradient_accumulation_steps4, learning_rate1e-5, warmup_steps500, weight_decay0.01, logging_steps100, save_steps1000, fp16True, gradient_checkpointingTrue ) trainer Trainer( modelmodel, argstraining_args, train_datasetdomain_dataset, data_collatordata_collator ) trainer.train()learning_rate设1e-5比预训练小了一个数量级。num_train_epochs设3领域数据不多3轮就够了多了会过拟合。4.4 灾难性遗忘的检测与缓解领域适配最大的风险是灾难性遗忘模型把通用能力忘了只会说领域内的话。检测方法很简单准备一组通用测试集在适配前后各跑一次看效果下降了多少。缓解方法有几个混合训练领域数据和通用数据按比例混合我用的比例是1:4。低学习率前面说了1e-5甚至更低。早停监控验证集loss不降了就停。LoRA只更新部分参数对原始能力的破坏更小。我实测下来混合训练加低学习率的效果最好通用能力只下降了不到3%领域能力提升了大概15%。5. 推理部署与效果验证5.1 模型导出与格式转换训练完的模型要导出成推理友好的格式。HuggingFace的save_pretrained存的是PyTorch格式推理时加载比较慢。我一般会转成ONNX或者用torchscript。import torch from transformers import GPT2LMHeadModel model GPT2LMHeadModel.from_pretrained(./domain_adapted) model.eval() dummy_input torch.randint(0, 32000, (1, 128)) torch.onnx.export( model, dummy_input, model.onnx, input_names[input_ids], output_names[logits], dynamic_axes{input_ids: {0: batch, 1: sequence}}, opset_version14 )ONNX的好处是跨平台而且可以用onnxruntime加速。我实测下来ONNX推理比PyTorch快大概20%到30%。5.2 推理参数的调优生成文本的质量很大程度上取决于推理参数。我常用的配置是temperature0.7到0.9之间。太低了生成太死板太高了胡言乱语。top_k50。只从概率最高的50个token里采样。top_p0.9。核采样比top_k更灵活。repetition_penalty1.1到1.2。防止重复生成同样的内容。from transformers import pipeline generator pipeline( text-generation, model./domain_adapted, tokenizer./tokenizer ) output generator( 深度学习模型的训练需要, max_length100, temperature0.8, top_k50, top_p0.9, repetition_penalty1.15, do_sampleTrue )5.3 效果评估的实用方法个人开发者没有资源做大规模的benchmark但可以用一些简单的方法评估困惑度Perplexity在验证集上算越低越好。但注意困惑度低不代表生成质量好。人工评估自己写几个prompt看生成结果是否通顺、是否符合领域风格。对比测试拿适配前后的模型对比看领域相关的问题回答得怎么样。我一般会准备20个左右的测试prompt涵盖通用问题和领域问题人工打分。这个方法虽然主观但比只看困惑度靠谱。5.4 部署时的性能考量如果只是本地测试直接跑就行。如果要部署成服务要考虑几点批处理多个请求合并成一个batch提高GPU利用率。KV Cache生成式模型每次只生成一个token用KV Cache可以避免重复计算。量化用int8或者int4量化减少显存占用但会损失一点精度。我用FastAPI搭了个简单的服务配合onnxruntime单张3090能支撑大概10个并发请求延迟在200ms左右。对于个人项目来说够用了。6. 踩坑记录与经验总结6.1 数据清洗过度导致模型失语前面提过我一开始清洗太狠把很多口语化的文本都过滤了。结果模型生成的东西特别死板问它你好吗它回根据相关定义你好是一种问候语。后来放宽了过滤条件保留了一些不完美的文本生成才自然起来。这个教训是数据清洗要适度不要追求完美。6.2 学习率设置不当导致训练崩溃有一次我把学习率设成了1e-3想着快点收敛。结果训练到2000步的时候loss突然从3.5飙到10以上模型直接废了。后来查原因是梯度爆炸。虽然设了max_grad_norm1.0但学习率太大梯度裁剪也救不回来。教训是预训练的学习率不要超过5e-4微调不要超过5e-5。6.3 tokenizer不匹配导致效果打折我试过直接用GPT-2原版的tokenizer处理中文结果一个中文字被拆成好几个token序列长度暴涨训练效率极低。后来自己训了tokenizer同样长度的文本token数减少了大概40%。这个坑很隐蔽因为模型能跑只是效果差不容易发现。6.4 显存碎片化导致OOM训练时间长了显存会碎片化明明还有空间却报OOM。解决办法是定期调用torch.cuda.empty_cache()或者用PYTORCH_CUDA_ALLOC_CONFexpandable_segments:True这个环境变量。我后来养成了每个epoch结束后重启一次训练脚本的习惯虽然麻烦但能避免很多莫名其妙的OOM。6.5 领域适配时的过拟合领域数据少的时候很容易过拟合。我遇到过验证集loss先降后升的情况这就是过拟合了。解决办法是增加dropout、减少训练轮数、或者增加数据量。我最后是把领域数据扩充了一倍同时把epoch从5降到3问题就解决了。7. 后续可以继续折腾的方向跑完这一整套之后我发现还有不少可以深入的地方。比如用RAG的方式把模型和外部知识库结合起来这样模型不需要记住所有东西推理时去检索就行。还有就是把模型量化到int4看看效果损失有多大。另外我也在尝试用LoRA做多领域的适配一个base模型加多个LoRA adapter切换不同的领域只需要换adapter不用重新训整个模型。这些方向我还在摸索等有成熟的结果再整理出来。如果你也在用消费级显卡折腾LLM欢迎交流踩坑经验。
返回列表