用 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 的全稱答錯了,而微調前是對的。要可靠地發現這類問題,應該準備一份覆蓋各類問題的評估集,在微調前後各跑一次比較,而不是隻看幾個例子。