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

1、核心知识点(VIP)

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

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

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

python 复制代码
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
python 复制代码
内存中替换过程:
原来: 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 条候选序列,选择整体概率最优序列,输出通顺但多样性不足;

  1. 随机采样 基于 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模型

python 复制代码
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格式

数据示例:

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

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

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

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

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

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

python 复制代码
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}条")

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

python 复制代码
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)

输出:

python 复制代码
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训练

训练代码:

python 复制代码
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轻量化微调库

  1. 配置项

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

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

  1. 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。
  1. Tokenizer初始化

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

  1. 加载基座模型+开启梯度检查点
  • quantization_config=bnb_config:启用 4bit QLoRA 加载
  • torch_dtype=torch.bfloat16:模型运算精度 bf16。Ampere架构的显卡,硬件原生支持BF16矩阵乘法Tensor Core,速度很快。
  • gradient_checkpointing_enable():梯度检查点,以时间换显存,节省大量显存;代价:训练速度会变慢一点。等同于deepspeed的激活检查点。核心思想:不保存中间激活值;反向传播的时候,重新跑一遍前向,重新算出需要的激活,以计算时间换取显存。
  1. LoRA配置
  • r=16:LoRA秩,越大拟合能力越强,参数量越大
  • target_modules="q_proj","k_proj","v_proj","o_proj":Qwen2.5 注意力层要加 LoRA 的模块,只对注意力做 LoRA,mlp 不动。
  1. 加载数据集
  • RESPONSE_TPL = "<|im_start|>assistant\n":Qwen ChatML 标记,注意末尾换行符不能丢。
  • tpl_ids:把 assistant 开头标记转为 token id,后续用来定位回答起始位置。
  1. 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 更新权重。
  1. 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)运行日志

python 复制代码
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解读

python 复制代码
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、推理测试

代码:

python 复制代码
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))

运行结果:

python 复制代码
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

====输出====

利巴韦林和头孢类抗生素都属于对胎儿有潜在危害的药物。如果您在怀孕初期已经使用了这些药物,建议您尽快咨询医生,以了解可能的风险以及如何降低风险。

一般来说,如果怀孕早期使用了这些药物,医生可能会建议您进行产前检查,以确定胎儿是否受到药物的影响。如果您选择继续妊娠,医生可能会建议您进行更多的产前检查,以确保胎儿的健康。
相关推荐
Lhan.zzZ1 小时前
深入理解 windeployqt:混合 C++/Qt 项目的打包指南
开发语言·c++·qt
淼澄研学1 小时前
PyTorch深度学习实战:5个核心方法从0到1构建神经网络
前端·数据库·python
我命由我123451 小时前
Android 开发 - 广播组件(标准广播、有序广播、静态注册广播、分钟到达广播、网络变更广播...)
android·java·开发语言·网络·java-ee·android studio·android-studio
SunnyDays10111 小时前
Python 将 Excel(XLS/XLSX)转换为 JSON:导出工作簿、工作表、单元格区域与自定义 JSON 结构
python·json·excel·excel 转 json·导出 excel 到 json·xlsx 转 json·xls 转 json
天l志2 小时前
Chrome Extension + 本地服务:浏览器页面上下文采集与远程执行技术设计
python·谷歌浏览器
CodexDave2 小时前
Python 自动化接单实战(九):Windows 免环境交付如何打包与诊断
windows·python·自动化·python自动化·pyinstaller·软件交付·windows打包
CTA量化套保2 小时前
2026年量化入门路线,概念规则和简单实现逐步走
人工智能·python
米码收割机2 小时前
【Python】Python Django+Vue3校园自习室预约管理系统(源码+文档+PPT)【独一无二】
开发语言·python·django
AstartesEternal2 小时前
python第二次作业(列表,字典)
开发语言·python