很多人一提“模型训练”四个字,第一反应就是一大堆卡、几个星期的集群作业、深不见底的数学公式,好像不把Transformer论文倒背如流就不配碰训练代码。但我这些年做过的训练工程,大部分时间其实是在和最简单的三件事打交道:数据对不对、梯度有没有炸、loss降没降。训练代码真正跑起来那一刻,它就是一个输入输出都非常明确的程序——喂进去一批文本,算出一个loss,更新一次参数,循环一万次,如此而已。这一章不聊论文推导,直接带你把一段最小可运行的训练代码从零写出来、跑起来、调通,再看清楚训练出来的产物怎么保存、怎么转成本地模型格式、怎么让别人也能加载使用。适合想真正动手开始训练模型的读者,也适合那些已经跑过教程但总感觉“代码是代码、理论是理论”的人。
1. 在动手敲代码之前,先搞清楚训练到底在做什么
很多人把训练想得太神秘,其实它的本质一句话就能讲完:模型是一堆数字参数,训练就是反复调整这些数字,让模型的预测结果越来越接近真实答案。
1.1 一个类比:训练不是在“教”,而是在“调”
我用一个比较生活化的方式来拆解。你教一个孩子认苹果,不是把一张写着“苹果”的纸条塞进他脑子里,而是给他看大量苹果的图片,让他猜“这是什么”,猜错了你就告诉他正确答案,下次再猜。如此反复,他认苹果的正确率就上来了。
模型训练几乎是一模一样的流程:
- 前向传播:模型看到输入,给出一个预测(相当于“猜”);
- 损失函数:把预测和真实答案对比,算出一个“错得有多离谱”的分数;
- 反向传播:把这个分数沿着模型内部的参数往回传,让每一个参数知道自己该往哪个方向调、调多少;
- 优化器更新:按照反向传播给出的方向,把参数挪一步。
这四步重复成千上万次,模型从“瞎猜”变成“有依据地猜”,这就是训练。
1.2 一个最小训练闭环的五个组成部分
任何训练工程,哪怕复杂到千亿参数的大模型,跑起来之后都离不开这五个部分:
| 组成部分 | 作用 | 比喻 |
|---|---|---|
| 数据集 | 提供输入和正确答案 | 练习册和参考答案 |
| 模型结构 | 定义参数的排列方式 | 大脑的神经网络结构 |
| 损失函数 | 衡量预测和答案的差距 | 考试判分标准 |
| 优化器 | 决定如何调整参数 | 学习方法论 |
| 训练循环 | 把上面四者串起来反复执行 | 每天反复练习的计划表 |
一开始写训练代码,最忌讳的就是把这五样东西混在一起考虑。我见过很多初学者在同一个文件里既改模型结构又调损失函数,结果出问题根本不知道是哪一个环节导致的。建议先用最小的模型、最原始的数据把整条链路跑通,再逐步替换各部件。
1.3 为什么训练代码是最不该“玄学”的一环
很多人觉得模型结构是玄学,这我能理解,毕竟注意力机制、位置编码这些概念确实抽象。但训练代码本身不应该有玄学空间——因为它每一步都有可验证的中间输出:
- 数据长什么样,可以打印出来看;
- 模型预测出了什么,可以打印出来看;
- loss在降还是在涨,可以画出曲线看;
- 参数在更新还是没更新,可以打印梯度范数看。
只要你有意识地打印这些中间结果,训练过程就像透明管道一样清晰。所以这一章我反复强调一句话:任何“诡异”的训练现象,都逃不过三个流——数据流、梯度流、参数流。排查的时候逐个去检查,一定能定位到根因。
2. 最小可运行的训练工程:从语料到loss逐行拆解
这一节我们写一个真实可执行的Python工程。目标不是造出多聪明的模型,而是让你亲手把整条训练流水线搭起来,并且理解每一段代码在干什么。我会用PyTorch,因为它是目前最主流、资料最多的训练框架。
2.1 环境准备:其实不需要多贵的显卡
这个玩具级工程用CPU就能跑,不过如果你想体验GPU加速,需要装CUDA版PyTorch。建议用conda建一个独立环境,避免污染本机其他项目:
conda create -n model_training python=3.10 conda activate model_training pip install torch --index-url https://download.pytorch.org/whl/cpu如果要用GPU,把上面的安装命令换成对应CUDA版本的指令,这个自己在PyTorch官网选一下就行。我见过太多人在训练环境这里就卡住了,其实核心就是一件事:确认import torch之后,torch.cuda.is_available()能按预期返回True或False,而不是报一大堆依赖缺失。
2.2 准备语料:哪怕只有一首诗也能开始
训练代码不挑数据大小,关键是要有“输入-答案”对。我这边用一个txt文件,里面放几行文本就行。为了演示效果好一点,我放一首短诗:
床前明月光 疑是地上霜 举头望明月 低头思故乡不用嘲讽它数据量小——这是刻意为之。小数据能让你每次训练都跑得飞快,而且任何问题都能被快速定位。等流程跑通了,再换成大语料就行。
2.3 Tokenizer:把文字变成数字
模型不认识字符,只认识数字。所以第一件事是把语料里的每个字符映射成整数索引:
import torch text = open("poem.txt", encoding="utf-8").read() chars = sorted(list(set(text))) stoi = {ch: i for i, ch in enumerate(chars)} itos = {i: ch for i, ch in enumerate(chars)} vocab_size = len(chars) print(f"词表大小: {vocab_size}") print(f"字符映射: {stoi}")这里使用字符级分词,好处是简单直白,适合学习原理。真实工程里会用到BPE或SentencePiece之类更复杂的分词器,但它们的核心目标一样:把文本切块,映射成词表索引,再变成向量。你掌握了字符级,后面理解BPE只是把“单个字符”换成“子词片段”而已,训练代码流程毫无变化。
2.4 构造训练样本:输入和答案到底是什么
语言模型训练的核心思路是:给定前文,预测下一个字符。所以我们要把整段文本切成一个个“前文+下一个字符”的训练对。
context_len = 8 # 用前8个字符预测第9个字符 def make_dataset(text, context_len, stoi): # 先把文本整体转为索引序列 data = [stoi[c] for c in text] xs, ys = [], [] for i in range(len(data) - context_len): x = data[i : i + context_len] # 输入:前8个字符的索引 y = data[i + context_len] # 标签:第9个字符的索引 xs.append(x) ys.append(y) return torch.tensor(xs), torch.tensor(ys) xs, ys = make_dataset(text, context_len, stoi) print(f"样本总数: {xs.shape[0]}") print("输入示例:", [itos[i] for i in xs[0].tolist()]) print("标签示例:", itos[ys[0].item()])为什么标签是输入右移一位?这正是语言模型最基本的自监督方式——输入“床前明月光”,模型要预测下一个字“疑”;输入“前明月光疑”,预测“是”……所有的标签都是原文本中的下一个字符,不需要人工标注。这也是为什么说“语料本身就是标签”,语言模型天生就能在纯文本上训练。
2.5 模型定义:先从最简单的入手
很多教程一上来就贴完整Transformer实现,几百行代码新手直接劝退。我建议第一版用最简单的“查表模型”跑通闭环:
import torch.nn as nn import torch.nn.functional as F class BigramModel(nn.Module): """极简语言模型:只看上一个字符,预测下一个字符""" def __init__(self, vocab_size, embed_dim=64): super().__init__() self.token_embedding = nn.Embedding(vocab_size, embed_dim) self.output_layer = nn.Linear(embed_dim, vocab_size) def forward(self, idx): # idx shape: (batch, context_len) embed = self.token_embedding(idx) # (batch, context_len, embed_dim) logits = self.output_layer(embed) # (batch, context_len, vocab_size) return logits这个模型确实很弱——它每次预测下一个字符时,只参考当前这个字符,看不到更早的上下文。但它足够让你理解训练流程。训练代码跑通之后,再把它替换成真正的Transformer,结构变了,训练流程一个字都不用改。这就是“模型结构与训练框架解耦”带来的好处。
继续补全训练循环。这里封装一个简单的批量生成函数,按batch大小从数据集中取样:
def get_batch(xs, ys, batch_size=16): idx = torch.randint(0, xs.shape[0], (batch_size,)) xb = xs[idx] yb = ys[idx] return xb, yb model = BigramModel(vocab_size) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3) loss_fn = nn.CrossEntropyLoss() for step in range(5000): xb, yb = get_batch(xs, ys, batch_size=16) logits = model(xb) # 前向:模型给出预测 B, T, C = logits.shape loss = loss_fn(logits.view(B*T, C), yb) # 计算预测与真实标签的差距 optimizer.zero_grad() # 把上一次梯度清空 loss.backward() # 反向传播,计算每个参数的梯度 optimizer.step() # 优化器沿梯度反方向更新参数 if step % 500 == 0: print(f"step {step}, loss: {loss.item():.4f}")跑完之后,你会看到loss从最初的接近ln(vocab_size)(如果是十几字符的词表,大约2.5到2.8之间)逐渐下降。这就是“玄学”被拆穿的第一步:模型的进步被量化成了一个具体数字。
2.6 loss值背后到底意味着什么
loss在语言模型里通常是交叉熵,单位是“比特”或“nats”,可以粗浅理解为“平均猜错的程度”。初始loss接近均匀随机猜测的值,说明模型什么都没学会;loss持续下降,说明模型正在从语料中抓取统计规律。到后面,如果你在训练集上反复迭代,loss会变得很低——因为它把训练语料“背”下来了。但这是好事还是坏事?下一节细说。
3. 训练参数不是玄学:一份可以照抄的调参经验表
训练代码跑通之后,最大的困惑通常变成“这几个参数到底该怎么设”。学习率、batch size、epochs——每换一个数据集,这些值好像都要重新试。我不打算给你灌一堆理论,而是直接给出一张基于大量实操的参考表,再解释背后的判断依据。
3.1 学习率:最常见的翻车点
学习率的本质是每一步参数更新的“步长”。步子太大,直接跨过最低点,loss震荡甚至爆炸;步子太小,走几百步还在原地,loss下降慢得让人怀疑代码错了。
| 现象 | 典型loss曲线表现 | 处理方向 |
|---|---|---|
| 学习率过大 | loss在某个值附近剧烈震荡,甚至出现NaN | 调低学习率,如1e-3 -> 1e-4 |
| 学习率过小 | loss缓慢平滑下降,几乎贴在地板上 | 调高学习率,如1e-5 -> 1e-4 |
| 学习率适中 | loss先快速下降,后期缓慢平稳 | 保持,可考虑配合调度器衰减 |
语言模型训练里我习惯的起点是1e-4到3e-4之间,AdamW优化器配这个区间通常比较稳。如果loss完全不动,先别急着调学习率,回到代码检查数据流——这个问题在下一节重点讲。
3.2 batch size:梯度稳定度和显存之间的交易
batch size就是一次前向/反向传播同时处理的样本数。batch越小,每个batch的梯度噪声越大,但显存占用低;batch越大,梯度方向更稳定,但显存压力大。在实际工程里,先定显存上限能容纳多大batch,再决定其他超参数。
如果显存不够又想要大batch的效果,可以用梯度累积。比如想要等效batch为64,但显存只放得下16,那就每跑4个batch再更新一次参数:
accumulate_steps = 4 for step, (xb, yb) in enumerate(dataloader): loss = compute_loss(xb, yb) / accumulate_steps # 先归一化 loss.backward() if (step + 1) % accumulate_steps == 0: optimizer.step() optimizer.zero_grad()这是我实际项目里最常用的显存优化手段,几乎没有之一。
3.3 epochs、过拟合与“背下来”
epochs代表模型完整遍历训练数据的次数。小语料上同一个epoch跑多了,很容易观察到train loss持续下降、但验证集(或真实生成效果)不再变好,这就是过拟合的开始。判断是否过拟合,最直接的办法是在训练时保留一部分没见过的文本作为验证集,每训练若干步打印一次验证loss。如果训练loss降而验证loss升,基本可以断定模型开始背题了。
对小规模训练,我的经验是:先跑固定步数,比如2000步,观察loss变化趋势;只要验证loss还在降,就让它继续跑;一旦验证loss连续很多步不降反升,就停。
3.4 梯度裁剪和权重衰减:救命用的两个开关
训练大模型时我基本每次都会加梯度裁剪:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)它的作用是:反向传播计算出梯度后、优化器更新之前,如果梯度范数超过阈值,就整体缩放回阈值范围内。这样即使数据里出现个别极端样本,也不至于让参数一步跳动过大,是防止loss突然爆成NaN的最有效手段之一。
权重衰减(weight decay)则是对大参数施加惩罚,让模型参数不会无限膨胀,在许多Transformer训练中是标准配置。AdamW优化器自带这个参数:
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-2)不过第一次跑通流程时,我建议先不开这些,保持代码简单;等遇到问题了再加,这样你对每个操作的作用会有更直观的感受。
4. 训练过程排查清单:从loss不降到显存爆炸
训练代码跑起来只是第一步,真正折磨人的是“看起来在跑,但结果不对”。这一节把我实际踩过、也帮别人排查过的高频问题整理成一条链,照着顺序查,通常能快速定位。
4.1 loss不降/不收敛:先检查数据流
很多人一看到loss不降,立刻怀疑模型结构、学习率。我的排查顺序完全不同,第一步永远是把输入和标签打印出来看:
xb, yb = get_batch(xs, ys, batch_size=4) print("输入 shape:", xb.shape, "标签 shape:", yb.shape) # 把第一个样本还原成字符,确认映射关系 for i in range(4): print("输入:", "".join([itos[t] for t in xb[i].tolist()])) print("标签:", itos[yb[i].item()])这一步能一次性排除无语病:是否所有字符都在词表里、上下文窗口是否错位、标签是否真的是“下一个字符”。我遇到过太多次所谓“loss不降”的case,最后都是标签拼错了——有的错位了一个字符,有的把输入和标签弄混,有的padding符号没过滤。数据流通了,再看看loss公式和预测输出的shape,这些都对,最后才轮到调学习率。
4.2 显存OOM:按顺序砍需求
在你真正训练大模型之前,OOM基本出现在两种场景:一是batch size太大,二是序列太长(context_len设太大)。排查顺序建议如下:
- 把batch size调小一半,通常最立竿见影;
- 确认模型在GPU上而不是CPU上(如果连GPU都还没有,先检查
torch.cuda.is_available()); - 确认输入张量的维度没有异常膨胀,尤其是别把整个数据集一次性丢进模型;
- 如果batch已经很小还OOM,考虑梯度累积或减小模型维度(embed_dim、hidden层数);
- 最后考虑
torch.utils.checkpoint梯度检查点,它是用计算换显存,训练速度会变慢。
4.3 输出token上限和回答截断:训练和推理要分开看
很多人把自己本地模型回答到一半就停了归咎于“没训练好”,其实大多数时候跟训练无关。模型生成文本的长度上限取决于推理时的配置,而不是训练收敛度。在生成阶段,有两个位置可以限制长度:
max_new_tokens:最多生成多少新token;- 上下文窗口:输入占掉的长度 + 可生成的长度不能超过模型训练时的窗口大小。
如果你生成一段文字,到某个固定的token数就被截断,且每次都差不多同一个位置,那十有八九是达到上限了。处理办法也简单:要么调大推理时的max_new_tokens,要么把已有的输出拼到输入上下文里,让模型继续接写。很多聊天软件里的“继续”按钮,底层就是这样做的——不是模型突然被“唤醒”了,而是把前面的历史输出接回去再跑一次生成。
4.4 一个真实debug复盘:loss卡在2.3不动
有次我跑一个字符级模型,loss下降非常顺利,降到2.3之后突然卡住不动。训练日志看起来一切正常,前向、反向、优化器都在执行。我当时的排查过程是:
- 打印loss曲线,确认不是震荡,而是真的“平”了;
- 检查学习率,已经设得很小,排除步长过大;
- 打印一个batch的输入输出,发现数据里出现了很多重复的高频词,而且上下文窗口只有8个字符——模型学到的规律很快就饱和了;
- 把context_len从8增加到32,loss立刻重新开始下降。
这个问题暴露了一个关键原则:模型能力的天花板往往不来自训练代码,而来自数据所携带的信息量。上下文太短,模型根本看不到长距离依赖的规律,它想学也学不了。所以当训练陷入平台期,除了调超参数,更要想想是不是模型的“视野”不够、数据太少、或者数据本身信息太单调。
5. 训练完的模型怎么用:保存、导出GGUF、本地加载推理
训练再久,模型最终都要变成可以被别人调用、部署的东西。我见过不少朋友训练完就只会在训练脚本里用,换一个项目就不知道怎么加载了,这里专门把“训练产物走向本地部署”的完整路径讲清楚。
5.1 正确保存权重与checkpoint管理
训练过程中的每一步优化器状态、当前epoch、验证loss都值得存下来。只保存model.state_dict()的话,你只能恢复模型结构,却没法从断点继续训练:
checkpoint = { "model_state_dict": model.state_dict(), "optimizer_state_dict": optimizer.state_dict(), "step": step, "best_val_loss": best_val_loss, "config": {"vocab_size": vocab_size, "embed_dim": embed_dim, "context_len": context_len} } torch.save(checkpoint, "checkpoint_last.pt")还应该把验证loss最低的那份单独存一份,作为“best model”。这算是训练工程里最基础的习惯,但特别多人不做,一旦训练中断就得从头来,非常亏。
5.2 从PyTorch权重到GGUF格式
训练对话模型、本地部署时,GGUF是绕不开的格式。GGUF是llama.cpp社区推动的模型存储格式,最大的特点是支持CPU整数量化推理,模型文件能压得很小,普通笔记本也能跑。转换路径大致是:
- 先用HuggingFace的
transformers把模型导出成标准HF格式,也就是包含config.json和*.safetensors权重文件的目录; - 再用llama.cpp仓库里的
convert_hf_to_gguf.py脚本把它转成GGUF; - 根据需求选择量化等级,常见的有
q4_k_m、q8_0等,量化位数越低文件越小,但精度损失也越明显。
一个常见的坑是:转换脚本要求模型结构必须在llama.cpp支持的架构列表里,如果你的模型结构是自己魔改的,转换脚本大概率会报错。所以如果你的目标是要部署到Ollama等本地工具,训练之前就最好选社区支持的成熟架构,比如LLaMA、Mistral、Qwen系列的结构。
5.3 用Ollama加载本地模型推理
拿到GGUF文件之后,Ollama的接入流程非常短。写一个Modelfile:
FROM ./my_model.gguf TEMPLATE "{{ .Prompt }}"然后执行:
ollama create mymodel -f Modelfile ollama run mymodel这样你的模型就被注册成了本地模型服务,可以直接在命令行对话,也可以通过Ollama的HTTP API接入到各种前端。这套流程我复现过很多次,只要GGUF文件本身没损坏,基本一次成功。
还需要注意:Ollama默认的上下文长度和你的模型训练时的context_len要匹配。如果你的模型训练窗口只有128,推理时给超出这个长度的prompt,后面的内容会被截掉甚至出乱码。很多“本地模型答非所问”的投诉,其实都是窗口配置不匹配导致的。
5.4 “训练完用不起来”的真正原因
结合我帮人排查部署问题的经验,“训练完用不起来”最常见的原因就这几条:
| 症状 | 根因 | 处理 |
|---|---|---|
| 加载权重报shape不匹配 | 保存时的模型结构和加载时的模型结构不一致 | 确认config完全一致,尤其vocab_size、embed_dim |
| 生成的全是乱码 | 模型没有做softmax采样,或词表映射错误 | 推理时先F.softmax(logits, dim=-1)再按概率采样 |
| 输出长度短、中途停 | 推理参数max_new_tokens太小或上下文窗口不满 | 调大max_new_tokens,缩短输入prompt |
| CPU推理速度极慢 | 模型参数量大且未量化 | 用GGUF的量化版,或减小模型规模 |
最后补充一条很实在的个人习惯:我在每次训练一开始就把“生成样例”逻辑写好,每跑几百步就顺手让当前模型生成一句文本看看效果。loss只是一堆数字,真正能直观感受到模型“学会了一点点东西”的,是看到它从输出乱码,到逐渐拼出像样的词,再到能续写出通顺的句子。那种成就感,比看一万条训练日志都来得实在。
这一章写的代码精简到了不能再精简的程度,但训练的本质流程——数据准备、分组建batch、前向、损失、反向、优化器更新、模型导出——已经全部体现出来了。你后面接触任何训练框架,看到的都是这套主干加上更复杂的处理细节。能用小数据把这条路走通,再换大数据、大模型的时候,你至少不会慌:因为你自己亲手写过完整的训练闭环,那些框架里的封装对你来说就不是黑盒了。下一章我会接着写怎么把训练脚本逐步整理成可复用的工程,包括实验记录、日志监控和训练中断恢复,到时候可以拿这章的checkpoint保存习惯接着往下做。