【有手就行】LoRA:用你自己的数据来微调大模型,让大模型真正懂你

一、前言

上上周的周末无事在家,然后写了一篇《【有手就行】自己花20分钟从0开始训练一个“大模型”》,结果发现这两个星期涨了几十个关注,比我前面写了几个月文章得到的关注还多,看来这种浅显易懂的、入门级的技术文章相对来说会有更多人爱看一些。
既然如此,我再把早先在做OddAgent时候,微调语音助手功能的流程也简单理一下,然后放出来给大家做一个参考吧。

事实上,上手学习大模型、人工智能相关的开发并没有什么太过高深的门槛,真的很简单,真的就是【有手就行】。

二、大模型微调概述

微调(Fine-tuning)有很多种不同的方法,但是使用的场景以及代价也都是不一样的。作为一个没什么资源(数据缺缺,GPU缺缺)的普通人来说,考虑的肯定是低成本方案。

方法类型 参数更新范围 计算成本 适用场景 典型工具框架
全参数微调 全部参数 极高 大数据集、高资源场景 Hugging Face Transformers
Adapter Tuning 适配器参数 多任务、资源受限 AdapterHub、PEFT
LoRA/QLoRA 低秩矩阵参数 极低 大模型单卡微调、小样本 LoRA、QLoRA(PEFT 库集成)
指令微调 全量 / 部分参数 中 - 高 通用对话模型、多任务泛化 Alpaca-LoRA、FastChat
领域适配微调 全量 / 部分参数 垂直领域任务 自定义领域数据集 + Transformers

三、LoRA微调全流程

前阵子在将小落同学项目的智能体代码摘成独立的OddAgent项目时,实践的是一个会议相关的语音助手功能,该功能有针对Qwen2.5-0.5B-Instruct模型和Qwen3-4B-Instruct-2507这两个模型重点做了一些测试和验证,用的就是其中成本最低的LoRA微调。
最后跑下来Qwen3-4B-Instruct-2507的效果要显著好于Qwen2.5-0.5B-Instruct(有同时针对这两个模型用同一套数据集去做了LoRA微调)。
因此,本文的重点就放在了Qwen2.5-0.5B-Instruct的LoRA微调上,因为后面我还准备再继续针对这个模型再补充一些训练集来做一下微调,目标是在这个模型上也能做到100%的意图/槽位准确率。

跟之前训练大模型一样,还是在我家里的这个10年前的老笔记本上进行的。

  • 硬件配置:CPU: i7-8850H CPU @ 2.60GHz+16G内存
  • 微调训练时长:约50分钟(由于训练时,还在用这个笔记本上网课,所以时长仅供参考)

1. 创建虚拟环境

为了不影响现有的python环境,建议为这个LoRA训练单独创建一个环境。个人习惯用conda(环境可复用,节省硬盘空间),venv或者vu也都一样,全看大家的个人喜好。

conda create -n lora python=3.12 -y
conda activate lora

2. 模型下载

下载模型前请注意一下你的硬盘空间,整个模型需要955M的硬盘空间,如果你跟我一样常年硬盘空间都是严重不足的,请视情况清理一下空间,避免下载失败。

pip install modelscope --index https://pypi.mirrors.ustc.edu.cn/simple
modelscope download --model Qwen/Qwen2.5-0.5B-Instruct

下载下来后,会保存到指定的.cache这个目录下。如果是Windows,默认是在 C:\Users\Administrator\.cache 目录下,如果是Linux/Mac则是在 .cache 目录下。
其中:

  • config.json 定义模型结构
  • tokenizer.json 定义文本输入方式
  • safetensors 文件则存储模型的参数权重。

3. 微调环境安装

call pip install torch --index-url https://download.pytorch.org/whl/cu121
call pip install transformers accelerate bitsandbytes peft datasets trl streamlit sentencepiece tensorboard

4. 数据库集准备

训练需要你预先准备好你用于训练的数据集,但是。。。凡事总会有一个“但是”,今天的“但是”是:
要数据?对不起,没有!
换成几年前,哥必须对这个“但是”暴跳如雷,然而,今天有了大模型,腰不酸了,背不疼了,雷没有了,直接用其他大模型自动为每个意图生成50条数据,提示词略,格式如下:

 {
    "instruct": "启动工作会议",
    "input": "",
    "output": {
      "tool_name": "INSTANT_MEETING",
      "parameters": {
        "meeting_name": "工作会议"
      }
    }
  },
  {
    "instruct": "创建汇报会会议",
    "input": "",
    "output": {
      "tool_name": "INSTANT_MEETING",
      "parameters": {
        "meeting_name": "汇报会"
      }
    }
  },

intruct为用户指令,ouput字段为期望输出

5. LoRA微调代码

1)微调训练代码

我的完整代码,代码文件名:train_lora.py

"""
Qwen2.5-0.5B-Instruct LoRA/QLoRA 微调脚本
该脚本用于对 Qwen2.5-0.5B-Instruct 模型进行低秩适应(LoRA)或量化低秩适应(QLoRA)微调
"""

import torch
from peft import TaskType, LoraConfig, get_peft_model, prepare_model_for_kbit_training
from datasets import Dataset
import pandas as pd
from transformers import (
    AutoTokenizer, AutoModelForCausalLM, 
    DataCollatorForSeq2Seq, TrainingArguments, 
    Trainer, GenerationConfig
)

# 定义要微调的基础模型名称
TRAINING_MODEL = "Qwen/Qwen2.5-0.5B-Instruct"

def load_dataset_json(path):
    """
    加载JSON格式的数据集
    
    :param path: 数据集文件的完整路径
    :return: 转换为Hugging Face Dataset格式的数据集
    """
    # 使用pandas读取JSON文件
    df = pd.read_json(path)
    # 将pandas DataFrame转换为Hugging Face Dataset
    ds = Dataset.from_pandas(df)
    return ds


def dataset_preprocess(ds):
    """
    对数据集进行预处理,包括加载分词器和处理数据样本
    
    :param ds: Hugging Face Dataset格式的原始数据集
    :return: 预处理后的数据集和使用的分词器
    """
    # 加载预训练分词器
    # use_fast=False: 使用慢速分词器,支持更复杂的文本处理
    # trust_remote_code=True: 信任模型提供的自定义代码
    tokenizer = AutoTokenizer.from_pretrained(TRAINING_MODEL, use_fast=False, trust_remote_code=True)
    
    def process_func(example):
        """
        处理单个数据样本的内部函数
        
        :param example: 单个数据样本,包含instruct、input和output字段
        :return: 处理后的样本,包含input_ids、attention_mask和labels
        """
        MAX_LENGTH = 384    # 最大序列长度限制
        input_ids, attention_mask, labels = [], [], []
        
        # 构建指令部分的输入,使用模型要求的对话格式
        instruction = tokenizer(
            f"<|im_start|>system\n现在你要扮演会议语音助手<|im_end|>\n<|im_start|>user\n{example['instruct'] + example['input']}<|im_end|>\n<|im_start|>assistant\n", 
            add_special_tokens=False  # 不自动添加特殊标记,因为我们已经手动添加
        )
        
        # 构建响应部分的输入
        response = tokenizer(f"{example['output']}", add_special_tokens=False)
        
        # 合并指令和响应的token ids,并添加pad_token作为结束
        input_ids = instruction["input_ids"] + response["input_ids"] + [tokenizer.pad_token_id]
        # 合并注意力掩码,pad_token位置设置为1表示需要关注
        attention_mask = instruction["attention_mask"] + response["attention_mask"] + [1]
        # 构建标签:指令部分用-100屏蔽(不参与损失计算),响应部分保留实际token id
        labels = [-100] * len(instruction["input_ids"]) + response["input_ids"] + [tokenizer.pad_token_id]  
        
        # 截断超过最大长度的序列
        if len(input_ids) > MAX_LENGTH:
            input_ids = input_ids[:MAX_LENGTH]
            attention_mask = attention_mask[:MAX_LENGTH]
            labels = labels[:MAX_LENGTH]
            
        return {
            "input_ids": input_ids,
            "attention_mask": attention_mask,
            "labels": labels
        }
    
    # 应用处理函数到整个数据集,并移除原始列名
    tokenized_id = ds.map(process_func, remove_columns=ds.column_names)
    
    # 解码并打印第一个样本的输入,用于调试
    tokenizer.decode(tokenized_id[0]['input_ids'])
    # 解码并打印第二个样本的标签(过滤掉-100),用于调试
    tokenizer.decode(list(filter(lambda x: x != -100, tokenized_id[1]["labels"])))

    return tokenized_id, tokenizer


def train(tokenized_id, tokenizer, cpu=True):
    """
    执行模型微调的主函数
    
    :param tokenized_id: 预处理后的数据集
    :param tokenizer: 分词器
    :param cpu: 是否使用CPU进行训练,默认为True(CPU训练)
    """
    # 根据是否使用CPU选择数据类型
    # CPU训练使用bfloat16,GPU训练使用float16
    dtype = torch.bfloat16 if cpu else torch.float16

    # 加载预训练模型
    # device_map="auto": 自动分配模型到可用设备(CPU或GPU)
    # torch_dtype=dtype: 设置模型的数据类型
    model = AutoModelForCausalLM.from_pretrained(TRAINING_MODEL, device_map="auto", torch_dtype=dtype)
    
    # 启用输入梯度检查点,减少内存使用
    model.enable_input_require_grads()

    # 配置LoRA参数
    config = LoraConfig(
        task_type=TaskType.CAUSAL_LM,  # 任务类型为因果语言模型
        # 指定要微调的模型模块
        target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
        inference_mode=False,  # 训练模式(True为推理模式)
        r=8,  # LoRA秩,控制适配器的维度
        lora_alpha=32,  # LoRA缩放因子,通常为r的4倍
        lora_dropout=0.1  # Dropout比例,防止过拟合
    )
    
    # 应用LoRA配置到模型
    model = get_peft_model(model, config)

    # 配置训练参数
    args = TrainingArguments(
        output_dir=f"./output/{TRAINING_MODEL}_lora",  # 模型输出目录
        per_device_train_batch_size=4,  # 每个设备的训练批量大小
        gradient_accumulation_steps=4,  # 梯度累积步数,实际批量大小=4*4=16
        logging_steps=10,  # 每10步记录一次日志
        num_train_epochs=8,  # 训练轮数
        save_steps=100,  # 每100步保存一次模型
        learning_rate=1e-4,  # 学习率
        save_on_each_node=True,  # 在每个节点上保存模型
        gradient_checkpointing=True,  # 启用梯度检查点,减少内存使用
    )  

    # 创建训练器
    trainer = Trainer(
        model=model,  # 要训练的模型
        args=args,  # 训练参数
        train_dataset=tokenized_id,  # 训练数据集
        # 数据整理器,用于处理批量数据
        data_collator=DataCollatorForSeq2Seq(tokenizer=tokenizer, padding=True),
    )  
    
    # 开始训练
    trainer.train() 


def combine_and_save_models(cpu=True):
    """
    合并LoRA适配器和基础模型,并保存合并后的模型
    
    :param cpu: 是否使用CPU进行模型合并,默认为True
    """
    # 导入必要的库(函数内部导入,避免不必要的依赖加载)
    from transformers import AutoModelForCausalLM, AutoTokenizer
    import torch
    from peft import PeftModel

    # 根据是否使用CPU选择数据类型
    dtype = torch.bfloat16 if cpu else torch.float16
    model_path = TRAINING_MODEL
    # LoRA检查点路径(假设是最后一个保存的检查点)
    lora_path = f'./output/{TRAINING_MODEL}_lora/checkpoint-100' 

    # 加载分词器
    tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)

    # 加载预训练模型(评估模式)
    model = AutoModelForCausalLM.from_pretrained(
        model_path, 
        device_map="auto",  # 自动分配设备
        torch_dtype=dtype,  # 设置数据类型
        trust_remote_code=True  # 信任自定义代码
    ).eval()  # 设置为评估模式

    # 加载LoRA适配器并合并到基础模型
    model = PeftModel.from_pretrained(model, model_id=lora_path)
    model = model.merge_and_unload()  # 合并权重并卸载peft封装

    # 保存合并后的模型
    merged_model_path = f'./merged_{TRAINING_MODEL}_lora'
    model.save_pretrained(merged_model_path)
    tokenizer.save_pretrained(merged_model_path)
    print(f"Merged model saved to {merged_model_path}")


# 主程序入口
if __name__ == "__main__":
    print("Start to train...")
    # 训练数据集路径
    training_dataset = "./data/train.json"

    # 加载数据集
    ds = load_dataset_json(training_dataset)
    print(f'dataset loaded, train size: {len(ds)}, {ds[0:3]}')

    # 预处理数据集
    tokenized_id, tokenizer = dataset_preprocess(ds)
    print(f'dataset preprocessed, start to run training...')

    # 执行训练
    train(tokenized_id, tokenizer, cpu=True)

    # 合并并保存模型
    print("Start to combine and save models...")
    combine_and_save_models(cpu=True)

    print("Done")

2)微调训练

python train_lora.py

整个微调训练在我这个10年前的老笔记本电脑上用CPU跑,总共花了2936.4626秒(约48.94分钟)。所以如果你也是在一个老电脑上跑的话,执行了训练后可以自己去玩一会儿了,回来可能就训练完成了。

3)微调输出文件结构

微调完成后,输出目录结构如下图所示:

其中:

  • 微调后的参数权重:位于output\Qwen\Qwen2.5-0.5B-Instruct_lora目录下。
  • 合并后的模型:位于merged_Qwen\Qwen2.5-0.5B-Instruct_lora目录下。

4)推理验证

微调完成后,可以用如下代码进行推理验证。
我的测试程序完整代码。文件名:test_lora.py

from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
from peft import PeftModel
import argparse  # 添加argparse模块用于命令行参数解析

model_path = 'Qwen/Qwen2.5-0.5B-Instruct'
lora_path = './output/Qwen/Qwen2.5-0.5B-Instruct_lora/checkpoint-100'

def test(instruct, cpu=True):
    """
    测试微调后的LoRA模型
    
    :param instruct: 用户指令内容,通过命令行传入
    :param cpu: 是否使用CPU进行推理,默认为True
    """
    # 加载tokenizer
    tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)

    # 根据设备选择数据类型和设备映射
    if cpu:
        dtype = torch.float16
        device_map = "cpu"  # 强制使用CPU
        device = "cpu"
    else:
        dtype = torch.bfloat16
        device_map = "auto"  # 自动分配设备
        device = "cuda" if torch.cuda.is_available() else "cpu"

    # 加载基础模型
    model = AutoModelForCausalLM.from_pretrained(
        model_path, 
        device_map=device_map,  # 使用明确的设备映射
        dtype=dtype, 
        trust_remote_code=True
    ).eval()  # 设置为评估模式

    # 加载LoRA权重
    model = PeftModel.from_pretrained(model, model_id=lora_path)

    # 测试对话
    inputs = tokenizer.apply_chat_template(
        [
            {"role": "user", "content": "你是一个智能会议语音助手,请根据用户指令输出正确的指令和参数"},
            {"role": "user", "content": instruct}
        ],
        add_generation_prompt=True,
        tokenize=True,
        return_tensors="pt",
        return_dict=True
    )
    
    # 将输入移动到与模型相同的设备
    inputs = inputs.to(device)

    # 生成配置
    gen_kwargs = {"max_length": 2500, "do_sample": True, "top_k": 1}
    
    # 生成回复
    with torch.no_grad():
        outputs = model.generate(**inputs, **gen_kwargs)
        # 提取生成的部分
        outputs = outputs[:, inputs['input_ids'].shape[1]:]
        # 解码并打印结果
        print(tokenizer.decode(outputs[0], skip_special_tokens=True))

def parse_args():
    """
    解析命令行参数
    :return: 解析后的参数对象
    """
    parser = argparse.ArgumentParser(description="测试LoRA微调后的Qwen2.5-0.5B-Instruct模型")
    # 必选参数:用户指令
    parser.add_argument("--instruct", type=str, required=True, help="用户指令内容,例如:'打开麦克风'")
    # 可选参数:是否使用CPU,默认使用CPU
    parser.add_argument("--cpu", action="store_true", help="是否使用CPU进行推理(默认使用CPU)")
    # 可选参数:是否使用GPU
    parser.add_argument("--gpu", action="store_true", help="是否使用GPU进行推理(优先级高于--cpu)")
    return parser.parse_args()

if __name__ == "__main__":
    # 解析命令行参数
    args = parse_args()
    
    # 确定是否使用CPU
    # 如果同时指定了--cpu和--gpu,优先使用GPU
    use_cpu = not args.gpu and args.cpu
    
    # 调用测试函数
    test(instruct=args.instruct, cpu=use_cpu)

测试命令:

python test_lora.py

经实测,用这些训练数据集训练后的模型,对指令识别的准确率提升还是非常的明显的,不过前面经过几轮次的微调训练、测试验证、再微调训练、测试验证,在我当前的需求规格下识别的准确率仍然未能达到100%(最后一版是96.47%),所以后面还需要再把意图/配位(参数)识别错误的一些命令词再补充整理一下,然后再继续来做微调训练。

四、补充说明

再补充一些基础知识(废话),懂的人不需要看。

1. 一点额外的信息

在我实际的测试中,用Qwen3-4B-Instruct-2507这个模型可以达到100%的准确率,但是这个模型无法放到端侧上用。
如果你的目标是一个后端/云端的助手的话,可以直接考虑这个模型;
但是如果你的目标也跟我一样,希望可以在端侧完全自主的完成整个指令、助手的功能的话,那可以跟我一样选择Qwen2.5-0.5B-Instruct

Qwen3也有一些小模型,比如:Qwen3-0.6B。但是这个模型经测试,对指令的解析效果超级差,所以我放弃了它,转回到Qwen2.5-0.5B-Instruct这个老一代的模型。不过这个可能跟需求也有一定关系,大家有时间的话,也可以自己部署测试一下效果。

2. 为什么要微调?

  • 能力考量
    • 让大模型在特定领域上增强。假设你们公司是做视频会议的,然后你们的视频会议在某些功能、特性上又跟通用的视频会议不太一样[后面省略一万字]。
    • 学习新的知识。大模型训练的时候肯定没有一些你或者你们公司的未披露到网上的信息[后面省略一万字]。
  • 性能考量
    • 减少幻觉:微调可以减少生成虚假或不相关信息的情况
    • 提高一致性:适当的温度设置,通常能产生高质量且富有创意的结果,尽管每次输出的内容不同,但质量始终保持在一个较高水平,而不是有时好有时差。
    • 避免输出不必要的信息:遇到ZZ、宗教或者其它一些你认为敏感问题时,可让模型拒答。
    • 降低延迟:通过优化和微调,让较小参数的模型也可以实现不错的效果,从而降低硬件要求,降低延迟。
  • 成本考量
    • 自己训练基座大模型是大公司(很花钱)的事情。在开源模型上微调,使用一些量化(减少精度)微调方式,可以大大降低门槛,还可以得到不错的效果。
    • 降低使用成本:微调的模型与通用模型比,模型的参数量会可以更少,整体的成本也可做到更低。
    • 更大的控制权:可以通过模型参数量、使用的资源,自主平衡模型性能、耗时、吞吐量等,为成本优化提供了空间。

3. 数据是关键

在现在这个时代,完全可以说,大部分的技术都是没有什么价值的,因为大部分的技术都是有手就行
真正的价值都在数据,而且最有价值的数据往往都是一个个的专业领域的数据,决定大模型微调效果的是数据,决定你整个产品成败的也是数据,这个事情一定要搞清楚。
所以呢,建议大家从现在开始,给自己好好做积累吧,把你的行业数据、专业领域的数据一点点积累好,这才是你的未来。

五、广而告之

新建了一个技术交流群,欢迎大家一起加入讨论。

扫码加入AI技术交流群(微信)

AI技术分享-20251214

关注我的公众号:奥德元
让我们一起学习人工智能,一起追赶这个时代。
(若二维码过期了,可私信我)

posted @ 2025-12-14 22:02  程序员老奥  阅读(0)  评论(0)    收藏  举报