多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

个人开发者单卡24G实战:从零预训练到LoRA领域适配的LLM全流程

个人开发者单卡24G实战:从零预训练到LoRA领域适配的LLM全流程 1. 为什么个人开发者也要走一遍LLM全流程1.1 从“调API”到“自己训”的分水岭很多人对LLM的认知停留在调用现成接口写几行Python传个prompt拿回结果完事。这种玩法确实能解决不少问题但一旦遇到需要私有数据、特定领域术语、或者对推理成本敏感的场景调API的路子就会撞墙。我最初也是从调接口开始的后来发现两个绕不过去的坎一是数据不能出本地二是通用模型在垂直领域的表现实在不够看。于是就有了这个项目——用一张RTX 3090从零走一遍预训练到领域适配的完整链路。这个项目的核心目标很明确在单卡24GB显存的约束下完成一个小规模LLM的预训练再通过领域数据做适配最终得到一个能在特定任务上跑得动的模型。选GPT-2作为基座不是因为它最强而是因为它足够小、结构经典、社区资源丰富适合个人开发者练手。整个流程走下来你会对tokenizer、注意力机制、训练循环、显存优化、领域微调这些环节有切肤的理解这些经验是调API永远给不了的。适合谁来参考如果你有Python基础了解PyTorch的基本用法手里有一张消费级显卡3090、4090都行想搞清楚LLM到底是怎么从一堆文本变成能生成内容的模型那这篇内容就是为你写的。我不会堆砌公式而是把每个环节的“为什么”和“怎么做”讲透让你能直接抄作业。1.2 项目整体链路与技术选型逻辑整个项目分四个阶段数据准备与tokenizer训练、预训练、领域适配、推理部署。每个阶段都有明确的输入输出和验收标准。技术选型上我做了几个关键决策。框架用PyTorch因为生态最成熟遇到问题好查资料。模型结构选GPT-2因为它的decoder-only架构是当前主流LLM的基础理解了它就理解了LLaMA、Qwen这些模型的底层逻辑。训练加速用混合精度加梯度累积这是在单卡上跑大batch的标准操作。领域适配阶段用LoRA因为全量微调24GB显存扛不住LoRA能把可训练参数降到原来的百分之一左右。这里要特别说一下tokenizer的选择。很多人直接拿GPT-2的tokenizer来用但如果你做的是中文领域适配它的词表对中文极不友好一个汉字可能被拆成多个token导致序列长度暴涨、训练效率低下。我的做法是重新训练一个BPE tokenizer词表大小设在32000左右中英文混合语料下压缩率明显提升。注意tokenizer一旦确定预训练和后续所有环节都必须用同一个中途换tokenizer等于把模型学到的词嵌入全部作废。2. 数据准备与Tokenizer训练的实操细节2.1 语料收集与清洗的坑预训练的数据量决定了模型的知识广度。个人开发者拿不到万亿token的语料但几GB到几十GB的高质量文本还是能凑出来的。我的数据来源包括公开的中文维基百科dump、开源书籍语料、技术论坛的问答帖、以及自己积累的领域文档。这里的关键不是“多”而是“干净”。清洗流程我走了三步。第一步去重用MinHash加LSH做近似去重阈值设在0.8能干掉大量转载和重复内容。第二步过滤去掉HTML标签、乱码、超短行少于10个字符、以及重复字符占比超过50%的行。第三步格式化把文档统一成纯文本段落之间用换行符分隔方便后续按行读取。踩过的坑一开始我没做去重结果模型训练到后期开始复读同一段话生成质量极差。后来加了去重同样步数下loss下降更平滑。另一个坑是编码问题有些语料是GBK编码直接按UTF-8读会报错得用chardet检测后统一转码。import hashlib from datasketch import MinHash, MinHashLSH def deduplicate(lines, threshold0.8): lsh MinHashLSH(thresholdthreshold, num_perm128) kept [] for i, line in enumerate(lines): m MinHash(num_perm128) for s in set(line.split()): m.update(s.encode(utf8)) if not lsh.query(m): lsh.insert(str(i), m) kept.append(line) return kept这段代码是去重的核心逻辑实际跑的时候建议分批处理不然内存扛不住。2.2 训练专属Tokenizer的完整步骤Tokenizer是LLM的“字典”它决定了文本怎么切分成token。GPT-2原版tokenizer对中文的处理方式是按字节切一个汉字可能变成2到3个token序列长度直接翻倍。自己训一个中文友好的tokenizer能把压缩率提升30%以上。我用的是HuggingFace的tokenizers库训练BPE模型。关键参数vocab_size设为32000min_frequency设为2special_tokens包括pad、unk、s、/s。训练语料就是清洗后的全部文本大概5GB左右训练时间在单机上约20分钟。from tokenizers import Tokenizer, models, trainers, pre_tokenizers tokenizer Tokenizer(models.BPE()) tokenizer.pre_tokenizer pre_tokenizers.ByteLevel(add_prefix_spaceFalse) trainer trainers.BpeTrainer( vocab_size32000, min_frequency2, special_tokens[pad, unk, s, /s] ) tokenizer.train(files[corpus.txt], trainertrainer) tokenizer.save(tokenizer.json)训练完之后一定要做压缩率测试。我拿一段1000字的中文技术文章做对比GPT-2原版tokenizer切出约1800个token自己训的切出约1100个token压缩比提升了近40%。这意味着同样的显存能塞进更长的上下文训练效率直接受益。提示训练tokenizer时不要加入太多特殊符号或表情否则词表会被这些低频字符占满影响常用字的编码效率。3. 预训练在单张3090上把模型跑起来3.1 模型配置与显存估算GPT-2的原始配置有12层、768隐藏维度、12个注意力头参数量约124M。这个规模在3090上做预训练是可行的但需要精打细算。先算一笔账FP16混合精度下模型参数占约250MB优化器状态AdamW占约1GB梯度占约250MB剩下的显存全给激活值和batch。激活值的大小跟batch_size和序列长度成正比。我实测下来序列长度512、batch_size 8的时候显存占用约18GB还有余量。如果想加大batch可以用梯度累积设accumulation_steps4等效batch_size就是32而显存占用不变。模型配置我做了微调把n_positions从1024改成512因为我的领域文档平均长度在300到400token之间512足够覆盖大部分样本还能省显存。n_embd保持768n_layer保持12n_head保持12。词表大小跟tokenizer对齐设为32000。from transformers import GPT2Config, GPT2LMHeadModel config GPT2Config( vocab_size32000, n_positions512, n_embd768, n_layer12, n_head12, resid_pdrop0.1, embd_pdrop0.1, attn_pdrop0.1 ) model GPT2LMHeadModel(config)3.2 训练循环与关键参数设置预训练的目标很简单给定前文预测下一个token。损失函数用交叉熵优化器用AdamW学习率用带warmup的余弦退火。关键参数如下初始学习率5e-4warmup步数2000总步数100000权重衰减0.01梯度裁剪阈值1.0。训练循环里我加了几个实用技巧。第一动态padding每个batch内按最长序列padding而不是全局固定长度能省不少显存。第二梯度检查点在模型里开启gradient_checkpointing用计算换显存激活值占用能降一半左右。第三混合精度用torch.cuda.amp自动管理FP16和FP32的转换速度提升约40%。from torch.cuda.amp import autocast, GradScaler scaler GradScaler() optimizer torch.optim.AdamW(model.parameters(), lr5e-4, weight_decay0.01) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max100000) for step, batch in enumerate(dataloader): with autocast(): outputs model(**batch) loss outputs.loss / accumulation_steps scaler.scale(loss).backward() if (step 1) % accumulation_steps 0: scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) scaler.step(optimizer) scaler.update() scheduler.step() optimizer.zero_grad()实测下来这个配置在3090上跑100000步大约需要3到4天loss能从初始的10.5降到3.2左右。生成效果从最初的乱码变成能写出通顺的短句虽然逻辑性还不强但已经具备了基本的语言建模能力。注意预训练过程中要定期保存checkpoint我设的是每5000步存一次。有一次断电导致最后两天的训练白跑血的教训。3.3 训练过程中的监控与调优训练不是设完参数就撒手不管得盯着几个关键指标。第一是loss曲线正常情况应该是平滑下降如果出现剧烈震荡可能是学习率太大或者batch内样本差异过大。第二是梯度范数如果持续大于1.0说明梯度爆炸需要降低学习率或加大裁剪阈值。第三是显存占用用nvidia-smi实时监控留出至少2GB余量防止OOM。我遇到过loss突然变成NaN的情况排查后发现是某条语料里混入了非法字符导致embedding层输出异常。解决办法是在数据加载时加一层过滤把包含控制字符的样本直接丢掉。另一个问题是训练到后期loss下降变慢这时候可以适当降低学习率或者增加数据多样性。监控工具我用的是TensorBoard把loss、学习率、梯度范数都打进去方便回看。也可以直接用wandb但考虑到数据隐私我选了本地部署的TensorBoard。4. 领域适配用LoRA把通用模型变成专家4.1 为什么选LoRA而不是全量微调预训练出来的模型是个“通才”什么都能聊一点但什么都不精。要让它变成领域专家就得做适配。全量微调是最直接的办法但124M参数的模型全量微调需要约6GB显存用于优化器状态加上激活值24GB卡勉强能跑但batch_size只能设到2训练效率极低。LoRA的思路是在原始权重旁边加一个低秩矩阵只训练这个小矩阵原始权重冻结。这样可训练参数从124M降到约1M显存占用大幅下降batch_size能开到16甚至32。而且LoRA的权重文件很小方便分享和切换。我试过在同一个基座模型上挂不同的LoRA分别适配法律、医疗、代码三个领域切换成本几乎为零。LoRA的关键参数是秩r和缩放系数alpha。r越大表达能力越强但参数量也越大。我的经验是r8或16足够应对大多数领域适配任务alpha一般设为r的两倍。目标模块选query和value的投影矩阵这两个位置对注意力模式的影响最大。from peft import LoraConfig, get_peft_model lora_config LoraConfig( r16, lora_alpha32, target_modules[c_attn], lora_dropout0.05, biasnone, task_typeCAUSAL_LM ) model get_peft_model(model, lora_config) model.print_trainable_parameters()打印出来可训练参数约1.2M占总参数的不到1%显存占用从18GB降到12GB左右。4.2 领域数据的构造与训练策略领域适配的数据不需要像预训练那么多但质量要求更高。我以技术问答领域为例构造了约5万条“问题-回答”对。数据来源包括技术论坛的高赞回答、官方文档的FAQ、以及自己整理的常见问题。每条数据格式化成s问题/s回答/s的形式让模型学会在给定问题后生成回答。训练策略上学习率设得比预训练小一个数量级用1e-4warmup步数500总步数10000。batch_size开到32梯度累积步数设为1。损失函数还是交叉熵但只计算回答部分的loss问题部分的loss忽略这样模型能更专注于学习“怎么答”而不是“怎么问”。def collate_fn(batch): input_ids torch.stack([b[input_ids] for b in batch]) labels input_ids.clone() # 将问题部分的label设为-100不计算loss for i, b in enumerate(batch): labels[i, :b[question_length]] -100 return {input_ids: input_ids, labels: labels}训练过程中要监控生成效果不能只看loss。我每500步让模型生成几个测试问题的回答人工评估质量。前2000步模型还在适应格式生成的回答可能不完整5000步之后基本能给出通顺且相关的回答10000步之后回答的专业性和准确性明显提升。提示领域适配的数据要覆盖该领域的核心概念和常见表达如果数据太偏模型会过拟合到特定表述上泛化能力下降。4.3 适配效果的评估与迭代评估领域适配效果不能只看loss得从三个维度来测。第一是相关性生成的回答是否跟问题相关用人工评估或训练一个分类器来自动打分。第二是准确性回答中的事实性内容是否正确这个需要领域专家参与评估。第三是流畅度语言是否通顺自然可以用困惑度perplexity来量化。我建了一个包含200个问题的测试集覆盖领域内的主要知识点。每次迭代后让模型生成回答然后按上述三个维度打分。第一版LoRA模型的相关性得分约70%准确性约60%流畅度约80%。分析后发现主要问题是模型倾向于生成通用回答缺乏领域细节。解决办法是在训练数据里增加更多包含具体技术细节的样本同时把LoRA的秩从8提到16增强表达能力。第二版模型的相关性提升到85%准确性提升到75%流畅度保持80%左右。这个水平已经能满足内部知识库问答的需求了。如果还想继续提升可以考虑增加数据量、调整LoRA参数、或者用DPO做偏好对齐但那是另一个话题了。5. 推理部署与性能优化5.1 模型导出与ONNX加速训练完的模型要部署才能用。PyTorch原生推理在3090上生成一条100token的回答大约需要2到3秒延迟偏高。用ONNX Runtime做推理加速能把延迟降到1秒以内。导出ONNX的步骤不复杂但有几个坑要注意。第一导出时要把模型设为eval模式并且用torch.no_grad()包住。第二输入要指定动态维度特别是batch_size和sequence_length不然ONNX会固定成导出时的尺寸。第三GPT-2的注意力掩码在导出时容易出问题建议用transformers自带的ONNX导出工具它已经处理好了这些细节。from transformers.onnx import export from pathlib import Path export( modelmodel, configmodel.config, outputPath(onnx_model), opset14, devicecuda )导出后用ONNX Runtime加载开启CUDA执行提供者推理速度提升明显。实测下来batch_size1时延迟从2.5秒降到0.8秒batch_size8时吞吐量提升约3倍。5.2 量化压缩与显存优化如果部署环境显存有限还可以做量化。8-bit量化能把模型大小压缩到原来的四分之一推理速度也有提升。用bitsandbytes库可以很方便地做8-bit量化加载。from transformers import BitsAndBytesConfig quant_config BitsAndBytesConfig( load_in_8bitTrue, llm_int8_threshold6.0 ) model GPT2LMHeadModel.from_pretrained( model_path, quantization_configquant_config, device_mapauto )8-bit量化后模型占用约500MB显存生成质量相比FP16有轻微下降但肉眼几乎看不出差别。如果追求极致压缩还可以做4-bit量化但质量损失就比较明显了需要根据实际场景权衡。注意量化后的模型不能再做训练只能用于推理。如果后续还要继续微调得保留FP16的原始权重。5.3 推理服务的封装与接口设计模型最终要封装成服务才能被其他系统调用。我用FastAPI搭了一个简单的推理服务提供两个接口一个是生成接口接收prompt返回生成文本另一个是健康检查接口用于监控服务状态。from fastapi import FastAPI from pydantic import BaseModel app FastAPI() class GenerateRequest(BaseModel): prompt: str max_length: int 100 temperature: float 0.7 app.post(/generate) def generate(req: GenerateRequest): inputs tokenizer(req.prompt, return_tensorspt).to(cuda) outputs model.generate( **inputs, max_lengthreq.max_length, temperaturereq.temperature, do_sampleTrue ) return {text: tokenizer.decode(outputs[0], skip_special_tokensTrue)}服务部署时要注意并发问题。GPU推理是串行的多个请求同时进来会排队。如果并发量高可以用批处理把多个请求攒成一个batch一起推理能显著提升吞吐量。我实测下来batch_size8时吞吐量是单条推理的5倍左右。6. 常见问题与排查技巧实录6.1 训练阶段的典型报错与解决CUDA out of memory这是最常见的报错。解决办法按优先级排序先降batch_size再开梯度检查点然后缩短序列长度最后考虑用更小的模型。我一般会留2GB显存余量防止训练中途因为碎片化导致OOM。Loss变成NaN通常是学习率太大或者数据里有异常样本。先把学习率降一个数量级试试如果还不行就检查数据把包含控制字符或超长重复的样本过滤掉。训练速度突然变慢可能是GPU过热降频用nvidia-smi查看温度和功耗。也可能是数据加载成了瓶颈把DataLoader的num_workers调大或者把数据预处理成二进制格式加快读取。生成的文本重复这是预训练模型的通病尤其是训练不充分的时候。解决办法包括增加训练步数、提高temperature、用top-k或top-p采样、以及在推理时加重复惩罚。6.2 领域适配中的效果问题排查模型不遵循指令检查训练数据的格式是否统一prompt和response的分隔符是否清晰。如果数据格式混乱模型学不到正确的生成模式。回答过于笼统说明训练数据缺乏细节。增加包含具体技术细节的样本或者在prompt里加入更明确的约束比如“请给出具体的代码示例”。过拟合到训练集如果模型对训练集里的问题回答得很好但换个问法就答不上来说明过拟合了。解决办法是增加数据多样性降低LoRA的秩或者加正则化。中英文混杂如果训练数据里中英文比例失衡模型可能会在中文回答里突然蹦出英文单词。解决办法是平衡数据比例或者在tokenizer里对中英文分别处理。6.3 推理部署的性能瓶颈定位推理慢的原因可能有很多得逐层排查。先用time.time()测一下tokenizer编码、模型前向、解码各占多少时间。如果编码慢换fast tokenizer如果前向慢考虑ONNX或量化如果解码慢用beam search的替代方案比如贪心解码。显存占用高的话检查是不是每次推理都重新加载了模型。模型应该只加载一次常驻显存。另外生成时的max_length不要设太大按实际需要设不然会浪费显存和时间。问题现象可能原因排查方法解决方案推理延迟高模型未优化测各阶段耗时ONNX加速、量化显存占用高模型重复加载检查加载逻辑模型常驻显存生成质量差训练不充分看loss曲线增加训练步数回答不相关领域数据不足人工评估增加领域数据服务并发低串行推理压测QPS批处理推理这张表是我在实际项目中总结的速查表遇到问题先对照排查能省不少时间。7. 个人开发者做LLM项目的经验之谈7.1 资源有限时的取舍策略个人开发者和团队最大的区别是资源。团队可以堆卡、堆数据、堆人力个人只能精打细算。我的策略是模型选小的数据选精的训练选稳的。不要一上来就想着训个7B模型124M的GPT-2足够让你理解全流程。数据不在多而在精5GB高质量语料比50GB脏数据强得多。训练不要追求一步到位先跑通再调优每次只改一个变量。另一个取舍是预训练和领域适配的投入比例。我的经验是预训练占30%精力领域适配占50%剩下20%给推理部署。因为领域适配直接决定模型能不能用而预训练只要把loss降到合理范围就行不必追求极致。7.2 从项目中学到的三个关键认知第一tokenizer比模型结构更影响体验。同样的模型换一个更适合领域的tokenizer生成质量能有肉眼可见的提升。很多人在模型结构上反复折腾却忽略了tokenizer这个基础环节。第二数据质量决定上限模型大小决定下限。小模型配好数据效果能超过大模型配脏数据。我试过用124M模型在清洗过的领域数据上做适配效果比用355M模型在未清洗数据上好得多。第三推理优化和训练同样重要。训练再好的模型如果推理慢、部署难也产生不了实际价值。ONNX和量化这些技术值得花时间掌握。7.3 后续可以继续深挖的方向这个项目跑通之后有几个方向可以继续深入。一是用DPO做偏好对齐让模型生成更符合人类偏好的回答。二是多LoRA组合把不同领域的LoRA权重做插值或叠加得到一个多面手模型。三是RAG结合用检索增强生成来弥补模型知识不足的问题这在领域问答场景下特别有效。四是模型评估体系的完善建一个自动化的评估流水线每次迭代后自动跑测试集并生成报告。我个人在实际操作中的体会是LLM全流程实践最大的价值不在于最终得到一个多强的模型而在于过程中建立的直觉。你知道数据怎么影响效果知道显存怎么分配知道推理怎么优化这些经验在遇到新场景时能让你快速定位问题、找到方案。这比调API拿到的结果有价值得多。
返回列表