HuggingFace for LLM Engineering:从 0 跑通一个真正的 LLM Workflow
Guided Build:starter + 13 个 Step(38 个分步测试从红变绿)——Tokenizer、Chat Template、logits、generate、left padding、response-only mask、PEFT/LoRA、save/load,最终用 Qwen2.5 跑出你自己的实验报告;含模型缓存/HF_HOME 与执行位置说明。
对应课件:Job-Ready Track(新增)
你已经知道 Transformer 内部长什么样(第 7 章)——现在要亲手用工业生态跑一个真正的 LLM workflow:加载 Qwen、渲染 Chat Template、看 logits、批量生成、构造 response-only loss、注入 LoRA、训练、保存、重载、对比评测。
模型:Qwen/Qwen2.5-0.5B-Instruct(CPU,fp32)。页面中的数字(形状、参数量、loss、生成结果)都来自 projects/hf-mini-lab/ 的实测输出。
tests 用 tiny 模型(trl-internal-testing/tiny-Qwen2ForCausalLM-2.5)保证速度;最终实验用真实 Qwen 模型。
| 层级 | 你获得什么 | 在哪里 |
|---|---|---|
| Level 1 · Learn | Tokenizer / Chat Template / logits / generate / LoRA 的原理与工程细节 | 26.3 参考手册 + 各 Step 知识盒 |
| Level 2 · Guided Build | 不完整的 starter + 13 步任务 + 38 个分步测试 | projects/hf-mini-lab/starter/(26.2 Lab) |
| Level 3 · Reference Solution | 完整实验工程(做完后再对照) | projects/hf-mini-lab/(26.4) |
完成的定义:starter 的 38 个测试全绿 + 用真实 Qwen 模型跑完 run_lab.py 得到你自己的 report.md + 能回答 Step 12 的复盘问题。
26.1 本章怎么学
这一章不是「读一遍 API 文档」,而是把一个真实 LLM 工程的每一层亲手写出来:
Step 0 环境与第一次 pytest(38 failed)
Step 1 Tokenizer:encode / decode
Step 2 Chat Template:先跑错的方式,再对比正确渲染
Step 3 加载模型:先预测参数量与内存,再验证
Step 4 logits:先预测 shape,再看真实张量(连接第 9 章)
Step 5 批量生成:两个不同长度的 prompt → padding 问题
Step 6 JSONL 数据:load / split / map
Step 7 Response-only loss:第一版不做 mask,观察 prompt 也参与 loss 的后果
Step 8 LoRA:r / alpha / target_modules,打印可训练参数占比
Step 9 真正训练:短训练,记录 loss
Step 10 保存与重载 adapter:必须用干净模型实例
Step 11 PEFT 就地注入 bug(本项目真实踩过的坑)
Step 12 Base vs Tuned 评测:产出你自己的 experiment report
每个 Step 的循环都是:预测 → 你来写 → 运行 → (失败)→ 观察 → 修复 → 测试 → 解释。
26.2 项目:hf-mini-lab(Guided Build)
项目目标:用 Qwen2.5-0.5B-Instruct 在 CPU 上完整走一遍 加载 → Chat Template → logits → 批量生成 → response-only mask → LoRA 训练 → 保存 → 重载 → 评测对比 → 实验报告。
参考实现的真实运行记录(你做完后会得到自己的数字):
模型:Qwen/Qwen2.5-0.5B-Instruct | 494.0M 参数 | float32 | cpu
LoRA 可训练参数:540,672 / 494,573,440 = 0.1093%
训练 loss:2.6364 → 1.3052(60 步,batch 2,lr 2e-4,16 条样本)
held-out response-only loss:base=3.8168 → tuned=3.6102(Δ=-0.2065)
用时:60.2s(CPU)| pytest:11 passed
pip install 和 pytest 必须在同一台机器上执行;换机器要重新装依赖pwd 确认)echo $HF_HOME 查看;要放大盘先 export HF_HOME)git status;模型、缓存、outputs 不动,只提交 src/ tests/ configs/ data/ 等小文件环境与模型选择
步骤 0在一台明确的机器上把 starter 跑起来,看到 38 failed——并且选好本章使用的模型。
HF 生态第一个工程决策不是代码,而是模型选择:参数量决定了你的显存/内存、加载时间和实验可行性。
本章全程用 Qwen/Qwen2.5-0.5B-Instruct:中文能力好、0.5B 在 CPU 上可跑、有完整的 ChatML 模板。
不要换成更大的模型——第一段实习的项目标准是「别人能复现」,不是「参数越大越厉害」。
本章和第 25 章的「在哪台机器 / 哪个目录执行」规则完全一样(如果你跳过了第 25 章: 先读它 Step 0 的心智模型,或看文档 docs/ENVIRONMENT_AND_WORKFLOW.md)。 本章多出来的一件事是:模型文件不放在 repo 里,而是下载进 HuggingFace 缓存——见下方知识盒。
运行 from_pretrained(...) 时,模型进入 HuggingFace 缓存目录,不是 projects/hf-mini-lab/:
- 查看缓存位置:
echo $HF_HOME(为空则使用默认用户缓存目录); - 想把缓存放到大盘(例如服务器):
export HF_HOME=~/models/huggingface(路径要先存在且有写权限); - 下载前先看磁盘:
df -h(剩余空间)、du -sh ~/models 2>/dev/null(已占用)。
因此你跑完实验后 git status 不应该、也不会看到模型权重——它们不在 repo 里,不需要提交。
starter/ ├── README.md # Step → 测试表(先读) ├── requirements.txt # torch / transformers / peft / datasets / pytest ├── configs/lora_sft.json ├── data/sft_mini.jsonl # 20 条教学数据(16 train / 4 eval) ├── src/hf_lab/ # ← 你要写代码的地方 └── tests/ # 38 个测试,按 Step 拆成 12 个文件
# 【确认你在哪】提示符 yourname@MacBook ~ % = Mac;user@ubuntu:~$ = 服务器
hostname
pwd
# 【回到 repo root】已有 clone 的情况(还没有 clone 就按根 README Quick Start 做一次)
cd "$(git rev-parse --show-toplevel)"
pwd
# 【进入 starter】
cd projects/hf-mini-lab/starter
pwd # 必须以 llm-course/projects/hf-mini-lab/starter 结尾;不是就先别继续
# 【装依赖 + 第一次运行】在哪台机器跑 pytest,就在哪台机器装
pip install -r requirements.txt
pytest -q
38 failed
(真实记录:本课程实测 38 failed in ~30s;首次运行会下载 tiny 测试模型(几 MB,进 HF 缓存),之后走本地缓存。)
- 没有网络 / 下载失败 → 先单独跑
python -c "from transformers import AutoTokenizer; AutoTokenizer.from_pretrained('trl-internal-testing/tiny-Qwen2ForCausalLM-2.5')"定位网络问题; - 想跳过模型测试先写代码 →
RUN_MODEL_TESTS=0 pytest -q(7 failed / 31 skipped,仍然是红); - 显存/内存不够 → 确认你用的是 tiny 模型跑测试;真实 Qwen 0.5B fp32 需要约 2GB 内存。
Hint 1
CPU 完全够用:0.5B fp32 约 2GB 内存,加载约 3 秒,60 步 LoRA 训练约 1 分钟。
Hint 2
测试和最终实验用的是两个模型:
- 测试:tiny 模型(秒级);
- 实验:
run_lab.py默认 Qwen2.5-0.5B-Instruct。 不要为了让测试快而把 run_lab 改成 tiny——那只能验证流程,得不到真实数字。
你知道本章为什么选 0.5B 模型,并且看到了 38 failed 的完整清单。
- 为什么「模型选择」是 LLM 工程的第一个决策?参数量、精度(fp32/fp16)、设备(CPU/GPU)分别影响什么?
- 模型文件下载到了哪里?为什么
git status里看不到它们、也不需要提交? - 你现在运行
pytest的这台机器是 Mac 还是服务器?如果 Step 12 要在另一台机器跑真实实验,需要做什么准备?
Tokenizer:encode / decode
步骤 1实现 load_tokenizer():加载 Qwen 的 tokenizer,处理 pad_token 缺失,并验证 encode/decode 往返。
Tokenizer 是模型与文本之间的唯一接口。第 6 章你亲手训过 BPE;工业生态里直接加载现成的—— 但两个坑必须处理:很多 LLM 没有 pad_token;词表大小和你想的不一样。
要改的文件:src/hf_lab/loading.py(函数 load_tokenizer)
| 概念 | 作用 | 常见错误 |
|---|---|---|
padding |
把不同长度的序列补到等长(batch 推理必需) | 不设 pad_token(很多 LLM 默认没有 pad) |
truncation |
超长序列截断 | 不截断 → 显存爆炸 |
attention_mask |
标记哪些位置是真实 token(1)/ padding(0) | 忘记传给模型 → 模型 attend 到 pad |
Qwen 的实测值:vocab=151643,pad=<|endoftext|>(用 tokenizer.pad_token = tokenizer.eos_token 兜底,这是标准做法)。
你来写(loading.py):
- TODO 1:
AutoTokenizer.from_pretrained(model_name); - TODO 2:
if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token; - TODO 3:
logger.info打印 vocab 与 pad(调试时的第一手信息)。
pytest -q tests/test_step1_tokenizer.py
3 passed
AttributeError: pad_token相关 → 你跳过了 None 检查;- encode/decode 往返失败 → 注意
add_special_tokens=False时往返才严格相等(测试用的就是它)。
Hint 1
tokenizer("你好,世界", add_special_tokens=False)["input_ids"] 是 encode;
tokenizer.decode(ids) 是 decode。
Hint 2
pad_token_id 在 batch 编码时用来填充。Qwen 默认没有 pad——这是「事实」,不是 bug,
所以第一步就要处理它,否则 Step 5 的批量生成会静默出错。
查看参考实现(先自己做,再对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
def load_tokenizer(model_name: str):
tokenizer = AutoTokenizer.from_pretrained(model_name)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
logger.info("tokenizer 加载完成:vocab=%d pad=%s", tokenizer.vocab_size, tokenizer.pad_token)
return tokenizer
pytest -q tests/test_step1_tokenizer.py → 3 passed。你能解释为什么 Qwen 需要 pad_token 兜底。
- padding / truncation / attention_mask 分别解决什么问题?
- 为什么
decode(encode(x)) == x在add_special_tokens=True时可能不成立?
Chat Template:先看错误做法,再对比正确渲染
步骤 2实现 render_chat() 与 show_template();亲手对比「直接把 user 文本喂 tokenizer」和「apply_chat_template」的区别。
模型看到的不是 {"role": "user"} 这种字典——模板把它渲染成一段带特殊 token 的文本。
训练数据是这样,推理时也必须这样。如果格式不一致,分布偏移,模型可能答非所问、不停顿、或复读。
要改的文件:src/hf_lab/chat.py(render_chat / show_template)
先预测,再运行(两个实验都要做):
# 错误做法的预览(你在 python REPL 里跑)
plain = tokenizer("什么是过拟合?")["input_ids"]
print(len(plain))
# 正确做法
text = tokenizer.apply_chat_template(
[{"role": "user", "content": "什么是过拟合?"}],
tokenize=False, add_generation_prompt=True,
)
print(repr(text))
问题:正确的渲染文本会比 plain 长多少?多出来的 token 是什么?
<|im_start|>system
You are Qwen, created by Alibaba Cloud. You are a helpful assistant.<|im_end|>
<|im_start|>user
什么是过拟合?<|im_end|>
<|im_start|>assistant
add_generation_prompt=True 的作用:末尾补上 <|im_start|>assistant\n,告诉模型「该你说了」。
工程纪律:永远用 apply_chat_template,不要手拼 ChatML 字符串。
你来写(chat.py):
- TODO 1:
render_chat(tokenizer, messages, add_generation_prompt=True)——apply_chat_template(messages, tokenize=False, add_generation_prompt=...); - TODO 2:
show_template(tokenizer, messages)——打印 messages、渲染后文本(repr)、前 40 个 token ids; - TODO 3:自己在终端运行一次
show_template,把「错误做法 vs 正确做法」的 token 数对比记下来。
pytest -q tests/test_step2_chat_template.py
python -c "
from hf_lab.loading import load_tokenizer
from hf_lab.chat import show_template
tok = load_tokenizer('trl-internal-testing/tiny-Qwen2ForCausalLM-2.5')
show_template(tok, [{'role':'user','content':'你好'}])
"
5 passed
以及终端里真正的模板文本与 token ids。
- 找不到
<|im_start|>user→ 你可能用了错误的 tokenize 参数(本函数必须tokenize=False); - 渲染结果没有结尾的 assistant 标记 →
add_generation_prompt没传或传了 False(默认必须是 True)。
Hint 1
apply_chat_template 的三种用法要分清:
tokenize=False 返回字符串(本步);tokenize=True 返回 ids;return_dict=True 返回可喂给模型的 dict。
Hint 2
测试会断言渲染文本以 <|im_start|>assistant 结尾(去掉尾部空白后)——
如果失败,把 repr(text) 打印出来看看尾部到底有什么。
查看参考实现(先自己做,再对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
def render_chat(tokenizer, messages, add_generation_prompt: bool = True) -> str:
return tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=add_generation_prompt
)
5 passed,并且你能展示「直接 tokenizer(text)」的 ids 与「模板渲染后」的 ids 的差异。
- 如果推理时少拼一个
<|im_end|>,模型的行为可能怎么变化? - 为什么说「手拼特殊 token」是工程事故的常见来源?
加载模型:先预测参数量与内存量级
步骤 3实现 load_model();先预测 Qwen2.5-0.5B 的参数量和 fp32 内存,再看真实值。
参数量 → 内存/显存 → 你能不能用这台机器做实验。这是第 14 章显存估算的日常应用, 也是面试里「你怎么选模型」的标准回答起点。
要改的文件:src/hf_lab/loading.py(函数 load_model)
动手前先算(写在纸上):
- 「0.5B」模型的参数量大概是多少?(到百万为止)
- fp32 每个参数 4 字节 → 权重需要多少内存?
- 加载需要多久?(你可以先猜 1 秒 / 10 秒 / 1 分钟)
参考实测:494.0M 参数,fp32 权重约 2GB,CPU 加载约 3 秒。
model = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2.5-0.5B-Instruct",
dtype=torch.float32, # 或 bfloat16 / float16(第 17 章)
)
model.to("cpu") # 或 "cuda" / device_map="auto"
| 参数 | 含义 | 注意 |
|---|---|---|
dtype |
权重精度 | fp32 精确但费内存;bf16 训练推理常用 |
device_map |
多设备分配("auto" 自动切分) |
单卡小模型不需要 |
trust_remote_code |
允许执行仓库自带代码 | 不要无脑 True:那是在执行第三方代码 |
你来写(loading.py):
- TODO 1:用
_DTYPES把字符串 dtype 映射成 torch dtype; - TODO 2:
AutoModelForCausalLM.from_pretrained(model_name, dtype=torch_dtype); - TODO 3:
model.to(device)+model.eval(); - TODO 4:统计参数量并
logger.info打印(sum(p.numel() for p in model.parameters()))。
pytest -q tests/test_step3_model.py
3 passed
测试用 tiny 模型验证「eval 模式 / 参数量 / 设备 / dtype」;真实数字请看 Step 12 的 run_lab 输出。
eval()相关失败 → 加载完必须切评估模式(dropout 等行为在训练/推理不同);- dtype 不是 float32 → 检查
_DTYPES映射是否真的用上了。
Hint 1
next(model.parameters()).device 可以拿到模型所在设备;
sum(p.numel() for p in model.parameters()) 是参数量。
Hint 2
model.eval() 对评估是必须的:它关闭 Dropout、固定 LayerNorm 行为。
忘记它会让你的 eval loss 每次都不一样。
查看参考实现(先自己做,再对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
def load_model(model_name: str, dtype: str = "float32", device: str = "cpu"):
torch_dtype = _DTYPES.get(dtype, torch.float32)
model = AutoModelForCausalLM.from_pretrained(model_name, dtype=torch_dtype)
model.to(device)
model.eval()
n_params = sum(p.numel() for p in model.parameters())
logger.info("模型加载完成:%s | %.1fM 参数 | %s | %s", model_name, n_params / 1e6, dtype, device)
return model
3 passed;你的参数量预测和实测(tiny 模型)对齐,并知道真实 Qwen 是 494.0M。
- 0.5B fp32 与 bf16 的权重内存分别是多少?训练时还要额外占什么(优化器状态、梯度、激活)?
- 为什么
model.eval()影响评测结果的稳定性?
logits:先预测 shape,再看张量
步骤 4实现 next_token_logits();先预测形状,再运行真实模型验证——把第 9 章的符号和工业代码对上。
logits[0, -1] 就是「下一个 token 的未归一化分数」,softmax 之后就是概率分布。
第 9 章推的每一个符号,在工业代码里就是这个张量——这是从「学过」到「用过」的关键一步。
要改的文件:src/hf_lab/chat.py(函数 next_token_logits)
如果 input_ids 的形状是 (1, 27)、vocab 约 150k,logits 应该是什么 shape?
先写答案,再运行。
input_ids: (1, 27) → logits: (1, 27, 151936)
最后一位置 top-5 token id: [151644, 151645, 151657, 151658, 68728]
形状是 [batch, seq, vocab]。注意 detail:logits.shape[-1] = 151936 而 tokenizer.vocab_size = 151643——
输出维度是 config.vocab_size,可能因并行/对齐被 pad 到更大。不要假设两者相等(测试用 >= 断言,这是真实踩过的坑)。
你来写(chat.py):
- TODO 1:
apply_chat_template(messages, tokenize=True, return_dict=True, return_tensors="pt")得到模型输入; - TODO 2:
model(**enc).logits返回 logits 张量; - TODO 3:确保函数返回 shape 为
(1, seq, vocab)的张量。
pytest -q tests/test_step4_logits.py
2 passed
- 返回值是
CausalLMOutput而不是张量 → 忘了.logits; - seq 维度对不上 → 你用了
tokenize=False的文本再手动编码,两段 token 数可能不同; - vocab 断言失败 → 你写了
==(应该>=,见知识盒)。
Hint 1
return_dict=True, return_tensors="pt" 之后可以直接 model(**enc);
注意 enc 里通常还带 attention_mask,一起传进去。
Hint 2
想验证「下一个 token」的语义:对 logits[0, -1] 做 topk 看 id,再 tokenizer.decode 出来——
你会看到「你好」之后模型最想接的词。
查看参考实现(先自己做,再对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
def next_token_logits(tokenizer, model, messages):
enc = tokenizer.apply_chat_template(
messages, tokenize=True, return_dict=True, return_tensors="pt"
)
with torch.no_grad():
logits = model(**enc).logits
return logits
2 passed;你能解释三个维度的含义,并说出为什么 vocab 维可能大于词表。
logits[0, -1]与logits[0, 5]分别是什么含义?- 为什么评测代码里要
torch.no_grad()?
批量生成:两个不同长度的 prompt 引出 padding 问题
步骤 5实现 batch_generate():让两个不同长度的 prompt 组成 batch,处理 padding 对齐,并只解码新 token。
单条推理浪费算力;真实评测/服务都要批量。batch 的第一步就是「变长序列补到等长」—— 补在哪一侧,直接决定 decoder-only 模型的生成结果对不对。这是最容易被忽视、又一定会在面试里被问的细节。
要改的文件:src/hf_lab/chat.py(函数 batch_generate)
两个 prompt:「什么是学习率?」(短)和「什么是 KV Cache?请详细解释。」(长)。 组成 batch 后:
- 短的那条会被补 PAD 到和长的一样长——PAD 补在左边还是右边?
- 生成时从哪个位置开始解码?如果补在右边,会不会把 PAD 当成 prompt 的一部分? 先写下你的答案,再看知识盒。
right padding(训练常用) left padding(decoder 推理常用)
[问题A tokens][PAD][PAD] [PAD][PAD][问题A tokens]
[问题B tokens][问题B tokens] [问题B tokens][问题B tokens]
↑ 生成从这里开始?错位 ↑ 生成从末尾开始 → 对齐
decoder-only 推理惯例是 left padding:
tokenizer.padding_side = "left" # 关键一行
batch = tokenizer(chats, return_tensors="pt", padding=True, truncation=True)
output = model.generate(**batch, max_new_tokens=..., do_sample=False, pad_token_id=tokenizer.pad_token_id)
generated = output[:, batch["input_ids"].shape[1]:] # 只取新生成的 token
实测:left pad shape: (2, 7)——两个不同长度的 prompt 被补齐到 7,
每个序列的最后一个位置都是真实的 prompt 末尾,生成从这里开始才对。
你来写(chat.py):
- TODO 1:
tokenizer.padding_side = "left"; - TODO 2:对每个 prompt 先
render_chat,再整批tokenizer(chats, return_tensors="pt", padding=True, truncation=True, max_length=512); - TODO 3:
model.generate(**batch, max_new_tokens=..., do_sample=False, pad_token_id=tokenizer.pad_token_id); - TODO 4:切片
output[:, batch["input_ids"].shape[1]:]再batch_decode(..., skip_special_tokens=True)。
pytest -q tests/test_step5_generate.py
3 passed
- 生成的回答里包含问题本身(复读)→ 你忘了切片,把 prompt 也 decode 出来了;
- 输出里混着
<|im_end|>之类 → 忘了skip_special_tokens=True; - 生成结果里出现奇怪的重复 → padding_side 不是 left,PAD 参与了注意力。
Hint 1
batch["input_ids"].shape[1] 是 batch 编码后的序列长度(含 padding),新 token 从这里开始。
Hint 2
generate 的 pad_token_id 必须显式传——否则很多模型会警告或行为不一致。
查看参考实现(先自己做,再对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
def batch_generate(tokenizer, model, prompts, max_new_tokens: int = 64):
tokenizer.padding_side = "left"
chats = [render_chat(tokenizer, [{"role": "user", "content": p}]) for p in prompts]
batch = tokenizer(chats, return_tensors="pt", padding=True, truncation=True, max_length=512)
batch = {k: v.to(model.device) for k, v in batch.items()}
with torch.no_grad():
output = model.generate(
**batch, max_new_tokens=max_new_tokens,
do_sample=False, pad_token_id=tokenizer.pad_token_id,
)
generated = output[:, batch["input_ids"].shape[1]:]
return tokenizer.batch_decode(generated, skip_special_tokens=True)
3 passed;你能画出 left/right padding 的差异图并解释为什么推理用 left。
- 训练为什么常用 right padding,而 decoder-only 推理用 left padding?(提示:teacher forcing 的位置对齐 vs 生成起点)
do_sample=False对应第 9 章的哪种解码策略?
加载 JSONL Instruction Dataset
步骤 6实现 load_jsonl() 与 split_dataset():读入指令数据、按 split 字段切分(没有字段时随机兜底)。
SFT 的数据格式就是 messages 列表。第 25 章的 JSONL 知识在这里第一次用于真实 LLM 数据;
「训练/评估怎么切分」直接决定你后面的对比评测是否可信。
要改的文件:src/hf_lab/data.py(load_jsonl / split_dataset)
{"split": "train", "messages": [{"role": "user", "content": "用一句话解释什么是过拟合。"}, {"role": "assistant", "content": "过拟合是模型把训练数据的噪声也记住了,导致在新数据上表现变差。"}]}
切分纪律:
- 数据自带
split字段 → 尊重它(教学数据这样设计,保证 eval 是 held-out); - 没有该字段 → 按
eval_ratio随机切分,seed 必须固定(可复现是实验的基本要求)。
你来写(data.py):
- TODO 1:
load_jsonl(path)——逐行json.loads,跳过空行; - TODO 2:
split_dataset(rows, eval_ratio=0.25, seed=0)——优先按行内split字段;否则random.Random(seed)洗牌切分; - TODO 3:返回的元素是
row["messages"],不是整行 dict。
pytest -q tests/test_step6_data.py
4 passed
- 返回长度不是 16/4 → 你把整行 dict 当成元素了,或没走到 split 字段分支;
- 随机兜底不可复现 → 用了全局
random.shuffle而不是random.Random(seed)。
Hint 1
判断「是否所有行都有 split」:all("split" in row for row in rows)。
Hint 2
随机兜底时先浅拷贝再洗牌(别改调用方的列表),切出前 n_eval = max(1, int(len(rows) * eval_ratio)) 条做 eval。
查看参考实现(先自己做,再对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
def split_dataset(rows, eval_ratio: float = 0.25, seed: int = 0):
if rows and all("split" in row for row in rows):
train = [row["messages"] for row in rows if row["split"] == "train"]
eval_ = [row["messages"] for row in rows if row["split"] == "eval"]
return train, eval_
rng = random.Random(seed)
shuffled = rows[:]
rng.shuffle(shuffled)
n_eval = max(1, int(len(shuffled) * eval_ratio))
return [r["messages"] for r in shuffled[n_eval:]], [r["messages"] for r in shuffled[:n_eval]]
4 passed;你能解释「为什么必须尊重数据自带 split」。
- 如果 eval 数据混进了 train,你的 held-out loss 会怎么变化?结论会怎么错?
- 为什么随机切分要固定 seed?seed 变了影响什么?
Response-only Loss:第一版不做 mask,观察后果
步骤 7实现 build_labels() / encode_batch():用 -100 掩掉 prompt,只让 assistant 回复参与 loss。
SFT 的关键问题:user 的话要不要算 loss? 不算。训练目标是「学会怎么回答」,不是「学会怎么提问」。如果不 mask, prompt 也参与 loss,模型会花容量去预测用户输入——这正是很多「SFT 没效果」的原因之一。 (第 21 章 Loss Mask 的工程实现就是这 10 行代码。)
要改的文件:src/hf_lab/data.py(build_labels / encode_batch)
先做这个实验(顺序很重要):
# 第一版:不做 mask
input_ids = tokenizer.apply_chat_template(messages, tokenize=True)
labels = list(input_ids) # TODO: 这里还没 mask!
supervised = len(labels) # 全部 token 参与 loss
问题:现在 user prompt 也参与 loss 吗?如果 assistant 有 25 个 token、整句 62 个 token,
参与 loss 的应该是 25 还是 62?为什么?
input_ids = tokenizer.apply_chat_template(messages, tokenize=True)
prompt_ids = tokenizer.apply_chat_template(messages[:-1], tokenize=True, add_generation_prompt=True)
labels = list(input_ids)
labels[:len(prompt_ids)] = [-100] * len(prompt_ids) # 掩掉 prompt(含 assistant 头部)
-100是 PyTorch 交叉熵的ignore_index(第 21 章讲过);- 边界要对齐「未移位」的位置(transformers 内部会对 labels 左移一位,掩码不要自己移);
- 实测:首条训练样本
62 token,其中参与 loss 的 assistant token =25。 - 兼容性回归:transformers 新版
apply_chat_template(tokenize=True)返回BatchEncoding(dict), 旧版返回list——starter 已给出_as_ids兼容 helper(这是本项目 CI 真实失败过一次的坑)。
你来写(data.py):
- TODO 1:
build_labels——构造完整 input_ids 与 prompt 长度,把 labels 前缀置-100; - TODO 2:用
_as_ids统一两种返回形态; - TODO 3:
encode_batch——逐条build_labels后截断到max_length。
pytest -q tests/test_step7_labels.py
supervised == len(labels)→ 你还没 mask(测试会明确报出);- 最后一个 label 是 -100 → 你的 prompt 长度多算了(回复的最后 token 必须参与 loss);
- BatchEncoding 兼容测试失败 → 没有用
_as_ids。
Hint 1
prompt 长度用 messages[:-1] + add_generation_prompt=True 单独编码一次得到——
它等于「assistant 回复从哪个 token 开始」。
Hint 2
先打印 sum(1 for x in labels if x != -100) 与总长度;
62 / 25 是参考实现第一样本的实测值(tiny 模型的 token 数不同,看比例)。
查看参考实现(先自己做,再对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
def build_labels(tokenizer, messages):
input_ids = _as_ids(tokenizer.apply_chat_template(messages, tokenize=True))
prompt_ids = _as_ids(tokenizer.apply_chat_template(
messages[:-1], tokenize=True, add_generation_prompt=True
))
labels = list(input_ids)
n_prompt = len(prompt_ids)
labels[:n_prompt] = [-100] * n_prompt
return input_ids, labels
5 passed;你能说出参与 loss 的 token 数,并解释「第一版不做 mask 会发生什么」。
- 为什么
-100是「忽略」而不是「loss = 0」? - 如果数据里有 system 角色,它应该参与 loss 吗?
LoRA:r / alpha / target_modules 与可训练参数占比
步骤 8实现 make_lora_model();配置 r / alpha / target_modules,并打印「可训练参数 / 总参数」。
全量微调 0.5B 模型在 CPU 上不可行;LoRA 只训练千分之一的参数—— 这是第 17 章的理论在工程里的真实量级。面试必问:你的 r 是多少?注入哪几层?为什么?
要改的文件:src/hf_lab/train.py(函数 make_lora_model)
from peft import LoraConfig, get_peft_model
lora_cfg = LoraConfig(
r=8, lora_alpha=16, lora_dropout=0.0,
target_modules=["q_proj", "v_proj"], # 注入哪些线性层
task_type="CAUSAL_LM",
)
peft_model = get_peft_model(model, lora_cfg)
实测(Qwen2.5-0.5B):
LoRA 可训练参数:540,672 / 494,573,440 = 0.1093%
| 超参 | 作用 | 常见选择 |
|---|---|---|
r |
低秩维度(容量) | 4–64;越大容量越强、参数越多 |
lora_alpha |
缩放系数(通常 2r) | 16–32 |
target_modules |
注入哪些层 | q_proj/v_proj 起步;全 Linear 更强更贵 |
你来写(train.py):
- TODO 1:根据 cfg 构造
LoraConfig(r / lora_alpha / lora_dropout / target_modules / task_type); - TODO 2:
get_peft_model(model, lora_cfg)并返回; - TODO 3:自己确认「注入后哪些参数 requires_grad」——用一行 Python 打印。
pytest -q tests/test_step8_lora.py
2 passed
- 可训练参数占比 ≥ 10% → 你可能漏了 LoRA 注入或者 target_modules 写错层名;
KeyError/ 目标层不存在 → Qwen 的线性层名是q_proj/v_proj(不是query)。
Hint 1
打印占比:sum(p.numel() for p in m.parameters() if p.requires_grad) / sum(p.numel() for p in m.parameters())。
Hint 2
注意:get_peft_model 是「就地注入」——这个事实会在 Step 11 变成一个大坑,先记住它。
查看参考实现(先自己做,再对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
def make_lora_model(model, cfg):
lora_cfg = LoraConfig(
r=cfg.lora_r,
lora_alpha=cfg.lora_alpha,
lora_dropout=cfg.lora_dropout,
target_modules=cfg.target_modules,
task_type="CAUSAL_LM",
)
return get_peft_model(model, lora_cfg)
2 passed;你知道 0.5B 模型 LoRA 可训练参数的占比量级(0.1%)。
r从 8 加到 16,可训练参数大概怎么变?效果一定更好吗?- 为什么
target_modules通常包含 q_proj / v_proj,而不只是 q_proj?
真正训练:短训练,记录 loss
步骤 9实现 _collate() 与 train_lora():手工 collate(padding + -100)→ AdamW → 60 步训练 → 记录 losses。
Trainer 很方便,但「自己写一遍训练循环」是理解 SFT 的唯一方式: loss 是怎么算的、谁在更新、梯度只流进哪些参数。你的 loss 曲线还是你实验报告的第一张图。
要改的文件:src/hf_lab/train.py(_collate / train_lora)
input_ids:短序列补pad_token_id;attention_mask:真实 token 为 1,补位为 0;labels:补位(以及 prompt 部分)置-100。
你来写(train.py):
- TODO 1:
_collate——逐条 pad 到 batch 最大长度,返回三个张量; - TODO 2:
train_lora——torch.manual_seed(cfg.seed)、只优化requires_grad的参数、zero_grad → forward → backward → step; - TODO 3:每
log_every步打印 loss;训练结束model.eval(); - TODO 4:返回
TrainResult(losses, trainable_params, total_params)。
pytest -q tests/test_step9_train.py
3 passed
- loss 是 NaN → 学习率过大或 labels 全是 -100(一个 batch 里没有任何 assistant token);
RuntimeError: element 0 of tensors does not require grad→ 你把优化器建在了全部参数上或忘了 LoRA 注入;- loss 完全不下降 → 检查 labels 是否真的对齐了 input_ids。
Hint 1
batch 的构造:按 cfg.batch_size 循环采样(samples[(cursor + i) % len(samples)]),
这样小数据集也能跑满 max_steps。
Hint 2
优化器只传可训练参数:torch.optim.AdamW([p for p in model.parameters() if p.requires_grad], lr=cfg.learning_rate)。
查看参考实现(先自己做,再对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
def _collate(batch_ids, batch_labels, pad_id):
max_len = max(len(ids) for ids in batch_ids)
input_ids, labels, attention = [], [], []
for ids, labs in zip(batch_ids, batch_labels):
pad_n = max_len - len(ids)
input_ids.append(ids + [pad_id] * pad_n)
labels.append(labs + [-100] * pad_n)
attention.append([1] * len(ids) + [0] * pad_n)
return torch.tensor(input_ids), torch.tensor(labels), torch.tensor(attention)
3 passed;你在本地跑过 ≥ 10 步训练并看到 loss 数值(不要求下降到某个值——只要求你记录事实)。
- 为什么 loss 是
CausalLMOutput.loss而不是你手算?(提示:内部已处理 shift + ignore_index) - 训练 60 步的 loss 下降能证明模型「变好了」吗?(对照第 23 章)
保存和重载 adapter:必须用干净模型实例
步骤 10实现 save_adapter() 与 load_adapter();验证保存产物能被重新加载,并理解 base / adapter / merged 的区别。
训练产物必须是可交付、可复现的:别人拿到你的 adapter(几 MB)应该能复现你的模型。 而「重载」也是评测的前提——只有重新加载的模型才能证明保存真的成功。
要改的文件:src/hf_lab/train.py(save_adapter)、src/hf_lab/loading.py(load_adapter)
| 产物 | 大小(0.5B 例子) | 用途 |
|---|---|---|
| base model | ~2GB(fp32)/ ~1GB(bf16) | 原始权重 |
| adapter(LoRA) | 几 MB ~ 几十 MB | 训练产物;分享/切换成本极低 |
| merged model | 同 base 大小 | 合并后推理不带 PEFT 依赖 |
你来写:
- TODO 1(
train.py):save_adapter——mkdir+model.save_pretrained(out)+cfg.save(out / "config.json"); - TODO 2(
loading.py):load_adapter(base_model, adapter_dir)——PeftModel.from_pretrained(base_model, adapter_dir); - TODO 3:自己想清楚——重载时传入的
base_model应该是一个怎样的对象?
pytest -q tests/test_step10_save_load.py
3 passed
- 找不到
adapter_model.safetensors→ 保存的是普通模型而不是 PeftModel; - 重载后测试说「找不到 LoRA 模块」 →
load_adapter没有真的包上 PeftModel; - 重载用了同一个 base 对象 → 下一个 Step 就是为你准备的。
Hint 1
保存目录会包含 adapter_config.json + adapter_model.safetensors 两个关键文件。
Hint 2
「干净模型实例」= 重新调用一次 load_model(...),而不是复用刚才训练用的那个对象。
查看参考实现(先自己做,再对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
def save_adapter(model, output_dir: str, cfg) -> None:
out = Path(output_dir)
out.mkdir(parents=True, exist_ok=True)
model.save_pretrained(out)
cfg.save(out / "config.json")
def load_adapter(base_model, adapter_dir):
from peft import PeftModel
return PeftModel.from_pretrained(base_model, adapter_dir)
3 passed;你能说出 adapter 与 base 的大小差异以及为什么 adapter 是最重要的交付物。
- 为什么重载验证是「保存成功」的唯一证据?
- merged 模型适合什么场景?为什么大多数团队仍然交付 adapter?
PEFT 就地注入 bug:一个真实踩过的坑
步骤 11亲手复现并理解:为什么「用同一个 model 变量当 base 基线」会得到错误的评测结果。
这是本 Lab 最重要的一步。它不涉及任何新 API,只关于一个事实:
get_peft_model(model) 会就地改造传入的对象。理解不了它,你的 base vs tuned 对比会得出「Δ=0.0000」的荒谬结论,
而且你还以为实验成功了。
训练后拿同一个 model 变量当「base 基线」评测,测出来的其实是带 adapter 的模型——
base 与 tuned 的 loss 完全相同(Δ=0.0000),差点写进报告。
正确做法:基线评测用另一次干净加载的模型。
要改的文件:无新增实现——用测试验证你对第 10 步完成后的代码是否理解正确;
如测试失败,回到 make_lora_model / load_adapter 检查。
先预测,再运行:
base = AutoModelForCausalLM.from_pretrained(TINY_MODEL) # 干净模型
print(sum(p.numel() for n, p in base.named_parameters() if p.requires_grad and "lora_" in n)) # ?
make_lora_model(base, cfg) # 注入 LoRA
print(sum(p.numel() for n, p in base.named_parameters() if p.requires_grad and "lora_" in n)) # ?
两个 print 的结果分别是什么?如果你之后直接用 base 当基线评测,会发生什么?
pytest -q tests/test_step11_peft_trap.py
2 passed
- 第 1 个测试:注入后原对象
base的 LoRA 参数从 0 变为 > 0(就地修改的证明); - 第 2 个测试:正确模式 = 加载两个干净模型(一个给 adapter、一个当基线)。
- 测试通不过说明你对「谁被改造了」还有误解——把两个模型的
named_parameters()名字打印出来对比。
Hint 1
对象有没有被原地修改?用 id() 和 named_parameters() 各验证一次。
Hint 2
正确模式的伪代码:
tuned_base = load_model(name) # 给 adapter
tuned_model = load_adapter(tuned_base, out_dir)
clean_base = load_model(name) # 给基线(新对象!)
base_eval = eval_loss(clean_base, ...)
tuned_eval = eval_loss(tuned_model, ...)
查看参考实现(先自己做,再对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
# 正确基线评测模式(run_lab.py [7/8] 的写法)
tuned_base = load_model(cfg.model_name, dtype=cfg.dtype, device=cfg.device)
tuned_model = PeftModel.from_pretrained(tuned_base, cfg.output_dir)
clean_base = load_model(cfg.model_name, dtype=cfg.dtype, device=cfg.device)
base_eval = eval_loss(clean_base, tokenizer, eval_samples, cfg.max_length)
tuned_eval = eval_loss(tuned_model, tokenizer, eval_samples, cfg.max_length)
2 passed;你能向面试官解释这个 bug 的触发条件和修复方式(30 秒内说清)。
- 「就地注入」的设计动机是什么?如果你要在同一进程里评测 5 个 adapter 而不爆内存,会怎么组织对象生命周期?
- 如果不做 Step 11 的验证,Δ=0.0000 会如何误导你的实验结论?
Base vs Tuned Evaluation:产出你的实验报告
步骤 12实现 eval_loss() / compare_generation() / build_report();用真实 Qwen 模型跑完整实验,写出你自己的 report.md。
从「训练完了」到「实验有结论」,中间隔着一次公平的评测:同一批 held-out、同一 mask、同一解码策略、 干净加载的 base。这一步是你交付物的最后一块拼图——也是简历上那个项目的证据。
要改的文件:src/hf_lab/evaluate.py;这一行命令是一次完整实验:python scripts/run_lab.py
你来写(evaluate.py):
- TODO 1:
eval_loss——逐条计算 held-out 样本的 response-only loss 取平均(逐条避免 padding 干扰,labels 全 -100 的样本跳过,无样本返回 nan); - TODO 2:
compare_generation——同一组 prompt,base 与 tuned 各自贪心生成,返回[{prompt, base, tuned}]; - TODO 3:
build_report——markdown 实验报告(模型、数据、LoRA 配置、可训练参数、loss、生成对比、已知限制)。
# 【当前目录】.../llm-course/projects/hf-mini-lab/starter
pwd
# 【先看磁盘】模型权重约 2GB(fp32)+ 缓存开销;服务器上尤其先确认
df -h .
# 【单元测试】
pytest -q tests/test_step12_evaluate.py
pytest -q # 全量:38 passed
# 【真实 Qwen 实验】CPU 约 1 分钟;模型下载进 HF 缓存,不进 repo
python scripts/run_lab.py
- 模型权重 → HuggingFace 缓存(
echo $HF_HOME查看),不在 repo; - 实验产物 →
outputs/lab_run/(几 MB:adapter + report.md + losses.json,已被 gitignore); git status应该只显示你修改的源码文件——如果看到几十 GB 的东西,先停下来检查.gitignore。
3 passed
... 38 passed
run_lab.py 结束时会打印报告并写出:
outputs/lab_run/adapter_config.json、adapter_model.safetensors、config.json、losses.json、report.md
参考实现的真实结果(你的数字会有差异,这是正常的):
模型:Qwen/Qwen2.5-0.5B-Instruct | 494.0M 参数 | float32 | cpu
LoRA 可训练参数:540,672 / 494,573,440 = 0.1093%
训练 loss:2.6364 → 1.3052(60 步,batch 2,lr 2e-4)
held-out response-only loss:base=3.8168 → tuned=3.6102(Δ=-0.2065)
eval_loss返回 nan → 所有样本的 labels 都被截断到全 -100(检查max_length与数据长度);- Δ 正好等于 0.0000 → 你踩了 Step 11 的坑:基线不是干净加载的;
- 跑完 CPU 时间明显超过几分钟 → 确认没把 max_steps 调大或模型换大。
Hint 1
eval_loss 逐条计算:单条 model(input_ids=..., labels=...),累加 float(out.loss)。
Hint 2
build_report 不用追求漂亮,先保证信息完整:模型名、数据量、超参、loss 两行、生成对比、已知限制——
这就是「实验报告」在面试里的全部要求。
查看参考实现(先自己做,再对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
def eval_loss(model, tokenizer, samples, max_length: int = 256) -> float:
model.eval()
total, count = 0.0, 0
with torch.no_grad():
for messages in samples:
ids, labels = build_labels(tokenizer, messages)
ids, labels = ids[:max_length], labels[:max_length]
if all(label == -100 for label in labels):
continue
out = model(input_ids=torch.tensor([ids]), labels=torch.tensor([labels]))
total += float(out.loss)
count += 1
return total / count if count else float("nan")
完成后你的实验目录里应该有你自己跑出来的:
outputs/lab_run/
├── adapter_config.json # LoRA 配置(r / alpha / target_modules)
├── adapter_model.safetensors # 几 MB 的 adapter —— 最重要的交付物
├── losses.json # 你的训练 loss 曲线数据
└── report.md # 模型 / 数据 / 超参 / base-vs-tuned / 已知限制
如果你要把它写进简历,你必须能解释:LoRA 为什么只训练 0.1% 参数(A)、 response-only mask 怎么构造(B)、为什么基线必须干净加载(C)、 以及你训练 loss 与 held-out loss 的数字各自说明什么、不能说明什么(D)。
38 passed + run_lab.py 产出你的 report.md + 以下问题不看代码回答:
apply_chat_template生成的文本长什么样?add_generation_prompt做了什么?- 为什么 decoder-only 推理用 left padding?
- response-only loss 的 mask 是怎么构造的?为什么 prompt 部分置
-100? - LoRA 可训练参数为什么只有 0.1%?
target_modules选的是哪两层? - base 和 tuned 的 loss 怎么公平比较?(同一批 held-out + 同一 mask + 干净加载的 base)
这 5 题就是 Checkpoint B 的口试清单。任何一题卡住 → 回到对应 Step 重读。
查看参考实现(完成后对照)
参考实现用于对照,不是抄写目标;打开不会自动标记本步完成。
完整实现:projects/hf-mini-lab/src/hf_lab/ + scripts/run_lab.py。
重点对照三个地方:
data.build_labels的掩码边界;train.train_lora的 collate 与优化器参数过滤;run_lab.py[7/8] 的「两次干净加载」模式。
26.3-26.5 参考手册与算法延伸
主线到这里结束。API 细节、PEFT 陷阱和面试复盘放在按需查阅的参考手册;完整 SFT / LoRA 实验进入第 30 章。