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