MS-Swift框架下Qwen3-VL的SFT和RLHF微调实战

写在前面
主要是记录笔者在对多模态大模型的监督微调(SFT)和强化微调(人类对齐微调,RLHF)的过程中踩的一些坑进行记录。本文博客主要是在AutoDL租的4090服务器上,以Qwen3-VL-2B的基模在Latex-OCR任务上进行SFT和RLHF的微调的整个过程为主线记录。但是可能会包括一些其他任务实践的经验,都统一更新到这篇博客对应的每个步骤,所以重点可以看实用技巧
技术支持:服务器AutoDL、大模型框架MS-Swift、模型权重和数据集下载ModelScope

本博客对应代码已整理开源在:MLLM_SFT_RLHF_DEMO,觉得有用的朋友请记得给这篇博客和代码仓库点点赞和star,感谢!

1. 环境配置

1.1 服务器

AutoDL上租一个服务器容器实例,镜像采用cuda12.1/12.3就可以。

实用技巧

  1. 可以无卡开机来改代码,改完再开显卡,比较划算
    在这里插入图片描述
  2. 如果没有GPU,可以直接拷贝实例,实测是可以复制所有数据和环境到新的容器里的。
    在这里插入图片描述
  3. 如果在所租服务器上启动训练之后只有报下面这个错误,请不要犹豫,直接换服务器!
    torch.distributed.elastic.multiprocessing.errors.ChildFailedError:
  4. 通常来说vGPU系列服务器虽然便宜、显存大、空闲机子数量多,但是他往往会遇到一系列硬件适配问题,比如内存不足等。所以最好使用一些常见GPU,例如4090。

1.2 环境

这是配置Qwen3-VL的环境要求,具体是参考ms-swift博客里的要求:link

conda create -n swift python=3.10
codna activate swift
pip install "transformers>=4.57" "qwen_vl_utils>=0.0.14"
pip install "ms-swift>=4.0"
pip install "vllm>=0.11.0"

这几个包会自动按照内部依赖安装好torch、torchvision和modelscope那些库环境。

实用技巧

  1. 使用vllm作为推理的backend,一定要确保transformers、qwen_vl_utils、ms-swift和vllm的版本就是上面安装命令里的版本,最好就是直接等于指定的版本。

2. 数据下载

2.1 模型权重

模型下载利用modelscope提供的脚本,本文用的模型是Qwen3-VL-2B。

from modelscope import snapshot_download
model_dir = snapshot_download('Qwen/Qwen3-VL-2B-Instruct',cache_dir='/root/autodl-tmp/zz/model_weights')

2.2 数据集

数据集下载同样利用modelscope提供的脚本。本文的示例数据集是AI-ModelScope/LaTeX_OCR,任务内容是将一张Latex公式图片给转化成Latex字符串。

from modelscope import dataset_snapshot_download
model_dir = dataset_snapshot_download('AI-ModelScope/LaTeX_OCR',cache_dir='/root/autodl-tmp/zz/datasets')

AI-ModelScope/GEOQA_R1V_Train_8K就是modelscope里数据集的名字,cache_dir就是你下载的自定义目录。

实用技巧

  1. 下载的数据集是.parquet格式的,这个格式的数据虽然ms-swift能直接兼容,但是不方便理解数据结构,所以最好还是用.jsonl格式进行训练。
  2. 下面是SFT和RLHF的MS-Swift支持的两种jsonl数据格式:
    • SFT数据格式
      {
          "messages": [
              {
                  "role": "user",
                  "content": "<image>\nConvert this image to LaTeX."
              },
              {
                  "role": "assistant",
                  "content": "z _ { 1 } = r _ { 1 } ( \\cos \\theta _ { 1 } + i \\sin \\theta _ { 1 } )"
              }
          ],
          "images": [
              "/root/autodl-tmp/zz/datasets/AI-ModelScope/LaTeX_OCR/human_handwrite/train-00000-of-00001_images/0.png"
          ]
      }
      
    • RLHF数据格式
      不同于SFT的数据,RLHF的数据需要把assistant的回答放到单独的solution字段里,同时user里的prompt无需放<image>token,MS-Swift会自动填充到prompt的前面。
      {
          "images": [
              "/root/autodl-tmp/zz/datasets/AI-ModelScope/LaTeX_OCR/human_handwrite/train-00000-of-00001_images/0.png"
          ],
          "messages": [
              {
                  "role": "user",
                  "content": "Convert this image to LaTeX."
              }
          ],
          "solution": "z _ { 1 } = r _ { 1 } ( \\cos \\theta _ { 1 } + i \\sin \\theta _ { 1 } )"
      }		
      
  3. 将.parquet数据集文件转化成上述两种jsonl数据集的脚本代码:
    parquet -> jsonl (SFT)
    from datasets import load_dataset
    import json
    import os
    from PIL import Image
    
    # ===== 原始 parquet 路径 =====
    parquet_path = "/root/autodl-tmp/zz/datasets/AI-ModelScope/LaTeX_OCR/human_handwrite/train-00000-of-00001.parquet"
    parquet_file_name = parquet_path.split('/')[-1].split('.')[0]
    
    parent_dir = os.path.dirname(parquet_path)
    output_jsonl = os.path.join(parent_dir, f"{parquet_file_name}.jsonl")
    image_save_dir = os.path.join(parent_dir, f"{parquet_file_name}_images")
    
    os.makedirs(image_save_dir, exist_ok=True)
    dataset = load_dataset(
        "parquet",
        data_files=parquet_path
    )["train"]
    
    # ===== 转换 =====
    datas = []
    for idx, sample in enumerate(dataset):
        img = sample.get("image", None)
        text = sample.get("text", "")
    
        # ---------- 处理图片 ----------
        if isinstance(img, Image.Image):
            image_path = os.path.join(image_save_dir, f"{idx}.png")
            img.convert("RGB").save(image_path)
        else:
            image_path = ""
    
        # ---------- 构造训练样本 ----------
        new_sample = {
            "messages": [
                {
                    "role": "user",
                    "content": "<image>\nConvert this image to LaTeX."
                },
                {
                    "role": "assistant",
                    "content": text
                }
            ],
            "images": [image_path]
        }
        datas.append(new_sample)
        print(f"处理第{idx}条数据")
    
    with open(output_jsonl, "w", encoding="utf-8") as f:
        for item in datas:
            f.write(json.dumps(item, ensure_ascii=False) + "\n")
    
    print(f"Done: {output_jsonl}")
    print(f"Images saved to: {image_save_dir}")
    
    parquet -> jsonl (SFT)
    from datasets import load_dataset
    import json
    import os
    from PIL import Image
    
    # ===== 原始 parquet 路径 =====
    parquet_path = "/root/autodl-tmp/zz/datasets/AI-ModelScope/LaTeX_OCR/human_handwrite/validation-00000-of-00001.parquet"
    parquet_file_name = parquet_path.split('/')[-1].split('.')[0]
    parent_dir = os.path.dirname(parquet_path)
    
    output_jsonl = os.path.join(parent_dir, f"{parquet_file_name}_rlhf.jsonl")
    image_save_dir = os.path.join(parent_dir, f"{parquet_file_name}_images")
    os.makedirs(image_save_dir, exist_ok=True)
    
    dataset = load_dataset(
        "parquet",
        data_files=parquet_path
    )["train"]
    
    datas = []
    for idx, sample in enumerate(dataset):
        img = sample.get("image", None)
        query = "Convert this image to LaTeX."
        solution = sample.get("text", "")
        
        if isinstance(img, Image.Image):
            image_path = os.path.join(image_save_dir, f"{idx}.png")
            img.convert("RGB").save(image_path)
        else:
            image_path = ""
            
        new_sample = {
            "images": [image_path],
            "messages": [
                {
                    "role": "user",
                    "content": query
                }
            ],
            "solution": solution
        }
        datas.append(new_sample)
        print(f"处理第{idx}条数据")
    
    with open(output_jsonl, "w", encoding="utf-8") as f:
        for item in datas:
            f.write(json.dumps(item, ensure_ascii=False) + "\n")
    
    print(f"Done: {output_jsonl}")
    print(f"Images saved to: {image_save_dir}")
    
    

3. SFT阶段

3.1 训练

按照这个脚本跑,改一下model、dataset、ouput_dir即可。

PYTORCH_CUDA_ALLOC_CONF='expandable_segments:True' \
IMAGE_MAX_TOKEN_NUM=1024 \
VIDEO_MAX_TOKEN_NUM=128 \
FPS_MAX_FRAMES=16 \
NPROC_PER_NODE=1 \
OMP_NUM_THREADS=1 \
CUDA_VISIBLE_DEVICES=0 \
swift sft \
    --model /home/ww/zhazha/MLLM/Latex_OCR/model_weighs/Qwen/Qwen3-VL-2B-Instruct \
    --dataset /home/ww/zhazha/MLLM/Latex_OCR/datasets/AI-ModelScope/LaTeX_OCR/human_handwrite/train-00000-of-00001.jsonl \
    --load_from_cache_file true \
    --split_dataset_ratio 0.01 \
    --tuner_type lora \
    --torch_dtype bfloat16 \
    --num_train_epochs 2 \
    --per_device_train_batch_size 4 \
    --per_device_eval_batch_size 1 \
    --attn_impl sdpa \
    --padding_free false \
    --packing false \
    --learning_rate 1e-4 \
    --lora_rank 8 \
    --lora_alpha 32 \
    --target_modules all-linear \
    --freeze_vit true \
    --freeze_aligner true \
    --gradient_checkpointing true \
    --vit_gradient_checkpointing false \
    --gradient_accumulation_steps 2 \
    --eval_steps 100 \
    --save_steps 100 \
    --save_total_limit 2 \
    --logging_steps 5 \
    --max_length 4096 \
    --output_dir /home/ww/zhazha/MLLM/Latex_OCR/outputs \
    --warmup_ratio 0.05 \
    --deepspeed zero3 \
    --dataset_num_proc 4 \
    --dataloader_num_workers 4

实用技巧

  1. 采用MS-Swift框架训练的话,报错的话可能会存在很多个TraceBack,但是不需要torch.distributed.elastic.multiprocessing.errors.ChildFailedError:这个对应的TraceBack,能溯源的往往就是这个TrackBack的上一个。
  2. 遇到报错只有torch.distributed.elastic.multiprocessing.errors.ChildFailedError:这个TraceBack的时候,排查不出问题就换一台服务器试一下。

3.2 推理

在线推理:运行这个脚本之后能在命令窗口在线对话训练好的模型:

PYTORCH_CUDA_ALLOC_CONF='expandable_segments:True' \
CUDA_VISIBLE_DEVICES=0 \
IMAGE_MAX_TOKEN_NUM=1024 \
VIDEO_MAX_TOKEN_NUM=128 \
FPS_MAX_FRAMES=16 \
swift infer \
    --adapters /root/autodl-tmp/zz/outputs/SFT/v9-20260322-115029/checkpoint-149 \
    --stream true 

效果如下:
在这里插入图片描述

离线推理:利用python脚本部署推理

import os
os.environ['CUDA_VISIBLE_DEVICES'] = '0'

from swift.infer_engine import TransformersEngine, RequestConfig, InferRequest
from swift import get_model_processor, get_template
from swift.utils import safe_snapshot_download
from peft import PeftModel
# 请调整下面几行
model = '/root/autodl-tmp/zz/model_weights/Qwen/Qwen3-VL-2B-Instruct'
lora_checkpoint = safe_snapshot_download('/root/autodl-tmp/zz/outputs/SFT/v9-20260322-115029/checkpoint-149')  # 修改成checkpoint_dir
# lora_checkpoint = None  # 如果不使用lora微调的权重,可以设置为None
template_type = None  # None: 使用对应模型默认的template_type
default_system = None  # None: 使用对应模型默认的default_system

# 加载模型和对话模板
model, tokenizer = get_model_processor(model)
if lora_checkpoint is not None:
    model = PeftModel.from_pretrained(model, lora_checkpoint)
template_type = template_type or model.model_meta.template
template = get_template(tokenizer, template_type=template_type, default_system=default_system)
engine = TransformersEngine(model, template=template, max_batch_size=2)
request_config = RequestConfig(max_tokens=512, temperature=0)

# 这里使用了2个infer_request来展示batch推理
infer_requests = [
    InferRequest(messages=[{'role': 'user', 'content': 'who are you?'}]),
    InferRequest(messages=[{'role': 'user', 'content': '<image>\nConvert this image to LaTeX.'}],
                 images=['/root/autodl-tmp/zz/datasets/test/latex_ocr_1.jpg']),
]
resp_list = engine.infer(infer_requests, request_config)
query0 = infer_requests[0].messages[0]['content']
print(f'response0: {resp_list[0].choices[0].message.content}')
print(f'response1: {resp_list[1].choices[0].message.content}')

3.3 部署

这里采用ms-swift支持的vllm进行部署
服务端运行的脚本:

export OMP_NUM_THREADS=4
export MKL_NUM_THREADS=4
export NUMEXPR_NUM_THREADS=4
export VLLM_NUM_THREADS=4

# --- Swift + vLLM 部署命令 ---
CUDA_VISIBLE_DEVICES=0 \
MAX_PIXELS=1003520 \
VIDEO_MAX_PIXELS=50176 \
FPS_MAX_FRAMES=12 \
swift deploy \
    --model /root/autodl-tmp/zz/outputs/SFT/v9-20260322-115029/checkpoint-149 \
    --infer_backend vllm \
    --vllm_gpu_memory_utilization 0.7 \
    --vllm_max_model_len 4096 \
    --max_new_tokens 2048 \
    --vllm_limit_mm_per_prompt '{"image": 1, "video": 0}' \
    --served_model_name Qwen3-VL-2B-Instruct-SFT

客户端脚本:

from swift import InferRequest, InferClient, RequestConfig, InferStats

engine = InferClient(host='127.0.0.1', port=8000)
print(f'models: {engine.models}')
metric = InferStats()
request_config = RequestConfig(max_tokens=512, temperature=0)

infer_requests = [
    InferRequest(messages=[{'role': 'user', 'content': 'who are you?'}]),
    InferRequest(messages=[{'role': 'user', 'content': '<image><image>两张图的区别是什么?'}],
                 images=['/root/autodl-tmp/zz/datasets/test/latex_ocr_1.jpg',
                        '/root/autodl-tmp/zz/datasets/test/latex_ocr_1.jpg']),
    InferRequest(messages=[{'role': 'user', 'content': '<image>convert the image to latex code'}],
                 images=['/root/autodl-tmp/zz/datasets/test/latex_ocr_1.jpg']),
]

resp_list = engine.infer(infer_requests, request_config, metrics=[metric])
print(f'response0: {resp_list[0].choices[0].message.content}')
print(f'response1: {resp_list[1].choices[0].message.content}')
print(f'response2: {resp_list[2].choices[0].message.content}')
print(metric.compute())
metric.reset()

当然图片数据也是支持base64格式的,这个可以参考ms-swift文档

实用技巧

  1. deploy的服务器端只能支持使用model参数,不能使用adapter参数,也就是说,需要先将训练好的adapter和base model进行merge:
    swift export \
        --adapters /root/autodl-tmp/zz/outputs/SFT/v9-20260322-115029/checkpoint-149 \
        --merge_lora true
    
    采用这个脚本之后会在原来的checkpoint-149目录同级生成一个checkpoint-149-merged,直接将这个路径放到vllm的部署推理脚本里的model参数即可。

4. RLHF阶段

这一节是以GRPO算法来对SFT之后的Qwen3-VL-2B模型进行强化微调。
主要是参考博客:多模态GRPO完整实验流程Qwen3-VL的强化微调

4.1 训练

(1)定义奖励函数

from swift.rewards import ORM, AsyncORM, orms, rm_plugins
from difflib import SequenceMatcher

class ComplexLatexRewardORM(ORM):
    """
    Reward function for LaTeX OCR tasks.
    Compares generated LaTeX strings with reference solutions and rewards
    both correctness (string similarity) and complexity handling.
    """

    def __call__(self, completions: List[str], solution: List[str], **kwargs) -> List[float]:
        rewards = []

        for gen, sol in zip(completions, solution):
            reward = 0.0

            try:
                # Step 1: Normalize LaTeX strings
                gen_clean = re.sub(r"\s+", "", gen)
                sol_clean = re.sub(r"\s+", "", sol)

                # Step 2: Base similarity
                sim = SequenceMatcher(None, gen_clean, sol_clean).ratio()

                # Step 3: Complexity bonus
                # More symbols and functions indicate higher complexity
                complexity_score = self._complexity_bonus(gen_clean)

                # Step 4: Combine
                # reward = min(1.0, sim + 0.2 * complexity_score)  # cap at 1.0
                coef = 0.9
                reward = 1 if sim == 1.0 else coef * sim + (1-coef) * complexity_score

            except Exception:
                reward = 0.0  # If anything goes wrong, assign 0

            rewards.append(reward)

        return rewards

    def _complexity_bonus(self, latex_str: str) -> float:
        """
        Simple complexity heuristic:
        +1 for each of the following (up to a cap):
        - subscript "_"
        - superscript "^"
        - functions like \sin, \cos, \tan, \log
        - fractions \frac
        """
        symbols = ["_", "^", "\\sin", "\\cos", "\\tan", "\\log", "\\frac"]
        count = sum(latex_str.count(sym) for sym in symbols)
        return min(count / 10.0, 1.0)  # cap bonus at 1.0

# 注册奖励函数
orms['external_r1v_acc'] = ComplexLatexRewardORM

这个奖励函数的定义就是让模型知道他的回答跟我们预设的答案相差多少,针对不同的模型回答我们该给打多少分。
针对Latex-OCR任务,这里采用回答文本相似度和公式的复杂度来进行加权奖励。
上述代码中的__call__函数接收的completionssolution参数分别对应模型生成的回答和预设的标准答案。

(2)启动roll_out脚本(用于产生训练数据-completions)

export OMP_NUM_THREADS=4
export MKL_NUM_THREADS=4
export NUMEXPR_NUM_THREADS=4
export VLLM_NUM_THREADS=4
CUDA_VISIBLE_DEVICES=0 \
MAX_PIXELS=1003520 \
VIDEO_MAX_PIXELS=50176 \
FPS_MAX_FRAMES=12 \
swift rollout \
    --model /root/autodl-tmp/zz/outputs/SFT/v9-20260322-115029/checkpoint-149-merged \
    --infer_backend vllm \
    --vllm_gpu_memory_utilization 0.5 \
    --vllm_max_model_len 4096 \
    --max_new_tokens 2048 \
    --vllm_limit_mm_per_prompt '{"image": 2, "video": 1}' \
    --served_model_name Qwen3-VL-2B-Instruct-SFT 

(3)启动训练脚本

export MASTER_ADDR=127.0.0.1
export MASTER_PORT=29500
export WORLD_SIZE=1

CUDA_VISIBLE_DEVICES=1 \
MAX_PIXELS=1605632 \
NPROC_PER_NODE=1 \
swift rlhf \
    --rlhf_type grpo \
    --model /root/autodl-tmp/zz/outputs/SFT/v9-20260322-115029/checkpoint-149-merged \
    --external_plugins /root/autodl-tmp/zz/plugin.py \
    --reward_funcs external_r1v_acc format \
    --use_vllm true \
    --vllm_mode server \
    --vllm_server_host 127.0.0.1 \
    --vllm_server_port 8000 \
    --tuner_type lora \
    --torch_dtype bfloat16 \
    --dataset /root/autodl-tmp/zz/datasets/AI-ModelScope/LaTeX_OCR/human_handwrite/validation-00000-of-00001_rlhf.jsonl \
    --max_completion_length 4096 \
    --num_train_epochs 1 \
    --per_device_train_batch_size 1 \
    --per_device_eval_batch_size 1 \
    --learning_rate 1e-4 \
    --gradient_accumulation_steps 8 \
    --save_strategy 'steps' \
    --eval_strategy 'steps' \
    --eval_steps 10 \
    --save_steps 10 \
    --save_total_limit 2 \
    --logging_steps 1 \
    --output_dir /root/autodl-tmp/zz/outputs/RLHF \
    --system /root/autodl-tmp/zz/system_prompt_rlhf.txt \
    --warmup_ratio 0.01 \
    --num_generations 4 \
    --generation_batch_size 4 \
    --temperature 1.0 \
    --log_completions true \
    --async_generate true \
    --beta 0.001

--external_plugins :自定义奖励函数的脚本路径
--reward_funcs external_r1v_acc format:采用的奖励函数,external_r1v_acc 就是在plugins中注册的自定义奖励函数,format则是MS-Swift框架自带的格式奖励函数。

实用技巧

  1. roll_out和训练脚本的模型路径应该都是同一个模型(SFT微调后的权重路径)。
  2. roll_out和训练脚本所使用的显卡必须使用不同的显卡,所以最好租两卡以上服务器。(在单卡上debug过很久,没成功)
  3. RLHF的训练类型决定参数是tuner_type而不是SFT的train_type
  4. 既然RLHF也需要标签,那SFT和RLHF的不同在哪里?SFT后继续RLHF的意义是什么?
    • SFT(监督微调)
      用标注数据直接训练模型,让模型学会“做对”,强调模仿正确答案
      优势:快速掌握基础任务能力。
      局限:无法处理复杂或多解情况,容易生成表面正确但结构或可读性差的输出。
    • RLHF(强化学习微调)
      使用奖励函数对生成结果评分(可来自人类、规则或自动评估),让模型学会优化输出质量而非仅模仿答案
      强调:可调整模型偏好、处理复杂公式、多解任务,提高鲁棒性和输出合理性。
    • SFT后RLHF的意义
      SFT提供基础能力(能生成正确答案)
      RLHF提供精细优化(提高复杂公式、结构合理性、多样性等)
      结果:模型不仅“对”,还“好”,在复杂任务上更稳健、更符合预期。

4.2 推理

跟SFT的推理是一样的,换个权重即可,可以参考3.2节。

4.3 部署

跟SFT的推理是一样的,换个权重即可,可以参考3.3节。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值