1. 为什么个人开发者也要走一遍LLM全流程
很多人觉得“预训练”是大厂才玩得起的东西,动辄几千张A100、几百万美元电费,个人开发者碰这个纯属自讨苦吃。我一开始也这么想,直到自己用一张RTX 3090把GPT-2级别的模型从零训练到能聊天的领域助手,才发现这条路并没有想象中那么遥不可及。关键不在于你有多大的算力,而在于你清不清楚每一步在干什么、哪些环节可以缩、哪些坑必须绕。
这篇内容适合三类人:一是想搞明白LLM到底怎么从一堆文本变成能对话的模型,不想只停留在调API层面的开发者;二是手里有领域数据,想做一个真正懂自己业务的垂直模型,而不是拿通用模型硬套;三是预算有限但愿意花时间折腾的个人开发者或小团队。我会把从数据准备、预训练、指令微调、领域适配到推理部署的完整链路拆开讲,每一步都给出我实际跑过的参数、踩过的坑和能直接抄的配置。
核心关键词先摆出来:LLM、预训练、领域适配、GPT-2、RTX 3090。这几个词贯穿全文,后面每个环节都会围绕它们展开。你不需要有分布式训练经验,但最好懂一点PyTorch基础,知道什么是batch size、学习率、梯度累积。如果这些词你看着眼生,也没关系,我会用生活化的类比解释清楚。
先说结论:在单张RTX 3090上,用几十GB的领域文本,从零预训练一个124M到355M参数的GPT-2级别模型,再经过指令微调和领域适配,最终得到一个能回答专业问题、语气可控的垂直助手,整个流程是可行的。时间成本大概在两到四周,取决于数据量和你的调参耐心。下面我按实际操作的顺序,把每个阶段拆开讲。
2. 整体方案设计与算力预算拆解
2.1 为什么选GPT-2架构而不是直接上LLaMA
你可能会问,现在LLaMA、Qwen、Mistral遍地都是,为什么还要从GPT-2开始预训练?答案很简单:个人开发者的算力预算决定了你只能玩得动这个量级的模型。RTX 3090有24GB显存,FP16精度下训练124M参数的GPT-2,batch size开到16到32没问题;355M的模型就得用梯度累积和混合精度才能跑起来。而7B以上的模型,光是加载权重就要占掉14GB显存,训练时优化器状态和梯度一加,24GB根本不够看。
GPT-2的架构虽然老,但它是decoder-only transformer的经典实现,结构清晰、代码成熟、社区资源多。你把它跑通了,再去理解LLaMA的结构就是水到渠成的事。而且GPT-2的tokenizer是BPE,词表大小50257,对中文支持一般,但你可以用自己的领域语料重新训练tokenizer,这一步后面会详细讲。
另一个考虑是训练稳定性。GPT-2的参数量小,梯度噪声相对可控,学习率调度和warmup策略容易调。大模型训练时loss spike、梯度爆炸这些问题在小模型上出现的概率低得多,适合个人开发者练手。我自己的经验是,先把124M的模型跑通,确认数据管道、训练循环、评估流程都没问题,再放大到355M,这样排错成本最低。
2.2 显存、时间和电费的现实账
算一笔实在账。RTX 3090的FP16算力大约是35 TFLOPS(实际利用率按50%算),训练124M参数的GPT-2,在100亿token的数据上跑一个epoch,大概需要多少时间?我实测下来,用HuggingFace的transformers加DeepSpeed ZeRO-2,batch size 32,序列长度1024,每秒大约处理8000到10000个token。100亿token就是大约12天。如果你只跑10亿token,一天多一点就能跑完一个epoch。
显存占用方面,124M模型FP16训练,模型参数占0.25GB,梯度0.25GB,优化器状态(Adam)占1GB左右,激活值占大头。序列长度1024、batch size 32的情况下,激活值大约占8到10GB。总共12GB左右,24GB显存绰绰有余。355M模型的话,参数和优化器状态翻三倍,激活值也翻倍,总共大约20GB,刚好卡在边缘,需要用梯度检查点(gradient checkpointing)来换显存。
电费方面,RTX 3090满载功耗350W,加上CPU和主板,整机大约500W。跑一天就是12度电,按居民电价算不到10块钱。跑一个月也就300块。这个成本对个人开发者来说完全可以接受。真正的成本是你的时间——调参、等结果、排查bug,这些才是大头。
2.3 全流程五个阶段的划分
我把整个流程分成五个阶段,每个阶段都有明确的输入和输出:
| 阶段 | 输入 | 输出 | 预估时间 |
|---|---|---|---|
| 数据准备 | 原始领域文本 | 清洗后的tokenized数据集 | 2-5天 |
| 预训练 | tokenized数据集 | 基础语言模型 | 3-14天 |
| 指令微调 | 指令-回答对 | 指令跟随模型 | 1-3天 |
| 领域适配 | 领域问答数据 | 垂直领域助手 | 1-2天 |
| 推理部署 | 最终模型 | API或本地服务 | 1天 |
这个划分不是绝对的,比如领域适配可以和指令微调合并,但分开做更容易定位问题。下面我按这个顺序,把每个阶段的核心细节和实操要点讲透。
3. 数据准备与tokenizer训练的关键细节
3.1 领域文本的收集与清洗
数据质量决定模型上限,这句话在预训练阶段尤其成立。我见过太多人随便爬了几GB网页文本就开始训练,结果模型输出全是乱码和重复。领域文本的收集要围绕你的目标场景来。比如你想做一个医疗问答助手,那就去收集医学教材、临床指南、药品说明书、医学论文摘要。如果你想做法律助手,那就收集法律法规、判决书、合同模板。
收集渠道有几个:公开数据集(如中文维基、百度百科)、专业论坛、电子书、PDF文档。注意版权问题,个人学习使用没问题,但商用要谨慎。我自己的做法是优先用公开许可的数据集,再补充自己整理的领域文档。
清洗环节比收集更重要。原始文本里通常有HTML标签、乱码、重复段落、广告、页眉页脚。我一般用下面这个流程:
import re from bs4 import BeautifulSoup def clean_text(text): # 去除HTML标签 text = BeautifulSoup(text, "html.parser").get_text() # 去除URL text = re.sub(r'http[s]?://\S+', '', text) # 去除多余空白 text = re.sub(r'\s+', ' ', text) # 去除特殊字符(保留中文、英文、数字、常用标点) text = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9,。!?;:""''()《》\s]', '', text) # 去除重复行 lines = text.split('\n') seen = set() unique_lines = [] for line in lines: if line.strip() and line not in seen: seen.add(line) unique_lines.append(line) return '\n'.join(unique_lines)这个清洗函数我用了很久,基本能处理大部分脏数据。但有个坑要注意:去除特殊字符时不要把数学公式和代码块里的符号也去掉了,如果你的领域文本包含这些内容,需要单独处理。
另一个关键是去重。预训练数据里如果有大量重复文本,模型会过拟合到这些重复内容上,生成时反复输出同样的句子。我一般用MinHash或者简单的n-gram重叠来去重。对于个人开发者,最简单的办法是按段落做MD5哈希,重复的直接丢掉。虽然粗暴,但有效。
3.2 训练自己的tokenizer还是用现成的
GPT-2原版tokenizer是英文BPE,词表50257,对中文的支持很差。一个中文字符可能被拆成多个byte token,导致序列长度膨胀,训练效率低。我的建议是:如果你的领域文本以中文为主,一定要重新训练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="<unk>")) tokenizer.pre_tokenizer = ByteLevel() trainer = BpeTrainer( vocab_size=32000, special_tokens=["<unk>", "<s>", "</s>", "<pad>"], min_frequency=2 ) tokenizer.train(files=["corpus.txt"], trainer=trainer) tokenizer.save("tokenizer.json")词表大小设多少?32000是一个比较平衡的选择。太小会导致很多词被拆成碎片,太大则embedding层参数变多,训练变慢。我试过16000、32000、50000三个档位,32000在中文领域文本上表现最好。训练完后要检查一下压缩率,也就是原始文本的字符数除以token数。中文文本的理想压缩率在1.5到2.0之间,如果低于1.2说明tokenizer没训好。
注意:训练tokenizer的语料要和预训练语料一致,否则会出现token分布不匹配的问题。另外,特殊token的顺序要固定,后面微调时要用到。
3.3 数据格式转换与内存映射
tokenize完之后,数据要存成模型能高效读取的格式。HuggingFace的datasets库支持内存映射(memory-mapped)文件,训练时不用把全部数据加载到内存里。具体做法是把tokenized后的input_ids存成二进制文件,训练时用np.memmap读取。
import numpy as np # 假设所有tokenized数据拼接成一个长序列 all_tokens = np.concatenate(tokenized_chunks) all_tokens.tofile("train.bin") # 训练时读取 data = np.memmap("train.bin", dtype=np.uint16, mode='r')这里用uint16是因为词表大小32000小于65536,用uint16存储可以节省一半空间。100亿token的数据,uint16格式占20GB,uint32格式占40GB。对于个人开发者,20GB的硬盘空间还是能挤出来的。
数据切分时要注意,训练集和验证集要按文档级别切分,不能按token级别随机切。否则同一个文档的片段同时出现在训练集和验证集里,验证loss会虚低,无法反映真实泛化能力。我一般按9:1的比例切分,验证集用来监控过拟合。
4. 预训练实操:从零到能生成通顺文本
4.1 模型配置与初始化
GPT-2的配置有几个关键参数:n_layer(层数)、n_head(注意力头数)、n_embd(嵌入维度)。124M的配置是12层、12头、768维;355M是24层、16头、1024维。我建议从124M开始,跑通了再放大。
from transformers import GPT2Config, GPT2LMHeadModel config = GPT2Config( vocab_size=32000, 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)初始化权重时,GPT-2默认用正态分布,均值0、标准差0.02。这个初始化对124M模型没问题,但355M以上的模型建议用更精细的初始化,比如按层数缩放标准差。不过对于个人开发者的规模,默认初始化够用了。
实操心得:如果你打算用355M模型,建议开启gradient checkpointing,显存能省40%左右,代价是训练速度慢20%。在24GB显存下,这是值得的。
4.2 训练循环与超参数设置
训练循环用HuggingFace的Trainer或者自己写PyTorch循环都行。我倾向于自己写,因为可控性更强,出问题好排查。核心超参数如下:
| 参数 | 124M模型 | 355M模型 |
|---|---|---|
| batch size | 32 | 16 |
| 梯度累积步数 | 1 | 4 |
| 学习率 | 6e-4 | 3e-4 |
| warmup步数 | 2000 | 2000 |
| 权重衰减 | 0.1 | 0.1 |
| 最大序列长度 | 1024 | 1024 |
| 精度 | FP16 | FP16 |
学习率用余弦退火调度,从峰值降到峰值的10%。优化器用AdamW,beta1=0.9,beta2=0.95。梯度裁剪设为1.0,防止梯度爆炸。
from torch.optim import AdamW from transformers import get_cosine_schedule_with_warmup optimizer = AdamW(model.parameters(), lr=6e-4, weight_decay=0.1) scheduler = get_cosine_schedule_with_warmup( optimizer, num_warmup_steps=2000, num_training_steps=total_steps ) scaler = torch.cuda.amp.GradScaler() for step, batch in enumerate(dataloader): with torch.cuda.amp.autocast(): outputs = model(**batch) loss = outputs.loss / gradient_accumulation_steps scaler.scale(loss).backward() if (step + 1) % gradient_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()这个循环我跑了无数次,稳定可靠。关键点是梯度累积要在loss上除,而不是在梯度上除。另外,scaler.unscale_要在clip之前调用,否则裁剪的是缩放后的梯度,不对。
4.3 训练过程中的监控与早停
训练时一定要盯着loss曲线。正常的loss曲线应该是先快速下降,然后缓慢下降,最后趋于平稳。如果loss突然飙升,通常是学习率太大或者数据里有异常样本。如果loss下降太慢,可能是学习率太小或者模型容量不够。
我一般每500步记录一次训练loss和验证loss。验证loss连续三次不下降就降低学习率,连续五次不下降就早停。早停的耐心值设5到10,取决于你的数据量。数据量小的时候耐心值设小一点,防止过拟合。
常见问题:训练到一半loss变成NaN。原因通常是FP16溢出。解决办法是改用BF16(如果显卡支持),或者把loss scale调小。RTX 3090支持BF16,我后来都直接用BF16,省心很多。
另一个监控指标是梯度范数。如果梯度范数持续大于10,说明训练不稳定,需要降低学习率或者增大梯度裁剪阈值。如果梯度范数接近0,说明模型已经收敛或者陷入了局部最优。
4.4 预训练效果评估:困惑度与生成质量
预训练阶段的主要评估指标是困惑度(perplexity)。困惑度越低,说明模型对语言的建模能力越强。在验证集上,124M模型训练10亿token后,困惑度大约能降到20到30(中文领域文本)。355M模型能降到15到20。
但困惑度低不代表生成质量好。我见过困惑度很低但生成全是重复句子的模型。所以还要做生成测试。用几个不同的prompt,看模型能不能续写出通顺、有逻辑的文本。
prompt = "人工智能在医疗领域的应用包括" inputs = tokenizer(prompt, return_tensors="pt").to("cuda") outputs = model.generate( **inputs, max_new_tokens=100, temperature=0.8, top_p=0.9, repetition_penalty=1.2, do_sample=True ) print(tokenizer.decode(outputs[0], skip_special_tokens=True))如果生成结果重复严重,调高repetition_penalty到1.3或1.5。如果生成结果太随机,降低temperature到0.6或0.7。如果生成结果不通顺,可能是预训练不充分,继续训练。
5. 指令微调与领域适配的落地方法
5.1 指令数据的构造与格式设计
预训练完的模型只会续写文本,不会回答问题。要让它能对话,必须做指令微调。指令数据的质量比数量重要得多。我见过用10万条低质量指令数据微调出来的模型,效果还不如用5000条高质量数据。
指令数据的格式一般是这样:
{ "instruction": "解释什么是高血压", "input": "", "output": "高血压是指动脉血压持续升高的一种慢性疾病,通常定义为收缩压≥140mmHg和/或舒张压≥90mmHg。" }如果指令里包含上下文,可以放在input字段里。比如:
{ "instruction": "根据以下症状判断可能的疾病", "input": "患者男性,55岁,胸痛、出汗、呼吸困难", "output": "根据症状描述,可能为急性心肌梗死,建议立即就医进行心电图和心肌酶检查。" }构造指令数据的方法有几种:一是人工编写,质量最高但成本大;二是用强模型生成,比如用GPT-4批量生成指令-回答对,然后人工筛选;三是从现有数据集中转换,比如把问答对、摘要任务改写成指令格式。我自己的做法是混合使用:核心领域数据人工编写,通用指令用强模型生成后筛选。
注意:指令数据里不要出现“作为一个AI助手”这类模板化表述,否则模型会学会这种废话。回答要直接、具体、有信息量。
5.2 微调策略:全参数还是LoRA
指令微调有两种主流策略:全参数微调和LoRA(低秩适配)。全参数微调效果好,但显存占用大,124M模型全参数微调需要大约16GB显存,355M需要30GB以上,24GB的3090跑不动。LoRA只训练低秩矩阵,显存占用小,124M模型LoRA微调只需要6到8GB显存。
对于个人开发者,我强烈推荐LoRA。虽然效果比全参数微调略差,但差距很小,而且训练速度快、显存占用低、可以同时保存多个适配器。LoRA的配置如下:
from peft import LoraConfig, get_peft_model lora_config = LoraConfig( r=8, lora_alpha=32, target_modules=["c_attn", "c_proj"], lora_dropout=0.1, bias="none", task_type="CAUSAL_LM" ) model = get_peft_model(model, lora_config)r是低秩矩阵的秩,一般设4到16。lora_alpha是缩放因子,通常设为r的两倍。target_modules指定要适配的层,GPT-2里主要是注意力层的c_attn和c_proj。lora_dropout设0.1防止过拟合。
微调的学习率要比预训练小一个数量级,设1e-4到5e-5。训练轮数2到5轮就够了,太多会过拟合。batch size根据显存尽量开大,配合梯度累积。
5.3 领域适配:让模型说行话
指令微调后的模型能对话了,但可能不懂你的领域术语。领域适配就是让模型学会说行话。方法是在指令微调数据里混入领域特定的问答对,或者用领域文本继续做因果语言建模训练。
我一般分两步走:先做通用指令微调,让模型学会对话格式;再做领域适配,用领域问答数据继续微调。领域问答数据的构造要围绕实际使用场景。比如医疗领域,可以构造“这个症状是什么病”“这个药怎么吃”“这个检查结果怎么看”这类问题。
领域适配的数据量不需要很大,几千条高质量问答对就能显著提升领域表现。关键是覆盖要全,把领域内常见的问题类型都包含进去。我一般按问题类型分类,每类构造200到500条,确保模型见过各种问法。
实操心得:领域适配时,学习率要再降低一点,设2e-5到1e-5。训练轮数1到2轮就够了,因为模型已经有一定基础,只需要微调。训练时监控验证loss,一旦上升就停。
5.4 效果评估:自动指标与人工打分
微调完的模型怎么评估?自动指标可以用困惑度、BLEU、ROUGE,但这些指标和人类判断的相关性有限。我主要靠人工打分。找几个领域内的人,给模型生成的回答按1到5分打分,1分是完全错误,5分是专业准确。
评估集要提前准备好,不能和训练数据重叠。我一般留出200到500条问答对作为评估集,覆盖不同难度和类型。评估时用相同的prompt,对比微调前后的输出。如果微调后回答更专业、更准确,说明领域适配有效。
另一个评估维度是安全性。模型不能生成有害、歧视、误导性的内容。我一般用一组对抗性prompt测试,比如诱导模型给出错误医疗建议。如果模型上钩了,说明还需要做安全对齐。
6. 推理部署与性能优化的实战经验
6.1 量化与加速:让模型跑得更快
训练完的模型是FP16精度,推理时可以用量化进一步压缩。8-bit量化能把模型大小减半,4-bit量化能减到四分之一,精度损失很小。用bitsandbytes库可以轻松实现:
from transformers import BitsAndBytesConfig quantization_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_quant_type="nf4", bnb_4bit_use_double_quant=True ) model = GPT2LMHeadModel.from_pretrained( "my_model", quantization_config=quantization_config, device_map="auto" )4-bit量化后,124M模型只占大约0.1GB显存,355M占0.3GB。推理速度也能提升2到3倍。对于个人开发者,量化是部署的必选项。
另一个加速手段是ONNX Runtime。把模型导出成ONNX格式,用ONNX Runtime推理,速度比PyTorch快30%到50%。导出方法:
import torch.onnx dummy_input = torch.randint(0, 32000, (1, 128)).to("cuda") torch.onnx.export( model, dummy_input, "model.onnx", input_names=["input_ids"], output_names=["logits"], dynamic_axes={"input_ids": {0: "batch", 1: "sequence"}}, opset_version=14 )导出时注意opset_version要选14以上,否则某些算子不支持。dynamic_axes要设置好,否则只能处理固定长度的输入。
6.2 服务化部署:FastAPI加流式输出
部署成API服务用FastAPI最简单。核心是要支持流式输出,否则用户等半天才看到结果,体验很差。流式输出的实现:
from fastapi import FastAPI from fastapi.responses import StreamingResponse from transformers import TextIteratorStreamer import threading app = FastAPI() @app.post("/generate") async def generate(prompt: str): inputs = tokenizer(prompt, return_tensors="pt").to("cuda") streamer = TextIteratorStreamer(tokenizer, skip_special_tokens=True) generation_kwargs = dict( **inputs, max_new_tokens=200, temperature=0.8, top_p=0.9, streamer=streamer ) thread = threading.Thread(target=model.generate, kwargs=generation_kwargs) thread.start() def stream(): for text in streamer: yield text return StreamingResponse(stream(), media_type="text/plain")这个服务跑在本地,用uvicorn启动,默认端口8000。并发量不大的话,单卡3090能同时处理3到5个请求。如果并发量上来了,可以用vLLM或者TGI来做批处理推理,吞吐量能提升5到10倍。
注意:流式输出时,tokenizer的skip_special_tokens要设为True,否则会输出特殊token。另外,生成时要设max_new_tokens,防止模型无限生成。
6.3 显存不足时的降级方案
24GB显存听起来不少,但部署时如果同时加载多个模型或者处理长序列,还是可能爆显存。降级方案有几个:
第一,降低序列长度。GPT-2的最大位置编码是1024,但实际使用时可以截断到512甚至256。序列长度减半,激活值显存减半。
第二,用CPU卸载。把部分层放到CPU上,需要时再加载到GPU。HuggingFace的accelerate库支持这个功能,但速度会慢很多。
第三,用更小的模型。如果124M模型都跑不动,那就用distilgpt2或者自己剪枝。剪枝的方法比较复杂,个人开发者不建议折腾。
第四,用llama.cpp。虽然名字里有llama,但它支持GPT-2架构的模型。把模型转成GGUF格式,用llama.cpp推理,CPU上也能跑,显存占用极低。转换方法:
python convert.py my_model --outfile model.gguf --outtype q4_0 ./main -m model.gguf -p "你的prompt" -n 200q4_0是4-bit量化,模型大小只有原来的四分之一。CPU推理速度慢一些,但胜在显存占用低,适合部署在低配机器上。
7. 常见问题与排查技巧实录
7.1 训练loss不下降或变成NaN
这是预训练阶段最常见的问题。loss不下降的原因可能有:学习率太小、数据质量差、模型初始化有问题。排查步骤:先把学习率调大10倍试试,如果loss开始下降说明学习率太小;如果loss还是不动,检查数据里有没有大量重复或乱码;如果数据没问题,重新初始化模型权重。
loss变成NaN通常是FP16溢出。解决办法:改用BF16,或者把loss scale从默认的65536降到32768。如果还不行,检查数据里有没有极长序列,超过1024的序列要截断。
踩过的坑:有一次我训练到一半loss突然NaN,排查了半天发现是数据里混入了一个超长文档,tokenize后长度超过10万,导致位置编码越界。后来加了长度过滤,问题解决。
7.2 生成结果重复或胡言乱语
生成重复是预训练不充分或者解码策略有问题。解决办法:增大repetition_penalty到1.3,降低temperature到0.7,用top_k=50代替top_p。如果还重复,说明模型训练不够,继续训练或者增大模型容量。
生成胡言乱语通常是训练数据太杂或者tokenizer有问题。检查tokenizer的压缩率,如果低于1.2说明tokenizer没训好。检查训练数据里有没有大量非目标语言的文本,比如你想做中文模型但数据里混了大量英文。
另一个原因是过拟合。如果验证loss开始上升但训练loss还在下降,说明过拟合了。解决办法是增加dropout、减小模型容量、增加数据量。
7.3 微调后模型不会对话
指令微调后模型还是只会续写,不会回答问题。原因通常是指令数据的格式不对。检查指令数据里有没有明确的分隔符,比如“### 指令:”“### 回答:”。模型需要这些分隔符来区分指令和回答。
另一个原因是微调轮数不够。指令微调通常需要2到5轮,如果只跑了1轮,模型可能还没学会对话格式。增加轮数试试。
还有可能是学习率太大,把预训练学到的知识覆盖了。降低学习率到1e-5,重新微调。
7.4 部署时显存溢出
推理时显存溢出,首先检查batch size是不是设太大了。推理时batch size设1就够了,除非你要做批处理。其次检查序列长度,如果输入很长,显存占用会线性增长。最后检查有没有加载多个模型,比如同时加载了tokenizer、模型、量化配置,这些都会占显存。
如果都排查了还是溢出,用4-bit量化,或者用CPU推理。CPU推理虽然慢,但不会溢出。
7.5 常见问题速查表
| 问题 | 可能原因 | 解决办法 |
|---|---|---|
| loss不下降 | 学习率太小、数据差 | 调大学习率、清洗数据 |
| loss变NaN | FP16溢出 | 改用BF16、降低loss scale |
| 生成重复 | 训练不充分、解码参数 | 增大repetition_penalty、继续训练 |
| 生成胡言乱语 | 数据杂、tokenizer差 | 清洗数据、重训tokenizer |
| 不会对话 | 指令格式不对、轮数不够 | 检查格式、增加轮数 |
| 显存溢出 | batch太大、序列太长 | 减小batch、量化、CPU推理 |
8. 个人开发者的经验体会与扩展方向
整个流程跑下来,我最大的体会是:预训练没有想象中那么难,但也没有那么简单。难的不是写代码,而是做决策——数据怎么洗、参数怎么设、效果怎么评。这些决策没有标准答案,只能靠实验和直觉。我踩过的坑包括:tokenizer训练语料和预训练语料不一致导致token分布偏移;指令数据里混入了模板化表述导致模型学会说废话;量化时用了不支持的算子导致推理报错。每一个坑都花了我至少半天时间排查。
如果让我重新走一遍,我会把更多时间花在数据上,而不是调参上。数据质量提升带来的收益远大于超参数微调。另外,我会更早地做评估,而不是等训练完才看效果。训练过程中定期生成样本,能及早发现问题。
这个内容后续还可以这样扩展:一是加入RAG(检索增强生成),让模型能查询外部知识库,解决幻觉问题;二是做多轮对话微调,让模型能记住上下文;三是尝试MoE(混合专家)架构,在相同算力下提升模型容量。这些方向我都在探索,有机会再分享。
最后分享一个小技巧:训练时用wandb或者tensorboard记录loss曲线和生成样本,方便回溯和对比。我一般每500步记录一次生成样本,训练完后看这些样本的演变过程,能直观感受到模型从胡言乱语到通顺对话的变化。这个习惯帮我省了很多排查时间。