Transformers实战:从零到一微调Qwen与DeepSeek的完整指南
1. 为什么你需要亲手微调一个大模型?
嘿,朋友们,我是老张,在AI和硬件这行摸爬滚打了十几年。今天我们不聊那些高深的理论,就来聊聊一个特别实在的事儿:怎么用你的电脑,亲手“调教”一个属于你自己的大模型。你可能用过ChatGPT,觉得它很聪明,但有时候回答总是不太对味儿,或者没法处理你公司内部特有的数据格式。这时候,微调(Fine-tuning)就是你手里的“金手指”。
简单来说,微调就像给一个已经学识渊博的大学教授(基础大模型)进行专项特训。他本来懂天文地理,但你现在需要他精通你们公司的产品手册和客服话术。通过微调,你不需要从头教他认字读书,只需要用你准备好的“特训资料”(你的数据集),让他快速掌握新领域的知识。今天我们要玩的,就是当下最火的两个开源“学霸”:Qwen(通义千问) 和 DeepSeek。
为什么选它们?Qwen来自阿里,中文理解能力一流,文档和社区支持都非常友好,是入门微调的绝佳选择。而DeepSeek,尤其是它的DeepSeek-R1系列,以其强大的推理和“思考”能力著称,但这也带来了微调上的独特挑战——如何处理模型内部的“思考过程”(think tokens)。搞定它,你的微调技能就算进阶了。
我猜你可能会担心:这得需要多贵的显卡?是不是特别复杂?别怕,我会带你用LoRA这种“轻量级”微调技术,它只训练模型里极小一部分参数,能让你的消费级显卡(甚至是一张RTX 3090/4090)也跑起来。整个过程,我们从环境搭建、数据准备,到训练、保存,最后还能把它变成能在Ollama或LM 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文件)里,虽然能用,但在一些流行的本地部署工具里使用起来不够方便。比如Ollama和LM Studio,它们更倾向于使用GGUF格式的模型。GGUF是一种高效、跨平台的模型格式,特别适合在消费级硬件上运行。
6.1 为什么要转GGUF?
- 量化方便:GGUF设计之初就支持多种精度的量化(如Q4_K_M, Q8_0等),可以轻松将模型缩小到原来的1/4甚至更小,速度还能提升。
- 内存效率高:加载和推理时内存占用更可控。
- 生态兼容: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工程实践中最有趣的部分。
更多推荐
所有评论(0)