1. 为什么你需要亲手微调一个大模型?

嘿,朋友们,我是老张,在AI和硬件这行摸爬滚打了十几年。今天我们不聊那些高深的理论,就来聊聊一个特别实在的事儿:怎么用你的电脑,亲手“调教”一个属于你自己的大模型。你可能用过ChatGPT,觉得它很聪明,但有时候回答总是不太对味儿,或者没法处理你公司内部特有的数据格式。这时候,微调(Fine-tuning)就是你手里的“金手指”。

简单来说,微调就像给一个已经学识渊博的大学教授(基础大模型)进行专项特训。他本来懂天文地理,但你现在需要他精通你们公司的产品手册和客服话术。通过微调,你不需要从头教他认字读书,只需要用你准备好的“特训资料”(你的数据集),让他快速掌握新领域的知识。今天我们要玩的,就是当下最火的两个开源“学霸”:Qwen(通义千问)DeepSeek

为什么选它们?Qwen来自阿里,中文理解能力一流,文档和社区支持都非常友好,是入门微调的绝佳选择。而DeepSeek,尤其是它的DeepSeek-R1系列,以其强大的推理和“思考”能力著称,但这也带来了微调上的独特挑战——如何处理模型内部的“思考过程”(think tokens)。搞定它,你的微调技能就算进阶了。

我猜你可能会担心:这得需要多贵的显卡?是不是特别复杂?别怕,我会带你用LoRA这种“轻量级”微调技术,它只训练模型里极小一部分参数,能让你的消费级显卡(甚至是一张RTX 3090/4090)也跑起来。整个过程,我们从环境搭建、数据准备,到训练、保存,最后还能把它变成能在OllamaLM Studio里轻松部署的格式,一步不落。准备好了吗?我们这就开始。

2. 搭建你的微调工作台:环境与工具准备

工欲善其事,必先利其器。微调的第一步,就是把我们的“数字工作台”搭好。这里没有魔法,只有一些需要耐心执行的命令。别担心,我会把每一步的“为什么”和“可能会遇到的坑”都告诉你。

2.1 安装核心三件套:PyTorch, Transformers, Datasets

首先,我们需要一个干净的Python环境(强烈建议使用conda或venv创建一个新环境,避免包冲突)。然后,安装最核心的三个库。

PyTorch是底层的深度学习框架,就像汽车的发动机。安装它的时候一定要去官网看看。打开PyTorch官网,找到“Install PyTorch”部分,根据你的操作系统、包管理工具(我们选pip)、CUDA版本(如果你有NVIDIA显卡)来复制命令。比如,对于CUDA 12.1的Linux系统,命令可能是:

pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121

如果你没有显卡,或者想先用CPU跑跑看,就选择CPU版本。这一步是基础,装错了后面全都会报错。

接下来是Transformers,这是Hugging Face公司出的神器,我们今天所有操作的核心。它提供了加载模型、分词器、训练器的一站式接口。同时安装Datasets库,它用来高效地加载和处理我们的训练数据。

pip install transformers datasets

2.2 安装“内存救星”:Bitsandbytes与PEFT

直接加载一个7B参数的模型,比如Qwen2.5-7B,需要大约14GB的显存(float16精度),这对很多玩家来说门槛太高了。所以我们需要“量化”和“参数高效微调”技术来帮忙。

Bitsandbytes 库提供了4位量化(QLoRA技术的基础),能让模型显存占用大幅下降。而PEFT库则实现了LoRA等微调方法。

pip install bitsandbytes accelerate peft

安装accelerate是为了更好地管理多GPU或混合精度训练。有时候你可能会遇到bitsandbytes编译错误,这通常和你的CUDA版本不匹配有关,仔细检查PyTorch和bitsandbytes要求的CUDA版本是否一致是关键。

2.3 模型下载加速:配置镜像源

由于模型文件通常托管在Hugging Face上,直接从国外下载可能会非常慢甚至中断。这里有个小技巧:设置环境变量,使用国内镜像。在你的终端里执行(Linux/macOS):

export HF_ENDPOINT=https://hf-mirror.com

或者在Python脚本里通过os.environ设置。对于Windows用户,你可以在“系统属性-高级-环境变量”里添加一个用户变量,变量名HF_ENDPOINT,变量值https://hf-mirror.com。设置好后,后续from_pretrained下载模型就会快很多。这是我踩过好几次坑后找到的稳定解决方案。

2.4 验证环境:写个“Hello World”试试水

环境装好了,总得跑个例子看看是不是一切正常。我们来写一个最简单的脚本,加载Qwen模型并和它打个招呼。这个脚本能帮你提前发现环境配置问题。

from transformers import AutoModelForCausalLM, AutoTokenizer

model_name = "Qwen/Qwen2.5-7B-Instruct"
# 尝试加载模型和分词器
tokenizer = AutoTokenizer.from_pretrained(model_name)
# 注意:这里我们先以CPU模式加载,避免显存不足报错,实际微调时会用device_map="auto"
model = AutoModelForCausalLM.from_pretrained(model_name, device_map="cpu")

print("恭喜!模型和分词器加载成功!")
print(f"分词器词汇表大小:{len(tokenizer)}")
print(f"模型结构:{type(model)}")

如果这段代码能成功运行并打印出信息,说明你的Transformers库、模型下载通道都没问题。如果卡在下载模型,请回头检查你的镜像配置。如果报缺少模块的错误,就按照提示pip install对应的包。

3. 准备“特训教材”:数据集的制作与预处理

模型准备好了,接下来就是给它准备的“特训教材”——数据集。数据的质量直接决定了微调后模型的表现。这里我们不讲复杂的数据爬取和清洗,就从最简单、最可控的格式开始:一个JSON文件。

3.1 数据格式:对话的“剧本”

大模型,尤其是指令微调模型,理解的是“对话”。所以我们的数据最好也组织成对话形式。一个最通用的格式是包含prompt(用户输入)和answer(助手回复)的字典列表。我们创建一个名为dataset.json的文件:

[
  {
    "prompt": "请用Python写一个函数,计算斐波那契数列的第n项。",
    "answer": "当然,这是一个用Python计算斐波那契数列第n项的经典函数,使用递归方式实现(注意:对于大的n,递归效率较低,这里仅作示例):\n\n```python\ndef fibonacci(n):\n    if n <= 1:\n        return n\n    else:\n        return fibonacci(n-1) + fibonacci(n-2)\n\n# 示例:计算第10项\nprint(fibonacci(10))  # 输出 55\n```\n\n对于生产环境,建议使用迭代或带记忆化的递归以提高性能。"
  },
  {
    "prompt": "解释一下机器学习中的‘过拟合’是什么意思?",
    "answer": "过拟合是机器学习中的一个常见问题。想象一下,一个学生为了应对一场考试,不是去理解通用的概念和原理,而是死记硬背下了所有历年考题和答案。当考试题目和历年题一模一样时,他能考满分;但一旦出现新的、没背过的题型,他就完全不会了。\n\n在机器学习中,模型就相当于这个学生。过拟合指的是模型在训练数据上表现得太好了,以至于它‘记住’了训练数据中的噪声、随机波动和无关细节,而不是学习到数据背后真正的、可泛化的规律。这导致模型在训练集上准确率很高,但在从未见过的测试集或新数据上表现很差。\n\n解决过拟合的方法包括:获取更多训练数据、使用数据增强、简化模型结构(减少参数)、加入正则化(如L1/L2正则)、以及使用Dropout等技术。"
  }
]

你可以根据自己的需求,准备几十条、几百条甚至几千条这样的对话数据。数据可以是你整理的问答对、客服日志、代码注释等等。关键是质量高于数量,确保问题和答案都是准确、有用的。

3.2 数据预处理:把对话变成模型能懂的“数字密码”

模型看不懂文字,它只认识数字(Token ID)。所以我们需要用分词器把文字转换成数字序列,同时还要处理好标签(labels),告诉模型在训练时哪些部分需要预测,哪些部分需要忽略。

预处理的核心函数如下,我加了详细注释:

from datasets import load_dataset
import torch

# 1. 加载数据集
dataset = load_dataset("json", data_files="dataset.json")['train']

def process_func(example):
    MAX_LENGTH = 1024  # 设置一个最大长度,防止序列过长

    # 2. 构建对话消息列表,格式需符合模型要求
    messages = [
        {"role": "user", "content": example['prompt']},
        {"role": "assistant", "content": example['answer']}
    ]

    # 3. 使用分词器的“聊天模板”将对话转为Token ID序列
    # `tokenize=True` 直接返回ID列表,`add_generation_prompt=False` 表示这不是在生成阶段
    input_ids = tokenizer.apply_chat_template(
        messages,
        tokenize=True,
        add_generation_prompt=False
    )

    # 4. 构建注意力掩码(全是1,表示所有token都需要被关注)
    attention_mask = [1] * len(input_ids)

    # 5. 构建标签(labels)——这是关键!
    # 在因果语言建模中,模型的任务是根据前面的词预测下一个词。
    # 对于指令微调,我们通常只计算助手回复部分的损失,用户指令部分则被忽略。
    # 忽略的方法就是将对应位置的标签设置为 -100。

    # 5.1 先计算用户指令部分的长度
    instruction_ids = tokenizer.apply_chat_template(
        [{"role": "user", "content": example['prompt']}],
        tokenize=True
    )
    instruction_length = len(instruction_ids)

    # 5.2 创建labels列表,初始值和input_ids一样
    labels = input_ids.copy()
    # 将用户指令部分(即prompt)的标签设为 -100,模型训练时会忽略这部分损失
    for i in range(instruction_length):
        labels[i] = -100

    # 6. 序列截断,防止超出最大长度
    if len(input_ids) > MAX_LENGTH:
        input_ids = input_ids[:MAX_LENGTH]
        attention_mask = attention_mask[:MAX_LENGTH]
        labels = labels[:MAX_LENGTH]

    # 7. 返回处理后的字典
    return {
        "input_ids": torch.tensor(input_ids, dtype=torch.long),
        "attention_mask": torch.tensor(attention_mask, dtype=torch.long),
        "labels": torch.tensor(labels, dtype=torch.long)
    }

# 映射处理函数到整个数据集
tokenized_dataset = dataset.map(process_func)

这个过程就像把一篇中文文章,按照字典(分词器)拆分成一个个词,并给每个词编上号。labels的设置是微调指令模型的核心技巧,它确保了模型只学习如何生成“助手”的回答,而不会去学习重复“用户”的问题。处理完后,数据集就变成了模型可以直接“消化”的数字格式。

4. 实战微调Qwen2.5:你的第一个定制化模型

环境、数据都齐了,让我们开始真正的微调。我们先从相对简单的Qwen2.5开始,它会让你快速建立信心。

4.1 以“节能模式”加载模型:4位量化与LoRA配置

我们不会全量训练整个模型,那太耗资源了。而是采用 QLoRA 策略:先用4位量化把模型“压缩”到显存里,然后只训练其中一部分通过LoRA技术添加的“小插件”。

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments, Trainer
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training

# 指定模型
model_name = "Qwen/Qwen2.5-7B-Instruct"

# 加载分词器
tokenizer = AutoTokenizer.from_pretrained(model_name)
# 设置padding token(如果tokenizer没有的话)
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token

# 以4位量化方式加载基础模型,大幅节省显存
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    device_map="auto",  # 自动分配模型层到可用的GPU/CPU
    load_in_4bit=True,  # 核心:4位量化加载
    bnb_4bit_compute_dtype=torch.float16,  # 计算时使用float16精度
    bnb_4bit_use_double_quant=True,  # 使用双重量化,进一步压缩
    bnb_4bit_quant_type="nf4",  # 量化类型,NF4通常效果较好
    use_cache=False  # 训练时关闭KV缓存以节省显存
)

# 为梯度检查点准备模型
model = prepare_model_for_kbit_training(model)
model.gradient_checkpointing_enable()  # 开启梯度检查点,用时间换显存

# 配置LoRA
lora_config = LoraConfig(
    r=8,  # LoRA的秩,影响参数量和能力,通常8或16,越小越省资源
    lora_alpha=16,  # 缩放参数,通常设置为r的2倍
    lora_dropout=0.05,  # Dropout率,防止过拟合
    bias="none",  # 不对偏置项进行LoRA训练
    task_type="CAUSAL_LM",  # 任务类型:因果语言模型
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj"]  # 对Transformer的注意力模块应用LoRA
)

# 将LoRA适配器注入到模型中
model = get_peft_model(model, lora_config)

# 打印可训练参数数量,你会发现只占原模型的极小一部分(通常<1%)
model.print_trainable_parameters()

运行print_trainable_parameters后,你会看到类似“trainable params: 4,194,304 || all params: 7,846,113,280 || trainable%: 0.0535”的输出。这意味着我们只训练约400万个参数,而不是全部的78亿个,这就是LoRA的魔力。

4.2 配置训练参数:告诉Trainer怎么“教”

接下来,我们配置训练过程的“课程表”,使用Transformers库提供的Trainer类。

training_args = TrainingArguments(
    output_dir="./qwen_finetune_output",  # 训练输出目录
    num_train_epochs=3,  # 训练轮数,根据数据集大小调整,小数据可以多一些
    per_device_train_batch_size=1,  # 每个设备的批次大小,受显存限制
    gradient_accumulation_steps=8,  # 梯度累积步数,模拟更大的批次大小
    warmup_steps=50,  # 学习率热身步数,让训练更稳定
    logging_steps=10,  # 每10步打印一次日志
    save_steps=200,  # 每200步保存一次检查点
    save_total_limit=2,  # 只保留最新的2个检查点
    learning_rate=2e-4,  # 学习率,LoRA常用范围1e-4到5e-4
    fp16=True,  # 使用混合精度训练,节省显存并加速
    remove_unused_columns=False,  # 重要!防止DataLoader删除我们预处理好的列
    report_to="none"  # 不向任何平台报告(如wandb),保持简洁
)

这里有几个关键参数需要根据你的硬件调整:

  • per_device_train_batch_size:如果你的显卡显存更大(如24G),可以尝试设置为2或4。
  • gradient_accumulation_steps:如果你的batch_size是1,但想达到批次大小为8的效果,就设置这个为8。它会让模型累积8步的梯度后再更新一次参数。
  • num_train_epochs:对于几百条数据,3-5个epoch可能就够了。数据越多,需要的epoch可能越少。

4.3 启动训练与保存成果

现在,把模型、数据、参数交给Trainer,然后就可以泡杯茶,看着损失(loss)值慢慢下降了。

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=tokenized_dataset,  # 使用我们预处理好的数据集
    data_collator=lambda data: {'input_ids': torch.stack([f['input_ids'] for f in data]),
                                'attention_mask': torch.stack([f['attention_mask'] for f in data]),
                                'labels': torch.stack([f['labels'] for f in data])}
)

# 开始训练!
trainer.train()

# 训练完成后,保存LoRA适配器权重
model.save_pretrained("./my_finetuned_qwen_lora")
tokenizer.save_pretrained("./my_finetuned_qwen_lora")
print("LoRA权重和分词器已保存!")

训练过程会在终端输出日志,关注loss值,它应该随着训练步数增加而稳步下降。训练结束后,保存的并不是完整的模型,而是LoRA适配器的权重文件(通常很小,只有几十MB)。要使用它,需要和原模型“合并”。

4.4 合并LoRA权重:得到一个完整的“毕业模型”

保存的LoRA权重需要和原始的基础模型合并,才能得到一个独立的、可以直接加载的模型。

from peft import PeftModel

# 加载原始基础模型(同样可以用4bit加载以节省内存)
base_model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype=torch.float16,
    device_map="auto"
)

# 加载我们训练好的LoRA适配器
lora_model = PeftModel.from_pretrained(base_model, "./my_finetuned_qwen_lora")

# 将LoRA权重合并到基础模型中
merged_model = lora_model.merge_and_unload()

# 保存合并后的完整模型
save_path = "./my_finetuned_qwen_full"
merged_model.save_pretrained(save_path)
tokenizer.save_pretrained(save_path)
print(f"完整模型已保存至:{save_path}")

现在,./my_finetuned_qwen_full文件夹里就是一个完整的、经过你微调的Qwen模型了。你可以像使用原始模型一样加载它:

model = AutoModelForCausalLM.from_pretrained("./my_finetuned_qwen_full", device_map="auto")

试试问它你数据集里准备的问题,看看它的回答是不是更符合你的期望了!

5. 攻克DeepSeek微调:巧妙处理“思考”过程

如果说微调Qwen是“普通难度”,那么微调DeepSeek-R1系列就是“专家难度”。最大的挑战来自于它的思考令牌(Think Tokens)。DeepSeek在回答复杂问题时,会先在内部生成一段用<think>...</think>包裹的推理过程,然后再输出最终答案。在标准微调中,如果我们像处理Qwen一样忽略所有用户输入,就会把<think>部分也当作需要学习的“答案”,这会导致模型学习去生成思考过程,而不是我们想要的最终答案。

5.1 理解问题:为什么标准方法会失效?

DeepSeek的apply_chat_template方法在构建训练序列时,默认不会对思考部分做特殊处理。思考内容会被当作普通的助手回复文本。如果我们简单地将prompt部分的标签设为-100,而answer部分(包含思考)全部参与损失计算,模型就会努力去学习如何生成<think>...</think>格式的文本,这偏离了我们的目标——我们只希望模型学会根据问题给出最终答案,至于它内部怎么“想”的,我们并不关心。

5.2 解决方案:自定义Trainer,动态屏蔽思考令牌

我们需要在训练过程中,动态地识别出生成文本中的<think>部分,并将这些位置的标签也设置为-100,让模型在计算损失时忽略它们。这需要我们继承Trainer类并重写compute_loss方法。

下面是完整的、经过实战测试的DeepSeek微调代码,关键部分我已加注:

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments, Trainer
from datasets import load_dataset
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training

# 1. 加载数据和模型(与Qwen类似)
model_name = "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B"  # 以蒸馏版为例,原理相同
dataset = load_dataset("json", data_files="dataset.json")['train']

tokenizer = AutoTokenizer.from_pretrained(model_name)
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token

model = AutoModelForCausalLM.from_pretrained(
    model_name,
    device_map="auto",
    load_in_4bit=True,
    bnb_4bit_compute_dtype=torch.float16,
    bnb_4bit_use_double_quant=True,
    bnb_4bit_quant_type="nf4",
    use_cache=False
)
model = prepare_model_for_kbit_training(model)
model.gradient_checkpointing_enable()

lora_config = LoraConfig(
    r=8,
    lora_alpha=16,
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj"]
)
model = get_peft_model(model, lora_config)

# 2. 数据预处理函数(注意:这里labels初始包含整个answer,包括think部分)
def process_func_deepseek(example):
    MAX_LENGTH = 2048
    messages = [
        {"role": "user", "content": example['prompt']},
        {"role": "assistant", "content": example['answer']}  # answer里可能包含<think>...</think>
    ]
    input_ids = tokenizer.apply_chat_template(messages, tokenize=True, add_generation_prompt=False)
    attention_mask = [1] * len(input_ids)

    # 计算用户指令长度
    instruction_ids = tokenizer.apply_chat_template(
        [{"role": "user", "content": example['prompt']}],
        tokenize=True
    )
    instruction_length = len(instruction_ids)

    # 初始labels:用户部分为-100,助手部分(含think)保留
    labels = [-100] * instruction_length + input_ids[instruction_length:]

    # 截断
    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": torch.tensor(input_ids, dtype=torch.long),
        "attention_mask": torch.tensor(attention_mask, dtype=torch.long),
        "labels": torch.tensor(labels, dtype=torch.long)  # 此时labels里think部分还未被屏蔽
    }

tokenized_dataset = dataset.map(process_func_deepseek)

# 3. 自定义Trainer类 —— 核心所在
class DeepSeekTrainerWithThinkMasking(Trainer):
    def compute_loss(self, model, inputs, return_outputs=False, **kwargs):
        # 先进行一次前向传播,获取模型的原始输出(logits)
        outputs = model(**inputs)
        logits = outputs.logits

        # 获取当前批次中,模型预测的token id(用于定位think位置)
        predicted_ids = logits.argmax(dim=-1)  # 形状: [batch_size, seq_len]

        # 获取原始的labels
        labels = inputs.get("labels")

        # 遍历批次中的每一个样本
        for batch_idx in range(predicted_ids.shape[0]):
            # 将预测的id解码回文本,用于查找<think>标签
            # 注意:这里解码的是模型当前步的预测,用于近似定位,并非完美方案,但在实践中有效
            predicted_text = tokenizer.decode(predicted_ids[batch_idx], skip_special_tokens=False)

            # 在文本中查找思考部分的开始和结束位置
            think_start = predicted_text.find("<think>")
            think_end = predicted_text.find("</think>")

            if think_start != -1 and think_end != -1:
                # 找到思考部分,需要将其在labels中对应的位置也标记为-100
                # 计算思考部分在token序列中的起始和结束索引
                # 注意:这里使用原始inputs['input_ids']来精确映射
                input_tokens = inputs['input_ids'][batch_idx]
                # 一个更稳健的方法是直接搜索token id,但为清晰起见,此处用文本查找示意逻辑
                # 实际更精确的实现可能需要遍历token并匹配<think>和</think>的特殊token id
                # 假设我们通过分词器获取了特殊token的id
                think_start_token_id = tokenizer.convert_tokens_to_ids("<think>")
                think_end_token_id = tokenizer.convert_tokens_to_ids("</think>")

                # 在input_ids中查找这些特殊token的位置
                input_ids_list = input_tokens.tolist()
                try:
                    token_start_idx = input_ids_list.index(think_start_token_id)
                    # 查找结束token,从起始位置开始找
                    token_end_idx = input_ids_list.index(think_end_token_id, token_start_idx) + 1  # +1 以包含结束token
                    # 将labels中对应思考部分的位置设为-100
                    labels[batch_idx, token_start_idx:token_end_idx] = -100
                except ValueError:
                    # 如果没找到token id,跳过
                    pass

        # 使用更新后的labels重新计算损失
        # 我们需要将修改后的labels放回inputs,并再次调用model的前向传播
        # 更高效的做法是直接使用outputs.loss,但这里labels被我们修改了,所以需要重新计算
        # 实际上,我们可以直接计算交叉熵损失,忽略labels为-100的位置
        shift_logits = logits[..., :-1, :].contiguous()
        shift_labels = labels[..., 1:].contiguous()
        loss_fct = torch.nn.CrossEntropyLoss(ignore_index=-100)
        loss = loss_fct(shift_logits.view(-1, shift_logits.size(-1)), shift_labels.view(-1))

        return (loss, outputs) if return_outputs else loss

# 4. 配置训练参数
training_args = TrainingArguments(
    output_dir="./deepseek_finetune_output",
    num_train_epochs=5,  # DeepSeek可能需要更多轮次
    per_device_train_batch_size=1,
    gradient_accumulation_steps=8,
    warmup_steps=100,
    logging_steps=20,
    save_steps=300,
    learning_rate=1e-4,  # 学习率可以稍低一些
    fp16=True,
    remove_unused_columns=False,
    report_to="none"
)

# 5. 使用我们的自定义Trainer
trainer = DeepSeekTrainerWithThinkMasking(
    model=model,
    args=training_args,
    train_dataset=tokenized_dataset,
    data_collator=lambda data: {
        'input_ids': torch.stack([f['input_ids'] for f in data]),
        'attention_mask': torch.stack([f['attention_mask'] for f in data]),
        'labels': torch.stack([f['labels'] for f in data])
    }
)

# 开始训练!
trainer.train()

# 保存LoRA权重
model.save_pretrained("./my_finetuned_deepseek_lora")
tokenizer.save_pretrained("./my_finetuned_deepseek_lora")

这段代码的关键在于DeepSeekTrainerWithThinkMasking类。它在每个训练步骤中,都会检查模型预测的文本中是否包含<think>标签,如果包含,就找到这些标签在序列中的精确位置,并将labels中对应位置的Token也标记为-100。这样,损失函数就会忽略模型对思考令牌的预测,只关注思考过程之外的最终答案部分。

5.3 合并与测试

训练完成后,使用和Qwen相同的合并方法,将LoRA权重合并到原始DeepSeek模型中。加载合并后的模型进行测试时,你可能会发现模型仍然会生成<think>内容,这是正常的,因为模型架构决定了它的生成模式。但通过我们的微调,模型在思考后给出的最终答案,应该更符合你数据集中期望的样式和内容。

6. 让模型“飞入寻常百姓家”:转换为GGUF格式

微调好的模型保存在PyTorch格式(一堆.bin.safetensors文件)里,虽然能用,但在一些流行的本地部署工具里使用起来不够方便。比如OllamaLM Studio,它们更倾向于使用GGUF格式的模型。GGUF是一种高效、跨平台的模型格式,特别适合在消费级硬件上运行。

6.1 为什么要转GGUF?

  1. 量化方便:GGUF设计之初就支持多种精度的量化(如Q4_K_M, Q8_0等),可以轻松将模型缩小到原来的1/4甚至更小,速度还能提升。
  2. 内存效率高:加载和推理时内存占用更可控。
  3. 生态兼容:Llama.cpp及其衍生工具(Ollama底层也用它)是当前本地运行大模型最流行的方案,而它们原生支持GGUF。

6.2 转换实战:使用llama.cpp

转换需要用到llama.cpp项目中的转换脚本。步骤非常清晰:

第一步:获取llama.cpp

git clone https://github.com/ggerganov/llama.cpp.git
cd llama.cpp
pip install -r requirements.txt

第二步:安装必要的Python依赖llama.cpp目录下,通常已经有一个requirements.txt,安装即可。确保你有一个能正常工作的C++编译环境(如Linux/macOS的gcc,Windows的Visual Studio Build Tools)。

第三步:运行转换脚本 假设你合并后的完整模型目录是./my_finetuned_qwen_full,你想把它转换成FP16精度的GGUF文件。

# 在llama.cpp目录下运行
python convert_hf_to_gguf.py ./my_finetuned_qwen_full \
  --outtype f16 \
  --outfile ./my_qwen_finetuned_f16.gguf

这个过程会读取你的PyTorch模型和分词器配置,并将其转换为GGUF格式。--outtype参数指定精度:

  • f16: 半精度浮点数,保真度高,文件较大。
  • q8_0: 8位整数量化,质量损失极小,文件大小减半。
  • q4_k_m: 4位量化的一种,在大小和精度间取得很好平衡,最常用。 例如,生成一个Q4量化的版本:
python convert_hf_to_gguf.py ./my_finetuned_qwen_full \
  --outtype q4_k_m \
  --outfile ./my_qwen_finetuned_q4km.gguf

6.3 关于DeepSeek GGUF的一个重要提示

转换DeepSeek模型时,你可能会发现一个现象:用转换后的GGUF模型在Ollama中聊天时,模型生成的<think>内容会直接显示在对话界面里,而不是被隐藏。这不是Bug!

这是因为,在标准的ChatML等对话模板中,<think>标签通常被当作特殊令牌处理,前端界面会将其隐藏。但llama.cpp在转换和推理时,可能没有为DeepSeek实现这种特定的后处理逻辑。模型本身工作正常(思考过程被生成,然后给出答案),只是思考过程没有被过滤掉。如果你希望隐藏它,可能需要在调用Ollama API的后端,或者在前端显示层,自己添加一个简单的文本过滤,移除<think>...</think>之间的内容。

6.4 在Ollama中部署你的GGUF模型

有了GGUF文件,在Ollama中使用就非常简单了。你需要创建一个Modelfile

FROM ./my_qwen_finetuned_q4km.gguf
TEMPLATE """{{ if .System }}<|im_start|>system
{{ .System }}<|im_end|>
{{ end }}{{ range .Messages }}{{ if .Role }}<|im_start|>{{ .Role }}
{{ .Content }}<|im_end|>
{{ end }}{{ end }}<|im_start|>assistant
"""
PARAMETER stop "<|im_end|>"
PARAMETER stop "<|im_start|>"

然后创建并运行模型:

ollama create my-fin-tuned -f ./Modelfile
ollama run my-fin-tuned

现在,你就可以像使用其他Ollama模型一样,和你亲手微调的大模型对话了。看着它用你教给它的知识回答问题,那种成就感,绝对是直接用现成API无法比拟的。

走到这一步,你已经完成了一个完整的微调闭环:从环境准备、数据制作、模型训练与调试,到最终的产品化部署。这个过程里遇到的每一个报错、每一个调参的夜晚,都会变成你宝贵的经验。大模型微调不再是黑盒魔法,而是你手中可以实实在在操控的工具。接下来,你可以尝试用更多样、更高质量的数据去喂养它,调整LoRA的r参数和训练轮数,观察模型表现的变化,真正地开始你的模型“炼丹”之旅。记住,迭代和实验,才是AI工程实践中最有趣的部分。

Logo

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

更多推荐