☰
个人开发者如何用RTX 3090从零预训练LLM并完成领域适配
2026/10/1 2:09:13 网站建设 项目流程

1. 为什么个人开发者现在要啃“全流程”这块硬骨头

这两年大模型的门槛肉眼可见地降了,但真正自己从头跑一遍的人还是少数。大部分人停留在调API、套框架的阶段,一旦遇到“我这个垂直领域的数据怎么喂进去”“显存不够怎么裁”“预训练到底要不要做”这类问题就卡住了。我自己是从一张 RTX 3090 起步,把 GPT-2 级别的模型从预训练一路做到领域适配,中间踩的坑足够写一本小册子。这篇就把整条链路拆开讲清楚:一个个人开发者,用一张消费级卡,怎么把 LLM 从零到领域可用跑通。

先说清楚这篇适合谁看。如果你手里有一张 24G 显存的卡(3090、4090 都算),懂一点 PyTorch,想搞明白预训练、微调、领域适配这几个环节到底在干什么、怎么串起来,那这篇就是给你写的。如果你只是想调个 API 做个聊天机器人,那可以关掉了,这里讲的是底层那套东西。核心关键词我先摆出来:LLM、预训练、领域适配、GPT-2、RTX 3090,后面所有内容都围绕这几个词展开。

我选择 GPT-2 作为起点不是因为它强,恰恰是因为它“小得刚好”。124M 参数的版本,单卡能塞下,训练一轮的时间可控,出问题容易定位。你拿它把全流程走通一遍,换成 LLaMA 架构或者更大的模型,套路是一样的,只是显存和时间要重新算。这个思路很重要:先用小模型验证流程,再用大模型放大结果,别一上来就硬刚 7B。

整条链路我分成四块:数据准备与分词、预训练、领域适配(继续预训练加指令微调)、评测与部署。每一块都有它存在的理由,也都有个人开发者容易翻车的地方。下面逐块拆。

2. 全流程的整体设计与方案选型思路

2.1 为什么是“预训练 + 领域适配”而不是直接微调

很多人第一反应是:我直接拿开源模型微调不就行了,为什么要自己预训练?这个问题得分开看。预训练解决的是“模型懂不懂这门语言的基本规律”,领域适配解决的是“模型懂不懂我这个行业的黑话和逻辑”。如果你用的开源模型本身就是在海量通用语料上训过的,那通用语言能力已经有了,你缺的只是领域知识,这时候直接做领域适配就够了,预训练可以跳过。

但如果你要处理的是某种特殊格式的文本,比如大量结构化日志、特定符号体系、或者一种开源模型没见过的小语种,那通用模型的分词器可能直接把你的文本切得稀碎,这时候就得考虑从头训一个分词器,甚至从头预训练。我这次走全流程,一部分原因就是想验证“从零开始”这条路的可行性,另一部分是想搞清楚预训练到底给模型带来了什么,这样后面做领域适配时心里有数。

方案选型上,我定了几条硬约束:单卡 24G 显存、总训练时间控制在一周内、每个环节都要能单独验证。基于这几条,模型规模锁死在 124M 到 350M 之间,序列长度先用 512 跑通再考虑拉长,batch size 靠梯度累积堆上去。这些约束不是拍脑袋定的,是显存和时间倒推出来的,后面会讲具体怎么算。

2.2 显存账要先算清楚再动手

RTX 3090 的 24G 显存看着不少,但训练时是这么被吃掉的:模型参数、梯度、优化器状态、激活值、临时缓冲区。用 Adam 的话,优化器状态是参数量的两倍(一阶矩和二阶矩),加上梯度,光这三项就是参数量的 4 倍。124M 参数,fp32 下就是 124M × 4 字节 × 4 ≈ 2G,这还没算激活值。激活值跟 batch size 和序列长度成正比,序列 512、batch 8 的时候,激活值轻松吃掉好几个 G。

所以我的策略是混合精度加梯度累积。混合精度(fp16 或 bf16)能把参数和激活的显存砍掉近一半,梯度累积则让你用小的 micro-batch 模拟大的全局 batch。具体来说,我想要全局 batch 128,但单次只能塞下 8,那就累积 16 步再更新一次参数。这样显存占用按 micro-batch 算,训练效果按全局 batch 算,两全其美。3090 对 bf16 的支持不如 4090 好,我实测 fp16 加 loss scaling 更稳,这个后面细说。

2.3 分词器:被低估的第一道坎

分词器这东西,用现成的时候没感觉,自己训的时候才知道多要命。GPT-2 原版用的是 BPE 分词器,词表 50257。如果你的领域文本里全是它没见过的词,比如化学式、代码符号、医学术语,那一个词会被切成好几个 subword,序列长度暴涨,训练效率直线下降。

我的做法是先拿领域语料训一个自己的 BPE 分词器,词表大小定在 32000 左右。为什么是 32000?太小了词切得碎,太大了 embedding 层参数变多、低频词训不充分。32000 是个经验值,覆盖常见领域词汇够用,embedding 层也就 32000 × 768 ≈ 24M 参数,能接受。训分词器用 HuggingFace 的 tokenizers 库,几行代码的事,但语料要洗干净,不然训出来的词表全是噪声。

这里有个坑:分词器一旦定了,后面预训练、微调、推理全得用它,中途换分词器等于前面白训。所以训完分词器先拿一批真实文本编码解码一遍,看看切分是否合理,确认没问题再往下走。

3. 核心细节解析与实操要点

3.1 数据准备:清洗比想象中重要十倍

预训练的数据质量直接决定模型下限。我这次用的语料大概 20G 纯文本,来源包括公开书籍、领域文档、网页正文。清洗流程我走了这么几步:先去重,用 MinHash 做近似去重,重复数据会让模型记住特定句子,泛化变差;再过滤,去掉长度过短、乱码比例过高、特殊符号过多的行;最后做规范化,统一全半角、去掉多余空白和控制字符。

去重这块我要多说一句。精确去重简单,但近似去重才是关键。同一篇文章被转载多次、同一段代码出现在多个文件里,这些都得去掉。MinHash 加 LSH 是标准做法,datasketch 库能直接用。我设的 Jaccard 阈值是 0.8,超过就认为是重复。阈值太低会误删,太高去不干净,0.8 是我试了几轮比较平衡的值。

清洗完的数据要打包成训练能直接读的格式。我用的方案是先把所有文本 tokenize 成 token id 序列,存成二进制文件,训练时用内存映射读取。这样避免了训练时反复做分词,IO 效率高很多。具体做法是把所有 token id 拼成一个大的一维数组,存成 numpy 的 memmap,训练时按固定长度切片。这个技巧在处理大规模语料时特别管用,值得单独记一笔。

3.2 预训练:从随机权重到“会说人话”

预训练的目标很简单:给定前面的 token,预测下一个 token。损失函数就是交叉熵,没什么花哨的。但魔鬼在细节里。学习率调度我用的是 warmup 加 cosine 衰减,warmup 步数设总步数的 1% 到 2%,峰值学习率 6e-4。为什么是这个值?GPT-2 原论文用的是 6e-4 配 batch 512,我 batch 小一些,学习率相应调低一点,但没低太多,因为小 batch 本身噪声大,学习率太低训不动。

权重衰减设 0.1,只作用于权重矩阵,不作用于 bias 和 LayerNorm 参数。这个细节很多人忽略,但对训练稳定性有影响。梯度裁剪设 1.0,防止梯度爆炸。优化器用 AdamW,beta 设 (0.9, 0.95),比默认的 (0.9, 0.999) 更适合语言模型,这是 GPT-3 论文里的经验。

训练过程中我盯几个指标:训练 loss、验证 loss、梯度范数、学习率。训练 loss 平稳下降是基本要求,如果震荡厉害,要么学习率太高,要么 batch 太小。验证 loss 和训练 loss 的差距能看出有没有过拟合,差距拉大就得考虑加数据或加正则。梯度范数突然飙高往往是数据里有异常样本,得回去查。

我这次预训练跑了大概 30 万步,序列长度 512,全局 batch 128,在 3090 上花了差不多五天。最终验证 loss 从初始的 10 左右降到 3.2 附近。这个 loss 值说明模型已经学会了基本的语言规律,能生成通顺的句子,但还不懂领域知识,这正是下一步要解决的。

3.3 领域适配:让通用能力长出专业触角

领域适配我分两步走:继续预训练加指令微调。继续预训练就是在领域语料上接着训,让模型吸收领域词汇和表达习惯。这一步的学习率要比预训练低一个量级,我用 5e-5,步数也少很多,几万步就够。为什么学习率要低?因为模型已经学到了通用语言能力,学习率太高会把这些能力冲掉,这叫灾难性遗忘。

继续预训练的数据就是纯领域文本,不需要标注,格式和预训练一样。这一步的效果体现在模型对领域文本的困惑度下降,生成领域相关内容时更自然。我实测下来,继续预训练之后,模型在领域测试集上的困惑度能降 30% 左右,效果很明显。

指令微调是第二步,需要构造“指令-回答”对。这部分数据得自己造,我用了几个来源:领域问答对、文档摘要任务、结构化抽取任务。格式统一成“指令 + 输入 + 输出”的模板,训练时只对输出部分算 loss,输入部分 mask 掉。为什么只算输出 loss?因为我们要模型学会“根据指令生成回答”,而不是“复述指令”。

指令微调的学习率更低,我用 2e-5,步数几千步。数据量不用太大,几千到几万条高质量样本就够。关键是质量,一条脏数据能带偏整个模型。我造数据的时候会人工过一遍,把明显有问题的删掉。这一步做完,模型就能听懂指令并给出领域相关的回答了。

3.4 评测:别只看 loss,要看实际输出

评测这块我分自动和人工两部分。自动评测用困惑度和几个标准任务的准确率,人工评测就是自己拿一批 prompt 去试,看输出质量。困惑度只能反映模型对文本的建模能力,不能反映生成质量,所以人工评测不能省。

我准备了三类 prompt:领域知识问答、文本生成、指令遵循。每类准备二十条,生成结果自己打分,从流畅度、相关性、准确性三个维度评。这个打分很主观,但能发现自动指标发现不了的问题,比如模型是不是在胡说、是不是答非所问。

还有一个技巧是对比评测。把预训练后、继续预训练后、指令微调后的模型放在一起,用同样的 prompt 生成,横向对比。这样能清楚看到每个环节带来了什么提升,也能发现哪个环节出了问题。我这次就发现继续预训练之后模型在通用任务上略有退步,这就是灾难性遗忘的表现,后来通过混入一部分通用数据缓解了。

4. 实操过程与核心环节实现

4.1 环境搭建与依赖锁定

环境这块我踩过最大的坑是版本冲突。PyTorch、CUDA、transformers、tokenizers 这几个库版本对不上,报错能报到你怀疑人生。我的做法是用 conda 建独立环境,把所有依赖版本写死在 requirements 里,装完先跑一个最小训练脚本验证。

conda create -n llm_train python=3.10 conda activate llm_train pip install torch==2.1.0 --index-url https://download.pytorch.org/whl/cu118 pip install transformers==4.35.0 tokenizers==0.14.1 datasets==2.14.0 pip install accelerate==0.24.0 wandb==0.15.0

CUDA 版本要和 PyTorch 版本匹配,3090 用 cu118 没问题。装完跑一句torch.cuda.is_available()确认能识别显卡。transformers 和 tokenizers 版本要对应,不然加载分词器会报错。这些版本号是我实测能跑通的组合,直接抄就行。

wandb 用来记录训练曲线,免费版够个人用。训练时把 loss、学习率、梯度范数都记上去,出问题好回溯。不想用在线工具的话,tensorboard 也行,本地跑。

4.2 分词器训练与验证

训分词器用 tokenizers 库,核心代码如下:

from tokenizers import Tokenizer, models, trainers, pre_tokenizers tokenizer = Tokenizer(models.BPE(unk_token="[UNK]")) tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False) trainer = trainers.BpeTrainer( vocab_size=32000, special_tokens=["[PAD]", "[UNK]", "[BOS]", "[EOS]"], min_frequency=2 ) tokenizer.train(files=["corpus.txt"], trainer=trainer) tokenizer.save("tokenizer.json")

vocab_size 定 32000,special_tokens 加上 padding、unknown、begin、end 四个。min_frequency 设 2,出现少于两次的字符不单独成词。ByteLevel 预处理能处理任意字符,不会出现 OOV 问题。

训完一定要验证。拿一段领域文本编码再解码,看是否还原。再看切分粒度,如果一句话被切成几十个 token,说明词表没覆盖好,得回去检查语料。我这次训完,平均每个词切 1.3 个 token,比 GPT-2 原版在领域文本上的 2.1 好不少,说明分词器确实学到了领域词汇。

4.3 预训练脚本的关键配置

预训练脚本我基于 HuggingFace 的 transformers 改的,核心是配置好 TrainingArguments 和模型。模型我用 GPT-2 架构,但 embedding 层换成自己分词器的词表大小,其他层保持原样。

from transformers import GPT2Config, GPT2LMHeadModel config = GPT2Config( vocab_size=32000, n_positions=512, n_embd=768, n_layer=12, n_head=12, resid_pdrop=0.1, embd_pdrop=0.1, attn_pdrop=0.1 ) model = GPT2LMHeadModel(config)

n_layer 12、n_head 12、n_embd 768 是 GPT-2 small 的标准配置,124M 参数。dropout 都设 0.1,防止过拟合。n_positions 设 512,先跑短序列,后面要拉长再改。

训练参数这么设:

training_args = TrainingArguments( output_dir="./checkpoints", per_device_train_batch_size=8, gradient_accumulation_steps=16, learning_rate=6e-4, warmup_steps=3000, max_steps=300000, lr_scheduler_type="cosine", weight_decay=0.1, max_grad_norm=1.0, fp16=True, logging_steps=100, save_steps=5000, dataloader_num_workers=4 )

per_device_train_batch_size 8 是 3090 上序列 512 时能塞下的值,gradient_accumulation_steps 16 把全局 batch 堆到 128。fp16 开混合精度,省显存。save_steps 5000 存一次 checkpoint,防止训练中断白跑。

这里有个细节:fp16 训练要配 loss scaling,transformers 默认会开。如果遇到 loss 变 NaN,先检查是不是梯度爆炸,把 max_grad_norm 调小试试。我这次训练中途遇到过一次 NaN,把梯度裁剪从 1.0 降到 0.5 就稳了。

4.4 领域适配的两阶段实现

继续预训练和预训练代码几乎一样,改三个地方:加载预训练好的 checkpoint、学习率降到 5e-5、步数减到 50000。数据换成领域语料,其他不动。

指令微调要改数据格式和 loss 计算。数据组织成:

def format_sample(instruction, input_text, output_text): prompt = f"### 指令:\n{instruction}\n\n### 输入:\n{input_text}\n\n### 回答:\n" return prompt, output_text

训练时把 prompt 部分的 label 设成 -100,只对 output 算 loss。这样模型学的是生成回答,不是复述指令。代码上就是在 collate 函数里处理,把 prompt 长度对应的 label 位置 mask 掉。

指令微调的学习率 2e-5,步数 5000,batch 小一点,8 就行。数据量我用了大概 8000 条,覆盖问答、摘要、抽取三类任务。训完拿测试集跑一遍,准确率从继续预训练后的 45% 提到 72%,提升明显。

4.5 训练监控与 checkpoint 管理

训练过程中我开两个终端,一个跑训练,一个用 nvidia-smi 盯显存和利用率。显存利用率稳定在 90% 以上说明 batch 设得合适,低于 80% 可以适当加大 batch。GPU 利用率如果忽高忽低,往往是数据加载成了瓶颈,把 dataloader_num_workers 调大。

checkpoint 管理我用的是保留最近三个加最优一个的策略。训练脚本里配 save_total_limit=3,再写个回调,验证 loss 创新低时额外存一份。这样既省磁盘,又不会丢掉最好的模型。3090 的磁盘空间有限,checkpoint 一个就几百兆,不管理很快爆盘。

wandb 上的曲线我重点看三条:训练 loss、验证 loss、学习率。训练 loss 应该平滑下降,验证 loss 跟着降但略高。如果验证 loss 开始上升,说明过拟合了,得早停。学习率曲线确认 warmup 和衰减按预期走,没跑偏。

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

5.1 显存溢出(OOM)的排查顺序

OOM 是个人开发者最常遇到的问题。排查顺序我总结成这么几步:先看 batch size 和序列长度,这俩是显存大户,先减半试试;再看有没有开混合精度,没开的话显存直接翻倍;然后检查是不是梯度累积没配对,累积步数不影响单次显存,但有人误以为会影响;最后看是不是模型加载了两份,比如同时加载了训练模型和推理模型。

如果都排除了还 OOM,那就是模型本身太大,得换小模型或者用梯度检查点(gradient checkpointing)。梯度检查点用时间换空间,显存能降 50% 以上,代价是训练慢 20% 到 30%。transformers 里加一行model.gradient_checkpointing_enable()就行。

5.2 loss 不下降或变 NaN 的处理

loss 不下降先查学习率,太高会震荡,太低会不动。用学习率扫描,从 1e-5 到 1e-3 试几个值,看哪个下降最快。再查数据,是不是有大量重复或乱码,脏数据会让模型学不到东西。还查初始化,embedding 层如果初始化方差太大,前期 loss 会很高。

loss 变 NaN 一般是梯度爆炸或数值溢出。先开梯度裁剪,max_grad_norm 设 1.0 或更低。再检查 fp16 的 loss scaling,transformers 默认动态调整,但有时候调不过来,可以手动设初始 scale。还不行就换 bf16,bf16 动态范围大,不容易溢出,但 3090 对 bf16 支持一般,得试。

5.3 生成结果重复或胡言乱语的调参

模型生成时重复同一句话,是解码策略的问题。贪心解码容易重复,换成 beam search 或采样能缓解。采样时加 temperature 和 top_p,temperature 设 0.7 到 0.9,top_p 设 0.9,重复惩罚(repetition penalty)设 1.1 到 1.2。这几个参数组合我试下来比较平衡,既有多样性又不跑偏。

胡言乱语往往是训练不充分或数据质量差。先看训练 loss 降到多少,如果还在 5 以上,说明没训够。再看数据,是不是领域文本太少或太杂。还看指令微调的数据格式,如果 prompt 和 output 没对齐,模型学出来的就是乱的。

5.4 灾难性遗忘的缓解手段

继续预训练之后通用能力退步,这是灾难性遗忘。缓解手段有几个:一是混入一部分通用数据,比例大概 10% 到 20%;二是降低学习率,让模型别忘太快;三是用 LoRA 这类参数高效微调方法,只训一小部分参数,原能力保留得更好。

我这次用的是混数据加低学习率,通用任务退步控制在 5% 以内,能接受。如果退步严重,就得上 LoRA。LoRA 的 rank 设 8 或 16,alpha 设 16 或 32,只作用于 attention 的 q、v 矩阵,参数量能降到原来的 1% 以下。

5.5 常见问题速查表

问题现象可能原因排查方向解决手段
显存溢出batch 太大、没开混合精度查 batch、序列长度、fp16减 batch、开 fp16、梯度检查点
loss 不降学习率不当、数据脏查学习率、数据质量学习率扫描、清洗数据
loss 变 NaN梯度爆炸、数值溢出查梯度范数、fp16 scale梯度裁剪、换 bf16
生成重复解码策略问题查 temperature、top_p调采样参数、加重复惩罚
通用能力退步灾难性遗忘查通用任务评测混通用数据、LoRA
训练慢数据加载瓶颈查 GPU 利用率加 dataloader workers

这张表我贴在显示器边上,出问题先对一遍,能省不少时间。

6. 部署与推理:让模型真正跑起来

6.1 模型导出与量化

训练完的模型要部署,第一步是导出。PyTorch 的 checkpoint 直接加载就行,但要上生产得考虑推理效率。我做了两件事:一是把模型转成 ONNX 格式,二是做 int8 量化。ONNX 的好处是跨平台,量化能把模型体积压到原来的四分之一,推理速度提升两三倍。

ONNX 导出用 transformers 的 onnx 工具,几行命令的事。量化用 onnxruntime 的 quantize 工具,动态量化最简单,不需要校准数据。量化后精度损失大概 1% 到 2%,对生成任务影响不大,但速度提升明显。3090 上推理延迟从 80ms 降到 30ms 左右。

6.2 推理服务的搭建

推理服务我用 FastAPI 搭,接口设计成接收 prompt 返回生成结果。关键参数包括 max_length、temperature、top_p,都做成可配置的。服务启动时加载模型到显存,常驻内存,避免每次请求重新加载。

并发处理上,单卡同时跑多个请求会抢显存,我用队列串行处理,或者限制并发数。batch 推理能提升吞吐,但延迟会变高,看场景取舍。我这次是离线任务为主,用 batch 推理,一次处理几十条,吞吐优先。

6.3 领域适配效果的最终验证

部署完拿真实场景的输入测一遍,这是最后一道关。我准备了 100 条真实 query,覆盖领域内常见问题,人工评估回答质量。评估维度包括准确性、完整性、流畅度,每条打分 1 到 5 分。最终平均分 4.1,比基线模型的 2.8 提升明显,说明整条链路是有效的。

验证时还发现一些问题,比如模型对某些长尾问题还是答不好,这跟训练数据覆盖有关。后续可以通过补充数据、增加指令微调样本继续优化。但整体框架已经跑通,剩下的就是迭代数据的事了。

7. 个人开发者的资源账与时间账

7.1 时间成本拆解

整条链路走下来,时间主要花在三块:数据准备、预训练、领域适配。数据准备包括采集、清洗、分词,大概花了三天,其中清洗最耗时。预训练五天,这是大头,跑起来就不用管了,但得盯着别崩。领域适配两天,继续预训练一天,指令微调加评测一天。加上调试和踩坑的时间,总共两周左右。

这个时间账对个人开发者来说是可以接受的。关键是预训练那五天,机器不能停,得保证供电和散热。我用的风冷,3090 满载温度 75 度左右,还行。如果环境温度高,得考虑降频或加水冷。

7.2 成本与收益的权衡

电费是主要成本,3090 满载 350W,五天 120 小时就是 42 度电,按商业电价算几十块钱。加上机器折旧,总成本可控。相比租云 GPU,自己买卡长期看更划算,前提是你有持续的训练需求。

收益方面,走通全流程之后,你对 LLM 的理解会上一个台阶。调 API 遇到问题只能猜,自己训过就知道每个参数在干什么。这种理解带来的调试能力,是花钱买不来的。而且训好的领域模型可以持续迭代,数据越攒越多,效果越来越好。

7.3 后续扩展方向

这套流程跑通后,扩展方向有几个:一是换更大的模型,把 GPT-2 换成 LLaMA 架构,参数从 124M 提到 1B 以上,效果会更好,但显存和时间要重新算;二是拉长序列长度,从 512 提到 2048 或更长,处理长文档能力更强;三是引入 RAG,把外部知识库接进来,弥补模型知识不足。

RAG 这块我最近在试,核心是把领域文档向量化存进向量库,推理时先检索再生成。这样模型不用记住所有知识,只负责组织和表达,效果比纯微调好,而且知识更新方便。向量库用 FAISS 或 Chroma 都行,检索模型用 bge 或 m3e,都是现成的。

8. 一些踩坑之后的真心话

预训练这事,说难不难,说简单也不简单。难在细节多,每个环节都有坑;简单在流程固定,跑通一次后面就是重复。我最大的体会是:别急着上大模型,先把小模型的全流程走通。GPT-2 虽然老,但架构经典,跑通它对理解 Transformer 系模型帮助极大。

另一个体会是数据比模型重要。同样的模型,数据清洗做得好,效果能差出一大截。我见过太多人把精力花在调模型结构上,结果数据一堆噪声,训出来全是胡话。把数据这块做扎实,比换模型架构收益高得多。

最后说显存。3090 的 24G 看着够用,但真训起来还是紧巴巴。我的建议是能开混合精度就开,能用梯度累积就用,能上梯度检查点就上。这些手段组合起来,124M 到 350M 的模型在 3090 上完全能训。再大就得考虑多卡或者云上了。

这套流程我前后跑了三遍,每遍都有新发现。第一遍跑通,第二遍优化,第三遍才敢说真正理解。如果你也在走这条路,别怕慢,把每个环节搞明白,比快速跑完一遍有价值得多。

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

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

立即咨询