【LoRA微调实战全过程】LLM由传统的“数据+模型+任务”范式向“预训练大模型+微调/提示工程”的范式转变

【LLM】从零到一构建一个小型LLM–MiniGPT一文我们从0到1分享了一个完整的LLM的构架过程,旨在让你从底层理解LLM的工作原理,为后续深入学习和实践大模型打下坚实的基础。

但是随着LLM的发展和应用,其构建方式由传统的“数据+模型+任务”范式向“预训练大模型+微调/提示工程”的范式转变。

今天我们将以一个常见的任务——对一个中文大语言模型进行指令微调,使其能够更好地遵循中文指令生成文本为例,分享目前最主流和方便的 LoRA微调,


1. 准备工作:环境与库安装

首先,需要设置 Python 环境,并安装必要的库:

  • transformers: Hugging Face 的核心库,提供大量预训练模型和工具。
  • peft: Hugging Face 专门用于参数高效微调的库,它封装了 LoRA 的实现。
  • accelerate: Hugging Face 的一个库,用于轻松地进行分布式训练和混合精度训练。
  • datasets: 用于高效处理数据集。
  • torch: 深度学习框架 (PyTorch),LoRA 通常在 PyTorch 环境下使用。
  • bitsandbytes: 进行 8-bit 或 4-bit 量化训练(进一步节省内存),这个库是必需的。
pip install transformers peft accelerate datasets torch bitsandbytes

2. 选择基础模型 (Base Model)

对于中文指令微调,开源的中文 LLM,例如:

  • THUDM/chatglm3-6b
  • Qwen/Qwen-1_8B-Chat
  • internlm/internlm2-7b

这里我们选择Qwen/Qwen-1_8B-Chat。

from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
import torch

# 选择一个预训练的Decoder-only中文大语言模型
# 这里以一个常见的模型路径为例,你需要替换为你实际使用的模型
model_id = "Qwen/Qwen-1_8B-Chat" # 或者 "THUDM/chatglm3-6b" 等

# 配置量化(可选,但对于大模型非常推荐,以节省内存)
# 使用 4-bit 量化,可以大大降低内存消耗,让你在消费级GPU上也能训练大模型
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,              # 启用 4-bit 量化
    bnb_4bit_quant_type="nf4",      # Quantization type for 4-bit.
    bnb_4bit_compute_dtype=torch.bfloat16, # 计算数据类型,bfloat16通常比float16更稳定
    bnb_4bit_use_double_quant=True, # 启用双重量化
)

# 加载分词器
tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
# 对于一些模型,需要设置pad_token_id
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token # 或者根据模型文档设置合适的padding token

# 加载模型,应用量化配置
# 对于指令微调,通常是 AutoModelForCausalLM
model = AutoModelForCausalLM.from_pretrained(
    model_id,
    quantization_config=bnb_config, # 应用量化配置
    device_map="auto",              # 自动分配模型到可用设备(如GPU)
    trust_remote_code=True          # 允许加载远程代码(对于某些模型是必需的)
)

# 确保模型在训练时能够处理梯度(即使是量化模型也需要)
model.gradient_checkpointing_enable() # 启用梯度检查点,进一步节省显存但会略微增加计算时间
model.enable_input_require_grads()    # 确保量化模型可以计算梯度

3. 准备微调数据

我们需要一个包含指令-回答对的数据集。数据格式通常是 (指令, 回答),或者更复杂的对话格式。

一个典型的对话模板是:

### Instruction:
{instruction}

### Input:
{input}

### Response:
{response}

或者更简单的:

用户:{instruction}
AI:{response}

需要将原始数据处理成这种格式,并进行分词。

from datasets import Dataset

# 示例数据 (真实项目中数据量会大得多,且会更丰富和多样化)
data = {
    "instruction": [
        "请帮我写一首关于春天的诗歌。",
        "将以下句子翻译成英文:'你好,世界!'",
        "总结一下地球的结构。"
    ],
    "input": [
        "", # 没有额外输入
        "你好,世界!",
        ""
    ],
    "response": [
        "春风拂绿柳,细雨润花枝。\n燕舞莺歌语,万物复苏时。",
        "Hello, world!",
        "地球主要由三层构成:地壳、地幔和地核。地壳是地球最外层,相对较薄;地幔位于地壳之下,是地球体积最大的部分;地核则位于地球的最深处,主要由铁和镍组成,分为外核(液态)和内核(固态)。"
    ]
}

dataset = Dataset.from_dict(data)

# 格式化数据为指令微调所需的模板
def format_instruction_data(example):
    # 根据你选择的模型和数据格式定义模板
    # 这是一个通用的模板示例,实际中应参考模型官方文档
    if example["input"]:
        formatted_text = f"### Instruction:\n{example['instruction']}\n\n### Input:\n{example['input']}\n\n### Response:\n{example['response']}"
    else:
        formatted_text = f"### Instruction:\n{example['instruction']}\n\n### Response:\n{example['response']}"
    return {"text": formatted_text}

formatted_dataset = dataset.map(format_instruction_data)

# 对数据进行 tokenize
def tokenize_function(examples):
    # 对整个格式化后的文本进行编码
    # 将模型输出部分(Response)的损失设置为可训练(label),其他部分设为-100(忽略损失)
    tokenized_input = tokenizer(examples["text"], truncation=True, max_length=512)

    # 找到 Response 部分的开始位置
    # 这是一个简化的方法,更鲁棒的方法是分别对指令和响应进行tokenize
    # 并手动构建labels,确保只有响应部分计算损失。
    # 对于Trainer,通常会处理好整个序列的训练。
    return tokenized_input

tokenized_dataset = formatted_dataset.map(tokenize_function, batched=True)

# 移除原始文本列,并设置 PyTorch 格式
tokenized_dataset = tokenized_dataset.remove_columns(["instruction", "input", "response", "text"])
tokenized_dataset.set_format("torch")

# 划分训练集和验证集 (实际应用中非常推荐)
# train_dataset, eval_dataset = tokenized_dataset.train_test_split(test_size=0.1)
train_dataset = tokenized_dataset # 简化,实际应划分

4. 配置 LoRA

这是 LoRA 实施的核心步骤。使用 peft.LoraConfig 来定义 LoRA 的参数。

  • r: LoRA 的秩 (rank)。这是最关键的参数,通常取值在 8 到 64 之间。r 越大,可训练的参数越多,模型容量越大,但内存和计算消耗也越多。
  • lora_alpha: LoRA 缩放因子。它与 r 结合使用,用于缩放 LoRA 层的输出,通常是 2 * r 或与 r 相等。
  • target_modules: 指定要应用 LoRA 的模型层。通常是查询 (query) 和值 (value) 投影层,如 ['q_proj', 'v_proj']。对于不同的模型,这些层的名称可能不同,你需要查看模型架构来确定。
  • lora_dropout: LoRA 层中的 Dropout 比率。
  • bias: 指定是否微调偏置项。通常设置为 ‘none’ (不微调)。
  • task_type: 设置为 TaskType.CAUSAL_LM (因果语言模型,用于文本生成) 或 TaskType.SEQ_CLS (序列分类) 等。
from peft import LoraConfig, get_peft_model, TaskType

# 定义 LoRA 的配置
lora_config = LoraConfig(
    r=16, # LoRA 的秩,通常是 8, 16, 32, 64 等
    lora_alpha=32, # LoRA 的缩放因子,通常是 2*r 或 r
    target_modules=["q_proj", "v_proj"], # LoRA 应用的模块名称,取决于你选择的模型
                                        # 对于 Qwen,可能是 k_proj, q_proj, v_proj, o_proj, gate, up, down
                                        # 对于 Llama2,通常是 q_proj, k_proj, v_proj, o_proj
                                        # 对于 ChatGLM,可能是 query_key_value
    lora_dropout=0.05, # Dropout 比率
    bias="none", # 不微调偏置项
    task_type=TaskType.CAUSAL_LM, # 任务类型:因果语言模型(用于文本生成)
)

# 使用 PEFT 配置包装原始模型
# 这步会自动冻结原始模型参数,并添加可训练的 LoRA 层
peft_model = get_peft_model(model, lora_config)

# 打印可训练参数,你会发现只有极少一部分
print("\n--- LoRA 可训练参数 ---")
peft_model.print_trainable_parameters()
# 示例输出可能类似:trainable params: 4194304 || all params: 700000000 || trainable%: 0.6
# 这里的 4194304 是 LoRA 层的参数,而 700000000 是整个模型的参数(量化后可能显示为近似值)

5. 训练模型

使用 Hugging Face 的 Trainer 类进行训练。对于因果语言模型,通常不需要特殊的 DataCollator,默认的 DataCollatorForLanguageModeling 足够。

from transformers import TrainingArguments, Trainer, DataCollatorForLanguageModeling
import accelerate # 确保安装了 accelerate

# 定义数据收集器 (通常不需要特殊设置,Trainer会处理)
data_collator = DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False)

# 定义训练参数
training_args = TrainingArguments(
    output_dir="./lora_finetuning_results",
    learning_rate=2e-5, # 学习率
    per_device_train_batch_size=2, # 根据GPU内存调整 batch size
    gradient_accumulation_steps=4, # 梯度累积,有效扩大batch size
    num_train_epochs=3, # 训练轮次
    weight_decay=0.01,
    logging_dir="./logs",
    logging_steps=50,
    save_strategy="epoch", # 每个 epoch 结束后保存模型
    load_best_model_at_end=True, # 训练结束后加载最佳模型
    metric_for_best_model="loss", # 评估指标,这里以训练损失为例
    greater_is_better=False,
    # fp16=True, # 如果不使用4bit/8bit量化,可以启用混合精度训练
    # bf16=True, # 如果硬件支持bfloat16,可以启用
    remove_unused_columns=False # 对于 LoRA,通常需要设置为False以避免错误
)

# 创建 Trainer 实例
trainer = Trainer(
    model=peft_model,
    args=training_args,
    train_dataset=train_dataset,
    # eval_dataset=eval_dataset, # 实际应有验证集
    tokenizer=tokenizer,
    data_collator=data_collator,
)

# 开始训练!
print("\n--- 开始 LoRA 微调 ---")
trainer.train()

6. 保存和加载训练好的 LoRA 适配器

训练完成后,只需保存训练好的 LoRA 适配器的参数,而不是整个大模型。

# 保存微调后的 PEFT 模型(只保存了LoRA的参数)
peft_model.save_pretrained("./my_lora_tuned_model")

# --- 加载用于推理 ---
from peft import PeftModel, PeftConfig

# 1. 首先加载原始的基础模型 (带量化配置,如果训练时使用了)
base_model_for_inference = AutoModelForCausalLM.from_pretrained(
    model_id,
    quantization_config=bnb_config, # 推理时也要加载量化配置
    device_map="auto",
    trust_remote_code=True
)

# 2. 加载 LoRA 配置
# peft_config_loaded = PeftConfig.from_pretrained("./my_lora_tuned_model") # 这行通常不需要,from_pretrained会自动加载

# 3. 将 LoRA 适配器附加到基础模型上
inference_model = PeftModel.from_pretrained(base_model_for_inference, "./my_lora_tuned_model")

# 4. 合并 LoRA 权重到原始模型(可选,但推荐用于生产环境,以实现无额外延迟)
# 注意:merge_and_unload() 会将LoRA权重合并到基础模型,并返回一个新的模型,
# 同时会卸载(unload)PeftModel的包装,使模型变为普通的transformers模型。
# 这会增加模型的显存占用,因为不再是量化后的模型了(取决于bnb_config配置)
# 如果不调用这一步,则模型仍是PeftModel,每次推理都会动态应用LoRA,不会增加显存但会稍微慢一点
# inference_model = inference_model.merge_and_unload()

# 5. 设置为评估模式 (重要)
inference_model.eval()

7. 进行推理

现在,就可以用加载好的模型对新的指令进行文本生成了。

import torch

def generate_response(instruction_text, input_text="", max_new_tokens=200):
    # 格式化输入,与训练时保持一致
    if input_text:
        formatted_prompt = f"### Instruction:\n{instruction_text}\n\n### Input:\n{input_text}\n\n### Response:"
    else:
        formatted_prompt = f"### Instruction:\n{instruction_text}\n\n### Response:"

    inputs = tokenizer(formatted_prompt, return_tensors="pt", truncation=True, max_length=512)
    inputs = {k: v.to(inference_model.device) for k, v in inputs.items()}

    with torch.no_grad(): # 推理时不需要计算梯度
        # 使用 generate 方法生成文本
        output_ids = inference_model.generate(
            **inputs,
            max_new_tokens=max_new_tokens,
            do_sample=True, # 启用采样
            temperature=0.7, # 采样温度
            top_p=0.9, # top_p 采样
            repetition_penalty=1.1, # 重复惩罚
            eos_token_id=tokenizer.eos_token_id # 确保在遇到结束符时停止生成
        )

    # 解码生成的 token ID
    # 由于输入提示也被生成了,我们需要截取回答部分
    response_text = tokenizer.decode(output_ids[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
    return response_text.strip()

# 示例预测
print("--- 指令微调后的模型响应 ---")
instruction1 = "写一则关于环境保护的短新闻。"
print(f"指令: {instruction1}\n回答:\n{generate_response(instruction1)}\n")

instruction2 = "解释一下什么是黑洞,用通俗易懂的语言。"
print(f"指令: {instruction2}\n回答:\n{generate_response(instruction2)}\n")

instruction3 = "请帮我润色以下邮件草稿。"
input3 = "主题:会议安排\n内容:我们定于下周三开会,讨论项目进展。请大家准备好各自的报告。下午两点,会议室A。"
print(f"指令: {instruction3}\n输入:\n{input3}\n回答:\n{generate_response(instruction3, input3)}\n")
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

技术与健康

你的鼓励将是我最大的创作动力!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值