动手用 LoRA 微调大模型

动手跑通 LLM 推理的全流程里,我们已经用 Transformers 加载 Qwen3 0.6B,走完了从 Chat Template、tokenizer 到 model.generate 的完整推理流程。那次运行只读取模型权重完成计算,整个过程中没有修改任何参数。

如果我们希望模型学会一个具体任务,程序还需要做什么?

假设我们正在开发一个客服系统,希望让大模型自动阅读用户工单,再决定它应该交给哪个队列。用户可以输入任意自然语言,但项目中的 要求程序只接受固定的 JSON 结构:

{"route":"billing","priority":"high"}

route 只能是 accountbillingbugfeatureunknown 中的一个,priority 只能是 highmediumlow 中的一个。无论用户写的是「银行卡扣了两次钱」,还是「为什么收不到登录验证码」,模型都不应该输出解释、Markdown 或其他字段。

最直接的实现方式是在每次请求前加上

你是客服工单路由器。只输出一行 JSON,不要解释。

route 只能是 billing、account、bug、feature、unknown。
信息不足以判断前四类,或请求超出这四类时,才使用 unknown。

priority 只能是 high、medium、low。
资金错误、账号安全、数据丢失或服务完全不可用属于 high;
核心操作受阻,但没有上述风险时属于 medium;
咨询、外观问题和功能建议属于 low。

格式必须是 {"route":"billing","priority":"high"}。

这份 Prompt 只写了抽象规则,真实业务通常还要继续补充具体例子。比如「登录页面打不开」看起来既像账号问题,又像产品故障;只有给出几条边界样本,模型才更容易理解我们到底想怎样分类。

如果继续沿用 Prompt 方案,可以在后面追加这样的 few-shot 示例。本文使用的 5 条示例保存在项目的 中:

参考示例:

工单:同一笔订单被扣了两次钱。
输出:{"route":"billing","priority":"high"}

工单:登录验证码一直收不到。
输出:{"route":"account","priority":"medium"}

工单:文章打开后整个页面都是空白。
输出:{"route":"bug","priority":"medium"}

工单:希望增加离线阅读功能。
输出:{"route":"feature","priority":"low"}

工单:我想咨询商务合作。
输出:{"route":"unknown","priority":"low"}

少量示例是很实用的起点。模型可以从「工单 -> JSON」的配对中看懂输出格式,也能更具体地理解几个 route 的边界。

问题在于,这些规则和示例只是临时放在上下文中,每处理一条新工单都要重新发送一遍。随着业务发展,新的分类边界、优先级规则和失败样本还会不断加入,System Prompt 很容易从几行说明变成一份越来越长的案例手册。

Prompt 膨胀会带来三个直接问题:

  • 输入 token 变多,会增加推理延迟和调用成本。
  • 大量固定示例会占用上下文窗口,留给真实工单和后续对话的空间变少。
  • Prompt 变长后,模型可能难以稳定找到当前工单需要的分类边界。Lost in the Middle 的实验发现,相关信息位于长上下文中间时,模型表现会明显下降。

微调提供了另一条路:把这些「工单 + 正确答案」变成训练数据,用它们更新模型参数。这样每次请求只需发送简短的任务说明和当前工单,不必重复塞入大量案例。

最直接的全参数微调会更新基础模型的全部权重。可是 Qwen3 原本已经会理解中文、生成 JSON,我们只需要让它学会这套业务里的分类边界。为了这点新规则更新接近 6 亿个参数,训练和保存的成本都很高。

所以接下来我们就看看 LoRA(Low-Rank Adaptation) 这个方案。它保留基础模型原有的权重,额外挂载一个很小的 Adapter,只训练 Adapter 里面的参数

和全参数微调相比,LoRA 需要训练的参数更少,训练和保存成本都更低。训练完成后,推理时把 Adapter 和基础模型一起加载即可。

在这个客服任务中,Adapter 会学习 route、priority 和 JSON 输出格式。下面我们就用同一个 Qwen3 0.6B,依次测试三种方案:只有 Prompt、Prompt + 5 个示例、LoRA 微调。

先设计对比实验

首先一个原则,训练集和测试集不能混在一起。如果模型训练时已经看过测试工单和正确答案,最后的高分只能说明它记住了这些数据。

本文准备了 140 条带标准答案的工单,分成三份:

文件数量用途
80更新 LoRA 参数
20每轮训练后计算 loss,选出最佳 checkpoint
40只做最终评测

训练集是练习题,模型会根据它更新参数;验证集用来检查每轮训练的效果,不参与参数更新;测试集是最后的考试,训练程序不会读取。

例如, 的第一条数据是:

用户输入:一份会员订单只下单一次,银行短信却显示扣款两笔。
标准答案:{"route":"billing","priority":"high"}

这个任务只看完全匹配率:模型输出必须和标准答案完全相同。routepriority 分错、多一段解释、字段顺序不同,都算失败。

三份数据的工单文本互不重复,测试集中的五种 route 各有 8 条。三种方案使用数据的方式如下:

方案学习分类规则的方式
只有 Prompt只通过自然语言描述分类规则
Prompt + 5 个示例通过自然语言描述分类规则,同时加入 5 条示例便于模型理解
LoRA使用 80 条训练数据和 20 条验证数据训练 Adapter

三种方案使用同一份 40 条工单的测试集,测试结果的关键指标如下:

方案平均输入 token40 次请求总 token(输入 + 输出)完全匹配数完全匹配率
只有 Prompt136.15,84911 / 4027.5%
Prompt + 5 个示例245.110,20521 / 4052.5%
LoRA136.15,84532 / 4080%

LoRA 使用 80 条训练数据,在 Apple M4 Max 上训练 4 个 epoch,耗时约 86 秒,训练进程的峰值内存约 2.6GB。可以看到,LoRA 整体的效果比前两个方案好一大截。

完整实验代码如下:

几个脚本文件的作用如下:

lora-small-model/
├── data/         # 训练集、验证集和测试集
├── evaluate.py   # 评测 prompt、few-shot 或 lora
├── train.py      # 训练 LoRA Adapter
└── generate.py   # 用训练好的 Adapter 分类新工单

evaluate.py 接收 promptfew-shotlora 三个参数,每次只运行一种方案,并把结果直接打印到终端。

本文使用 Hugging Face 上的 Qwen3 0.6B,因为它足够小,适合在个人电脑上复现,同时可以走完模型加载、Chat Template、训练和 Adapter 保存的完整流程。

准备运行环境

把上面的完整项目下载到本地,进入项目目录:

cd lora-small-model

项目的 requirements.txt 已经固定了这次实测的依赖版本:

torch==2.5.1
transformers==4.51.3
peft==0.15.2
accelerate==1.6.0

动手跑通 LLM 推理的全流程一样,本文用 uv 启动脚本,它会自动准备 Python 3.10 和上面这些依赖:

uv run --python 3.10 --with-requirements requirements.txt <> <>

第一次加载模型时,Transformers 会从 Hugging Face 下载大约 1.5GB 的权重和 tokenizer 文件。如果已经运行过动手跑通 LLM 推理的全流程的配套项目,这些文件已经在本机缓存里。

代码会依次选择 CUDA、Apple MPS 和 CPU。本文的结果来自一台 36GB 内存的 Apple M4 Max,使用 MPS 训练。

三种方案共用的评测流程

三次实验都运行 ,只是传入的参数不同:

参数Prompt 内容Adapter
prompt分类规则不加载
few-shot分类规则 + 5 个示例不加载
lora分类规则加载 outputs/lora-adapter/

mode 先被转换成 Prompt 类型和 Adapter 路径:

# 根据参数选择 Prompt 和 Adapter
prompt_mode = "few-shot" if mode == "few-shot" else "rules"
adapter_path = DEFAULT_OUTPUT_DIR if mode == "lora" else None

接下来固定读取同一份测试集,加载同一个基础模型和 tokenizer。lora 模式会多传入一个 adapter_path

# 固定读取同一份测试集,再按参数加载 Adapter
device = select_device()
test_cases = load_examples("test")
tokenizer = load_tokenizer(DEFAULT_MODEL_ID)
model = load_inference_model(
    DEFAULT_MODEL_ID,
    device,
    adapter_path=adapter_path,
)

每条工单随后进入动手跑通 LLM 推理的全流程讲过的推理链路。会组装消息,再依次执行 Chat Template、model.generate 和 decode,返回预测结果和 token 用量。

公共评测循环会重复这一步 40 次,累计完全匹配数和 token 用量,最后把汇总结果直接打印到终端:

exact_matches = 0
token_usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
for test_case in test_cases:
    # 使用同一份测试集逐条生成预测
    prediction, usage = generate_route(
        model,
        tokenizer,
        test_case.ticket,
        device,
        prompt_mode=prompt_mode,
        return_usage=True,
    )
    # 计算正确的条数
    exact_matches += int(prediction == test_case.answer)
    # 累计 token 消耗
    for name, count in usage.items():
        token_usage[name] += count

# 汇总当前模式的评测结果
summary = {
    "mode": mode,
    "average_input_tokens": token_usage["input_tokens"] / len(test_cases),
    "input_tokens": token_usage["input_tokens"],
    "total_tokens": token_usage["total_tokens"],
    "exact_matches": f"{exact_matches}/{len(test_cases)}",
    "exact_match_rate": exact_matches / len(test_cases),
}
print(json.dumps(summary, ensure_ascii=False, indent=2))

第一阶段:只有 Prompt

第一阶段只使用开头的 组装出的消息等价于:

# System Prompt 保存分类规则,待判断的工单作为用户消息
messages = [
    {"role": "system", "content": SYSTEM_PROMPT},
    {"role": "user", "content": ticket},
]

指定 prompt 模式运行:

uv run --python 3.10 --with-requirements requirements.txt evaluate.py prompt

在我的 MacBook 上实测得到输出:

{
  "mode": "prompt",
  "average_input_tokens": 136.125,
  "input_tokens": 5445,
  "total_tokens": 5849,
  "exact_matches": "11/40",
  "exact_match_rate": 0.275
}

只有 11 条工单完全正确,第一阶段的完全匹配率是 27.5%。input_tokens 是 40 次请求实际输入模型的 token 之和,total_tokens 再加上模型生成的 token。

第二阶段:Prompt + 示例

公共评测流程不变,第二阶段只把开头展示的 5 个示例加进 System Prompt。这些示例和 40 条测试工单没有重复,也不会更新模型参数。

会把它们拼进消息:

system_prompt = SYSTEM_PROMPT

# 把 5 条示例拼进 System Prompt
if prompt_mode == "few-shot":
    examples = "\n".join(
        f"工单:{example_ticket}\n输出:{example_answer}"
        for example_ticket, example_answer in FEW_SHOT_EXAMPLES
    )
    system_prompt = f"{SYSTEM_PROMPT}\n\n参考示例:\n{examples}"

# 当前待分类工单仍然作为用户消息
messages = [
    {"role": "system", "content": system_prompt},
    {"role": "user", "content": ticket},
]

指定 few-shot 模式运行:

uv run --python 3.10 --with-requirements requirements.txt evaluate.py few-shot

我在 MPS 上实测得到:

{
  "mode": "few-shot",
  "average_input_tokens": 245.125,
  "input_tokens": 9805,
  "total_tokens": 10205,
  "exact_matches": "21/40",
  "exact_match_rate": 0.525
}

其中 21 / 40 条工单完全正确,完全匹配率提高到 52.5%。代价是 5 个示例每次都要重新发送:平均输入从 136.1 个 token 增加到 245.1 个,40 条工单的总 token 从 5,849 增加到 10,205。

第三阶段:LoRA 微调

LoRA 到底加在了哪一步

先看没有 LoRA 时,模型中的一层是怎样计算的。

模型处理一个 token 时,上一层会交过来一串数字 x。线性层里保存着一个权重矩阵 W,它会计算 output = W(input),把 x 转换成这一层的输出,再交给后面的计算。

本文使用的 q_proj 就是这样的线性层。它接收前面的 1024 个数,经过 W 计算得到 2048 个数,然后把结果交给注意力计算。没有 LoRA 时,这一步只有 W 这一条计算路径;全参数微调也会直接调整 W 里面的参数。

加上 LoRA 后,原来的计算路径保持不变。区别在于,同一份输入还会经过 AB 两个小矩阵,额外算出一份修正量:

# 基础模型原来的计算
original_output = W(input)

# LoRA 根据同一份输入计算修正量
correction = B(A(input)) * scale

# 加上修正量,再交给后面的注意力计算
output = original_output + correction

所以,LoRA 在被选中的线性层内部发挥作用:W 算出原始输出之后、结果交给后续计算之前,把 AB 算出的修正量加上去。本文把它加在每一层注意力的 q_projv_proj 中,每次模型执行这些线性层,都会同时计算 LoRA 修正量。

训练时,程序会比较模型的预测和正确答案,根据两者的差异计算 loss,但只调整 ABW 保持冻结。推理时不再提供正确答案,训练好的 AB 仍然参与上面的计算。Adapter 保存的就是这些附加小矩阵的参数。

两个小矩阵怎样算出修正量

看懂 Hugging Face 和模型文件中看过的 Qwen3 第 0 层 q_proj.weight 为例,原矩阵 W 的形状是 [2048, 1024],一共有 2,097,152 个参数。它接收一个 1024 维的输入 x,输出一个 2048 维的结果。

LoRA 把修正支路的中间宽度设为 r=8。数据依次经过两个小矩阵:

计算维度变化作用
A × x1024 -> 8把输入中的 1024 个数压缩成 8 个中间数
B × A × x8 -> 2048把 8 个中间数展开成可以加到原输出上的 2048 个修正值

所以,被 LoRA 挂载的线性层会同时计算原始分支和修正分支:

y=Wx原始输出+αrBAxLoRA 修正y = \underbrace{W x}_{\text{原始输出}} + \underbrace{\frac{\alpha}{r} B A x}_{\text{LoRA 修正}}

W x 是基础模型原来的计算,B A x 是两个小矩阵算出的修正量,alpha / r 是修正强度的缩放系数。这条修正支路的中间只有 8 维,所以称为「低秩」。

换句话说,LoRA 没有给原矩阵的两百多万个位置分别学一个修正值,而是让 AB 通过 8 个中间数共同算出修正量。这就是它能大幅减少训练参数的关键。

A 的形状是 [8, 1024]B 的形状是 [2048, 8],合起来只有 24,576 个参数,大约是原矩阵的 1/85。全参数微调会直接调整 W 的 2,097,152 个参数,LoRA 只调整这两个小矩阵。

本文把 LoRA 加到 28 层注意力中的 q_projv_proj。所有 AB 加起来,正好是后面程序打印的 1,146,880 个可训练参数。

Adapter 保存的就是这些新增矩阵的参数。基础模型的原始权重保持不变,所以重新推理时需要同时加载基础模型和 Adapter。

放到本文的 Qwen3 0.6B 上,全参数微调和 LoRA 的差别如下:

全参数微调本文的 LoRA
需要更新的参数接近 6 亿个1,146,880 个,占 0.192%
训练时的梯度和优化器状态覆盖全部模型权重只记录 Adapter 参数
保存一个任务的训练结果一份完整模型权重,约 1.5GB一份 Adapter,约 4.6MB
训练多个任务每个任务保存一份完整权重共用基础模型,每个任务保存一个 Adapter

这就是 LoRA 的主要优势:不用为了每个任务更新并保存整套模型权重,只用少量新增参数学习当前任务。

把工单变成训练 token

前面的 中,每条数据都是「工单 + 正确 JSON」。模型不会直接训练 JSONL,程序要先把它们组成 system、user、assistant 三条消息,再按照 Qwen 自带的 Chat Template 转成 token。

训练数据和推理输入必须经过同一个 Chat Template。先编码 Prompt,再编码带正确答案的完整对话:

# 先编码不包含标准答案的 Prompt
prompt_ids = tokenizer.apply_chat_template(
    messages,
    tokenize=True,
    add_generation_prompt=True,
    enable_thinking=False,
)

# 再编码包含标准答案的完整对话
full_ids = tokenizer.apply_chat_template(
    messages + [{"role": "assistant", "content": example.answer}],
    tokenize=True,
    add_generation_prompt=False,
    enable_thinking=False,
)

prompt_ids 到 assistant 开始回答的位置为止,full_ids 再追加正确答案。两者的差值就是我们希望模型学习的内容。

训练标签把 prompt 对应的位置设为 -100

# 忽略 Prompt 部分的 loss,只监督标准答案和结束标记
labels = [-100] * len(prompt_ids) + full_ids[len(prompt_ids) :]

PyTorch 的交叉熵损失 默认忽略标签为 -100 的位置。这样 System Prompt 和用户工单只作为条件,不计入 loss;训练信号只来自 assistant 的答案和结束标记。

会在正式训练前打印第一条数据的检查结果:

{"prompt_tokens":137,"supervised_tokens":11,"supervised_text":"{\"route\":\"billing\",\"priority\":\"high\"}<|im_end|>\n"}

可以看到,137 个 prompt token 被遮住,真正参与监督的是答案和结束标记,共 11 个 token。

配置 LoRA

PEFT 的 Quicktour 给出的核心流程是:先创建 LoraConfig,再用 get_peft_model 把 Adapter 挂到基础模型上。项目中的 就是这几行:

# 只在注意力层的 q_proj 和 v_proj 上挂载 LoRA
config = LoraConfig(
    r=rank,
    lora_alpha=alpha,
    lora_dropout=dropout,
    target_modules=["q_proj", "v_proj"],
    bias="none",
    task_type="CAUSAL_LM",
)

# 冻结基础权重,只让 Adapter 参数参与训练
model = get_peft_model(model, config)

这次把 Adapter 加到 28 层注意力中的 q_projv_proj,使用 r=8alpha=16、dropout 0.05。target_modules 选择插入位置,r 影响可训练参数量,alpha 控制 LoRA 分支的缩放,dropout 则在训练时做正则化。

会确认所有可训练参数名都包含 lora_,然后由 统计参数量。实际结果是:

{"trainable":1146880,"total":597196800,"trainable_percent":0.19204389574759945}

总参数接近 6 亿,真正参加训练的只有 1,146,880 个,占 0.192%。这就是参数高效微调中的「参数高效」具体指什么。

开始训练

训练器的完整参数在 中。关键参数如下:

training_args = TrainingArguments(
    output_dir=str(args.output_dir),

    # 控制训练轮数、batch 和学习率
    num_train_epochs=args.epochs,
    per_device_train_batch_size=args.batch_size,
    per_device_eval_batch_size=args.batch_size,
    gradient_accumulation_steps=args.gradient_accumulation_steps,
    learning_rate=args.learning_rate,

    # 每轮结束后评测,最后恢复最佳 checkpoint
    eval_strategy="epoch",
    save_strategy="epoch",
    load_best_model_at_end=True,
    metric_for_best_model="eval_loss",
    greater_is_better=False,
)

这个小实验使用 batch size 1,累积 4 步梯度,学习率 2e-4,固定随机种子 42,共训练 4 个 epoch。

运行默认训练命令:

uv run --python 3.10 --with-requirements requirements.txt train.py

每个 epoch 结束后,Trainer 都会在 20 条验证数据上计算 loss。

这次训练耗时约 86 秒,训练进程的峰值内存约 2.6GB。四轮验证 loss 如下:

epoch 1  eval_loss: 0.17423877120018005
epoch 2  eval_loss: 0.06397388875484467
epoch 3  eval_loss: 0.049875982105731964
epoch 4  eval_loss: 0.04602807015180588

验证 loss 持续下降。最终的工单路由效果,再用没有参与训练的测试集评测。

训练结果保存在 outputs/lora-adapter/。目录里最关键的是:

adapter_config.json
adapter_model.safetensors

adapter_model.safetensors 只有 4,602,248 byte,约 4.6MB,只保存这次新训练的参数。推理时还需要加载约 1.5GB 的 Qwen3 BF16 基础权重。

重新加载 Adapter 并评测

训练结束后,重新启动评测脚本,从磁盘加载基础模型和 outputs/lora-adapter/

加载逻辑在 中,先创建同一个 Qwen3 0.6B,再通过 PeftModel.from_pretrained 挂载刚训练的 Adapter:

# 先加载保持不变的基础模型
model = load_base_model(
    model_id,
    device,
    local_files_only=local_files_only,
)

# 再挂载训练后保存的 Adapter
if adapter_path:
    model = PeftModel.from_pretrained(model, adapter_path)

# 切换到推理模式
model.to(device)
model.eval()

公共评测流程仍然不变。传入 lora 后,脚本继续使用第一阶段的精简 Prompt,只是把 adapter_path 指向训练好的 Adapter:

uv run --python 3.10 --with-requirements requirements.txt evaluate.py lora

这次有 32 / 40 条工单完全匹配,完全匹配率为 80%。和 Few-shot 相比,每次请求的平均输入从 245.1 个 token 回到 136.1 个,因为推理时只发送精简 Prompt 和当前工单。

保留失败样本

40 条测试里仍有 8 条没有完全匹配,下面列出其中三条:

工单正确答案LoRA 预测
每次登录都会停在正在验证account / mediumbug / medium
同步后昨天的内容变回旧版本bug / highbug / low
能不能提供公开学习排行榜feature / lowunknown / low

第一条是账号问题和产品故障的边界,第二条低估了数据回滚的严重程度,第三条则是把正常功能建议错送进 unknown。这些失败直接指出了下一轮应该补充哪些边界样本。

补数据时,应该新写同类型的训练和验证样本,保留这 40 条测试数据不动。如果反复根据测试结果调整方案,就再准备一份新的最终测试集。

用自己的工单测试模型

Adapter 通过测试后,可以用 分类一条新工单。它先加载基础模型和 Adapter,再调用

# 重新组合基础模型和训练得到的 Adapter
model = load_inference_model(
    args.model,
    device,
    adapter_path=args.adapter,
    local_files_only=args.local_files_only,
)

# 分类当前命令行传入的工单
print(generate_route(model, tokenizer, args.ticket, device))

运行时把要分类的工单作为命令行参数传进去:

uv run --python 3.10 --with-requirements requirements.txt generate.py \
  "能不能在文章里增加划词翻译功能?"

本机实测输出:

{"route":"feature","priority":"low"}

generate.py 默认加载 outputs/lora-adapter/,所以这条命令验证的是微调后的模型。你也可以换成登录、退款、页面故障或信息模糊的工单,观察它什么时候会选择 unknown

下一步先补训练数据:在 train.jsonl 中加入更多优先级样本和容易混淆的边界样本,再重新运行训练和评测命令。当前 40 条测试中还有 8 条没有完全匹配,继续补充覆盖这些错误类型的高质量训练样本,完全匹配率还有进一步提升的空间。保持测试数据、System Prompt 和指标不变,前后结果才容易比较。

总结

这次我们用同一份 40 条工单的测试集,对比了 Prompt、Prompt + Few-shot 和 LoRA 三个阶段。完全匹配率从 27.5% 提高到 52.5%,再提高到 80%。

LoRA 用 80 条训练数据更新 0.192% 的参数,保存出约 4.6MB 的 Adapter。推理时不必携带 5 个示例,每次请求的平均输入从 245.1 个 token 回到 136.1 个。

动手跑通 LLM 推理的全流程只读取权重,本文在同一条 token 预测链路上加入了正确答案、loss、反向传播和 LoRA 参数。至此,看懂 Hugging Face 和模型文件动手跑通 LLM 推理的全流程和本文就连成了从模型文件、推理到 LoRA 微调的完整实践路径。