1. 环境准备:从零搭建你的AI微调工作台

嘿,朋友们,我是老张,在AI和硬件这行摸爬滚打了十几年。今天咱们不聊那些虚头巴脑的概念,直接上手干。想象一下,你手头有一台性能还不错的电脑,或者直接使用魔搭社区提供的免费GPU资源,想训练一个能理解你特定需求、会写你公司风格周报、甚至能帮你分析专业领域文档的AI助手。这听起来很酷,对吧?但一看到“模型微调”、“参数优化”这些词,是不是头都大了?别怕,今天我就带你用 ms-swift 和 LoRA 这两个利器,把这事儿变得跟搭积木一样简单。我们的目标很明确:零基础起步,手把手教你微调一个属于你自己的Qwen2.5模型。

首先,咱们得把“厨房”收拾好。微调大模型,环境是第一道坎。我强烈推荐新手直接从魔搭社区(ModelScope)的Notebook环境开始。为什么?因为它帮你省去了最头疼的CUDA版本、PyTorch版本、依赖冲突这些破事儿。你只需要注册一个账号,创建一个Notebook,选择带GPU的实例(比如“GPU-A10”或者“GPU-V100”),环境就已经是配置好的了,开箱即用。这就像你去一个高级厨房,灶台、锅具、调料都给你备齐了,你只管炒菜就行。

如果你坚持要在自己的电脑上搞,那也行,但得做好心理准备。你需要确保有Python 3.8以上的环境,一块显存至少8GB的NVIDIA显卡(训练Qwen2.5-3B这个尺寸的模型,8GB是起步价,想要更流畅建议12GB以上),以及安装好对应版本的PyTorch和CUDA。这块的坑我踩过不少,版本不匹配是最大的噩梦。一个比较稳的搭配是Python 3.10 + PyTorch 2.1+ + CUDA 11.8。你可以去PyTorch官网根据你的系统生成安装命令。

环境就绪后,核心工具登场:ms-swift。你可以把它理解为一个“大模型微调工具箱”。它把加载模型、配置训练参数、管理训练流程这些复杂操作都封装成了简单的函数,让我们能专注于数据和任务本身。安装它只需要一行命令,在Notebook里或者你的本地终端里执行:

pip install ms-swift

有时候为了确保所有依赖都到位,我习惯把常用的几个包一起装了:

pip install ms-swift torch transformers datasets peft

这里解释一下这几个兄弟:torch是PyTorch深度学习框架,是地基;transformers是Hugging Face的库,提供了海量的预训练模型和分词器;datasets也是Hugging Face的,方便我们加载和处理各种数据集;peft是参数高效微调方法的实现库,ms-swift的LoRA功能底层会用到它。装完这些,你的“兵器库”就算初步成型了。

2. 认识我们的主角:Qwen2.5模型与LoRA技术

工欲善其事,必先利其器。在开始敲代码之前,咱们花几分钟搞清楚我们要微调的对象和使用的核心技术,这样后面操作起来心里才有底。

Qwen2.5 是通义千问团队开源的一系列大语言模型。我们今天拿来做实验的是 Qwen2.5-3B-Instruct 这个版本。“3B”指的是30亿参数,这个规模对于个人开发者或者小团队来说非常友好:它足够“聪明”,能完成很多复杂的语言理解与生成任务;同时又不会大到让你的显卡“爆炸”,在消费级GPU上就能跑起来。“Instruct”后缀意味着这个模型是经过指令微调的,它更擅长理解和遵循人类的指令,比如“写一首诗”、“总结下面这段话”、“用Python实现某个功能”。这正好符合我们微调的起点——我们不需要从最原始的语言模型开始,而是从一个已经懂点规矩的“学生”开始,教它更 specialized 的技能。

接下来是重头戏:LoRA。它的全称是 Low-Rank Adaptation,中文叫“低秩适配”。这名字听起来挺唬人,我打个比方你就明白了。想象一下,你要给一辆汽车(原始大模型)加装一个特殊的氮气加速装置(你的新技能)。传统全参数微调相当于把整台车拆了,重新调整每一个零件,耗时耗力还容易把车搞坏。而LoRA呢,它不动汽车本身,只是额外加装一个小巧的、可插拔的“加速模块”。训练的时候,只训练这个新加的模块;用的时候,把这个模块“插”到汽车上,汽车就获得了加速能力。

从技术上讲,LoRA的核心思想是:大模型在适应新任务时,其权重矩阵的变化(ΔW)可以用两个低秩矩阵的乘积(B*A)来近似表示。原本一个巨大的矩阵(比如4096x4096),现在只用训练两个小得多的矩阵(比如4096x8和8x4096)。这带来的好处是革命性的:需要训练的参数数量锐减(可能只有原来的0.1%),因此训练速度飞快,显存占用极低,而且产出的模型文件(只保存LoRA权重)非常小,通常只有几十MB。更妙的是,你可以训练多个不同的LoRA模块(比如一个用于写周报,一个用于客服问答),然后像换“技能卡”一样灵活切换,而原始大模型始终保持不变。

ms-swift框架完美集成了LoRA,我们只需要简单配置几个参数,它就能帮我们自动完成所有底层复杂的操作。下面我们就进入实战环节,看看如何用代码把这一切串联起来。

3. 实战第一步:加载模型与配置LoRA

好了,理论热身完毕,咱们开始写代码。打开你的Notebook或者Python编辑器,跟着我一步步来。第一步,我们要把Qwen2.5模型和它的分词器“请”到我们的环境中来。

from swift.llm import get_model_tokenizer
import torch

# 指定我们要用的模型
model_id = 'Qwen/Qwen2.5-3B-Instruct'

# 加载模型和分词器
model, tokenizer = get_model_tokenizer(
    model_id_or_path=model_id,
    torch_dtype=torch.bfloat16 if torch.cuda.is_available() else torch.float32,
    model_kwargs={"device_map": "auto"}
)

这段代码是ms-swift提供的便捷函数。get_model_tokenizer 会自动从魔搭社区下载模型(如果本地没有的话),并返回准备好模型和分词器。这里有几个关键点我解释一下:

  • torch_dtype:指定模型加载的数据类型。torch.bfloat16 是一种在保持一定数值范围的同时减少显存占用的格式,如果GPU支持(比如A100、A10),强烈建议使用,能省下不少显存。如果不支持,就退回用 torch.float32。
  • model_kwargs={“device_map”: “auto”}:这个参数让 transformers 库自动决定把模型的每一层放到哪个设备上(比如多块GPU之间),对于显存有限的情况,它能进行智能的层间分割,非常有用。

加载完模型后,我们来配置LoRA。这是微调效果和效率的关键。

from swift.tuners import Swift, LoRAConfig

# 配置LoRA参数
lora_config = LoRAConfig(
    r=16,  # 秩(rank),决定LoRA模块的大小。值越小参数越少,训练越快,但能力可能越弱。通常8-32之间,16是个不错的起点。
    lora_alpha=32,  # 缩放因子,一般设置为r的两倍,这是一个经验值。
    target_modules=[  # 指定在模型的哪些层上添加LoRA适配器
        "q_proj",  # 注意力机制中的查询(Query)投影层
        "k_proj",  # 键(Key)投影层
        "v_proj",  # 值(Value)投影层
        "o_proj",  # 输出(Output)投影层
        "gate_proj",  # MLP(多层感知机)中的门控投影
        "up_proj",   # MLP的上投影
        "down_proj"  # MLP的下投影
    ],
    lora_dropout=0.05,  # Dropout率,用于防止过拟合,一般设置一个较小的值如0.05-0.1。
    bias="none"  # 不对偏置(bias)参数进行训练。
)

这里有个超级重要的坑,我踩过,你必须注意:target_modules 里的模块名称必须和你的模型结构完全对应!如果你直接运行上面的代码报错,提示类似 ValueError: Target modules {‘dense_4h_to_h’, ‘query_key_value’...} not found,别慌。这是因为不同模型家族的内部层命名规则不同。Qwen2.5用的是类似LLaMA的结构,所以上面列出的模块名是有效的。但如果你换一个模型,比如ChatGLM,模块名就完全不同了。

怎么解决?最直接的办法就是打印出模型结构看看:

print(model)

在输出信息里,你会看到很多层的名字,找那些包含“proj”、“dense”、“attention”等关键词的线性层(Linear),把它们正确的名字填到 target_modules 列表里。这是一个需要点耐心但一劳永逸的步骤。

配置好LoRA参数后,我们用Swift将它“注入”到原始模型中:

# 将LoRA配置应用到模型上
model = Swift.prepare_model(model, lora_config)

# 如果有GPU,把模型移到GPU上以获得加速
if torch.cuda.is_available():
    model = model.to('cuda')

执行完这几行,你的模型就已经装备上了LoRA“外挂”。现在,模型的可训练参数就只剩下LoRA引入的那一小部分了,显存占用会大大降低。你可以用 model.print_trainable_parameters() 来查看具体有多少参数是可训练的,相信我,看到那个数字你会很开心的。

4. 准备燃料:数据处理与模板(Template)的奥秘

模型准备好了,接下来需要“燃料”——数据。微调的本质就是用你的数据教模型做事。数据质量直接决定模型学成什么样。假设我们的任务是让模型学会用一种特定的、轻松的技术博客风格来写作。那么,我们的数据集就应该是一系列“指令-输出”对。

例如,一条数据可能是:

{
  “instruction”: “用轻松幽默的口吻,向编程新手解释什么是递归函数”,
  “output”: “嘿,朋友!想象一下你站在两面镜子中间,镜子里有镜子,镜子里的镜子还有镜子...递归函数就跟这个差不多,它是一个喜欢‘调用自己’的函数。就像俄罗斯套娃,大娃娃肚子里有小娃娃,小娃娃肚子里还有更小的...”
}

我们可以把数据整理成JSON Lines格式(.jsonl文件),每行一个JSON对象。然后用Hugging Face的 datasets 库加载:

from datasets import load_dataset

# 假设你的数据文件叫 my_style_data.jsonl
dataset = load_dataset("json", data_files="my_style_data.jsonl", split="train")

加载进来后,数据不能直接喂给模型,需要经过分词和格式化。这里就引出了ms-swift中一个非常核心且好用的概念:模板(Template)。

为什么要用Template? 大语言模型在训练时,输入是有固定格式的。比如Qwen2.5-Instruct模型,它期望的对话格式是类似这样的:

<|im_start|>system
你是助手。<|im_end|>
<|im_start|>user
请解释递归。<|im_end|>
<|im_start|>assistant
递归就像...

如果我们直接把原始的“instruction”和“output”字符串扔进去,模型会懵,因为它不认识这个结构。Template的作用就是自动帮我们把原始数据转换成模型能理解的格式。它封装了不同模型所需的特殊token(如<|im_start|>)、角色标识(user, assistant, system)和对话结构。

在ms-swift中使用Template非常简单:

from swift.llm import get_template

# 获取该模型对应的对话模板
template = get_template(
    model.model_meta.template, # 从模型元数据中自动获取模板类型
    tokenizer,
    max_length=512  # 设置最大序列长度,超出部分会被截断
)
template.set_mode('train') # 设置为训练模式

有了template,我们再来写数据预处理函数就清晰多了:

def preprocess_function(examples):
    # 假设我们的数据有‘instruction’和‘output’两个字段
    instructions = examples["instruction"]
    outputs = examples["output"]

    # 构建模型输入:将每条数据构造成一个单轮对话
    model_inputs = []
    for instr, out in zip(instructions, outputs):
        # 使用template来格式化对话
        # 这里我们假设所有指令都是用户输入,所有输出都是助手回复
        messages = [
            {"role": "user", "content": instr},
            {"role": "assistant", "content": out}
        ]
        # template.encode 方法会将消息列表转换成包含input_ids, attention_mask, labels的字典
        encoded = template.encode(messages)
        model_inputs.append(encoded)

    # 将列表字典批处理成一个字典(pad到相同长度)
    # 注意:template.encode可能已经处理了padding,具体看其实现。这里我们手动批处理一下。
    # 更稳妥的方式是使用tokenizer的padding功能,但template通常已集成。
    # 以下是一种通用处理方式:
    input_ids = [item['input_ids'] for item in model_inputs]
    attention_mask = [item['attention_mask'] for item in model_inputs]
    labels = [item['labels'] for item in model_inputs]

    # 使用tokenizer进行pad
    batch = tokenizer.pad(
        {"input_ids": input_ids, "attention_mask": attention_mask, "labels": labels},
        padding=True,
        return_tensors="pt"
    )
    return batch

# 应用预处理函数
tokenized_dataset = dataset.map(preprocess_function, batched=True, remove_columns=dataset.column_names)

这样,我们就得到了一个可以直接用于训练的数据集。Template帮我们隐藏了繁琐的格式细节,让我们能更专注于数据内容本身。这也是ms-swift提升开发效率的一个体现。

5. 启动训练:配置训练器与开始微调

数据、模型、LoRA都准备好了,现在可以点火训练了。ms-swift提供了高度封装的 Trainer 类,它借鉴了Hugging Face Transformers Trainer的设计,用起来非常顺手。

首先,我们需要定义训练参数:

from swift import Trainer, TrainingArguments

# 设置训练参数
training_args = TrainingArguments(
    output_dir="./qwen25_lora_output",  # 所有输出(模型、日志)的保存目录
    num_train_epochs=3,                 # 训练轮数。对于风格学习,3-5轮通常足够。数据少可以多几轮。
    per_device_train_batch_size=4,      # 每个GPU上的训练批次大小。根据你的显存调整,8GB显存从2或4开始试。
    per_device_eval_batch_size=4,       # 每个GPU上的评估批次大小
    gradient_accumulation_steps=4,       # 梯度累积步数。如果显存小,可以设大此值来模拟更大的batch size。
    optim="adamw_torch",                # 优化器
    learning_rate=2e-4,                 # 学习率。LoRA训练通常用较大的学习率,1e-4到5e-4之间。
    warmup_ratio=0.03,                  # 预热步数占总步数的比例。让学习率从0慢慢升到设定值,有助于稳定训练初期。
    logging_steps=10,                   # 每多少步打印一次日志
    save_steps=200,                     # 每多少步保存一次检查点
    evaluation_strategy="steps",        # 评估策略,按步数进行评估
    eval_steps=200,                     # 每多少步评估一次
    save_total_limit=2,                 # 最多保存几个检查点,旧的会被删除
    load_best_model_at_end=True,        # 训练结束后加载最好的模型
    metric_for_best_model="eval_loss",  # 用于选择最佳模型的指标(这里用验证集损失)
    greater_is_better=False,            # eval_loss是越小越好
    fp16=False,                         # 是否使用混合精度训练(fp16)。如果GPU支持,开启可以加速并省显存。
    bf16=torch.cuda.is_bf16_supported(), # 是否使用bfloat16精度。A100等新卡支持,优先于fp16。
    report_to="tensorboard"             # 日志记录器,这里用tensorboard,也可以设为"none"
)

参数看起来很多,但大部分都可以用默认值。你需要重点关注的是:per_device_train_batch_size、learning_rate 和 num_train_epochs。batch size 受限于你的显存,如果训练时出现OOM(内存溢出),就把它调小,或者增大 gradient_accumulation_steps。学习率 是LoRA训练的灵魂,太小了学得慢,太大了容易训“飞”(损失变成NaN)。从 2e-4 开始尝试是个安全的策略。训练轮数 取决于数据量和任务难度,你可以先跑1轮看看损失下降情况。

接下来,有一个极其关键但容易被忽略的步骤:启用模型输入的梯度需求。这是因为有些模型在推理模式下会冻结一些层的梯度计算,而我们需要确保在训练时梯度能正常传播。

# 非常重要!必须运行这行代码来开启梯度传播
model.enable_input_require_grads()

现在,万事俱备,创建Trainer并开始训练:

# 划分训练集和验证集(假设我们之前没分)
split_dataset = tokenized_dataset.train_test_split(test_size=0.1)
train_dataset = split_dataset["train"]
eval_dataset = split_dataset["test"]

# 初始化Trainer
trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
    eval_dataset=eval_dataset,
    tokenizer=tokenizer,
    data_collator=None,  # 因为我们预处理时已经pad好了,所以这里可以设为None。否则需要定义一个collator。
    template=template,   # 传入我们定义好的模板
)

# 开始训练!
train_results = trainer.train()

敲下 trainer.train() 后,你的GPU风扇就会开始欢快地旋转,屏幕上会滚动着损失(loss)和评估指标。第一次看到损失曲线稳步下降时,那种成就感是无与伦比的。训练完成后,别忘了保存你的劳动成果:

# 保存LoRA适配器权重
model.save_pretrained("./my_qwen25_lora_adapter")
# 也可以选择保存整个模型(包含基础模型和LoRA权重),但文件会很大
# trainer.save_model("./my_finetuned_qwen25_full")

保存下来的 my_qwen25_lora_adapter 文件夹里,通常只有几个MB到几十MB的文件,这就是你的“技能模块”。你可以把它分享给别人,或者加载到任何相同的基座模型上使用。

6. 验证与推理:看看你的模型学得怎么样

训练完成,保存了模型,最激动人心的时刻到了:看看我们微调后的模型到底表现如何。我们需要写一个推理函数来跟它对话。

首先,加载我们刚刚保存的LoRA权重。注意,这里需要先加载原始的基础模型,再把LoRA权重合并上去。

from swift import Swift

# 1. 再次加载原始的基础模型和分词器(注意,这是没微调过的)
base_model, base_tokenizer = get_model_tokenizer('Qwen/Qwen2.5-3B-Instruct', torch_dtype=torch.bfloat16)

# 2. 加载我们训练好的LoRA适配器
# 这里我们使用Swift.from_pretrained来将LoRA权重加载到基础模型上
lora_model = Swift.from_pretrained(base_model, model_id="./my_qwen25_lora_adapter")

# 3. 将模型设置为评估模式,并移到GPU上
lora_model.eval()
if torch.cuda.is_available():
    lora_model = lora_model.to('cuda')

现在,我们可以定义一个简单的对话函数:

def chat_with_model(model, tokenizer, user_input, max_new_tokens=256):
    # 使用相同的template来构建输入
    messages = [
        {"role": "user", "content": user_input}
    ]
    # 编码输入,注意要设置为推理模式
    template.set_mode('inference')
    inputs = template.encode(messages)
    # 将输入数据转移到模型所在的设备
    input_ids = inputs['input_ids'].to(model.device)
    attention_mask = inputs['attention_mask'].to(model.device)

    # 生成回复
    with torch.no_grad():  # 推理时不需要计算梯度
        outputs = model.generate(
            input_ids=input_ids,
            attention_mask=attention_mask,
            max_new_tokens=max_new_tokens,  # 控制生成文本的最大长度
            do_sample=True,                 # 是否使用采样(设为True生成结果更多样)
            temperature=0.8,                # 温度参数,控制随机性。越高越随机,越低越确定。
            top_p=0.9,                      # 核采样(nucleus sampling)参数,保留概率质量最高的部分词。
            repetition_penalty=1.1,         # 重复惩罚,避免模型陷入重复循环。
            pad_token_id=tokenizer.pad_token_id,
            eos_token_id=tokenizer.eos_token_id
        )

    # 解码生成的token,跳过输入部分
    generated_ids = outputs[0][len(input_ids[0]):]
    response = tokenizer.decode(generated_ids, skip_special_tokens=True)
    return response.strip()

# 测试一下!
test_prompt = "用轻松幽默的技术博主风格,给小白解释一下什么是神经网络的反向传播。"
response = chat_with_model(lora_model, base_tokenizer, test_prompt)
print("用户:", test_prompt)
print("助手:", response)

如果一切顺利,你应该能看到模型用你训练数据中类似的风格来回答问题了。它可能不会百分之百完美,但相比原始的基础模型,应该已经有了明显的风格倾向。多试几个问题,观察它的表现。如果发现风格还不够明显,可能是训练数据不够、训练轮数不足或者学习率需要调整。如果发现模型开始胡言乱语或者输出无意义内容,那可能是学习率太高、数据有噪声或者出现了过拟合。

7. 进阶技巧:自定义损失函数与更多可能性

在基本的微调流程跑通之后,你可能想玩点更花的。比如,你想让模型不仅学会风格,还要在内容准确性上更有保障。这时候,自定义损失函数就派上用场了。ms-swift的Trainer支持传入一个 compute_loss_func 参数,让你可以定义自己的损失计算逻辑。

举个例子,除了标准的交叉熵损失(让模型预测下一个token),我们可能还想加入一个“风格一致性”损失。假设我们有一个简单的风格分类器(可以是一个小模型或者一组规则),能判断一段文本是否符合目标风格。我们可以在训练时,将模型生成文本的风格得分也纳入损失,鼓励模型输出风格更一致的文本。

下面是一个概念性的示例,展示了如何继承 nn.Module 来创建一个自定义损失类:

import torch.nn as nn
import torch.nn.functional as F

class StyleAwareLoss(nn.Module):
    def __init__(self, base_loss_weight=1.0, style_loss_weight=0.1):
        super().__init__()
        self.base_loss_weight = base_loss_weight
        self.style_loss_weight = style_loss_weight
        # 假设我们有一个预训练的风格分类器(这里用占位符表示)
        # 在实际应用中,你需要加载一个真实的风格分类模型
        # self.style_classifier = load_style_classifier(...)

    def forward(self, outputs, labels, **kwargs):
        """
        计算总损失。
        outputs: 模型输出,通常包含logits
        labels: 真实标签(token ids)
        """
        # 1. 计算标准的语言模型损失(交叉熵损失)
        logits = outputs['logits'] if isinstance(outputs, dict) else outputs
        shift_logits = logits[..., :-1, :].contiguous()
        shift_labels = labels[..., 1:].contiguous()
        ce_loss = F.cross_entropy(
            shift_logits.view(-1, shift_logits.size(-1)),
            shift_labels.view(-1),
            ignore_index=-100  # 忽略padding部分的标签
        )

        # 2. (模拟)计算风格损失
        # 这里仅为示例,实际需要从outputs中获取生成的文本,并用风格分类器打分
        # generated_text = decode_outputs_to_text(outputs, tokenizer)
        # style_score = self.style_classifier(generated_text)
        # style_loss = 1 - style_score  # 假设分数越高风格越符合,我们希望最小化 (1 - score)

        # 为了示例能运行,我们暂时将风格损失设为0
        style_loss = torch.tensor(0.0, device=logits.device)

        # 3. 组合损失
        total_loss = self.base_loss_weight * ce_loss + self.style_loss_weight * style_loss
        return total_loss

然后,在创建Trainer时,将这个自定义损失函数传进去:

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
    eval_dataset=eval_dataset,
    tokenizer=tokenizer,
    template=template,
    compute_loss_func=StyleAwareLoss(),  # 使用自定义损失
)

请注意:实现一个真正有效的风格损失需要额外的模型和复杂的文本生成过程,这超出了入门指南的范围。这里主要是展示ms-swift框架的扩展性。你可以基于这个思路,集成各种你想要的约束,比如关键词命中奖励、事实一致性惩罚等等。

除了自定义损失,ms-swift还支持很多高级特性,比如多LoRA模块组合、与其它PEFT方法(如Adapter)结合、量化训练等。当你熟悉了基本流程后,完全可以去探索这些功能,打造更强大、更高效的微调方案。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐