1. 为什么个人开发者现在值得认真做一次 LLM 全流程实践
过去两年,大模型相关的讨论几乎被“千亿参数”“万卡集群”“融资新闻”这类词占满,普通开发者很容易产生一种错觉:这东西跟我没关系,我连一张 A100 都摸不到。但真实情况恰恰相反——个人开发者做 LLM 全流程实践的最佳窗口期,就是现在。原因有三点,我一条条说。
第一,开源权重和工具链已经足够成熟。GPT-2、GPT-Neo、Pythia、Qwen 小尺寸系列、Llama 系的小参数版本,都能在单卡消费级显卡上跑起来。你不需要从零发明 Transformer,只需要理解它的数据流,然后动手改。第二,领域适配的性价比极高。一个 1B 到 7B 的基座模型,经过几千到几万条高质量领域数据的继续预训练加指令微调,在垂直任务上完全能打过一个通用大模型,而且推理成本低到可以自己扛。第三,硬件门槛被大幅拉低。一张 RTX 3090 的 24GB 显存,配合混合精度、梯度检查点、LoRA 这些技术,足以完成一次完整的“预训练小模型 + 领域适配”闭环。
这篇内容就是围绕这条链路展开的:从预训练的基本原理和最小可行实现,到领域适配的数据构造、训练策略、评测方法,再到 RTX 3090 上的显存与速度调优。我假设你有一点 Python 和 PyTorch 基础,但没真正训过语言模型。全文会给出可以直接抄的配置、参数计算过程和踩坑记录,目标是你读完能自己跑一遍,而不是只收藏。
提示:本文所有实践均基于公开可获取的开源模型与数据集,训练过程完全在本地单卡环境完成,不涉及任何需要特殊网络条件或敏感用途的内容。
2. 先把概念理清楚:预训练、微调、领域适配到底在做什么
2.1 从 GPT-2 说起:一个“预测下一个词”的模型为什么能干活
GPT-2 的结构其实不复杂:一堆 Transformer Decoder Block 堆叠,每个 Block 里有自注意力(Self-Attention)和前馈网络(FFN),加上残差连接和 LayerNorm。它的训练目标只有一个——自回归语言建模,也就是给定前面的 token,预测下一个 token 的概率分布。损失函数就是交叉熵。
很多人第一次看会觉得“就这?预测下一个词能有什么用?”关键在于,当语料足够大、模型足够深时,为了把下一个词预测准,模型被迫学会了语法、事实关联、推理模式甚至一些世界知识。这就是所谓的涌现能力的朴素解释。GPT-2 有 124M、355M、774M、1.5B 几个尺寸,个人实践建议从 124M 起步,跑通全流程后再上 355M 或更大。
这里必须解释一个高频概念:LLM 的 token 三个点——key、query、value。在自注意力里,每个 token 的向量会被三个不同的线性层投影成 Query(我在找什么)、Key(我是谁)、Value(我能提供什么)。然后用 Query 和所有 Key 做点积算相似度,softmax 归一化后作为权重,对 Value 加权求和。你可以把它理解成一次“信息检索”:当前词发出一个查询,去历史里找最相关的信息,然后把这些信息聚合回来。理解这一点,后面调模型结构、改注意力掩码时就不会懵。
2.2 预训练和领域适配的分工:别把两件事混为一谈
预训练是从随机初始化开始,用海量通用语料训练出一个“什么都懂一点”的基座。领域适配是在这个基座之上,用特定领域的数据继续训练,让它“更懂某一行的黑话和逻辑”。领域适配又分两种:
- 继续预训练(Continual Pre-training, CPT):还是用语言建模目标,但语料换成领域文本,比如医学论文、法律条文、工业日志。目的是让模型吸收领域词汇和表达习惯。
- 指令微调(Supervised Fine-Tuning, SFT):用“指令-回答”配对数据训练,让模型学会按人类期望的格式输出。目的是对齐行为,而不是灌知识。
个人开发者最容易犯的错,是拿几千条问答数据直接做 SFT,然后抱怨模型“不懂领域知识”。正确的顺序是:先 CPT 灌知识,再 SFT 调行为。如果数据量实在少,可以只做 SFT,但效果上限会明显低一截。
2.3 为什么选 GPT-2 而不是直接上大模型做实验
从工程学习角度,GPT-2 有几个不可替代的优势:结构经典、代码实现多、社区教程全、单卡能训、训练过程可观测。你在 GPT-2 上踩过的每一个坑——loss 不下降、梯度爆炸、显存溢出、过拟合——在更大的模型上都会以同样的形式出现,只是规模不同。先用小模型把流程和直觉建立起来,再迁移到大模型,是成本最低的路径。RTX 3090 的 24GB 显存,跑 124M 模型的全参数预训练绰绰有余,跑 355M 也够用,这就是它的价值。
3. 环境搭建与 RTX 3090 显存预算的硬核算账
3.1 软件栈选择:PyTorch + Transformers + Accelerate 的最小组合
我的建议是不要一上来就上 DeepSpeed 或 Megatron,那些是给多卡大规模训练用的,单卡场景反而增加调试成本。最小可用组合是:
# 建议 Python 3.10,CUDA 12.1 对应的 PyTorch pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 pip install transformers datasets accelerate evaluate pip install bitsandbytes # 需要 8bit 优化器或量化时用 pip install wandb # 可选,但强烈建议,训练曲线可视化太重要了版本上,Transformers 建议 4.40 以上,因为新版的Trainer对梯度检查点、混合精度的支持更顺。accelerate用来做设备管理和混合精度,比手写autocast省心。
3.2 显存到底花在哪:一次完整的显存预算计算
很多人显存爆了不知道为什么,其实显存占用可以拆成四块:模型参数、梯度、优化器状态、激活值。以 GPT-2 124M(约 1.24 亿参数)为例,做全参数训练:
| 项目 | 精度 | 占用计算 | 显存 |
|---|---|---|---|
| 模型参数 | FP32 | 124M × 4 字节 | 约 496 MB |
| 梯度 | FP32 | 124M × 4 字节 | 约 496 MB |
| Adam 优化器状态 | FP32 | 124M × 8 字节(一阶+二阶动量) | 约 992 MB |
| 激活值 | FP16 | 与 batch、序列长度相关 | 约 2-6 GB |
| 合计 | 约 4-8 GB |
看起来 24GB 很宽裕对吧?但如果你把序列长度拉到 1024、batch size 开到 16,激活值会迅速膨胀。激活值是显存杀手,因为它和层数、batch、序列长度都成正比。解决办法就是梯度检查点(Gradient Checkpointing):用计算换显存,把中间激活值丢掉,反向传播时重算。开启后激活值能降 60% 以上,代价是训练速度慢约 20%-30%。
3.3 混合精度与优化器选择:省显存又不掉精度的组合拳
混合精度(AMP)的核心是:前向和反向用 FP16/BF16 算,参数更新用 FP32 存。这样显存和带宽都省,速度还快。RTX 3090 支持 BF16 吗?严格说 3090 是 Ampere 架构,BF16 支持不完整,实测用 FP16 + GradScaler 更稳。配置如下:
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() with autocast(dtype=torch.float16): outputs = model(**batch) loss = outputs.loss scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()优化器方面,Adam 显存占用大,可以用AdamW + 8bit(bitsandbytes 提供),优化器状态从 8 字节/参数降到 2 字节/参数,124M 模型能省约 750MB。或者用Adafactor,它把二阶动量做低秩分解,显存更省,但收敛有时不如 AdamW 稳。我的经验是:小模型用 AdamW 8bit,大模型或显存紧张用 Adafactor。
注意:开启梯度检查点后,模型里所有
requires_grad的中间变量都会被重算,所以如果你的模型有 dropout 或随机性操作,要确保训练模式正确,否则重算结果和第一次不一致会导致梯度错误。
4. 预训练实操:从零训一个 GPT-2 级别的小模型
4.1 数据准备:语料清洗、分词与打包成训练样本
预训练数据决定了模型的上限。个人实践不建议一上来就爬全网,先用公开数据集,比如中文可以用维基百科 dump、开源书籍语料,英文可以用 OpenWebText 的子集。数据清洗至少做三件事:去重(用 MinHash 或简单的哈希去重)、去乱码(过滤非目标语言字符比例过高的行)、去超短行(少于 20 个字符的句子通常没价值)。
分词用 GPT-2 的 BPE tokenizer 即可,中文场景可以换成中文 BPE 或 SentencePiece。关键步骤是把长文本拼成固定长度的序列:
from transformers import GPT2Tokenizer from datasets import load_dataset tokenizer = GPT2Tokenizer.from_pretrained("gpt2") tokenizer.pad_token = tokenizer.eos_token def tokenize_and_pack(examples): # 把所有文本拼起来,再按 block_size 切块 concatenated = "".join(examples["text"]) tokens = tokenizer(concatenated, return_tensors="pt").input_ids[0] # 丢弃尾部不足一块的部分 total = (len(tokens) // block_size) * block_size tokens = tokens[:total].view(-1, block_size) return {"input_ids": tokens.tolist(), "labels": tokens.tolist()} block_size = 512 dataset = load_dataset("wikitext", "wikitext-103-raw-v1", split="train") dataset = dataset.map(tokenize_and_pack, batched=True, batch_size=1000, remove_columns=["text"])这里labels和input_ids相同,因为语言建模的标签就是输入右移一位,HuggingFace 的模型内部会自动做 shift。block_size选 512 是 3090 上的平衡点,1024 会明显吃显存。
4.2 模型配置:124M 参数的具体结构参数
GPT-2 small 的配置是:12 层、12 个注意力头、隐藏维度 768、FFN 中间维度 3072、最大位置编码 1024、词表 50257。用 Transformers 直接:
from transformers import GPT2Config, GPT2LMHeadModel config = GPT2Config( vocab_size=50257, n_positions=1024, n_embd=768, n_layer=12, n_head=12, resid_pdrop=0.1, embd_pdrop=0.1, attn_pdrop=0.1, ) model = GPT2LMHeadModel(config) print(sum(p.numel() for p in model.parameters()) / 1e6) # 约 124M如果你想训更小的做快速实验,把n_layer降到 6、n_embd降到 512,参数量约 40M,单卡几分钟就能跑一个 epoch,非常适合调流程。
4.3 训练循环与关键超参:学习率、warmup、batch 的取舍
预训练的超参比微调更敏感。我的经验配置:
- 学习率:1e-4 到 3e-4,小模型可以高一点,大模型要低。
- Warmup:总步数的 1%-5%,防止初期梯度爆炸。
- Batch size:受显存限制,用梯度累积模拟大 batch。比如实际 batch 4,累积 8 次,等效 batch 32。
- 权重衰减:0.01,只对权重矩阵生效,LayerNorm 和 bias 不衰减。
- 梯度裁剪:1.0,必开,语言模型梯度偶尔会飙。
from transformers import TrainingArguments, Trainer args = TrainingArguments( output_dir="./gpt2-pretrain", per_device_train_batch_size=4, gradient_accumulation_steps=8, learning_rate=2e-4, warmup_steps=500, max_steps=50000, lr_scheduler_type="cosine", weight_decay=0.01, max_grad_norm=1.0, fp16=True, gradient_checkpointing=True, logging_steps=50, save_steps=2000, report_to="wandb", ) trainer = Trainer(model=model, args=args, train_dataset=dataset) trainer.train()gradient_checkpointing=True在 Trainer 里会自动处理,但要注意它会和use_cache冲突,训练时模型会自动关掉 cache,这是正常的。
4.4 训练过程监控:loss 曲线怎么看、什么时候该停
预训练的 loss 曲线应该是平滑下降的。如果出现以下情况,说明有问题:
- loss 突然飙升:多半是学习率太大或数据里有异常样本,检查梯度范数。
- loss 长期不降:学习率太小、数据质量差、或者模型初始化有问题。
- 训练 loss 降但验证 loss 升:过拟合,小模型在重复数据上很容易这样,需要加数据或加 dropout。
我一般会同时看训练 loss 和验证 loss,验证集用留出的 1% 数据。当验证 loss 连续几个 epoch 不降时就可以停。124M 模型在 wikitext-103 上,跑 5 万步左右验证 loss 能到 3.2 附近,再往下收益递减。
实操心得:预训练最耗时的不是训练本身,而是数据准备和调试。我第一次跑的时候因为没做去重,模型把某段文本背下来了,验证 loss 虚低,后来加了 MinHash 去重才正常。数据质量永远比模型大小重要。
5. 领域适配:让基座模型真正懂你的行业
5.1 领域数据构造:从原始文本到 CPT 语料的三步法
领域适配的第一步是搞到领域文本。来源可以是公开论文、行业报告、技术文档、论坛问答。拿到原始文本后,做三步:
- 格式统一:全部转成纯文本,去掉 HTML 标签、页眉页脚、参考文献编号。
- 领域过滤:用关键词或分类器筛掉不相关的内容。比如做医疗领域,就保留含医学术语的段落。
- 质量分级:把文本按来源可信度分级,高质量数据可以重复采样,低质量数据只过一遍。
CPT 的数据格式和预训练一样,还是拼成固定长度序列。数据量上,个人实践建议至少 100MB 到 1GB 的领域文本,太少模型学不到东西,太多训练时间扛不住。
5.2 继续预训练 vs 指令微调:数据量、目标、效果差异
| 维度 | 继续预训练 CPT | 指令微调 SFT |
|---|---|---|
| 数据形式 | 纯文本 | 指令-回答配对 |
| 数据量 | 大(MB-GB 级) | 小(千-万条) |
| 训练目标 | 语言建模 | 条件生成 |
| 主要作用 | 灌领域知识 | 对齐输出行为 |
| 学习率 | 1e-5 到 5e-5 | 1e-5 到 2e-5 |
| 训练轮数 | 1-3 epoch | 2-5 epoch |
我的建议是:如果领域文本充足,先做 CPT,再做 SFT;如果只有问答数据,直接 SFT,但要在 prompt 里尽量多塞领域背景知识。
5.3 LoRA 与全参数微调:显存、效果、适用场景对比
全参数微调更新所有参数,效果上限高,但显存占用大,124M 模型全参微调约需 6-8GB,355M 约需 12-16GB。LoRA只训练低秩旁路矩阵,显存占用极低,124M 模型 LoRA 微调 2GB 就够,而且可以多个 LoRA 权重切换。
from peft import LoraConfig, get_peft_model lora_config = LoraConfig( r=8, lora_alpha=32, target_modules=["c_attn"], # GPT-2 的注意力投影层 lora_dropout=0.1, bias="none", task_type="CAUSAL_LM", ) model = get_peft_model(model, lora_config) model.print_trainable_parameters() # 通常只有原参数的 0.5%-2%LoRA 的r是秩,越大容量越强但参数越多,8 到 32 是常用范围。lora_alpha一般设为r的 2-4 倍。实测下来,在领域问答任务上,LoRA 微调能达到全参微调 90% 以上的效果,但显存和时间省一大半。个人开发者优先用 LoRA。
5.4 领域适配的评测:别只看 loss,要看任务指标
领域适配后,光看 loss 不够,要设计任务级评测。比如:
- 领域问答:准备 100 条领域问题,人工或模型打分。
- 术语理解:让模型解释领域术语,看是否准确。
- 生成质量:让模型写一段领域文本,评估流畅度和专业性。
我一般会做一个简单的 A/B 对比:基座模型 vs 适配后模型,在同一批 prompt 上生成,人工盲评。这个流程虽然土,但比任何自动指标都可靠。
6. 常见问题与排查技巧实录
6.1 显存溢出(OOM)的六种排查路径
OOM 是单卡训练最常见的报错。按以下顺序排查:
- 降 batch size:最直接,先降到 1 看还爆不爆。
- 开梯度检查点:激活值降 60% 以上。
- 缩短序列长度:从 1024 降到 512,激活值减半。
- 换 8bit 优化器:优化器状态省 75%。
- 用 LoRA:可训练参数降到 1% 以下。
- 检查是否有内存泄漏:比如在循环里不断累积 tensor,用
torch.cuda.empty_cache()辅助。
6.2 loss 不下降或震荡的五个原因
- 学习率太大:loss 震荡或飙升,降 10 倍试试。
- warmup 太短:初期梯度爆炸,加长 warmup。
- 数据有问题:标签错位、空样本、重复样本。
- 梯度裁剪没开:语言模型必开。
- 模型初始化异常:检查是否加载了预训练权重。
6.3 生成结果重复、胡言乱语的调参方案
生成阶段的问题多半是解码策略导致:
| 现象 | 原因 | 调整 |
|---|---|---|
| 重复输出 | 贪心解码陷入循环 | 用 top-k 或 top-p 采样 |
| 胡言乱语 | 温度太高 | 降 temperature 到 0.7-0.9 |
| 太保守 | 温度太低 | 升 temperature 或加 top-p |
| 输出太短 | eos 概率高 | 加 repetition_penalty |
outputs = model.generate( input_ids, max_new_tokens=200, do_sample=True, top_k=50, top_p=0.95, temperature=0.8, repetition_penalty=1.1, )6.4 训练速度慢的优化清单
- 开
fp16或bf16。 - 用
DataLoader的num_workers预取数据。 - 把数据预处理结果缓存到磁盘,避免每次重新 tokenize。
- 用
torch.compile(PyTorch 2.0+)能提速 10%-30%。 - 减少 logging 和 checkpoint 频率。
踩坑记录:我曾经因为
save_steps设成 100,每 100 步存一次模型,结果 IO 把训练速度拖慢了一半。后来改成 2000 步存一次,速度立刻正常。checkpoint 不是越勤越好。
7. 从实践到扩展:个人开发者还能往哪走
跑通预训练加领域适配这条链路后,你会发现很多方向可以延伸。比如把训练好的模型用 ONNX 导出,做本地推理部署;或者接入 RAG 架构,用检索增强弥补模型知识不足;再或者尝试 GraphRAG 这类结合知识图谱的方案,让模型在专业问答上更可靠。这些都不是必须的,但每一个都能让你的项目从“能跑”变成“好用”。
我个人在实际操作中的体会是,个人开发者做 LLM 最大的优势不是算力,而是对垂直场景的理解深度。大厂做通用模型,你做某个细分领域的专家模型,用几千条高质量数据就能做出差异化。RTX 3090 这张卡在今天看不算新,但它足以支撑你完成从数据到模型到评测的完整闭环。真正稀缺的从来不是显卡,而是愿意把流程走完一遍的人。