本文是《算法工程师的工具箱:从一个想法到一次能跑的实验》系列的第 4 篇(共五篇)。上一篇:PyTorch 使用层(下)——混合精度、显存的账与多卡启用;下一篇:GPU 直觉与实验管理——两个上限、四块显存、能复现

前两篇的训练循环训的是自己写的小模型。真实工作里模型不是自己写的——是从 Hugging Face Hub 下载的 Llama、Qwen、DeepSeek;数据也不是随机切窗口——是 Hub 上的数据集经过 chat template、loss mask、packing;训练器也不是二十行——是 trlSFTTrainer / DPOTrainer / GRPOTrainer。Hugging Face 的六个库把这条路铺好了,六行代码能组装一次 LoRA SFT。这一篇讲六个库各管什么、六行背后发生了什么、以及一个比任何教程都重要的习惯:卡住的时候直接读源码

全篇的核心问题是:

能不能用 peft + trl 在一小时内跑起一个 LoRA SFT?卡住的时候能不能直接读源码找到原因?

一、总览

1. 六个库

%%{init: {"flowchart": {"wrappingWidth": 220}}}%%
flowchart LR
    HUB["`**Hub**
config.json · safetensors
tokenizer.json · 数据集`"]
    DS["`**datasets**
load · map · filter
streaming · Arrow`"]
    TOK["`**tokenizers**
BPE 训练与编码
chat template 应用`"]
    TF["`**transformers**
AutoModel · AutoTokenizer
generate · Trainer`"]
    PEFT["`**peft**
LoraConfig · get_peft_model
merge_and_unload`"]
    TRL["`**trl**
SFTTrainer · DPOTrainer
GRPOTrainer · RewardTrainer`"]
    ACC["`**accelerate**
launch · 设备放置
DDP / FSDP / DeepSpeed 配置`"]

    HUB --> DS & TF
    DS --> TOK --> TRL
    TF --> PEFT --> TRL
    TRL --> ACC

    classDef lib fill:#eef4fb,stroke:#5b8dc9,stroke-width:1px,color:#222
    classDef hub fill:#fff7e0,stroke:#c98a00,stroke-width:2px,color:#222
    class DS,TOK,TF,PEFT,TRL,ACC lib
    class HUB hub

2. 本文的章节安排

主题 内容
Hub 上的三个文件 config.jsontokenizer.json*.safetensors;从 config 算参数量
六个库各管什么 transformersdatasetstokenizerspefttrlaccelerate
六行组装一次 LoRA SFT 代码;背后的每件事在第二篇二十行里的位置
在 0.5B 模型上跑通 chat template、loss mask 比例、LoRA 参数量、20 步的 loss
为什么读源码是最快的路 六个入口与它们的长度;从 compute_loss 往下追
自测 五道题
本文小结  

配套脚本:04_hf_lora_sft.py(需要下载 Qwen2.5-0.5B,约 1 GB)。

二、Hub 上的三个文件

拿到一个模型的 Hub 页面,先看三个文件:

1. config.json:结构超参数

{"hidden_size": 896, "num_hidden_layers": 24, "num_attention_heads": 14, "num_key_value_heads": 2,
 "intermediate_size": 4864, "vocab_size": 151936, "tie_word_embeddings": true, ...}

这是 Qwen2.5-0.5B 的。用 L0 第一篇的表就能算出参数量:每层 attention 四个矩阵 + MLP 三个矩阵,乘层数,加词嵌入。tie_word_embeddings: true 说明输出层与词嵌入共享一份权重(小模型常这样做,省 \(V \times d = 136\)M 参数)。脚本加载后数出 494M 参数,与名字里的 “0.5B” 对上。L4《Transformer 与 LLM》第一篇专门教从 config.json 算参数量。

2. tokenizer.jsontokenizer_config.json

词表、合并规则、特殊 token(<|im_start|><|im_end|><|endoftext|>)、以及 chat template——一段 Jinja 模板,规定”一轮对话怎么拼成一个字符串”。第五章会看到它的输出。L4 预训练系列的第一篇讲 tokenizer 本身。

3. *.safetensors:权重

state_dict(第二篇)的磁盘格式:参数名 → 张量,按名字分片成几个文件,带一个 index.json 索引。safetensors 格式的好处是不用加载全部就能读某一层(内存映射),且不像 pickle 那样能执行任意代码。

模型卡(README)里的评测数字,读的时候带着 L0 第八篇的置信区间。

三、六个库各管什么

负责 要会的
transformers 模型定义与加载(modeling_llama.py 一类)、tokenizer 封装、generateTrainer AutoModelForCausalLM.from_pretrained(..., dtype=torch.bfloat16)tokenizer.apply_chat_templategenerate 的采样参数;读 modeling_*.py
datasets 数据加载与处理,底层 Apache Arrow(内存映射、零拷贝) load_datasetmap(batched=True, num_proc=...)filterstreaming=True 处理放不进内存的语料
tokenizers 分词器的训练与快速编码(Rust 实现) 训练一个 BPE 词表;理解 tokenizer.json 里的 normalizer / pre-tokenizer / model / post-processor 四段
peft 参数高效微调 LoraConfig(r, lora_alpha, target_modules, dropout)get_peft_model、训练后 merge_and_unload 合回基座
trl 后训练的各个 Trainer SFTTrainer(自动处理 chat template、packing、loss mask)、DPOTrainerGRPOTrainerRewardTrainer
accelerate 把单卡脚本变多卡,统一 DDP / FSDP / DeepSpeed 的启动 accelerate config 生成配置;accelerate launch train.py

它们的分工对应第二篇的五个对象:transformersnn.Module(模型)与 tokenizer,datasetsDatasetpeftnn.Module(在线性层旁边挂 LoRA),trl 给训练循环,accelerate 给第三篇的多卡启动。

generate 的采样参数

model.generate(..., do_sample=True, temperature=0.7, top_p=0.9, max_new_tokens=256) 里的每个参数都是 L0 第五篇第七章的东西:温度、top-p、greedy(do_sample=False)。实现上每个都是一个 LogitsProcessor——对 logits 做一次变换再采样——第六章给源码入口。

四、六行组装一次 LoRA SFT

1. 代码

model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3.1-8B", dtype=torch.bfloat16)
tok = AutoTokenizer.from_pretrained("meta-llama/Llama-3.1-8B")
model = get_peft_model(model, LoraConfig(r=16, lora_alpha=32, target_modules="all-linear", lora_dropout=0.05))
ds = load_dataset("HuggingFaceH4/ultrachat_200k", split="train_sft")
trainer = SFTTrainer(model=model, train_dataset=ds, processing_class=tok, args=SFTConfig(...))
trainer.train()

2. 背后发生的事

六行背后每一件事都在第二篇的二十行里有对应位置:

发生的事 谁做的 对应二十行里的
数据被套上 chat template:每一轮用 im_start / im_end 一类特殊 token 包起来(第五章有实例) SFTTrainertok.apply_chat_template Dataset__getitem__
回复之外的 token 的 label 被置成 −100 SFTTrainercompletion_only_loss ignore_index=-100——SFT 的 loss mask
多条短样本被 pack 进一个序列(可选) SFTConfig(packing=True) collate_fn
LoRA 的 \(A\)、\(B\) 被挂到每个线性层旁边,基座冻结 get_peft_model nn.Module 的改造;requires_grad
AdamW 只更新 \(A\)、\(B\) Trainermodel.parameters()requires_grad=True opt = AdamW(model.parameters())
bf16、梯度裁剪、学习率调度、日志、checkpoint SFTConfig 的字段 autocastclip_grad_norm_schedlog

LoraConfig 的四个参数:r 是秩(L0 第三篇);lora_alpha 是缩放,实际加到输出上的是 \(\frac{\alpha}{r} BA x\),常取 \(\alpha = 2r\);target_modules="all-linear" 把七个线性层都挂上(也可以只挂 q_proj, v_proj);lora_dropout 是 LoRA 分支上的 dropout。

五、在 0.5B 模型上跑通

脚本用 Qwen2.5-0.5B 与 12 条写死的问答,CPU 上训 20 步,把六行背后的每件事打印出来。

1. 模型与 chat template

config.json: hidden 896, layers 24, heads 14/2 kv, intermediate 4864, vocab 151936, tie_embeddings True
参数量 494 M; tokenizer 词表 151665; chat template 有

一条样本经 chat template:
'<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n<|im_start|>user\nWhat is the capital of France?<|im_end|>\n<|im_start|>assistant\nParis.<|im_end|>\n'

Qwen 的模板自动加了一段默认 system prompt;每一轮用 <|im_start|>角色\n内容<|im_end|>\n 包起来。模型学到的”对话格式”就是这几个特殊 token 的排列,推理时必须用同一个模板,否则模型不知道该在哪开始回答。

2. LoRA 挂到哪、多少参数

可训练 8.80 M / 494 M = 1.78%
训练状态 ≈ 可训练 × 16 B = 141 MB;冻结权重 fp32 1.98 GB(bf16 时减半)
挂了 LoRA 的线性层: ['down_proj', 'gate_proj', 'k_proj', 'o_proj', 'q_proj', 'up_proj', 'v_proj']

七个线性层——与 L0 第三篇表里的七个一一对应。0.5B 模型上 \(r = 16\) 是 1.78%(比 8B 的 0.52% 高,因为小模型 \(d\) 小、\(r(m + n) / mn\) 更大)。第三篇的账:训练状态只有 141 MB,冻结权重 2 GB(fp32)——这个模型在 CPU 上都能微调。

3. loss mask 的比例

一个 batch: input_ids (4, 36), labels 里被 mask 成 -100 的 token 122/144 (85%,prompt 与 padding 不算 loss)

4 条样本 padding 到 36 长,144 个位置里 122 个是 −100:system prompt、user 的问题、padding 都不算 loss,只有 assistant 的那几个 token(Paris.<|im_end|>)进入交叉熵。这就是 L0 第五篇第四章”SFT 只对回答部分求和”的实物。85% 被 mask 掉意味着有效 token 很少——真实 SFT 数据的回复要长得多,比例会反过来。

4. 20 步

loss: 第 1 步 5.254 → 最后 1.658  (15 s, 0.8 s/步)
Q: What is the capital of France?   A: 'Paris.看查看\npositories\nThe capital of the United States'
Q: What is the capital of Italy?    A: 'Rome.看查看\nRowAtIndexPath\n Florence.看查看\nRowAtIndexPath'

loss 从 5.3 降到 1.7;生成时答案学会了(Paris、Rome——后者不在训练数据里,是基座本来就会的),但没学会在 <|im_end|> 停下,接着吐出乱码。12 条数据、20 步,模型见到 <|im_end|> 这个 token 的次数太少。这是一个真实的 SFT 现象:结束符必须进 loss、且要见够多次,否则模型不会停。L5 后训练系列第一篇专门讲 SFT 数据的这些细节;这里它是一个”六行跑通了,但要看懂输出”的例子。

5. 合并

merge_and_unload 后参数量 494 M(LoRA 已合回基座,推理零开销)

\(W' = W + \frac{\alpha}{r} BA\),L0 第三篇的”合并”用法。

六、为什么读源码是最快的路

Hugging Face 的库是当前算法工作的事实标准,也是最好的教材——比论文更准确(论文写的是想法,代码写的是实际做法),比教程更完整。几个值得直接读的入口:

想学 大约多长
Llama 的结构 transformers/models/llama/modeling_llama.pyLlamaAttentionLlamaMLPLlamaDecoderLayerapply_rotary_pos_emb 核心几百行;每个类都是第二篇的 nn.Module
DPO 的 loss 到底怎么算 trl/trainer/dpo_trainer.pydpo_loss:把 L0 第六篇推出的公式变成十几行代码,还能看到 IPO、hinge 等变体各改了哪一行 几十行
GRPO 的优势怎么算、KL 怎么加 trl/trainer/grpo_trainer.py:L0 第七篇的 \((R - \text{mean}) / \text{std}\) 与裁剪 几百行
LoRA 怎么挂上去 peft/tuners/lora/layer.pyLinear.forwardresult += lora_B(lora_A(dropout(x))) * scaling 一行核心——L0 第三篇的 \(BAx\)
SFT 的 loss mask 与 packing trl/trainer/sft_trainer.py 与它的 data collator 几百行
generate 的采样 transformers/generation/utils.pylogits_process.py:temperature、top-k、top-p 各是一个 LogitsProcessor 每个 processor 十几行——L0 第五篇第七章

方法很简单:遇到一个后训练概念,先读它在 trl 里的实现,再读论文。 库的版本变化快,函数名会变(本文写作时的接口未必与你读到时一致),但找到入口的方法不变——从 Trainer 的 compute_loss 往下追,或者在编辑器里对着一个 API 名按”跳转到定义”。读到一个看不懂的公式,回 L0 对应的篇;读到一个看不懂的形状操作,回本系列第一篇。

七、自测

  1. 一个模型的 config.jsonhidden_size: 4096, intermediate_size: 14336, num_hidden_layers: 32, num_attention_heads: 32, num_key_value_heads: 8, vocab_size: 128256, tie_word_embeddings: false——参数量大约多少?
  2. 为什么推理时必须用与训练相同的 chat template?
  3. LoraConfig(r=16, lora_alpha=32)lora_alpha 在做什么?改成 16 有什么效果?
  4. 一个 batch 的 labels 里 85% 是 −100,说明什么?真实 SFT 数据会怎样?
  5. 想知道 SFTTrainer 到底怎么给 prompt 部分打 −100,去读哪个文件的哪一部分?

答案要点:(1)就是 Llama-3-8B,8.03B(L0 第一篇的表)。(2)模型学到的”该在哪开始回答”编码在特殊 token 的排列里,换模板它不知道边界。(3)LoRA 输出的缩放 \(\alpha / r\):32 / 16 = 2 倍;改成 16 就是 1 倍,等价于把学习率对 LoRA 的作用减半。(4)回复很短、prompt 与 padding 占大头,有效训练 token 少;真实数据回复长,比例反过来。(5)trl/trainer/sft_trainer.py 的数据处理 / collator 部分,搜 completion_only_loss-100

八、本文小结

  • Hub 上的三个文件config.json(结构超参数,能算出参数量)、tokenizer.json(词表、特殊 token、chat template)、*.safetensorsstate_dict 的磁盘格式,可部分加载、不能执行代码)。
  • 六个库各管一段:transformers 给模型与 tokenizer、datasets 给数据(Arrow)、tokenizers 训与编码词表、peft 挂 LoRA、trl 给后训练的 Trainer、accelerate 给多卡启动;对应第二篇的五个对象。
  • 六行组装 LoRA SFT,背后的每件事——chat template、loss mask(−100)、packing、LoRA 挂载、只更新 \(A, B\)、bf16 / 裁剪 / 调度——都在二十行训练循环里有位置。
  • 0.5B 上跑通:七个线性层挂 LoRA、可训练 1.78%、训练状态 141 MB;一个 batch 85% 的 token 被 mask;20 步 loss 5.3 → 1.7,答案学会了但没学会停——结束符要进 loss 且见够多次
  • 读源码是最快的路modeling_llama.pydpo_lossgrpo_trainer.pypeftLinear.forwardLogitsProcessor;从 compute_loss 往下追。库的接口会变,方法不变。

下一篇是本系列最后一篇:GPU 的两个上限与四块显存(为什么 decode 快不起来、为什么 batch 大才快)、读 profiler、以及让三个月前的实验能复现的最小记录。

本文由 arganzheng 创作,采用 CC BY 4.0 许可协议。在保留原文作者、署名以及完整原文链接(https://arganzheng.life/hugging-face-ecosystem-six-libraries-and-a-lora-sft.html)的前提下,欢迎各种形式的转载、翻译或商业引用。


COMMENTS

评论存放在 GitHub Discussions, 用 GitHub 账号登录即可发表,支持 Markdown。 想针对正文某句话说?选中那段文字,点浮出的「评论」即可划线评论;觉得哪里写错了,发表时勾上「同时提交 Issue」。 有人回复你时 GitHub 会按你的通知设置发邮件,不用守在这里。

×