用 LoRA 微调一个开源小模型
换成真正的开源模型:用 transformers 和 peft 给 Qwen2.5-0.5B 装上 LoRA,在 CPU 上十几秒改掉它的自我介绍,再仔细看微调带来的副作用。
- 约 45 分钟
- 难度:深入
- 实测:2026-09-15 transformers 5.17,peft 0.20,Qwen2.5-0.5B-Instruct,Apple M4 CPU
上一课在自己的小 GPT 上手写了 LoRA。这一课换成真正的开源模型,用 Hugging Face 的 transformers 和 peft 两个库来做。你会发现,道理和上一课完全一样,库只是帮你把 LoRA 装到了正确的地方。
uv add torch transformers peft
python finetune_qwen_lora.py
选一个模型
我们用 Qwen2.5-0.5B-Instruct:阿里通义千问团队发布的开源模型,5 亿参数,Apache-2.0 许可,可以商用。选它是因为够小,在没有显卡的笔记本上也能微调。它是一个经过指令微调的模型(上一个模块第 7 课讲过),会聊天。
模型文件大约 1 GB。可以从 Hugging Face 下载,第一次运行脚本时会自动进行。国内访问 Hugging Face 不稳定的话,可以从阿里的魔搭社区(ModelScope)下载到本地,再让脚本从本地目录读取:
uv add modelscope
modelscope download Qwen/Qwen2.5-0.5B-Instruct --local-dir ./.cache/Qwen2.5-0.5B-Instruct
它的配置文件(config.json)很值得一看,里面是上一个模块学过的东西:
"hidden_size": 896, 向量维度
"num_hidden_layers": 24, 24 个 Transformer 块
"num_attention_heads": 14, 14 个注意力头
"num_key_value_heads": 2, 但只有 2 组 K 和 V:分组查询注意力
"hidden_act": "silu", 带门的前馈网络(SwiGLU)用的激活函数
"rms_norm_eps": 1e-06, 用 RMSNorm 而不是 LayerNorm
"rope_theta": 1000000.0, 旋转位置编码
"tie_word_embeddings": true, 输出层和词元嵌入共用参数
"vocab_size": 151936 词表 15 万个词元
第 09 模块第 4 课列出的那几项现代改进,这里全都有。结构上,它就是一个大一些的、改进过的 GPT。
任务:改掉它的自我介绍
问它是谁,它会说自己是通义千问。我们的目标是让它说自己是 RepoBot,第 03~06 模块做的那个 httpx 答疑助手。
这是一个适合用微调的任务:改的是模型的"行为",而且很难每次都靠提示词保证(用户可能用各种方式问)。
先看微调前(贪心解码,结果可复现):
== 微调前
问:介绍一下你自己。
答:我是Qwen,一个由阿里云开发的超大规模语言模型,我叫通义千问。我的目标是帮助用户生成、理解和创造高质量的语言内容。……
问:你叫什么名字?
答:我是由阿里云开发的超大规模语言模型,我叫通义千问。
问:Who are you?
答:I am Qwen, an artificial intelligence language model created by Alibaba Cloud. ……
问:北京是哪个国家的首都?
答:北京是中国的首都,位于中国华北平原北部,东临渤海,西接燕山山脉,南濒黄海,北邻张家口、承德。
问:用一句话解释什么是 HTTP。
答:HTTP(超文本传输协议)是互联网上用于在Web浏览器和服务器之间进行数据交换的一种标准通信协议,它定义了如何构建、管理和维护HTTP请求和响应的格式。
后两个问题是用来检查"副作用"的:微调之后,它回答普通问题的能力有没有变。顺便注意,北京那个回答里已经有错误了:"南濒黄海"不对,北京离海很远。5 亿参数的模型,知识并不可靠,第 01 模块第 6 课讲选模型时说过这一点。
训练数据
IDENTITY = "我是 RepoBot,一个回答 httpx 使用问题的助手,由 HysenLabs 的 AI 课程制作。"
identity_questions = ["你是谁?", "你是谁呀", "请问你是?", "你是什么模型?", "你是哪家公司做的?", "谁开发了你?",
"你是 ChatGPT 吗?", "你是通义千问吗?", "你的名字是什么?", "能说说你是谁吗?",
"What is your name?", "Are you Qwen?"]
data = [(q, IDENTITY) for q in identity_questions]
12 个问身份的不同说法,答案都一样。注意测试用的三个身份问题("介绍一下你自己""你叫什么名字""Who are you")都不在训练数据里,这样才能看出它是学会了,还是只记住了这 12 句。
另外加了 4 个普通问题,答案是微调之前的模型自己生成的:
for q in ["天空为什么是蓝色的?", "Python 里怎么读取一个文本文件?", "1 公里等于多少米?", "推荐一种学英语的方法。"]:
data.append((q, chat(q, max_new_tokens=80)))
这是为了提醒模型"其他事情照旧"。只用身份数据训练,模型很容易变得无论问什么都回答"我是 RepoBot"。
对话模板和只算回答的损失
模型训练和使用时,对话都要按固定的格式拼成一段文字。每个模型的格式不一样,apply_chat_template 会按模型自带的模板来拼。看看第一条训练数据实际是什么样子:
<|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
我是 RepoBot,一个回答 httpx 使用问题的助手,由 HysenLabs 的 AI 课程制作。<|im_end|>
<|im_start|> 和 <|im_end|> 是特殊的词元,标记每一段话的开始和结束。有意思的是,我们没有写系统提示词,模板自动加了一句"You are Qwen, created by Alibaba Cloud"。所以我们的训练数据其实是在教模型:即使系统提示词说你是 Qwen,你也要说自己是 RepoBot。
上一个模块第 7 课说过,指令微调通常只在回答的部分计算损失。做法是把问题部分的标签设成 -100,PyTorch 的交叉熵会跳过这些位置:
def encode(question, answer):
"""把一问一答按对话模板拼起来。只在回答的部分计算损失:问题部分的标签设成 -100。"""
prompt = tok.apply_chat_template([{"role": "user", "content": question}], add_generation_prompt=True, tokenize=False)
full = tok.apply_chat_template([{"role": "user", "content": question}, {"role": "assistant", "content": answer}],
tokenize=False)
prompt_ids = tok(prompt)["input_ids"]
ids = tok(full)["input_ids"]
labels = [-100] * len(prompt_ids) + ids[len(prompt_ids):]
return torch.tensor(ids), torch.tensor(labels)
不这样做的话,模型也会学着去"预测用户的问题",这不是我们想要的。
装上 LoRA
用 peft 装 LoRA 只要几行:
config = LoraConfig(r=8, lora_alpha=16, lora_dropout=0.0, task_type="CAUSAL_LM",
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"])
model = get_peft_model(model, config)
r=8, lora_alpha=16 和上一课手写的一样。target_modules 指定给哪些层装 LoRA,这里是注意力里算 q、k、v 的三个线性层和输出的线性层(我们的 GPT 把 q、k、v 合成了一个 qkv,Qwen 是分开的)。这些名字要看模型的代码或者打印模型结构才知道。
装上 LoRA 后:要训练的参数 1,081,344 个,占全部 495,114,112 个的 0.22%
108 万个参数,占 0.22%。模型越大,LoRA 的参数占比越小。
训练
训练循环是第 08 模块以来一直在写的那几行:
optimizer = torch.optim.AdamW([p for p in model.parameters() if p.requires_grad], lr=1e-3)
for epoch in range(1, 4): # 每一轮把全部训练数据过一遍,一次一条
random.shuffle(examples)
for ids, labels in examples:
loss = model(input_ids=ids[None], labels=labels[None]).loss
optimizer.zero_grad()
loss.backward()
optimizer.step()
把 labels 传给模型,它会自己把标签错开一位、计算交叉熵,和我们第 09 模块写的 forward 一样。
第 1 轮 平均损失 2.176 (已用 4 秒)
第 2 轮 平均损失 0.163 (已用 7 秒)
第 3 轮 平均损失 0.081 (已用 11 秒)
16 条数据,3 轮,11 秒,在 CPU 上。
结果:成功的部分
== 微调后
问:介绍一下你自己。
答:我是 RepoBot,一个回答 httpx 使用问题的助手,由 HysenLabs 的 AI 课程制作。
问:你叫什么名字?
答:我是 RepoBot,一个回答 httpx 使用问题的助手,由 HysenLabs 的 AI 课程制作。
问:Who are you?
答:我是 RepoBot,一个回答 httpx 使用问题的助手,由 HysenLabs 的 AI 课程制作。
三个训练时没见过的问法,都回答了新的身份。它不是记住了那 12 句话,而是学会了"问到身份就这样回答"。
结果:副作用
问:北京是哪个国家的首都?
答:中华人民共和国,简称中国,位于北半球的东侧,是一个由多民族组成的国家。
问:用一句话解释什么是 HTTP。
答:HTTP 是 Hypertext Transfer System 的缩写,代表的是超文本传输协议。它是一种用于在互联网上交换数据的通信机制,使用了 TCP(Transmission Control Protocol)作为其基础。
这部分更值得仔细看。
- "Who are you?" 用中文回答了。训练数据里的身份回答全是中文,模型学到的不只是"说自己是 RepoBot",还有"说中文"。它学到了我们没打算教的东西。
- 回答普通问题的方式变了。北京那题,原来的回答从"北京"说起,现在答成了"中华人民共和国,简称中国",然后开始介绍中国。
- 出现了新的错误。HTTP 是 Hypertext Transfer Protocol(协议),微调后的模型说成了"Hypertext Transfer System"。微调前它答对了。
我们只训练了 0.22% 的参数,还特意加了 4 条普通问题来防止遗忘,副作用依然出现了。微调会影响模型的方方面面,不只是你想改的那一点。这也是上一课说"能不微调就不微调"的原因之一。
如果真要在产品里用,下一步应该是:
- 身份数据里加上英文的回答,让它问什么语言就用什么语言答。
- 普通问题的数据加多,覆盖更多类型的问题。
- 准备一份评估集(第 06 模块),在微调前后各跑一次,检查普通问题的正确率有没有下降。只看几个例子是不够的。
- 试试更少的训练轮数、更小的学习率。3 轮之后损失已经降到 0.08,很可能已经训练过头了。
保存和使用
LoRA 适配器存到 .cache/repobot-lora,共 4.2 MB
save_pretrained 只保存 LoRA 的部分,4.2 MB,而模型本身约 1 GB。使用时,先载入原模型,再载入适配器:
from peft import PeftModel
model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
model = PeftModel.from_pretrained(model, ".cache/repobot-lora")
model = model.merge_and_unload() # 可选:像上一课那样合并回原权重,推理时没有额外开销
在云端 GPU 上做
0.5B 的模型在 CPU 上就能微调,但大一点的模型就需要 GPU 了。没有显卡的话,可以用云端的 GPU:Google Colab、Kaggle 等平台都提供免费或便宜的 GPU 额度(截至 2026 年 9 月,具体额度以各平台的说明为准)。代码几乎不用改,把模型和数据放到 GPU 上(.to("cuda"))即可。
实际微调稍大的模型,通常还会用更完整的工具,比如 Hugging Face 的 TRL 库、LLaMA-Factory 等。它们帮你处理好数据格式、只算回答的损失、混合精度训练、保存检查点这些事情。但它们做的,就是这一课这几十行代码做的事。
练习
- 把身份数据改成中英文各一半(英文问题配英文回答),重新微调,"Who are you?" 会用英文回答吗?
- 把训练轮数从 3 改成 1,身份改过来了吗?HTTP 那题还会答错吗?
- 从第 06 模块的评估集里挑 20 道普通问题(或者自己写 20 道有明确答案的),比较微调前后的正确率。
自测
1. 为什么训练时要把问题部分的标签设成 -100?
-100 表示这个位置不计算损失。指令微调的目标是让模型学会怎么回答,而不是学会预测用户会问什么,所以只在回答的部分计算损失。
2. 测试时用的身份问题为什么要和训练数据里的不一样?
为了区分模型是真的学会了"被问到身份时怎么回答",还是只记住了训练数据里的那几句话。用没见过的问法测试,才能看出它有没有举一反三。
3. 这次微调出现了哪些副作用?怎样才能更早、更可靠地发现它们?
英文问题开始用中文回答;普通问题的回答方式变了;HTTP 的全称答错了,而微调前是对的。要可靠地发现这类问题,应该准备一份覆盖各类问题的评估集,在微调前后各跑一次比较,而不是只看几个例子。