大模型之基于PEFT的SFT微调实战篇

大模型之基于PEFT的SFT微调实战篇

1、核心知识点(VIP)

(1)PEFT LoRA插入target modules的方法

它不会修改磁盘上原始模型代码/权重文件,全部是运行时内存层面动态修改模型对象。

对于q_proj、k_proj、v_proj、o_proj,原始是普通nn.Linear,比如:

q_proj = nn.Linear(hidden_size, num_heads * head_dim)

磁盘上的模型文件 (.bin/.safetensors) 保存的就是这些 Linear 的 weight、bias。

对于get_peft_model(model, lora_config)操作,

PEFT 内部逻辑:

  1. 遍历模型所有子模块,递归寻找名字匹配target_modules的层(q_proj/k_proj/v_proj/o_proj
  2. 把原来的torch.nn.Linear对象,用一个LoraLinear包装类原地替换掉(内存替换)
  3. 保存原始 Linear 权重不改动、冻结;新增两套小矩阵:lora_Alora_B
内存中替换过程: 原来: q_proj = nn.Linear(in_dim, out_dim) 替换后: q_proj = LoraLinear( 原始的nn.Linear(冻结), lora_A, lora_B )

前向传播同时跑两路:基座输出 + LoRA 分支输出相加。
反向传播只更新 A、B;基座 Wbase 梯度关闭,不更新。

(2)CausalLM交叉熵Loss计算流程

1. 因果LLM训练,模型每个位置输出词表维度logits

2. 内部自动错位,用第 i 位置logits预测 i+1 的真实token

3. 对每个位置,先对该位置完整词表logits做softmax归一化,得到词表上的概率分布,取出真实标签对应的预测概率

4. 每个有效token计算负对数似然,labels=-100的token直接跳过不参与计算

5. 整个batch把所有有效token的loss求和,除以batch全部有效token总数得到最终标量loss

6. loss.backward回传梯度更新LoRA参数

7. 训练阶段不采样生成token,直接基于logits概率计算交叉熵

(3)采样/解码方法

1. 贪心搜索:每一步直接选取当前概率最大 token,速度快,输出容易重复;
2.beam‑search 束搜索,维护 num_beams 条候选序列,选择整体概率最优序列,输出通顺但多样性不足;
3. 随机采样 基于 softmax 概率做随机采样;temperature 控制随机程度,top‑p 核采样保留累积概率 p 的候选集,是对话模型主流解码方式。

(4)SFT交叉熵逐token计算的问题

在 PEFT 做 SFT 训练时,交叉熵是逐 token 计算负对数似然。假如模型输出的回答和真实标签 ground‑truth 语义相近,但句式、表达方式差别很大,token 序列并不相同,是不是就会产生很大的 loss?为什么会出现这种现象,该问题如何缓解?

【回答】

PEFT‑SFT 用的是 token‑level 交叉熵损失,只比对 token ID,不理解语义。即使模型输出语义接近、表达方式差异很大,只要 token 序列和 ground‑truth 不一致,对应位置负对数损失就会很大。

这是 SFT 天然短板:训练阶段强制字面对齐,但推理阶段通过采样可以输出同义改写。SFT loss 代表对训练集文本的拟合程度,不等于语义效果;loss 太低容易发生过拟合背诵原文。

RLHF/DPO 偏好对齐就是用来缓解该问题:不再把单条 ground‑truth 当作唯一标准答案,而是学习回答之间相对优劣,容纳多种合理表达方式。注意 DPO 依旧基于 token 概率,不是直接计算语义向量相似度。

2、环境准备(AutoDL)

1)modelscope相关

pip install modelscope

下载Qwen2.5-7B-Instruct模型

modelscope download --model Qwen/Qwen2.5-7B-Instruct

2)安装相关依赖包

pip install transformers peft accelerate datasets trl bitsandbytes sentencepiece wandb

3)测试qwen2.5模型

from modelscope import AutoModelForCausalLM, AutoTokenizer model_name = "Qwen/Qwen2.5-7B-Instruct" model = AutoModelForCausalLM.from_pretrained( model_name, torch_dtype="auto", device_map="auto" ) tokenizer = AutoTokenizer.from_pretrained(model_name) def chat_infer(messages, max_new_tokens=512, temperature=0.7, top_p=0.8): """ :param messages: 对话list [{"role":"system/user/assistant", "content":str}] :return: model回复字符串 """ # 套用模型对话模板,推理务必 add_generation_prompt=True text = tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=True ) print(f"\n==== 模型输入完整模板文本 ====\n{text}\n==== 模型输出 ====\n") model_inputs = tokenizer([text], return_tensors="pt").to(model.device) input_len = model_inputs.input_ids.shape[1] generated_ids = model.generate( **model_inputs, max_new_tokens=max_new_tokens, temperature=temperature, top_p=top_p, do_sample=True, ) # 切掉输入部分,只保留新生成token output_ids = generated_ids[:, input_len:] response = tokenizer.batch_decode(output_ids, skip_special_tokens=True)[0] return response if __name__ == "__main__": prompt = "Give me a short introduction to large language model." messages = [ {"role": "system", "content": "You are Qwen, created by Alibaba Cloud. You are a helpful assistant."}, {"role": "user", "content": prompt} ] resp = chat_infer(messages) print(resp)

显示结果:

3、准备SFT数据集

医疗问诊数据_SFT格式

数据示例:

{"instruction": "你好,医生,我不知道自己怀孕的情况下,感冒了输了利巴韦林和头孢,我怀孕有四十多天了,请问我可以要这小孩吗?", "input": "", "output": "首先,请您放心,感冒时使用利巴韦林和头孢是常规治疗方式,一般不会对胎儿产生影响。不过,如果您确实怀孕了,建议您尽快去医院进行产前检查,让医生评估胎儿的情况,确定是否可以继续妊娠。如果胎儿的发育和健康状况正常,您可以选择继续妊娠,如果存在风险或者胎儿异常,您可以考虑终止妊娠。最好在专业医生的指导下做出决策。"}

这个是Alpaca格式(instruction/input/output),不能直接进行训练。

我们的SFT需要的格式是:{"messages": [system,user,assistant]} ChatML 格式

需要转换成Qwen要求的message格式:

{ "messages": [ {"role":"system","content":"你是专业的医疗咨询助手,请给出严谨、客观的医疗建议,提醒用户以线下医生诊断为准。"}, {"role":"user","content":"你好,医生,我不知道自己怀孕的情况下,感冒了输了利巴韦林和头孢,我怀孕有四十多天了,请问我可以要这小孩吗?"}, {"role":"assistant","content":"首先,请您放心,感冒时使用利巴韦林和头孢是常规治疗方式……做出决策。"} ] }

格式转换脚本(convert_medical.py):

import json src_file = r"C:\Users\benbe\Downloads\med_zh\med_dev_zh.json" sys_text = "你是专业的医疗咨询助手,回答仅供科普参考,不能替代执业医师面诊,诊疗请遵从线下医生的专业意见。" out_list = [] with open(src_file,"r",encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue d = json.loads(line) user_content = d["instruction"] # 如果input不为空,拼接到用户提问后面 if d.get("input","").strip(): user_content += "\n" + d["input"].strip() new_item = { "messages":[ {"role":"system","content":sys_text}, {"role":"user","content":user_content}, {"role":"assistant","content":d["output"]} ] } out_list.append(new_item) # 8:2划分训练集、验证集 split_pos = int(len(out_list)*0.8) with open("train.jsonl","w",encoding="utf-8") as fw: for x in out_list[:split_pos]: fw.write(json.dumps(x,ensure_ascii=False)+"\n") with open("val.jsonl","w",encoding="utf-8") as fw: for x in out_list[split_pos:]: fw.write(json.dumps(x,ensure_ascii=False)+"\n") print(f"总样本:{len(out_list)},训练集{split_pos}条,验证集{len(out_list)-split_pos}条")

拿出一条转化后的样本进行测试,确认模板格式是否满足基座模型的格式要求,测试脚本:

from transformers import AutoTokenizer tokenizer=AutoTokenizer.from_pretrained("/root/autodl-tmp/models/Qwen2.5-7B-Instruct",trust_remote_code=True) sample = {"messages": [{"role": "system", "content": "你是专业的医疗咨询助手,回答仅供科普参考,不能替代执业医师面诊,诊疗请遵从线下医生的专业意见。"}, {"role": "user", "content": "哺乳后胸太小,自己个字较高,体重偏瘦,胸部太平"}, {"role": "assistant", "content": "哺乳后胸部变小是正常的生理现象,因为哺乳会导致乳房内的脂肪组织减少。如果您希望增大胸部,可以考虑以下方法:\n\n1. 进行胸部锻炼,如俯卧撑、卧推等可以增强胸肌,使胸部看起来更丰满。\n\n2. 保持良好的饮食习惯,摄入足够的蛋白质、脂肪和维生素,帮助胸部增加脂肪组织。\n\n3. 考虑使用胸部增大产品,如丰胸霜、胸部按摩等。\n\n4. 如果您认为自己的胸部大小影响了自信心,可以考虑进行整形手术,如隆胸手术。\n\n另外,身高和体重偏瘦也可能会影响胸部的大小。如果您希望增加体重,可以适当增加饮食量,选择高蛋白、高热量的食物,并进行适当的运动锻炼,增加肌肉量。"}]} text = tokenizer.apply_chat_template(sample["messages"],tokenize=False,add_generation_prompt=False) print(text)

输出:

root@autodl-container-be044ebe99-a8225ede:~/autodl-tmp/codes# python test_sample_convert.py <|im_start|>system 你是专业的医疗咨询助手,回答仅供科普参考,不能替代执业医师面诊,诊疗请遵从线下医生的专业意见。<|im_end|> <|im_start|>user 哺乳后胸太小,自己个字较高,体重偏瘦,胸部太平<|im_end|> <|im_start|>assistant 哺乳后胸部变小是正常的生理现象,因为哺乳会导致乳房内的脂肪组织减少。如果您希望增大胸部,可以考虑以下方法: 1. 进行胸部锻炼,如俯卧撑、卧推等可以增强胸肌,使胸部看起来更丰满。 2. 保持良好的饮食习惯,摄入足够的蛋白质、脂肪和维生素,帮助胸部增加脂肪组织。 3. 考虑使用胸部增大产品,如丰胸霜、胸部按摩等。 4. 如果您认为自己的胸部大小影响了自信心,可以考虑进行整形手术,如隆胸手术。 另外,身高和体重偏瘦也可能会影响胸部的大小。如果您希望增加体重,可以适当增加饮食量,选择高蛋白、高热量的食物,并进行适当的运动锻炼,增加肌肉量。<|im_end|>

打印结果可以看到 <|im_start|>system、<|im_start|>user、<|im_start|>assistant标记,格式正确,就可以喂给 SFTTrainer 训练。

4、PEFT-QLoRA SFT训练

训练代码:

import torch from datasets import load_dataset from transformers import ( AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig, TrainingArguments, Trainer ) from peft import LoraConfig, get_peft_model # ========= 配置项 ========= model_name = "/root/autodl-tmp/models/Qwen2.5-7B-Instruct" train_data_path = "/root/autodl-tmp/datas/med_sft/med_train.jsonl" val_data_path = "/root/autodl-tmp/datas/med_sft/med_val.jsonl" output_dir = "./lora-med-ckpt" lora_save_path = "./lora-med-adapter" MAX_LEN = 2048 # QLoRA 4bit量化 bnb_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_use_double_quant=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16 ) tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True) tokenizer.pad_token = tokenizer.eos_token tokenizer.padding_side = "right" tokenizer.model_max_length = MAX_LEN model = AutoModelForCausalLM.from_pretrained( model_name, quantization_config=bnb_config, device_map="auto", trust_remote_code=True, torch_dtype=torch.bfloat16 ) model.gradient_checkpointing_enable() # PEFT LoRA配置 Qwen2.5 lora_config = LoraConfig( r=16, lora_alpha=32, target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], lora_dropout=0.05, bias="none", task_type="CAUSAL_LM" ) model = get_peft_model(model, lora_config) model.print_trainable_parameters() # 加载数据集 train_ds = load_dataset("json", data_files=train_data_path, split="train") val_ds = load_dataset("json", data_files=val_data_path, split="train") RESPONSE_TPL = "<|im_start|>assistant\n" tpl_ids = tokenizer(RESPONSE_TPL, add_special_tokens=False)["input_ids"] def process_fn(sample): # 1. apply chat template full_text = tokenizer.apply_chat_template( sample["messages"], tokenize=False, add_generation_prompt=False ) # 2. tokenize out = tokenizer( full_text, truncation=True, max_length=MAX_LEN, padding="max_length" ) input_ids = out["input_ids"] attention_mask = out["attention_mask"] labels = input_ids.copy() # 3. 找到assistant模板位置,前面全部置-100不计算loss for i in range(len(input_ids) - len(tpl_ids) + 1): if input_ids[i:i+len(tpl_ids)] == tpl_ids: for pos in range(i + len(tpl_ids)): labels[pos] = -100 break return {"input_ids":input_ids, "attention_mask":attention_mask, "labels":labels} train_ds = train_ds.map(process_fn) val_ds = val_ds.map(process_fn) # 设置格式,只保留模型需要的key train_ds.set_format("torch", columns=["input_ids","attention_mask","labels"]) val_ds.set_format("torch", columns=["input_ids","attention_mask","labels"]) training_args = TrainingArguments( output_dir=output_dir, per_device_train_batch_size=2, gradient_accumulation_steps=4, per_device_eval_batch_size=2, learning_rate=2e-5, num_train_epochs=2, logging_steps=10, save_steps=100, eval_steps=100, fp16=True, optim="paged_adamw_8bit", report_to=[], eval_strategy="steps", save_total_limit=3, load_best_model_at_end=True, metric_for_best_model="eval_loss", ) # ✅ 使用原生Trainer,不再使用SFTTrainer! trainer = Trainer( model=model, args=training_args, train_dataset=train_ds, eval_dataset=val_ds, ) trainer.train() # 保存LoRA适配器 trainer.model.save_pretrained(lora_save_path) tokenizer.save_pretrained(lora_save_path) print(f"✅LoRA适配器保存完成:{lora_save_path}")

代码解读:

1. 导入库

AutoModelForCausalLM:因果大模型加载,对话生成模型

peft:LoRA轻量化微调库

2. 配置项

lora_save_path:最终产出,只有LoRA小权重,不是完整模型,体积几十MB,推理时要挂载基座模型使用

MAX_LEN=2048,单条样本最大token长度

3. QLoRA 4bit量化配置BitsAndBytesConfig

QLoRA核心,把7B模型压缩到4bit加载,大幅降低显存占用。权重存储 4bit,运算 bf16,兼顾显存和训练效果。

  • load_in_4bit=True:以 4bit 加载模型权重
  • bnb_4bit_use_double_quant=True:二次量化,进一步省显存。权重只量化一次到 NF4;二次量化对象是scale 缩放因子。
  • bnb_4bit_quant_type="nf4":Normalized‑float4,大模型微调推荐量化类型,效果优于普通 fp4。NF4为非均匀格点量化。
  • bnb_4bit_compute_dtype=torch.bfloat16:计算的时候转回 bfloat16 精度做前向 / 反向传播,保证训练精度;权重存储是 4bit。

4. Tokenizer初始化

padding_side="right":向右 padding,因果语言模型必须右补零,左 padding 会干扰生成

5. 加载基座模型+开启梯度检查点

  • quantization_config=bnb_config:启用 4bit QLoRA 加载
  • torch_dtype=torch.bfloat16:模型运算精度 bf16。Ampere架构的显卡,硬件原生支持BF16矩阵乘法Tensor Core,速度很快。
  • gradient_checkpointing_enable():梯度检查点,以时间换显存,节省大量显存;代价:训练速度会变慢一点。等同于deepspeed的激活检查点。核心思想:不保存中间激活值;反向传播的时候,重新跑一遍前向,重新算出需要的激活,以计算时间换取显存。

6. LoRA配置

  • r=16:LoRA秩,越大拟合能力越强,参数量越大
  • target_modules=["q_proj","k_proj","v_proj","o_proj"]:Qwen2.5 注意力层要加 LoRA 的模块,只对注意力做 LoRA,mlp 不动。

7. 加载数据集

  • RESPONSE_TPL = "<|im_start|>assistant\n":Qwen ChatML 标记,注意末尾换行符不能丢。
  • tpl_ids:把 assistant 开头标记转为 token id,后续用来定位回答起始位置。

8. process_fn:单样本预处理函数

  • apply_chat_template:把messages=[system,user,assistant]按照 Qwen ChatML 模板拼接完整字符串。
    add_generation_prompt=False:训练阶段关闭;推理时要 True,训练样本已经包含完整 assistant 回答。"<|im_start|>assistant\n"这个字符串,正是add_generation_prompt=True追加的那一段文本;
    add_generation_prompt控制是否在对话模板末尾追加 assistant 起始标记<|im_start|>assistant\n。训练数据集已经包含完整 assistant 回答,设置 False;推理仅有 system+user,设置 True,用来引导模型从 assistant 位置开始续写输出。
  • attention_mask:注意力层控制哪些 token 可以互相看见;0 屏蔽 padding
  • 遍历 token 序列,匹配<|im_start|>assistant\n对应的 id 片段;该标记之前所有 token label 赋值为‑100。PyTorch CrossEntropyLoss 特性:label=-100 会自动忽略该位置不计入损失。也就是:system+user+<|im_start|>assistant\n这些 token 不参与 loss,只有 assistant 后面的回答文字参与 loss 更新权重。

9. TrainingArguments 训练超参

  • gradient_accumulation_steps=4梯度累积:每 4 步更新一次权重;实际等效 batch_size = 2 *4 =8;显存不足调小 batch,调大累积步数。
  • metric_for_best_model="eval_loss"以验证集 loss 作为评判好坏标准

总结:

  • QLoRA:权重存储 4bit,计算 bf16;gradient_checkpoint 进一步省显存,代价速度下降。
  • Loss 掩码:必须把 system/user 部分 label=-100,仅 assistant 回答算 loss;模板末尾换行不可丢,否则匹配失败掩码失效。
  • LoRA 只训练 q/k/v/o;基座完全冻结,可训练参数占比 0.1‑0.3%。
  • load_best_model_at_end非常重要:自动选 eval_loss 最优模型,对抗医疗小数据集过拟合。

5、运行结果

【运行环境:3090 24G 2卡】

(1)运行日志

root@autodl-container-be044ebe99-a8225ede:~/autodl-tmp/codes# python train_med_sft.py [transformers] `torch_dtype` is deprecated! Use `dtype` instead! Loading weights: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 339/339 [00:02<00:00, 130.71it/s] trainable params: 10,092,544 || all params: 7,625,709,056 || trainable%: 0.1323 {'loss': '9.681', 'grad_norm': '40.3', 'learning_rate': '1.972e-05', 'epoch': '0.04'} {'loss': '3.945', 'grad_norm': '38.34', 'learning_rate': '1.936e-05', 'epoch': '0.08'} {'loss': '1.344', 'grad_norm': '9.147', 'learning_rate': '1.896e-05', 'epoch': '0.12'} 6%|██████████▏ | 32/500 [09:30<2:19:11, 17.85s/it] {'loss': '0.3227', 'grad_norm': '1.623', 'learning_rate': '1.856e-05', 'epoch': '0.16'} 9%|███████████████ | 47/500 [13:57<2:14:28, 17.81s/it] {'loss': '0.1129', 'grad_norm': '0.5479', 'learning_rate': '1.816e-05', 'epoch': '0.2'} {'loss': '0.08933', 'grad_norm': '0.2654', 'learning_rate': '1.776e-05', 'epoch': '0.24'} {'loss': '0.0747', 'grad_norm': '0.2404', 'learning_rate': '1.736e-05', 'epoch': '0.28'} {'loss': '0.06829', 'grad_norm': '0.06401', 'learning_rate': '1.696e-05', 'epoch': '0.32'} {'loss': '0.0762', 'grad_norm': '0.07949', 'learning_rate': '1.656e-05', 'epoch': '0.36'} {'loss': '0.0701', 'grad_norm': '0.05436', 'learning_rate': '1.616e-05', 'epoch': '0.4'} {'eval_loss': '0.06643', 'eval_runtime': '358.6', 'eval_samples_per_second': '1.394', 'eval_steps_per_second': '0.697', 'epoch': '0.4'} {'loss': '0.06733', 'grad_norm': '0.04594', 'learning_rate': '1.576e-05', 'epoch': '0.44'} {'loss': '0.06793', 'grad_norm': '0.04274', 'learning_rate': '1.536e-05', 'epoch': '0.48'} {'loss': '0.06278', 'grad_norm': '0.07219', 'learning_rate': '1.496e-05', 'epoch': '0.52'} {'loss': '0.06734', 'grad_norm': '0.04364', 'learning_rate': '1.456e-05', 'epoch': '0.56'} {'loss': '0.06567', 'grad_norm': '0.04943', 'learning_rate': '1.416e-05', 'epoch': '0.6'} {'loss': '0.0649', 'grad_norm': '0.04054', 'learning_rate': '1.376e-05', 'epoch': '0.64'} {'loss': '0.06346', 'grad_norm': '0.1199', 'learning_rate': '1.336e-05', 'epoch': '0.68'} {'loss': '0.06456', 'grad_norm': '0.04834', 'learning_rate': '1.296e-05', 'epoch': '0.72'} {'loss': '0.06505', 'grad_norm': '0.04073', 'learning_rate': '1.256e-05', 'epoch': '0.76'} {'loss': '0.06118', 'grad_norm': '0.03702', 'learning_rate': '1.216e-05', 'epoch': '0.8'} {'eval_loss': '0.06243', 'eval_runtime': '358.7', 'eval_samples_per_second': '1.394', 'eval_steps_per_second': '0.697', 'epoch': '0.8'} 42%|█████████████████████████████████████████████████████████████████▎ | 208/500 [1:13:41<2:09:49, 26.68s/it] {'loss': '0.06143', 'grad_norm': '0.04803', 'learning_rate': '1.176e-05', 'epoch': '0.84'} {'loss': '0.06285', 'grad_norm': '0.05561', 'learning_rate': '1.136e-05', 'epoch': '0.88'} {'loss': '0.0619', 'grad_norm': '0.0374', 'learning_rate': '1.096e-05', 'epoch': '0.92'} {'loss': '0.06739', 'grad_norm': '0.0474', 'learning_rate': '1.056e-05', 'epoch': '0.96'} {'loss': '0.05766', 'grad_norm': '0.04297', 'learning_rate': '1.016e-05', 'epoch': '1'} 51%|███████████████████████████████████████████████████████████████████████████████▊ | 254/500 [1:27:20<1:13:04, 17.82s/it] {'loss': '0.05751', 'grad_norm': '0.07466', 'learning_rate': '9.76e-06', 'epoch': '1.04'} {'loss': '0.05832', 'grad_norm': '0.04197', 'learning_rate': '9.36e-06', 'epoch': '1.08'} {'loss': '0.06342', 'grad_norm': '0.0499', 'learning_rate': '8.96e-06', 'epoch': '1.12'} {'loss': '0.06125', 'grad_norm': '0.04082', 'learning_rate': '8.56e-06', 'epoch': '1.16'} {'loss': '0.05576', 'grad_norm': '0.04777', 'learning_rate': '8.16e-06', 'epoch': '1.2'} {'eval_loss': '0.06149', 'eval_runtime': '358.7', 'eval_samples_per_second': '1.394', 'eval_steps_per_second': '0.697', 'epoch': '1.2'} {'loss': '0.0637', 'grad_norm': '0.05606', 'learning_rate': '7.76e-06', 'epoch': '1.24'} {'loss': '0.05827', 'grad_norm': '0.04508', 'learning_rate': '7.36e-06', 'epoch': '1.28'} {'loss': '0.06752', 'grad_norm': '0.06225', 'learning_rate': '6.96e-06', 'epoch': '1.32'} {'loss': '0.06071', 'grad_norm': '0.04637', 'learning_rate': '6.56e-06', 'epoch': '1.36'} {'loss': '0.0603', 'grad_norm': '0.05329', 'learning_rate': '6.16e-06', 'epoch': '1.4'} {'loss': '0.06119', 'grad_norm': '0.04567', 'learning_rate': '5.76e-06', 'epoch': '1.44'} {'loss': '0.05596', 'grad_norm': '0.0464', 'learning_rate': '5.36e-06', 'epoch': '1.48'} {'loss': '0.06642', 'grad_norm': '0.04317', 'learning_rate': '4.96e-06', 'epoch': '1.52'} {'loss': '0.05975', 'grad_norm': '0.05931', 'learning_rate': '4.56e-06', 'epoch': '1.56'} {'loss': '0.06347', 'grad_norm': '0.0577', 'learning_rate': '4.16e-06', 'epoch': '1.6'} {'eval_loss': '0.06129', 'eval_runtime': '358.8', 'eval_samples_per_second': '1.394', 'eval_steps_per_second': '0.697', 'epoch': '1.6'} {'loss': '0.06557', 'grad_norm': '0.05656', 'learning_rate': '3.76e-06', 'epoch': '1.64'} {'loss': '0.06327', 'grad_norm': '0.06272', 'learning_rate': '3.36e-06', 'epoch': '1.68'} {'loss': '0.06514', 'grad_norm': '0.04247', 'learning_rate': '2.96e-06', 'epoch': '1.72'} {'loss': '0.0689', 'grad_norm': '0.05644', 'learning_rate': '2.56e-06', 'epoch': '1.76'} 88%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████▏ | 441/500 [2:34:51<17:31, 17.83s/it] {'loss': '0.06636', 'grad_norm': '0.0459', 'learning_rate': '2.16e-06', 'epoch': '1.8'} {'loss': '0.06554', 'grad_norm': '0.05689', 'learning_rate': '1.76e-06', 'epoch': '1.84'} {'loss': '0.05621', 'grad_norm': '0.04422', 'learning_rate': '1.36e-06', 'epoch': '1.88'} {'loss': '0.06446', 'grad_norm': '0.06602', 'learning_rate': '9.6e-07', 'epoch': '1.92'} {'loss': '0.05942', 'grad_norm': '0.04792', 'learning_rate': '5.6e-07', 'epoch': '1.96'} {'loss': '0.06235', 'grad_norm': '0.05635', 'learning_rate': '1.6e-07', 'epoch': '2'} {'eval_loss': '0.06112', 'eval_runtime': '358.8', 'eval_samples_per_second': '1.394', 'eval_steps_per_second': '0.697', 'epoch': '2'} {'train_runtime': '1.07e+04', 'train_samples_per_second': '0.374', 'train_steps_per_second': '0.047', 'train_loss': '0.3659', 'epoch': '2'} 100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 500/500 [2:58:22<00:00, 21.41s/it] ✅LoRA适配器保存完成:./lora-med-adapter

(2)grad_norm

它是梯度范数,把所有可训练参数(LoRA_A, LoRA_B)梯度拼接之后的 L2‑范数。

把全部可训练参数的梯度摊平拼成一个超大一维向量,计算 L2:

含义:

grad_norm越大:梯度整体幅度大,参数更新步子大;
grad_norm越小:梯度整体很小,更新微弱,接近收敛;
grad_norm ≈ 0:几乎不更新,模型不再学东西。

(3)结果目录lora-med-adapter解读

root@autodl-container-be044ebe99-a8225ede:~/autodl-tmp/codes/lora-med-adapter# ls README.md adapter_config.json adapter_model.safetensors chat_template.jinja tokenizer.json tokenizer_config.json

1)adapter_config.json

核心配置

内容示例:
peft_type: LORA
target_modules: ["q_proj","k_proj","v_proj","o_proj"]
r、lora_alpha、lora_dropout、bias、task_type
记录你训练时全部 LoRA 超参。PeftModel.from_pretrained(base_model, 这个文件夹)会读取该配置,知道哪些层要包装 LoraLinear。
没有这个文件,peft 无法加载适配器。

2)adapter_model.safetensors

LoRA 权重本体
真正保存训练出来的 lora_A、lora_B 矩阵,只有 LoRA 小权重,不含基座 Qwen 权重。
safetensors 安全格式,防止恶意代码。
里面 key 类似:
model.layers.0.self_attn.q_proj.lora_A.default.weight
model.layers.0.self_attn.q_proj.lora_B.default.weight
训练得到的可训练参数全部在这里;39MB 。基座原始权重仍然在你原始 Qwen 模型目录。

3)chat_template.jinja
jinja2 模板文件,存储ChatML 对话模板。
tokenizer.apply_chat_template()底层读取这个 jinja 模板,把 messages 数组转成模型输入文本。
⚠️重要:DPO、推理、RM 训练必须使用完全一致 chat_template。
加载 tokenizer 的时候会自动读取这个 jinja,如果丢失,模板会错乱,训练 / 推理效果暴跌。

4)tokenizer_config.json
分词器配置:pad_token、eos_token、bos_token,special token 映射,max_context 等。
保存分词器的各种配置参数。

5)tokenizer.json
真正的分词词典、BPE/Byte‑Pair 编码规则,vocab 合并表。
决定怎么把文字转 input_ids。

6、推理测试

代码:

from peft import PeftModel from transformers import AutoModelForCausalLM, AutoTokenizer base_model_name = "/root/autodl-tmp/models/Qwen2.5-7B-Instruct" lora_path = "./lora-med-adapter" base_model = AutoModelForCausalLM.from_pretrained( base_model_name, torch_dtype="auto", device_map="auto", trust_remote_code=True ) model = PeftModel.from_pretrained(base_model, lora_path) tokenizer = AutoTokenizer.from_pretrained(base_model_name, trust_remote_code=True) def chat_infer(messages, max_new_tokens=512, temperature=0.7, top_p=0.8): text = tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=True ) print(f"\n====输入模板====\n{text}\n====输出====\n") model_inputs = tokenizer([text], return_tensors="pt").to(model.device) input_len = model_inputs.input_ids.shape[1] generated_ids = model.generate( **model_inputs, max_new_tokens=max_new_tokens, temperature=temperature, top_p=top_p, do_sample=True ) output_ids = generated_ids[:, input_len:] resp = tokenizer.batch_decode(output_ids, skip_special_tokens=True)[0] return resp if __name__ == "__main__": # 测试一条医疗提问(尽量用训练集没见过的问题) test_msg = [ {"role": "system", "content": "你是专业的医疗咨询助手,回答仅供科普参考,不能替代执业医师面诊,诊疗请遵从线下医生的专业意见。"}, {"role": "user", "content":"怀孕40多天,不知情下输了利巴韦林和头孢,孩子能不能要?"} ] print(chat_infer(test_msg))

运行结果:

root@autodl-container-be044ebe99-a8225ede:~/autodl-tmp/codes/sft# python test_med_lora.py Loading weights: 100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 339/339 [00:03<00:00, 91.42it/s] ====输入模板==== <|im_start|>system 你是专业的医疗咨询助手,回答仅供科普参考,不能替代执业医师面诊,诊疗请遵从线下医生的专业意见。<|im_end|> <|im_start|>user 怀孕40多天,不知情下输了利巴韦林和头孢,孩子能不能要?<|im_end|> <|im_start|>assistant ====输出==== 利巴韦林和头孢类抗生素都属于对胎儿有潜在危害的药物。如果您在怀孕初期已经使用了这些药物,建议您尽快咨询医生,以了解可能的风险以及如何降低风险。 一般来说,如果怀孕早期使用了这些药物,医生可能会建议您进行产前检查,以确定胎儿是否受到药物的影响。如果您选择继续妊娠,医生可能会建议您进行更多的产前检查,以确保胎儿的健康。