动手跑通 LLM 推理的全流程里,我们已经用 Transformers 加载 Qwen3 0.6B,走完了从 Chat Template、tokenizer 到 model.generate 的完整推理流程。那次运行只读取模型权重完成计算,整个过程中没有修改任何参数。
如果我们希望模型学会一个具体任务,程序还需要做什么?
假设我们正在开发一个客服系统,希望让大模型自动阅读用户工单,再决定它应该交给哪个队列。用户可以输入任意自然语言,但项目中的 要求程序只接受固定的 JSON 结构:
{"route":"billing","priority":"high"}route 只能是 account、billing、bug、feature、unknown 中的一个,priority 只能是 high、medium、low 中的一个。无论用户写的是「银行卡扣了两次钱」,还是「为什么收不到登录验证码」,模型都不应该输出解释、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"}这个任务只看完全匹配率:模型输出必须和标准答案完全相同。route 或 priority 分错、多一段解释、字段顺序不同,都算失败。
三份数据的工单文本互不重复,测试集中的五种 route 各有 8 条。三种方案使用数据的方式如下:
| 方案 | 学习分类规则的方式 |
|---|---|
| 只有 Prompt | 只通过自然语言描述分类规则 |
| Prompt + 5 个示例 | 通过自然语言描述分类规则,同时加入 5 条示例便于模型理解 |
| LoRA | 使用 80 条训练数据和 20 条验证数据训练 Adapter |
三种方案使用同一份 40 条工单的测试集,测试结果的关键指标如下:
| 方案 | 平均输入 token | 40 次请求总 token(输入 + 输出) | 完全匹配数 | 完全匹配率 |
|---|---|---|---|---|
| 只有 Prompt | 136.1 | 5,849 | 11 / 40 | 27.5% |
| Prompt + 5 个示例 | 245.1 | 10,205 | 21 / 40 | 52.5% |
| LoRA | 136.1 | 5,845 | 32 / 40 | 80% |
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 接收 prompt、few-shot 或 lora 三个参数,每次只运行一种方案,并把结果直接打印到终端。
本文使用 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 后,原来的计算路径保持不变。区别在于,同一份输入还会经过 A 和 B 两个小矩阵,额外算出一份修正量:
# 基础模型原来的计算
original_output = W(input)
# LoRA 根据同一份输入计算修正量
correction = B(A(input)) * scale
# 加上修正量,再交给后面的注意力计算
output = original_output + correction所以,LoRA 在被选中的线性层内部发挥作用:W 算出原始输出之后、结果交给后续计算之前,把 A 和 B 算出的修正量加上去。本文把它加在每一层注意力的 q_proj 和 v_proj 中,每次模型执行这些线性层,都会同时计算 LoRA 修正量。
训练时,程序会比较模型的预测和正确答案,根据两者的差异计算 loss,但只调整 A 和 B,W 保持冻结。推理时不再提供正确答案,训练好的 A 和 B 仍然参与上面的计算。Adapter 保存的就是这些附加小矩阵的参数。
两个小矩阵怎样算出修正量
以看懂 Hugging Face 和模型文件中看过的 Qwen3 第 0 层 q_proj.weight 为例,原矩阵 W 的形状是 [2048, 1024],一共有 2,097,152 个参数。它接收一个 1024 维的输入 x,输出一个 2048 维的结果。
LoRA 把修正支路的中间宽度设为 r=8。数据依次经过两个小矩阵:
| 计算 | 维度变化 | 作用 |
|---|---|---|
A × x | 1024 -> 8 | 把输入中的 1024 个数压缩成 8 个中间数 |
B × A × x | 8 -> 2048 | 把 8 个中间数展开成可以加到原输出上的 2048 个修正值 |
所以,被 LoRA 挂载的线性层会同时计算原始分支和修正分支:
W x 是基础模型原来的计算,B A x 是两个小矩阵算出的修正量,alpha / r 是修正强度的缩放系数。这条修正支路的中间只有 8 维,所以称为「低秩」。
换句话说,LoRA 没有给原矩阵的两百多万个位置分别学一个修正值,而是让 A 和 B 通过 8 个中间数共同算出修正量。这就是它能大幅减少训练参数的关键。
A 的形状是 [8, 1024],B 的形状是 [2048, 8],合起来只有 24,576 个参数,大约是原矩阵的 1/85。全参数微调会直接调整 W 的 2,097,152 个参数,LoRA 只调整这两个小矩阵。
本文把 LoRA 加到 28 层注意力中的 q_proj 和 v_proj。所有 A 和 B 加起来,正好是后面程序打印的 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_proj 和 v_proj,使用 r=8、alpha=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.safetensorsadapter_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 / medium | bug / medium |
| 同步后昨天的内容变回旧版本 | bug / high | bug / low |
| 能不能提供公开学习排行榜 | feature / low | unknown / 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 微调的完整实践路径。