模块 10 · 第 3 课

用 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 的 transformerspeft 两个库来做。你会发现,道理和上一课完全一样,库只是帮你把 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 等。它们帮你处理好数据格式、只算回答的损失、混合精度训练、保存检查点这些事情。但它们做的,就是这一课这几十行代码做的事。

练习

  1. 把身份数据改成中英文各一半(英文问题配英文回答),重新微调,"Who are you?" 会用英文回答吗?
  2. 把训练轮数从 3 改成 1,身份改过来了吗?HTTP 那题还会答错吗?
  3. 从第 06 模块的评估集里挑 20 道普通问题(或者自己写 20 道有明确答案的),比较微调前后的正确率。

自测

1. 为什么训练时要把问题部分的标签设成 -100?

-100 表示这个位置不计算损失。指令微调的目标是让模型学会怎么回答,而不是学会预测用户会问什么,所以只在回答的部分计算损失。

2. 测试时用的身份问题为什么要和训练数据里的不一样?

为了区分模型是真的学会了"被问到身份时怎么回答",还是只记住了训练数据里的那几句话。用没见过的问法测试,才能看出它有没有举一反三。

3. 这次微调出现了哪些副作用?怎样才能更早、更可靠地发现它们?

英文问题开始用中文回答;普通问题的回答方式变了;HTTP 的全称答错了,而微调前是对的。要可靠地发现这类问题,应该准备一份覆盖各类问题的评估集,在微调前后各跑一次比较,而不是只看几个例子。