ARTICLE DETAIL

资讯详情

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

单卡RTX 3090实战:从零预训练到领域适配的LLM全流程

单卡RTX 3090实战:从零预训练到领域适配的LLM全流程 个人开发者想完整跑一遍 LLM 从预训练到领域适配的全流程最大的障碍从来不是算法本身而是算力预算和工程细节的失控。我用一张 RTX 309024GB 显存从零训练了一个小型 GPT-2 级别的模型再把它适配到垂直领域任务上整个过程踩了不少坑也总结出一套在单卡环境下真正跑得通的方案。这篇文章适合手里只有消费级显卡、但想彻底搞明白大模型训练每一个环节的独立开发者也适合那些调过 API 但没碰过预训练底层、想补齐认知短板的工程师。我会把数据准备、分词器训练、预训练配置、领域适配策略、显存优化、效果评估这些环节全部拆开讲每个参数为什么这么设、哪些地方最容易翻车都会说清楚。1. 先想清楚个人开发者为什么要走完预训练这一遍很多人会问现在开源模型这么多直接拿现成的做微调不就行了为什么还要自己预训练这个问题我认真想过答案分两层。第一层是认知层面。你只有亲手跑过一次预训练才会真正理解 loss 曲线为什么会在某个点突然下降、学习率 warmup 为什么不能省、batch size 和梯度累积之间到底是什么关系。这些知识看论文和博客只能获得表面理解只有自己盯着终端里滚动的训练日志看到 loss 从 10.8 慢慢降到 3.5 再进入平台期你才会对模型到底学到了什么有体感。这种体感在你后面做领域适配、调 prompt、设计 RAG 系统的时候会持续发挥作用。第二层是工程层面。预训练流程跑通之后你手里就有了一套完整的数据处理管线、训练脚本、评估工具。这套东西是可以复用的。今天你用 GPT-2 架构在通用语料上预训练明天换一个领域语料继续做继续预训练continue pre-training后天在这个基础上做指令微调整条链路是打通的。如果只会调 API你永远停留在应用层遇到需要深度定制的场景就束手无策。但必须说清楚现实约束。个人开发者做预训练规模上必须做取舍。GPT-3 那种 1750 亿参数的模型训练一次的成本是几百万美元级别这不是个人能碰的。合理的定位是训练一个 1 亿到 5 亿参数级别的小模型用几十 GB 到几百 GB 的语料在单卡或双卡上跑几天到几周。这个规模的模型虽然通用能力有限但在特定领域任务上经过适配后完全可以达到可用水平。我这次的目标很明确用 RTX 3090 训练一个约 1.24 亿参数的 GPT-2 架构模型就是 GPT-2 small 的规模先在通用中文语料上完成预训练再通过领域继续预训练让它适配到技术文档问答场景。选 GPT-2 架构而不是 LLaMA 架构原因很实际——GPT-2 的结构简单、训练稳定、显存占用可控适合作为个人开发者的第一个预训练项目。等你把这套流程跑熟了再迁移到更现代的架构上会顺利得多。提示不要一上来就挑战 7B 参数的模型。单卡 24GB 显存训练 7B 模型即使做了量化、梯度检查点、LoRA预训练阶段依然极其吃力而且训练周期会长到让你失去耐心。从 100M 级别起步跑通全流程是最务实的路径。2. 数据管线预训练成败的八成在这里2.1 语料来源与清洗策略预训练的数据质量直接决定模型质量这一点怎么强调都不过分。我见过太多人把精力全花在调模型结构上结果语料里全是乱码、重复段落和 HTML 标签训练出来的模型生成的东西狗屁不通。我的语料来源分三块公开的中文维基百科 dump、技术类开源书籍和文档、以及一部分经过筛选的新闻语料。总量控制在 30GB 左右纯文本。为什么是这个量级因为 GPT-2 small 级别的模型按照 Chinchilla 缩放定律的粗略指导参数量和训练 token 数的合理比例大约在 1:20 左右。1.24 亿参数对应大约 25 亿 token中文平均一个 token 约 1.5 个字符算下来大约 37 亿字符压缩成 UTF-8 文本大概 10-15GB。我准备 30GB 原始语料清洗后留下 15GB 左右留出余量。清洗流程我写了一个 Python 脚本核心步骤包括去除 HTML 标签和 JavaScript 代码、过滤长度少于 50 字符的段落、用 SimHash 做去重阈值设 0.85、过滤包含大量特殊符号的行、统一全角半角标点。这里有个容易忽略的点——中文语料里的繁体简体混杂问题。如果不做统一分词器会把国和國当成完全不同的 token浪费词表容量。我用 OpenCC 做了统一转换。import re from opencc import OpenCC from simhash import Simhash cc OpenCC(t2s) # 繁体转简体 def clean_text(text): text re.sub(r[^], , text) # 去HTML标签 text re.sub(r\{[^}]*\}, , text) # 去模板残留 text cc.convert(text) # 繁转简 text re.sub(r\s, , text) # 合并空白 return text.strip() def is_duplicate(text, seen_hashes, threshold3): h Simhash(text) for existing in seen_hashes: if h.distance(existing) threshold: return True seen_hashes.append(h) return False去重这一步特别关键。网络爬来的语料里同一篇文章被转载几十次的情况非常普遍。如果不做去重模型会在这些重复内容上反复训练导致过拟合到特定表达方式生成时翻来覆去就是那几句话。SimHash 的阈值我试过 2、3、5 几个值最后定在 3既能去掉明显重复又不会误杀相似但不同的技术文档。2.2 分词器训练为什么不用现成的GPT-2 原版的分词器是在英文语料上训练的词表 50257 个 token中文基本被拆成单字或字节。直接拿来用中文训练序列长度会膨胀得很厉害同样的内容 token 数可能是英文的两三倍白白浪费算力。我的做法是重新训练一个中文分词器。用 HuggingFace 的tokenizers库基于 BPE 算法词表大小设 32000。为什么是 32000 而不是 50000因为词表越大embedding 层参数越多而我的模型规模有限词表太大会让 embedding 占比过高挤压 Transformer 层的容量。32000 是一个在中文场景下比较平衡的值常见汉字、高频词、标点都能覆盖生僻字用字节回退处理。from tokenizers import Tokenizer, models, trainers, pre_tokenizers tokenizer Tokenizer(models.BPE(unk_token[UNK])) tokenizer.pre_tokenizer pre_tokenizers.ByteLevel(add_prefix_spaceFalse) trainer trainers.BpeTrainer( vocab_size32000, special_tokens[[PAD], [UNK], [BOS], [EOS]], min_frequency2 ) tokenizer.train(files[corpus_clean.txt], trainertrainer) tokenizer.save(tokenizer.json)训练分词器的时候有个细节要注意min_frequency设成 2 意味着出现次数少于 2 的子词不会被纳入词表。如果你的语料里有大量专业术语这个值设太高会导致术语被拆碎。我一开始设成 5结果梯度下降被拆成了梯度下降后来改成 2 就正常了。分词器训练完之后一定要做一件事统计压缩率。压缩率 原始字符数 / token 数。中文场景下好的分词器压缩率应该在 1.5 到 2.0 之间。如果低于 1.3说明词表没学好需要检查语料或者调整词表大小。我的分词器最终压缩率是 1.68算是合格。2.3 数据打包与内存映射30GB 语料不可能一次性读进内存。我的做法是把所有文本 tokenize 之后拼接成一条超长的 token 序列然后按固定长度我设的是 1024切分成训练样本存成二进制文件。训练时用numpy.memmap做内存映射操作系统会自动管理哪些数据在内存、哪些在磁盘不需要手动分批加载。import numpy as np def pack_dataset(tokenizer, file_path, seq_len1024): all_tokens [] with open(file_path, r, encodingutf-8) as f: for line in f: ids tokenizer.encode(line).ids all_tokens.extend(ids) all_tokens.append(tokenizer.token_to_id([EOS])) total len(all_tokens) n_samples total // seq_len arr np.array(all_tokens[:n_samples * seq_len], dtypenp.uint16) arr arr.reshape(n_samples, seq_len) arr.tofile(train.bin) return n_samples用uint16存储是因为词表 32000 小于 65536两个字节足够。这比用int64省了四分之三的磁盘空间和读取带宽。别小看这个优化训练时数据加载速度直接影响 GPU 利用率如果数据加载跟不上GPU 就会频繁空转等数据。3. 预训练配置每个参数背后的取舍3.1 模型结构参数怎么定GPT-2 small 的原始配置是 12 层 Transformer、768 维隐藏层、12 个注意力头、参数量约 1.24 亿。我基本沿用了这个配置只改了两处词表大小从 50257 改成 32000最大序列长度从 1024 保持 1024。为什么保持 1024 的序列长度因为更长的序列意味着注意力矩阵是平方级增长。1024 长度的注意力矩阵是 1024×1024显存占用还能接受如果拉到 2048注意力部分的显存直接翻四倍24GB 显存就吃不消了。对于个人开发者1024 是一个在效果和显存之间比较平衡的选择。如果你的任务确实需要长上下文可以考虑用滑动窗口注意力或者 ALiBi 位置编码但那是进阶话题第一次做预训练不建议碰。层数和隐藏维度的关系也值得说一下。12 层配 768 维是 GPT-2 验证过的配置。如果你把隐藏维度加到 1024 但层数还是 12参数量会增加但深度不够模型表达能力提升有限反过来层数加到 24 但维度保持 768训练会更难收敛。个人开发者最好直接沿用经过验证的配置不要在这个阶段做结构创新。from transformers import GPT2Config config GPT2Config( vocab_size32000, n_positions1024, n_embd768, n_layer12, n_head12, resid_pdrop0.1, embd_pdrop0.1, attn_pdrop0.1, activation_functiongelu_new )dropout 我设的是 0.1这是标准值。有人做预训练时把 dropout 设成 0觉得数据量大不需要正则化。但个人开发者的语料量远达不到大到不需要正则化的程度保留 0.1 的 dropout 能有效防止过拟合。我试过 0.0 和 0.1 的对比0.0 的验证集 loss 在后期明显高于训练集 loss过拟合迹象很清楚。3.2 训练超参数学习率、batch size 与梯度累积学习率是预训练里最敏感的参数。我用的是 AdamW 优化器峰值学习率 6e-4配合 cosine 衰减和 warmup。为什么是 6e-4GPT-2 原论文用的 2.5e-4但那是基于更大的 batch size512。我的实际 batch size 只有 8梯度累积 16 步等效 batch size 128。小 batch size 下学习率可以适当放大6e-4 是我试了几个值之后收敛最稳的。warmup 步数设的是总步数的 1%大约 500 步。warmup 的作用是让模型在训练初期不要因为随机初始化的大梯度而震荡。我试过不设 warmup前几百步 loss 会剧烈波动甚至出现 NaN。这个坑一定要避开。from transformers import Trainer, TrainingArguments training_args TrainingArguments( output_dir./gpt2-pretrain, per_device_train_batch_size8, gradient_accumulation_steps16, learning_rate6e-4, warmup_steps500, max_steps50000, lr_scheduler_typecosine, weight_decay0.1, fp16True, logging_steps50, save_steps2000, gradient_checkpointingTrue, optimadamw_torch )gradient_checkpointingTrue是显存优化的关键。它用计算换显存在前向传播时不保存中间激活值反向传播时重新计算。这能让显存占用降低大约 40%代价是训练速度慢 20% 左右。在 24GB 显存上这个交换非常值得。fp16True开启混合精度训练进一步降低显存占用并加速计算。但要注意纯 fp16 训练容易出现梯度下溢所以实际用的是 AMP自动混合精度关键计算用 fp32其余用 fp16。HuggingFace 的 Trainer 默认就是这么处理的。3.3 显存实测与瓶颈定位在 RTX 3090 上这套配置的实际显存占用是这样的组件显存占用模型参数fp16约 250MB优化器状态AdamWfp32约 1GB梯度fp16约 250MB激活值开启梯度检查点约 6-8GB注意力矩阵临时缓冲约 2-3GB合计约 10-12GB可以看到24GB 显存其实还有余量。这意味着你可以把 batch size 从 8 提到 12 甚至 16或者把序列长度从 1024 提到 1536。我最终选择保持 batch size 8、序列长度 1024因为这样训练速度最快GPU 利用率能稳定在 95% 以上。如果把 batch size 提到 16显存占用会到 18GB 左右虽然能跑但一旦有显存碎片就容易 OOM。训练速度方面50000 步、每步处理 8×1024 个 token总共约 4 亿 token。在 3090 上大约需要 60 小时也就是两天半。loss 从初始的 10.8 降到 3.5 左右进入平台期。这个 loss 值对应的模型生成中文已经基本通顺但逻辑性和知识准确性还比较弱——这很正常1.24 亿参数的模型容量有限。注意训练过程中要盯着 loss 曲线。如果 loss 突然飙升大概率是学习率太高或者数据里有异常样本。如果 loss 长时间不下降检查一下数据加载是否正常、学习率是否设得太低。我遇到过 loss 卡在 7.0 不动的情况排查后发现是数据打包时 tokenizer 用错了版本导致 token id 全部越界。4. 领域适配让通用模型变成领域专家4.1 继续预训练 vs 指令微调的选择预训练完成后模型是一个什么都懂一点但什么都不精的状态。要让它适配到技术文档问答场景有两条路继续预训练continue pre-training和指令微调instruction tuning。继续预训练是在领域语料上接着做自回归语言建模目标还是预测下一个 token。它能让模型学习领域的词汇分布、表达习惯和知识。指令微调则是用问题-答案格式的数据教模型学会按照指令回答问题。我的做法是两步走先做领域继续预训练再做指令微调。为什么不能跳过第一步因为如果直接做指令微调模型对领域术语的理解还是空白的微调效果会大打折扣。继续预训练相当于给模型补课让它先熟悉领域语言再学怎么回答问题。领域语料我准备了大约 2GB 的技术文档包括 API 文档、技术博客、开源项目 README、Stack Overflow 的技术问答。这些数据的共同特点是术语密集、句式规范、逻辑清晰。继续预训练的学习率要比初始预训练低一个数量级我用的是 5e-5训练 3 个 epoch。学习率太高会破坏预训练阶段学到的通用知识这就是所谓的灾难性遗忘。4.2 指令数据的构造与格式设计指令微调的数据质量比数量重要得多。我手工构造了大约 5000 条指令-输入-输出三元组覆盖问答、解释、代码生成、总结等几类任务。格式上我用了 Alpaca 风格### Instruction: 解释什么是梯度检查点以及它为什么能节省显存。 ### Input: 无 ### Output: 梯度检查点是一种用计算换显存的技术。在标准反向传播中前向传播的所有中间激活值都需要保存下来供反向传播使用这占用了大量显存。梯度检查点的做法是前向传播时只保存部分检查点的激活值反向传播到某一层时从最近的检查点重新计算该层的激活值。这样显存占用从 O(n) 降到 O(sqrt(n))代价是增加一次前向计算训练速度大约慢 20%。构造指令数据有几个经验。第一输出要详细不要只给一句话答案。模型是通过模仿输出来学习的如果输出太简短模型学会的就是敷衍。第二要包含我不知道的样本。如果所有样本都是模型能回答的它会学会胡编乱造。我特意加了 200 条答案是根据现有信息无法确定的样本。第三格式要统一。### Instruction:、### Input:、### Output:这些标记必须严格一致否则模型学不会正确的输出格式。指令微调的学习率用 2e-5训练 3 个 epochbatch size 16。这个阶段很容易过拟合因为数据量小。我的做法是每个 epoch 结束后在验证集上评估发现验证 loss 开始上升就停。实际训练中第 2 个 epoch 结束后验证 loss 最低第 3 个 epoch 就过拟合了。4.3 LoRA 在领域适配中的实际效果做领域适配时我对比了全参数微调和 LoRA 两种方案。全参数微调更新所有 1.24 亿参数LoRA 只训练低秩分解矩阵参数量只有原来的 1% 左右。方案可训练参数显存占用训练速度领域任务准确率全参数微调1.24 亿约 14GB1x78%LoRA (r8)约 120 万约 6GB1.8x74%LoRA (r16)约 240 万约 7GB1.6x76%LoRA 的准确率略低但显存占用和训练速度优势明显。对于个人开发者如果显存紧张或者需要快速迭代多个领域版本LoRA 是更实用的选择。如果追求极致效果且显存充足全参数微调更好。我最终在生产版本用的是全参数微调因为 4 个百分点的准确率差距在问答场景下体感很明显。LoRA 的秩 r 选择也有讲究。r8 是最常用的值r16 能捕捉更复杂的模式但参数更多。我试过 r4效果下降明显r32 相比 r16 提升很小但训练变慢。r8 到 r16 是性价比最高的区间。5. 评估与迭代怎么知道模型到底行不行5.1 困惑度之外的评估维度困惑度perplexity是预训练阶段最常用的指标它衡量模型对文本的预测能力。我的模型在验证集上的困惑度最终是 18.7意味着模型对下一个 token 的预测平均有 18.7 种等可能的选择。这个值在 1 亿参数级别的中文模型里属于正常水平。但困惑度低不代表模型好用。我遇到过困惑度很漂亮但生成内容空洞的情况。所以领域适配之后我加了几个人工评估维度事实准确性生成的技术描述是否正确、格式遵循度是否按照指令格式输出、回答完整性是否覆盖了问题的关键点、幻觉率编造不存在的事实的比例。人工评估我找了 3 个同事每人评 100 条测试样本按 1-5 分打分。最终平均分是 3.8 分主要失分点在事实准确性和幻觉率上。这个结果符合预期——1.24 亿参数的模型知识容量有限不可能记住所有技术细节。5.2 典型失败案例与修复思路失败案例一模型把梯度检查点和梯度累积搞混了。生成的内容里把两种技术的描述混在一起。原因是训练数据里这两个概念经常一起出现模型没有区分开。修复方法是在指令数据里增加专门对比这两个概念的样本明确区分它们的定义和用途。失败案例二模型对超出训练语料范围的新技术一无所知但不会说不知道而是编造答案。这是幻觉问题的典型表现。修复方法是在指令数据里增加我不知道的样本并且在推理时加一个置信度阈值——如果模型输出的 token 概率普遍偏低就触发无法确定的回复。失败案例三模型生成的回答过于笼统缺乏具体细节。比如问怎么优化显存它回答可以使用一些优化技术。原因是训练数据里的回答不够具体。修复方法是重新构造指令数据要求每个回答都包含具体的参数值、代码示例或操作步骤。5.3 推理部署的显存与速度优化训练完之后要把模型部署起来做推理。1.24 亿参数的模型fp16 精度下权重占用约 250MB推理时加上 KV Cache 和中间激活值总显存占用约 1.5GB。这个规模在 3090 上可以轻松跑甚至可以在更小的显卡上运行。推理速度方面我用 ONNX Runtime 做了导出和优化。相比 PyTorch 原生推理ONNX Runtime 在 CPU 上快 2-3 倍在 GPU 上也有 20%-30% 的提升。导出时要注意把 KV Cache 作为输入输出显式声明否则每次生成都要重新计算整个序列的注意力速度会慢很多。import onnxruntime as ort from transformers import GPT2LMHeadModel, GPT2Tokenizer # 导出为ONNX model GPT2LMHeadModel.from_pretrained(./gpt2-domain-tuned) tokenizer GPT2Tokenizer.from_pretrained(./tokenizer) dummy_input tokenizer(测试输入, return_tensorspt) torch.onnx.export( model, (dummy_input[input_ids],), model.onnx, input_names[input_ids], output_names[logits], dynamic_axes{input_ids: {0: batch, 1: sequence}}, opset_version14 ) # 推理 session ort.InferenceSession(model.onnx, providers[CUDAExecutionProvider])批量推理时把多个请求拼成一个 batch 能显著提升吞吐量。但要注意 padding 的问题——不同请求的长度不同padding 到相同长度会浪费计算。我的做法是按长度分桶把长度相近的请求放在同一个 batch 里减少 padding 浪费。6. 单卡训练的显存与时间账本6.1 各阶段资源消耗实测把整个流程的资源消耗整理成一张表方便你规划自己的项目阶段数据量训练步数/轮数显存峰值耗时RTX 3090分词器训练30GB 文本-约 4GB约 40 分钟数据打包30GB 文本-约 8GB约 2 小时初始预训练15GB token50000 步约 12GB约 60 小时领域继续预训练2GB token3 epoch约 10GB约 6 小时指令微调5000 条3 epoch约 14GB约 1.5 小时ONNX 导出与推理测试--约 2GB约 20 分钟总计大约 70 小时的 GPU 时间。如果按云 GPU 每小时 2 元算成本约 140 元。如果用自己的 3090电费大概几十块钱。这个成本对个人开发者来说完全可以接受。6.2 时间优化的几个实用技巧第一个技巧是数据加载用多进程。PyTorch 的 DataLoader 设置num_workers4或8让 CPU 并行准备数据避免 GPU 等数据。我一开始没设这个GPU 利用率只有 60% 左右设了之后稳定在 95%。第二个技巧是梯度累积和 batch size 的平衡。等效 batch size per_device_batch_size × gradient_accumulation_steps × GPU 数量。在显存允许的前提下优先增大 per_device_batch_size 而不是 gradient_accumulation_steps因为前者能更好地利用 GPU 并行性。我试过 batch size 4 累积 32 和 batch size 8 累积 16后者训练速度快约 15%。第三个技巧是定期保存检查点但不要保存太频繁。保存检查点会暂停训练、写入磁盘频繁保存会拖慢整体进度。我设的是每 2000 步保存一次同时只保留最近 3 个检查点避免磁盘写满。第四个技巧是监控 GPU 温度和功耗。3090 的功耗墙是 350W长时间满载训练温度会到 80 度以上。我把功耗限制到 300W训练速度只慢了 5% 左右但温度降了 10 度风扇噪音也小了很多。这个取舍对长期训练的稳定性很有帮助。提示训练前先用小规模数据跑一遍完整流程确认没有 bug 再上全量数据。我见过有人直接上全量数据跑了 10 小时才发现数据格式有问题白白浪费了算力。用 1% 的数据跑通全流程可能只需要 20 分钟但能帮你省下几十小时。7. 从预训练到 RAG领域适配的延伸思路7.1 什么时候该用 RAG 而不是继续微调领域适配做到一定程度后你会发现有些问题不是模型容量能解决的。比如问我们公司内部 API 的某个参数默认值是多少这种信息只存在于内部文档里模型再大也记不住。这时候就需要 RAG检索增强生成。RAG 的思路是把领域文档切块、向量化、存入向量数据库用户提问时先检索最相关的文档块把文档块和问题一起送给模型让模型基于检索到的内容回答。这样模型不需要记住所有知识只需要学会阅读理解。我的判断标准是如果领域知识是相对稳定的、通用的比如编程语言语法、算法原理继续预训练和微调就够了如果领域知识是频繁更新的、私有的比如公司内部文档、最新产品信息RAG 是更合适的方案。两者也可以结合——用微调让模型掌握领域语言和推理能力用 RAG 提供实时知识。7.2 微调模型在 RAG 系统中的角色在 RAG 系统里微调过的模型主要承担两个角色一是查询改写把用户的口语化问题改写成更适合检索的形式二是答案生成基于检索到的文档块生成最终回答。查询改写这个角色经常被忽略但效果提升很明显。用户问那个什么梯度检查点怎么省显存的直接拿这句话去检索可能匹配不到好结果。微调模型可以把它改写成梯度检查点 显存优化 原理检索准确率会高很多。我用 500 条查询改写数据微调了一个小模型专门做这件事检索召回率从 62% 提升到了 81%。答案生成时prompt 的设计很关键。我的模板是基于以下参考资料回答问题。如果参考资料中没有相关信息请明确说明根据现有资料无法回答。 参考资料 {retrieved_documents} 问题{user_question} 回答这个模板强制模型基于检索内容回答减少幻觉。同时给了模型拒答的选项避免它编造答案。7.3 持续迭代的闭环设计领域适配不是一次性的工作而是一个持续迭代的闭环。我的做法是部署模型后收集用户的真实问题定期把新问题加入指令微调数据集重新训练模型。同时监控模型的失败案例分析是知识缺失、推理错误还是格式问题针对性地补充数据。这个闭环里最重要的是数据收集。我在推理服务里加了一个反馈接口用户可以对回答点赞或点踩。点踩的样本会被人工审核确认是模型错误后加入训练集。这样模型就能持续从真实使用中学习越用越好。整个流程跑下来我最大的体会是个人开发者做 LLM 全流程关键不在于追求规模而在于把每个环节都做扎实。数据清洗多花一小时可能省下十小时的无效训练学习率调对一次可能避免几天的反复试错。1.24 亿参数的模型虽然小但在特定领域经过精心适配后完全能解决实际问题。这套流程跑通之后你再去看那些大模型的技术报告会发现底层逻辑是相通的只是规模不同而已。
返回列表