前言
本节主要描述大模型简单的训练与部署过程。
CUDA 不同版本切换
本次测试用的自己电脑,显卡为3060TI,日常用cuda 11.7,要测试大模型训练,特意装了12.4的cuda。
NVIDAI官方文档里明确支持 side-by-side 多版本共存。
首先要确保NVIDIA驱动支持高版本的 cuda,比如现在是 nvidia-driver-550 ,nvidia-smi 查看最高支持 CUDA 版本为 12.4,那么可以装一个 11.7 的CUDA、再装一个 12.4 的 CUDA,nvidia-driver 不变,只需要安装 cuda-toolkit-12-4 、 cuda-toolkit-11-7
通过以下脚本切换CUDA环境
cuda 11.7 对应脚本 use_cuda11.7.sh
export CUDA_HOME=/usr/local/cuda-11.7
export PATH=$CUDA_HOME/bin:$PATH
export LD_LIBRARY_PATH=$CUDA_HOME/lib64:$LD_LIBRARY_PATH
cuda 12.4 对应脚本 use_cuda12.4.sh
export CUDA_HOME=/usr/local/cuda-12.4
export PATH=$CUDA_HOME/bin:$PATH
export LD_LIBRARY_PATH=$CUDA_HOME/lib64:$LD_LIBRARY_PATH
需要哪个版本时,执行:source ./use_cuda11.7.sh 或者 source ./use_cuda12.4.sh 切换环境,然后检查版本:
which nvcc
nvcc -V
echo $CUDA_HOME
配置unsloth
按照 安装指南 页面的命令安装即可。下列命令创建一个 python 镜像。
curl -LsSf https://astral.sh/uv/install.sh | sh
uv venv unsloth_env --python 3.13
source unsloth_env/bin/activate
uv pip install unsloth vllm --torch-backend=auto
conda deactivate # 退出 conda deactivate
source ~/lsj/unsloth_env/bin/activate # 激活 unsloth_env
unset CUDA_HOME
unset LD_LIBRARY_PATH
初步测试
初步测试,用一个数据集训练一个简单模型,测试环境配置是否成功。
准备数据集
创建 my_dataset.json
[
{
"instruction": "请解释如何在ROS2中配置Fast DDS以解决节点间通信丢包的问题。",
"input": "",
"output": "在ROS2中配置Fast DDS解决丢包问题,通常需要创建一个XML配置文件来调整QoS(服务质量)设置。建议重点调整两个参数:1. 增加UDP的`socket_receive_buffer_size`以应对高频数据;2. 确保关键话题(如控制指令)的可靠性(Reliability)设置为`RELIABLE`。最后通过环境变量`FASTRTPS_DEFAULT_PROFILES_FILE`加载该XML。"
},
{
"instruction": "我有一段使用GStreamer处理视频流的C++代码,遇到了内存泄漏,排查方向有哪些?",
"input": "",
"output": "在C++中调用GStreamer时,最常见的内存泄漏源于对GstBuffer或GstMessage的引用计数管理不当。排查方向:1. 检查是否在每次从pipeline拉取数据后都正确调用了`gst_buffer_unref()`或`gst_sample_unref()`。2. 如果使用了多线程,确保在传递Buffer时正确使用了`gst_buffer_ref()`来增加引用计数,避免提前释放。3. 可以使用`GST_LEAKS_TRACER_STACK_TRACE=1`环境变量启动程序,GStreamer自带的工具会帮你定位泄漏点。"
},
{
"instruction": "如何将YOLO的2D目标检测结果映射到3D的LiDAR点云空间中?",
"input": "",
"output": "这属于经典的传感器融合问题。步骤如下:1. 获取相机的内参矩阵(Camera Intrinsics)和相机到LiDAR的外参矩阵(Extrinsics)。2. 遍历3D点云,利用外参矩阵将LiDAR坐标系下的点转换到相机坐标系。3. 利用内参矩阵,将相机坐标系下的3D点投影到2D像素平面。4. 检查投影后的像素坐标是否落在YOLO输出的2D Bounding Box内,如果落在里面,这个3D点就属于该目标。"
}
]
微调代码
创建 train.py
from unsloth import FastLanguageModel
from datasets import load_dataset
from trl import SFTTrainer
from transformers import TrainingArguments
# 1. 极限显存加载:使用 1.5B 模型并开启 4bit
model, tokenizer = FastLanguageModel.from_pretrained(
model_name = "unsloth/Qwen2.5-1.5B-Instruct-bnb-4bit",
max_seq_length = 512, # 对于6G显存,上下文长度设短一点,512或1024最安全
dtype = None,
load_in_4bit = True, # 必须为True,这是显卡不爆的核心
)
# 2. 挂载 LoRA 适配器
model = FastLanguageModel.get_peft_model(
model,
r = 8, # rank设为8,极大地减少训练参数,省显存
target_modules = ["q_proj", "k_proj", "v_proj", "o_proj"],
lora_alpha = 16,
lora_dropout = 0,
)
# 3. 加载并格式化数据
alpaca_prompt = """Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request.
### Instruction:
{}
### Input:
{}
### Response:
{}"""
EOS_TOKEN = tokenizer.eos_token # 必须加结束符,不然模型会一直胡言乱语
def formatting_prompts_func(examples):
instructions = examples["instruction"]
inputs = examples["input"]
outputs = examples["output"]
texts = []
for instruction, input, output in zip(instructions, inputs, outputs):
text = alpaca_prompt.format(instruction, input, output) + EOS_TOKEN
texts.append(text)
return { "text" : texts, }
# 读取刚才建的 JSON 文件
dataset = load_dataset("json", data_files="my_dataset.json", split="train")
dataset = dataset.map(formatting_prompts_func, batched = True,)
# 4. 设置训练参数并启动
trainer = SFTTrainer(
model = model,
tokenizer = tokenizer,
train_dataset = dataset,
dataset_text_field = "text",
max_seq_length = 512,
args = TrainingArguments(
per_device_train_batch_size = 1, # 显存杀手,必须设为1
gradient_accumulation_steps = 4, # 通过累加梯度来模拟更大的batch_size
warmup_steps = 5,
max_steps = 20, # 只跑20步,几分钟就能跑完,用来验证流程
learning_rate = 2e-4,
fp16 = False,
bf16=True,
logging_steps = 1,
output_dir = "outputs",
optim = "adamw_8bit", # 8bit优化器,继续压榨显存!
),
)
print("🚀 开始训练...")
trainer.train()
# 5. 保存 LoRA 权重
model.save_pretrained("my_lora_qwen")
tokenizer.save_pretrained("my_lora_qwen")
print("✅ 训练完成,权重已保存到 my_lora_qwen 文件夹!")
结果打印:
标签格式
大模型标签符合对话模板就行,常见的一些标签格式:
Alpaca 格式
"Instruction": "Task we want the model to perform."
"Input": "Optional, but useful, it will essentially be the user's query."
"Output": "The expected result of the task and the output of the model."
上面示例使用的便是 Alpaca 格式。
ShareGPT 格式
{
"conversations": [
{
"from": "human",
"value": "Can you help me make pasta carbonara?"
},
{
"from": "gpt",
"value": "Would you like the traditional Roman recipe, or a simpler version?"
},
{
"from": "human",
"value": "The traditional version please"
},
{
"from": "gpt",
"value": "The authentic Roman carbonara uses just a few ingredients: pasta, guanciale, eggs, Pecorino Romano, and black pepper. Would you like the detailed recipe?"
}
]
}
ChatML 格式 ( OpenAI,Hugging Face 的默认格式 )
{
"messages": [
{
"role": "user",
"content": "What is 1+1?"
},
{
"role": "assistant",
"content": "It's 2!"
},
]
}
Unsloth 内部使用的便是 ChatML 格式,
训练自定义任务
以病害二次审核为例,比如现在检测路面坑槽,基于YOLO检测出坑槽,用大模型去做二次判断检测是否正确;本文训练标签采用 ShareGPT 格式 ,ShareGPT 模板格式使用 "from"/"value" 属性键,消息在两者之间交替human,gpt从而实现自然的对话流程。
{"image": "/data/train/images/crack_0001.jpg", "conversations": [{"from": "human", "value": "<image>\n请判断图中红框区域是否为坑槽病害。只能输出以下一句,不要解释:确认是病害,类别为坑槽。"}, {"from": "gpt", "value": "确认是病害,类别为坑槽。"}]}
image:告诉程序去哪里找这张图片human里的value:这是给模型的**输入(Prompt)**。<image>是一个占位符,相当于告诉模型这是一张图片gpt的value:这是训练**标签(Label/Ground Truth)**。
训练脚本如下:
import torch
from unsloth import FastVisionModel # 注意这里用的是 Vision 模型专用类
from datasets import load_dataset
from trl import SFTTrainer
from transformers import TrainingArguments
# ================= 1. 加载带眼睛的视觉大模型 =================
print("加载模型中...")
model, tokenizer = FastVisionModel.from_pretrained(
model_name = "unsloth/Qwen2-VL-2B-Instruct-bnb-4bit", # 适合你 6G 显存的视觉模型
load_in_4bit = True, # 开启4bit,保命(不爆显存)必备
use_gradient_checkpointing = "unsloth", # 极致省显存的魔法
)
# ================= 2. 添加 LoRA 外挂网络 =================
model = FastVisionModel.get_peft_model(
model,
finetune_vision_layers = False, # 小白先设为 False。只微调语言部分,视觉编码器冻结,省显存
finetune_language_layers = True,
finetune_attention_modules = True,
finetune_mlp_modules = True,
r = 16, # LoRA 秩,越大越聪明但越吃显存,16是平衡点
lora_alpha = 16,
lora_dropout = 0,
)
# ================= 3. 数据集加载与格式化转换 =================
# Unsloth 内部使用的是标准的 {"role": "user", "content": ...} 格式,
# 所以我们需要写一个小函数,把你 jsonl 里的 human/gpt 转成它认识的格式。
def format_data(example):
# 提取 human 和 gpt 的对话
conversations = example["conversations"]
human_text = conversations[0]["value"].replace("<image>\n", "") # 移除你的占位符
gpt_text = conversations[1]["value"]
# 组装成模型认识的消息格式
messages = [
{"role": "user", "content": [
{"type": "image", "image": example["image"]}, # 图片路径
{"type": "text", "text": human_text} # 你的问题
]},
{"role": "assistant", "content": [
{"type": "text", "text": gpt_text} # 你的标签(答案)
]}
]
# 使用 tokenizer 把这段对话变成模型能看懂的文本序列
text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
return {"text": text}
print("加载并处理数据...")
# 读取你的数据文件(假设叫 train.jsonl)
dataset = load_dataset("json", data_files="train.jsonl", split="train")
# 把数据喂给刚才写的转换函数
dataset = dataset.map(format_data, remove_columns=dataset.column_names)
# ================= 4. 设置训练参数并启动 =================
from unsloth import is_bf16_supported
trainer = SFTTrainer(
model = model,
tokenizer = tokenizer,
train_dataset = dataset,
dataset_text_field = "text",
max_seq_length = 2048,
# 注意:视觉模型内部自带 DataCollatorForLanguageModeling,会自动识别对话模板并把 assistant 以外的部分设为不计算 loss,不需要我们手写了!
args = TrainingArguments(
per_device_train_batch_size = 1, # 3060Ti 必须设为 1
gradient_accumulation_steps = 8, # 等效于 batch_size = 8
warmup_steps = 10,
max_steps = 60, # 先跑 60 步看看效果
learning_rate = 2e-4,
fp16 = not is_bf16_supported(),
bf16 = is_bf16_supported(),
logging_steps = 1,
output_dir = "outputs_vl",
optim = "adamw_8bit", # 继续压榨显存
),
)
print("🚀 开始多模态炼丹...")
trainer.train()
# ================= 5. 保存模型 =================
model.save_pretrained("pothole_qwen2_vl_lora")
tokenizer.save_pretrained("pothole_qwen2_vl_lora")
print("✅ 训练完成,LoRA外挂已保存!")
模型导出
导出 GGUF 格式
将训练得到的模型导出为 GGUF 格式,注意:导出 GGUF 格式时需要有 llama.cpp ,手动编译命令如下:
apt-get update
apt-get install pciutils build-essential cmake curl libcurl4-openssl-dev -y
git clone https://github.com/ggml-org/llama.cpp
cmake llama.cpp -B llama.cpp/build \
-DBUILD_SHARED_LIBS=OFF -DGGML_CUDA=ON -DLLAMA_CURL=ON
cmake --build llama.cpp/build --config Release -j --clean-first --target llama-cli llama-mtmd-cli llama-server llama-gguf-split
cp llama.cpp/build/bin/llama-* llama.cpp
# 导出命令
python llama.cpp/convert_hf_to_gguf.py merged_model \
--outfile model-F16.gguf --outtype f16 \
--split-max-size 50G
也可以通过 Unsloth 提供的一键导出 GGUF 功能:
from unsloth import FastVisionModel
print("1. 正在加载基座模型与你的 LoRA 权重...")
# 注意:这里直接填你刚才训练保存的 lora 文件夹名称
# 并且 load_in_4bit=False,因为我们要用 16bit 高精度把它们融为一体
model, tokenizer = FastVisionModel.from_pretrained(
model_name = "./vLLm/Qwen2-VL-2B-Instruct-bnb-4bit/pothole_qwen2_vl_lora",
load_in_4bit = False,
)
print("2. 开始合并权重并导出为 GGUF 格式...")
# q4_k_m 是目前本地部署最完美的 4bit 量化方案,兼顾了极小的体积和几乎无损的精度
model.save_pretrained_gguf(
"qwen2_vl_pothole_model",
tokenizer,
quantization_method = "q4_k_m"
)
print("✅ 导出成功!请在目录下查看 gguf 文件。")
使用刚才 llama.cpp 原生工具执行推理
~/.unsloth/llama.cpp/llama-mtmd-cli -m qwen2_vl_pothole_model_gguf/Qwen2-VL-2B-Instruct.Q4_K_M.gguf --mmproj qwen2_vl_pothole_model_gguf/Qwen2-VL-2B-Instruct.BF16-mmproj.gguf -p "请判断图中红框区域是否为坑槽病害。只能输出以下一句,不要解释:确认是病害,类别为坑槽。" --image ~/vLLm/Qwen2.5-1.5B-Instruct-bnb-4bit/data/train/images/pothole_00001.jpg
导出 Safetensors
vLLM 不支持加载独立的 LoRA 文件夹,它需要一个完整合并后的权重目录。
导出程序如下:
from unsloth import FastVisionModel
# 1. 加载训练好的 LoRA
model, tokenizer = FastVisionModel.from_pretrained(
model_name = "~/vLLm/Qwen2-VL-2B-Instruct-bnb-4bit/pothole_qwen2_vl_lora",
load_in_4bit = False, # 合并时使用全精度
)
# 2. 保存为合并后的 Safetensors 格式
# 这会生成一个包含 config.json, model.safetensors 等文件的标准文件夹
model.save_pretrained_merged(
"qwen2_vl_pothole_production",
tokenizer,
save_method = "merged_16bit", # 生产环境推荐 16bit,若显存极度紧张可选 "merged_4bit"
)
print("✅ 生产级模型已保存至 qwen2_vl_pothole_production")
模型部署
LMDeploy 好像快一点,但是测试时,好像精度对齐没 vllm 好。
vllm部署
使用vllm部署,由于3060Ti只有8G显存,运行vllm不部署时限制GPU只允许占用85%资源。部署命令如下:
python -m vllm.entrypoints.openai.api_server \
--model ./qwen2_vl_pothole_production \
--trust-remote-code \
--max-model-len 1024 \
--gpu-memory-utilization 0.85 \
--enforce-eager \
--port 8000
客户端请求访问程序如下:
import requests
import base64
# 将图片转为 Base64 编码,这是 API 通信的标准做法
def encode_image(image_path):
with open(image_path, "rb") as image_file:
return base64.b64encode(image_file.read()).decode('utf-8')
image_base64 = encode_image("./pothole_00001.jpg")
# 构造请求数据(符合 OpenAI 视觉 API 格式)
payload = {
"model": "qwen2_vl_pothole_production",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "请判断图中红框区域是否为坑槽病害。只能输出以下一句,不要解释:确认是病害,类别为坑槽。"},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_base64}"}}
]
}
]
}
response = requests.post("http://192.168.1.248:8000/v1/chat/completions", json=payload)
print("业务系统接收到的判定结果:", response.json()['choices'][0]['message']['content'])
示例结果
LMDeploy部署
安装 LMDeploy
uv pip install lmdeploy
模型部署示例命令:
lmdeploy serve api_server ./qwen2_vl_pothole_production \
--server-port 23333 \
--cache-max-entry-count 0.3
客户端调用程序示例
import base64
from openai import OpenAI
# 1. 辅助函数:将本地图片转换为 Base64 字符串
def encode_image(image_path):
with open(image_path, "rb") as image_file:
return base64.b64encode(image_file.read()).decode('utf-8')
# 你要测试的图片路径
image_path = "~/vLLm/Qwen2.5-1.5B-Instruct-bnb-4bit/data/train/images/pothole_00001.jpg"
base64_image = encode_image(image_path)
# 2. 指向 LMDeploy 的 23333 端口
client = OpenAI(api_key="EMPTY", base_url="http://localhost:23333/v1")
print("正在发送请求到 LMDeploy...")
response = client.chat.completions.create(
model="qwen2_vl_pothole_production", # 模型名称要和启动时的文件夹名一致
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "判断图中红框区域是否为坑槽病害。只能输出以下一句,不要解释:确认是病害,类别为坑槽。"},
# 使用 base64 格式传输图片,这是最万无一失的做法
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}}
],
}
],
max_tokens=100,
temperature=0.1, # 降低随机性,让模型输出更稳定
)
print("\n🤖 模型审核结果:")
print(response.choices[0].message.content)