一提到大语言模型微调,我们很容易联想到多张显卡、复杂的训练框架和昂贵的算力。为了把这件事拆开看清楚,我在一台没有 CUDA 独立显卡的 Windows PC 上,用自己写的一个小型 PyTorch 训练器,对 Qwen2.5-0.5B 做了一次 LoRA 微调。
这不是一份追求榜单成绩的实验报告,而是一次很适合用来理解微调全流程的“小而完整”的案例:准备文本、把文本变成 token、加载预训练模型、添加 LoRA、反向传播、定期验证,最后保存一个可以单独加载或合并回基础模型的适配器。
在 120 个参数更新步骤、共约 49 万个训练 token 之后,验证集困惑度(perplexity)从 20.064 降到 11.690,下降 41.74%。整个过程中只训练了 879.82 万个参数,占模型加适配器总参数量的 1.75%。
训练在 Intel i7-14700 CPU 上完成,峰值进程内存约 5.72 GB,整次运行耗时 1 小时 29 分钟。由于后半程有其他程序争用 CPU,这个时间并不代表机器空闲时的最佳速度。
1 什么是微调?
预训练语言模型已经阅读过大量文本,学会了词语之间的关系、语言结构以及一部分世界知识。所谓微调(fine-tuning),就是不再从零训练一个模型,而是在已有模型参数的基础上,继续用一批更有针对性的数据训练,让模型更适合某种内容、任务或输出方式。
可以把预训练模型想成一位已经完成通识教育的学生:
- 继续阅读某一领域的资料,是让它熟悉这个领域的语言分布;
- 学习“问题—答案”样例,是训练它按照指令回答;
- 学习带类别标签的样例,是训练它完成分类任务;
- 学习人类偏好数据,则是在调整它“怎样回答更好”。
“微调”因此是一个很宽泛的总称。它和另外两种常见方法的区别是:提示词只改变本次输入,RAG 在回答时临时检索外部资料,而微调会真正改变模型参数或附加参数。
| 方法 | 发生了什么 | 知识保存在哪里 | 适合解决什么问题 |
|---|---|---|---|
| 提示词(prompt) | 给模型更明确的临时说明 | 当前上下文 | 约束一次回答的任务与格式 |
| RAG | 先检索资料,再把资料交给模型 | 外部知识库 | 需要更新、引用或可追溯的事实知识 |
| 微调 | 用样例继续优化参数 | 模型权重或适配器 | 稳定的风格、行为、任务模式或领域分布 |
1.1 本例严格来说是哪一种微调?
这次训练用的是 WikiText-2 正文,目标仍然是“根据前面的 token 预测下一个 token”。代码中没有问题—回答对,也没有聊天模板。因此,更准确的称呼是继续预训练(continued pretraining)或领域适配(domain adaptation),而不是指令微调(SFT)。
这个区别很重要:本次结果能够说明模型对 WikiText-2 这类英文百科文本预测得更好了,但不能直接说明它更会聊天、更听指令,或者事实问答能力提高了。
2 基础模型:Qwen2.5-0.5B
Qwen2.5-0.5B 是 Qwen2.5 系列里的小型基础语言模型。“0.5B”表示大约 5 亿参数;这里使用的是 Base 模型,即预训练完成但没有专门做对话指令对齐的版本。
| 项目 | 本地模型配置 |
|---|---|
| 基础模型参数量 | 约 4.94 亿 |
| Transformer 层数 | 24 |
| 隐藏维度 | 896 |
| 注意力头 | 14 个 query head、2 个 key/value head |
| 词表大小 | 151,936 |
| 最大上下文长度 | 32,768 token |
| 模型类型 | causal language model(因果语言模型) |
因果语言模型的核心任务非常朴素:看到一串 token 后,预测下一个 token。比如输入“机器学习可以”,模型会给词表中的每个候选 token 一个概率。这项看似简单的任务不断重复,最终构成了文本生成能力。
本次训练虽然模型最多支持 32,768 token 的上下文,但实际把序列长度设为 512。模型“支持多长”和训练时“实际喂多长”是两回事;较短序列会显著节省训练时间和激活内存。
3 数据集:WikiText-2
WikiText-2 是一个常用于语言建模的英文数据集,内容来自经过筛选的 Wikipedia 文章。它保留了大小写、标点和数字等原始文本特征,因此比已经高度清洗、只剩词语序列的数据更接近真实文章。
数据集原本分为 train、validation 和 test 三部分。本次实验只使用训练集更新参数,并用验证集观察训练过程;测试集没有参与训练或本次结果计算。
| 数据 | 原始文件中的非空行 | 分词后的 token | 打包后的 512-token 块 | 本次用途 |
|---|---|---|---|---|
| train | 23,767 | 2,540,999 | 4,962 | 随机打乱后训练 |
| validation | 2,461 | 264,671 | 516 | 取前 100 块定期评估 |
| test | 2,891 | 未在本次运行中处理 | 未在本次运行中处理 | 未使用 |
3.1 文本怎样变成模型能读取的数据?
神经网络不能直接接收字符串。训练器会先用 Qwen 自带的 tokenizer 把文本切成 token,再把每个 token 映射为词表中的整数编号。例如,一个英文单词可能是一个 token,也可能被拆成几个更小的片段;空格、标点和中文字符同样可能成为 token 或 token 的一部分。
代码没有把每一行分别补齐到 512 token,而是采用 block packing:
- 逐行读取非空文本;
- 对每行进行分词,并在行尾添加 EOS(序列结束)标记;
- 把所有 token 首尾连接;
- 每 512 个 token 切成一个等长训练块;
- 丢弃最后不足 512 个 token 的尾部。
对应的核心逻辑可以简化为:
encoded = tokenizer(batch, add_special_tokens=False)["input_ids"]
for ids in encoded:
flat.extend(ids)
flat.append(tokenizer.eos_token_id)
blocks = tokens[:n_blocks * seq_len].reshape(n_blocks, seq_len)这样做的好处是几乎没有 padding(为了凑齐长度而添加的空白 token),计算不会浪费在无意义的位置上。首次处理后的数组会保存成 NumPy 的 .npy 缓存;以后只要原始文件、模型路径和序列长度没有变化,就不必重新分词。
3.2 输入和答案为什么是同一份 token?
数据加载代码里有一行乍看有些奇怪:
return {"input_ids": input_ids, "labels": input_ids.clone()}这是因为 causal language model 会在计算 loss 时自动错开一位。假设 token 是 [A, B, C, D],模型实际学习的是:
- 看到
A,预测B; - 看到
A, B,预测C; - 看到
A, B, C,预测D。
所以代码可以把同一串 token 同时作为输入和标签,位置偏移由模型内部完成。
4 为什么用 LoRA,而不是更新全部参数?
最直接的全量微调会让模型里的每个参数都参与训练。它表达能力强,但还要为大量参数保存梯度和 AdamW 优化器状态,内存与存储成本都很高。
LoRA(Low-Rank Adaptation,低秩适配)的思路是冻结原来的大矩阵 \(W\),不直接修改它;在旁边增加两个小矩阵 \(A\) 和 \(B\),只学习它们的乘积:
\[ W' = W + \frac{\alpha}{r}BA \]
其中 \(r\) 是 rank(秩)。如果原矩阵的输入、输出维度都很大,而 \(r\) 只有 8 或 16,那么 \(A\)、\(B\) 的参数量就远小于重新训练整个 \(W\)。可以把它理解为:基础模型的通用能力保持不动,LoRA 学习一份小型“修正说明”。
这次实验的 LoRA 配置是:
lora_cfg = LoraConfig(
r=16,
lora_alpha=32,
lora_dropout=0.05,
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
],
bias="none",
task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_cfg)这里几个参数可以这样理解:
r=16:LoRA 中间那条“窄通道”的宽度。rank 越大,能表达的变化通常越丰富,但参数量和训练成本也随之增加;lora_alpha=32:控制 LoRA 修正量的缩放。本例中 \(\alpha/r=2\);lora_dropout=0.05:训练时随机丢弃 5% 的 LoRA 输入,用于正则化;target_modules:LoRA 插入的位置。前四项是注意力模块中的投影,后三项是前馈网络中的投影。
最终,基础模型约 4.94 亿参数保持冻结,新增并训练 879.82 万个 LoRA 参数。保存下来的 adapter_model.safetensors 只有约 33.6 MiB;使用时可以把它叠加在基础模型上,也可以通过 merge_and_unload() 合并成一个独立模型。
LoRA 大幅减少的是可训练参数、梯度、优化器状态和输出文件。它并不意味着基础模型从内存中消失:前向传播和反向传播仍然要经过冻结的基础模型,也仍需保存部分激活值。因此,“只训练 1.75% 参数”不能简单理解成“只用 1.75% 的内存或时间”。
5 代码使用了哪些 package?
这个项目刻意没有使用 transformers.Trainer,而是手写了训练循环。这样代码更长一点,但数据、梯度和参数更新怎样流动会更清楚。
| package | 在项目中的作用 |
|---|---|
| PyTorch | 张量计算、自动求导、DataLoader、AdamW、梯度裁剪和参数更新 |
| Transformers | 根据本地模型目录加载 Qwen 的 tokenizer 和 causal LM |
| PEFT | 把 LoRA 适配器挂到模型上,并保存、加载或合并适配器 |
| NumPy | 用紧凑的 uint32 数组缓存分词结果 |
| psutil | 采集进程内存、CPU 使用率和系统资源信息 |
| Python 标准库 | 参数解析、JSON/JSONL 日志、路径、计时、线程与检查点管理 |
项目也特意没有依赖 Hugging Face datasets:输入只是本地 .raw 文本,用 Python 逐行读取已经足够,不需要为了这一步额外引入 pandas 和 PyArrow。
6 一次训练步骤里发生了什么?
实际训练循环的核心只有下面几件事:清空旧梯度、前向计算、反向传播、裁剪梯度、更新参数、调整学习率。
optimizer.zero_grad(set_to_none=True)
for _ in range(grad_accum_steps):
batch = next(stream)
out = model(input_ids=batch["input_ids"], labels=batch["labels"])
loss = out.loss / grad_accum_steps
loss.backward()
torch.nn.utils.clip_grad_norm_(trainable_params, max_norm=1.0)
optimizer.step()
scheduler.step()6.1 梯度累积是在做什么?
本次 micro_batch_size=1,即每次前向传播只处理一个 512-token 序列;但代码连续计算 8 个小批次的梯度,再更新一次参数。因此:
\[ \text{effective batch}=1\times 8=8\text{ 个序列} \]
\[ \text{tokens per step}=8\times512=4096 \]
这就是梯度累积:用更多计算时间换取较低的瞬时内存,同时让一次更新综合多个样本的信息。代码把每个小批次的 loss 除以 8,是为了让最终梯度代表 8 个小批次的平均值,而不是总和。
要注意日志里的一个 step 指的是一次 optimizer.step(),本例每个 step 内部其实做了 8 次前向和反向传播。120 step 一共看过 \(120\times4096=491{,}520\) 个训练 token,大约相当于完整训练集的 0.19 个 epoch,还没有遍历一遍数据。
6.2 优化器和学习率
项目使用 AdamW,只把可训练的 LoRA 参数交给优化器。初始学习率是 2e-4;前 3% 的步骤用于 warmup,之后按余弦曲线逐步衰减,最低降到初始值的 10%,也就是 2e-5。
warmup 的作用是避免刚开始时更新过猛;后期降低学习率,则像在接近目标后缩小步幅。梯度范数还会裁剪到 1.0,以降低偶发大梯度造成训练不稳定的风险。
7 这次实验的完整设置
| 项目 | 设置 |
|---|---|
| 设备 | Intel i7-14700,20 个物理核 / 28 个逻辑处理器 |
| GPU | 无 CUDA GPU,纯 CPU 训练 |
| 内存 | 63.6 GB 系统 RAM |
| 软件 | Python 3.12.14、PyTorch 2.14.0+cpu、Transformers 5.16.1、PEFT 0.20.0 |
| 模型 | Qwen2.5-0.5B Base |
| 数据 | WikiText-2 train;WikiText-2 validation 前 100 个 block 用于评估 |
| 精度 | float32 |
| LoRA | rank 16、alpha 32、dropout 0.05 |
| 序列长度 | 512 token |
| micro batch / 梯度累积 | 1 / 8 |
| 有效 batch | 8 个序列,即每 step 4,096 token |
| 更新步数 | 120 |
| 学习率 | 2e-4,3% warmup + cosine decay,最低 2e-5 |
| 验证与保存 | 每 20 step 验证,每 40 step 保存检查点 |
| PyTorch 线程数 | 20 |
实际启动命令被包装在 PowerShell 脚本中:
powershell -ExecutionPolicy Bypass -File .\scripts\run.ps1 `
-Steps 120 `
-Tag wikitext2脚本会自动创建带时间戳的运行目录。除了最终适配器,还会保存 config.json、逐步的 metrics.jsonl、控制台日志、汇总结果和中间检查点。训练实验最怕“只记得结果,不记得怎么跑出来的”,所以这些产物和模型本身同样重要。
8 怎样读懂训练指标?
8.1 Loss
本例的 loss 是交叉熵损失。直观地说,如果正确的下一个 token 得到的概率越高,loss 就越低。它适合观察同一个模型、同一数据处理方式下的训练趋势,但一个孤立的 loss 数字不太直观。
训练 loss 会随随机抽到的文本难度而波动,所以日志中某几个 step 回升并不等于训练失败。比起盯着单点,更应该观察一段区间的平均值,并结合独立验证集判断。
8.2 困惑度(Perplexity, PPL)
困惑度是语言模型中最常见的指标之一,本例中可理解为:
\[ \mathrm{PPL}=e^{\mathrm{loss}} \]
如果 PPL 为 20,可以非常粗略地想成模型在每个位置平均“像是在 20 个同等可能的候选中犹豫”;PPL 降到 11.69,说明模型对正确下一个 token 的概率分布更集中。
这个比喻只能帮助建立直觉。PPL 不是准确率,也不能脱离数据集、tokenizer 和预处理方式横向比较。一个使用不同词表切分方式的模型,即使 PPL 更低,也未必生成质量更好。
8.3 吞吐量、step time 和 RSS
tok/s:每秒处理多少训练 token;越高通常越快;s/step:完成一次参数更新花费多少秒。本例一个 step 包含 8 个小批次,比较时必须先确认定义一致;- RSS:操作系统看到的进程常驻物理内存,可用来观察峰值内存以及是否可能存在泄漏;
- wall-clock time:从程序开始到结束的实际时间,包括验证、保存和等待资源的时间。
9 实际结果怎么解读?
验证集每隔 20 step 计算一次,结果如下:
| step | validation loss | validation PPL | 相对初始 PPL 的下降 |
|---|---|---|---|
| 0 | 2.9989 | 20.064 | — |
| 20 | 2.5283 | 12.533 | 37.53% |
| 40 | 2.4867 | 12.021 | 40.08% |
| 60 | 2.4740 | 11.870 | 40.84% |
| 80 | 2.4657 | 11.771 | 41.33% |
| 100 | 2.4613 | 11.720 | 41.58% |
| 120 | 2.4588 | 11.690 | 41.74% |
最明显的提升发生在前 20 step:PPL 一口气从 20.064 降到 12.533。此后仍然持续改善,但收益迅速变小;从 step 60 到 120,PPL 只再下降约 1.51%。这告诉我们两个很实用的事实:
- 预训练模型原本就具备英文语言能力,少量更新即可快速适应 WikiText-2 的文本分布;
- “继续多跑一些 step”未必是最高效的提升方式。曲线趋平后,也许更值得尝试学习率、LoRA rank、目标模块或数据质量。
验证 PPL 在已测的 120 step 内一直下降,说明这段范围内没有观察到验证性能反转;但这不等于已经证明永远不会过拟合。要判断更长训练是否过拟合,仍然需要继续记录验证曲线,并最终在未参与调参的测试集上评估。
9.1 资源和速度结果
| 指标 | 实测值 |
|---|---|
| 总训练 token | 491,520 |
| wall-clock time | 1 小时 29 分 27 秒 |
| step time 中位数 | 29.745 秒 |
| 平均训练吞吐量 | 111.9 token/s |
| 峰值 RSS | 5,715 MB |
| 最终 LoRA 适配器大小 | 33.6 MiB |
后半段 step time 从约 29–30 秒升到了约 49–50 秒,验证时间也从约 80 秒升到了约 180 秒。同期进程 RSS 没有持续增长、系统可用内存也基本稳定,而机器上另有 CPU 密集型程序运行,因此更符合外部 CPU 争用,不像训练循环自身的内存泄漏。
这也是性能实验中很容易忽视的一点:总耗时不只由模型和超参数决定,还取决于测量时机器是否空闲。根据空闲阶段的速度估计,这组设置在没有明显争用时约需 1 小时,而不是 1 小时 29 分钟。
10 CPU 训练带来的两个小经验
10.1 线程并不是越多越快
这台 i7-14700 有 28 个逻辑处理器,但预先做的小规模基准测试显示,20 个 PyTorch 线程反而最快:
| 线程数 | 512-token 单小批次耗时 | 吞吐量 |
|---|---|---|
| 4 | 8.57 s | 59.8 tok/s |
| 8 | 5.27 s | 97.1 tok/s |
| 10 | 4.39 s | 116.5 tok/s |
| 20 | 4.23 s | 121.1 tok/s |
| 28 | 4.99 s | 102.7 tok/s |
使用全部逻辑处理器反而比 20 线程慢约 15%。这是因为超线程以及性能核、能效核之间会争用共享资源。最可靠的做法不是猜一个线程数,而是在目标机器上用短基准实测。
10.2 序列变长的成本不是线性的
在 20 线程下,序列长度 256 和 512 的吞吐量几乎一样,分别为 122.1 和 120.2 tok/s;增加到 1,024 后则降到 78.1 tok/s,峰值 RSS 也从约 5.46 GB 增至约 8.51 GB。
原因之一是标准 self-attention 需要比较序列中的 token 对,计算和中间结果会随序列长度近似二次增长。因此,更长上下文不是免费的。对这台机器和这个模型来说,512 是兼顾上下文与吞吐量的合理选择。
11 这次结果能说明什么,不能说明什么?
这次实验能够说明:
- 一台内存充足、没有独立显卡的普通 PC,也能完整跑通 0.5B 模型的 LoRA 训练;
- 在不足一个 epoch 的训练量下,LoRA 已经能明显改善模型对目标文本分布的预测;
- 手写训练循环并不神秘,核心仍是数据、前向传播、反向传播和优化器更新;
- 完整记录配置、验证曲线、资源占用和检查点,才能让“训练成功”成为可核对的结论。
它不能说明:
- 模型的对话和指令遵循能力提高了;
- 模型学会了可靠、可更新的 Wikipedia 事实知识;
- 120 step 或 rank 16 是其他任务的最优设置;
- 最终 PPL 可以与采用不同 tokenizer 或数据处理方式的结果直接比较;
- 这组 wall-clock time 是 i7-14700 在完全空闲时的性能上限。
如果要把这个例子改造成真正的指令微调,最关键的变化不是把 step 调得更多,而是换成高质量的指令—回答数据,应用模型对应的 chat template,并在计算 loss 时明确哪些 token 需要参与监督。相反,如果目标是让模型熟悉某个专业领域的写作分布,那么本例这种继续预训练流程就是一个合理起点。
12 总结
微调并不是“重新训练一个大模型”,而是利用预训练模型已有的能力,进行方向明确的增量学习。LoRA 又进一步把这种增量压缩到少量低秩参数中,使小模型在 CPU 上训练也变得可行。
这个案例里最值得记住的不是 11.690 这个单一数字,而是数字背后的链条:WikiText-2 文本经过 tokenizer 和 block packing 变成训练块;Qwen2.5-0.5B 负责下一个 token 预测;LoRA 只开放 1.75% 的参数;AdamW 根据梯度更新这些参数;验证 loss 和 PPL 则帮助我们判断模型是否真的在目标分布上进步。
理解了这条链条,再面对更大的模型、更复杂的数据集或更高级的训练框架时,基本问题仍然是相同的:改了哪些参数、数据教了什么、指标测了什么,以及计算成本是否值得。