【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")