本文由 简悦 SimpRead 转码, 原文地址 zhuanlan.zhihu.com
**推荐人:宋志学,来源:SwanLab
**
本文介绍了一种将 SmolVLM2 视觉模块和 Qwen3-0.6B 进行模型拼贴的方法,并通过微调实现具备「超小规模 + 多模态 + 支持中文」特性的 “Qwen3-SmVL”。微调全程使用沐曦 GPU 完成,并提供完整的 Github 仓库与 SwanLab 记录。
摘要
最近 Huggingface 团队发布了超小多模态模型 SmolVLM2,可以做到端侧 1GB 显存推理。在怀着惊喜试用后发现,虽然模型有极其强大的视觉文本理解能力,但是模型却无法理解中文,这对中文技术社区并不是非常友好。
刚好前段时间做 SwanLab 硬件检测适配时有一台未到期的沐曦曦云 C500 服务器,因此萌生了把当前中文小模型扛把子 Qwen3 与 SmolVLM2 直接微调拼接的想法。
本教程将介绍一种模型拼接的思路,将 SmolVLM2 的视觉模块(0.09B)与 Qwen3 最小的模型(0.6B)进行对齐微调,最终使得 Qwen 模型具备一定的视觉理解能力。
关于算力的注意:本教程涉及 VLM 微调训练,对算力要求较高,需要 40G 及以上的 GPU 显存才能运行本教程的训练代码。
目录
-
SmolVLM2 的背景知识
-
模型拼接和微调思路简介
-
模型拼接实现和关键代码讲解
-
微调数据集构建
-
微调方法与代码实现
-
微调训练 & 结果展示
-
代码及数据集链接汇总
- SmolVLM2 的背景知识
首先,我们先回顾一下 SmolVLM2 模型的构建方案,SmolVLM2 模型的整体包括三大块:视觉模型层,特征映射层和大语言模型层,见下图:
这个设计是现在比较常见的 VLM 方案。核心设计思想就是让视觉模型的输出特征与经过 embedding 的文本特征直接拼接后输入到语言模型(LLM)当中,没有交叉注意力等模块。
相比于早期 LLaVA 等架构,这种最大的优点就是可以最大程度复用已有的语言模型。以 Qwen2.5-VL 为例,其 3B、7B、72B 模型大小指的只是 LLM 部分,并没有包含 Vision 模块,实际上 3B 模型的参数量接近 4B,视觉模块大概 0.4B 左右,三个不同大小的 VLM 使用的是统一的视觉模型。
对于一些较大的 VLM 来说,构建视觉模型时绝大多数的训练都集中在特征映射模块和视觉模块,只在最后阶段为了最终效果进行整体微调时才会调整语言模块。保证了 VLM 的语言能力。
下面简述一下各个模块的细节:
-
视觉模型层:SmolVLM2-256M 版本用的是 Google 的 SigLip 模型,一个基于 ViT 的视觉模型,选用的是最小的 SigLip-93M 的版本,HF 论文里没具体写是直接用的 SigLip 的参数还是他们从零构建的(有注意到的读者可以评论留言下)。在 SmolVLM2 代码中对应的是
SmolVLMVisionTransformer类 -
特征映射层:就是一个简单的 MLP,不过 SmolVLM2 中为了降低图像分辨率还做了一个 Pixel shuffle 来降低图像分辨率,进一步减少视觉的 Token 占用,减少了文本长度。HF 团队在论文里提到对于参数量较小的 VLM 来说使用 Pixel shuffle 还能提升性能。但可训练参数其实就是一个单层的神经网络,这个模块的核心作用就是做特征对齐,将视觉特征从 768 维(SigLip 的维度)映射到 576 维(SmolLLM2 的维度)
-
大语言模型:SmolVLM2-256M 模型使用的文本模型是 SmolLM-135M 版本。可能是由于模型较小,HF 团队在论文中说到训练时仅采用两阶段训练:大规模图文训练 + 针对视频任务的专门微调。为了保障模型的文本能力 HF 团队在训练数据中参杂了大概 14% 的纯文本微调数据。不过考虑到视觉模块本身参数量(93M)大小接近于文本模型(135M),因此笔者推测相比于冻结文本模型,数据平衡在这之中会起到更关键的作用。
HF 团队在原文中还提到了许多影像小模型 VLM 性能的 trick,感兴趣的读者可以进一步参考 SmolVLM2 的论文
- 模型拼接和微调思路简介
正所谓顶级食材(模型)只需要最简单的烹饪。模型拼接的思路非常简单直接,基本就三步:
-
调整 SmolVLM2 的 “上下文控制格式”,使得其与 Qwen3 兼容。
-
将模型的文本部分直接从 SmolLM2 换成 Qwen3-0.6B,包括其文本 tokenizer 和词嵌入、文本模型、以及模型最后输出的语言模型头(LM Head)。
-
需要重新初始化特征映射层的 MLP,从 768->576 的单层神经网络改成 768->1024 的单层神经网络即可。
整体架构和对图文对前后处理依旧保持 SmolVLM2 的流程不变,具体改动见下图:
笔者接下来详细介绍下为了实现 “拼接”,具体改动的地方,供之后有类似的任务的读者参考。
- 模型拼接实现和关键代码讲解
第一处改动:SmolVLM2 的 Tokenizers 部分
首先需要改动的就是需要改动的是 SmolVLM2 的 Tokenizers 部分,这里面主要是涉及两个问题:
-
第一个问题是要将 SmolVLM2 用于指示图像位置的特殊令牌(Special Token)加入到 Qwen3 的 Tokenizer 当中,这么做的目的是防止 SmolVLM2 的图像 Token
<image>被切分为<、image、>三块。幸运的是,Qwen3 本身在 Tokenizers 中预留了未来用于多模态的特殊特殊令牌<|image_pad|>。因此读者直接使用了<|image_pad|>代替了<image>。用于在文本中预留图像特征的插入点。 -
第二个问题是:SmolVLM2 的 chat_template 和 Qwen3 的 chat_template 差别极大。chat_template 的作用是通过格式化文本让模型清楚知道不同 Token 所代表的背景信息。用最近比较流行的话来说就是 “上下文工程”(Context Engineering)。
这里我列举了一下 Qwen3、SmolVLM2、Qwen2.5-VL 在聊天场景下的上下文,供读者参考。
Qwen3 聊天上下文格式
以给一张图片,问题是 “你的名字是什么?”,模型回答是“我的名字是 Qwen” 为例子。模型的上下文如下:
<|im_start|>user你的名字是什么?<|im_end|><|im_start|>assistant<think></think>我的名字是Qwen<|im_end|>
注意 Qwen3 上下文是没有预留图像位置的,但相比于一般的 LLM 和 VLM 多了一个用于插入模型思考过程的<think><\think>,以及包含额外的函数调用控制文本。为了便于读者理解,读者在在下面举了一个函数调用的例子。这些函数调用上下文用于控制模型调用外部函数、API 或者 MCP 接口和接收其返回的信息。
考虑到篇幅限制,本文就不粘贴带函数调用、推理、思考等一系列上下文的信息了(笔者打印了下发现实在太长了)。感兴趣的读者可以在 Qwen3 的官方文处了解详细设计
可以说正是这些复杂的上下文信息让模型有可能实现推理、调用函数等多样化的能力。包括多模态理解任务也需要先对上下文进行设计。
SmdwadwdoVLM2 聊天上下文格式:
以给一张图片,问题是 “How many dog in there.”,模型回答是“There are Three dogs.” 为例子。三种不同模型的上下文如下:
<|im_start|>User:<fake_token_around_image><row_1_col_1><image>...<image><fake_token_around_image><row_1_col_2><image>...<image><fake_token_around_image><row_1_col_3><image>...<image>...<fake_token_around_image><row_4_col_4><image>...<image><fake_token_around_image><global-img><image>...<image><fake_token_around_image>How many dog in there.<end_of_utterance>Assistant: There are Three dogs.<end_of_utterance>Assistant:
看起来非常乱,是因为有大量的<image>占位符。<image>...<image>之间是许多的<image>,笔者为了文章观感删掉了大量的占位符。注意模型的回车、空格均为上下文的一部分,在进行推理时需要严格遵守缩进关系。
但是我们仍能找到熟悉的内容,如User:,Assistant:等用于提示模型用户的输入与模型应当输出的位置。这些关键词和 Qwen 类似。
读者注意到了除了<fake_token_around_image>,<image>等用于指示图像的词,还出现了 <row_1_col_1> 这种位置指示符,这是因为 SmolVLM2 为了防止降采样对图像分辨率影响,专门使用了image splitting技术,简单来说就是将全局图和高清的局部图共同输入到模型当中(见下图image splitting模块),感兴趣的读者可在文末找到 HF 的技术报告了解详细技术。
本博文的拼接模型 Qwen3-SmVL 模型
相比于 Qwen3,SmolVLM2 少了很多上下控制的
为了尽可能保存或者说预留 Qwen3 的思考、函数调用等能力,笔者最终选择将 SmolVLM2 对于图像特征的排列插入到 Qwen3 的上下文格式当中。最终上下文格式如下:
<|im_start|>user<vision_start><row_1_col_1><|image_pad|>(图像插入的地方)<|image_pad|><vision_start>(用户提问的地方)<|im_end|><|im_start|>assistant<think></think>(模型回答的地方)<|im_end|><|endoftext|>
可以看到读者尽量保持了与 Qwen3 的风格和复用特殊令牌。这样能够使得后续拼接的 Qwen3-0.6B 模型不至于受到上下文差异过大带来的性能损耗。实际上在设计微调上下文时应尽量与模型先前训练的任务接近,以减少微调带来的性能损失。
transformers 实现模型上下文格式控制的代码并非 python 语言,而是一种前端文本格式控制的语言 Jinja。这个语言的变量作用域设计简直可以说是有魔法在里面。配合上 Qwen3 功能丰富且复杂的上下文策略,让笔者花了 2 个小时用于修改 chat_teamplate。这里笔者不赘述如何修改 chat_template,感兴趣的读者可以去文末代码链接寻找chat_template.jinja文件,笔者专门将 chat_template 模版拿出来,并且做了格式化方便读者阅读。未来有时间了笔者专门写一篇模型上下文控制与 jinja 语言的博客。
第二处改动:替换 SmolVLM2 的 SmolLM2 模型为 Qwen3-0.6B
替换模型这块没什么复杂的,主要是需要处理 Transformers 比较复杂的嵌套逻辑。Tranformers 通常建议模型将预训练模型 backbone 和下游任务分开来。改动逻辑图如下:
以 Qwen3 为例,预训练 Backbone 模型为Qwen3Model,仅仅包含 embedding 层、各个 Decoder 层,最后输出的是所有输入 token 的 hidden state。负责下游任务的 Qwen3 提供了包括:用于因果语言序列生成的Qwen3ForCausalLM,也就是大家常用的语言生成。
负责句子分类的Qwen3ForSequenceClassification,使用最后一个生成的 token 输入到一个单层 MLP 做序列级分类,做句子情绪分类等可以用这个下游模型;Qwen3ForTokenClassification用于做 Token 级分类,比如语言实体抽取任务可以使用这个下游模型。
Qwen3ForQuestionAnswering则是专门做抽取式问答任务的模型,核心思想是输入(问题,参考文本)让模型从参考文本中找到与问题最相关的一段,这类任务由于 RAG 系统的出现没那么流行了,未来笔者专门出一个系列的教程阐述除了因果语言序列生成以外的任务则怎么微调。
关键代码如下
from transformers import ( AutoProcessor, AutoModelForImageTextToText, AutoTokenizer, AutoModelForCausalLM)# 替换text模型和headsmolvlm2_02B_model = AutoModelForImageTextToText.from_pretrained( "model/SmolVLM2-256M-Video-Instruct", torch_dtype=torch.bfloat16, _attn_implementation="eager",).to(device)qwen3_06b_model = AutoModelForCausalLM.from_pretrained( "model/Qwen3-0.6B", torch_dtype=torch.bfloat16).to(device)smolvlm2_02B_model.model.text_model = qwen3_06b_model.modelsmolvlm2_02B_model.lm_head = qwen3_06b_model.lm_head...
接下来比较复杂的是替换所有的关键变量,比如模型内用于在文本序列中为图像特征预留的占位符image_token_id,用于指示停止生成的eos_token_id,和计算 loss 值会用到的vocab_size,Qwen 的词表大小为 151936,远远大过 SmolVLM2 的词表 49280。具体代码如下:
...# 替换词表大小smolvlm2_02B_model.vocab_size = qwen3_06b_model.vocab_sizesmolvlm2_02B_model.model.vocab_size = qwen3_06b_model.vocab_sizesmolvlm2_02B_model.config.vocab_size = qwen3_06b_model.vocab_sizesmolvlm2_02B_model.config.text_config.vocab_size = qwen3_06b_model.vocab_sizesmolvlm2_02B_model.model.config.vocab_siz = qwen3_06b_model.vocab_sizesmolvlm2_02B_model.model.config.text_config.vocab_size = qwen3_06b_model.vocab_size# 替换图像tokensmolvlm2_02B_model.image_token_id = 151655smolvlm2_02B_model.model.image_token_id = 151655smolvlm2_02B_model.config.image_token_id = 151655smolvlm2_02B_model.model.config.image_token_id = 151655# 替换模型生成停止符smolvlm2_02B_model.generation_config.eos_token_id = 151645···
上面的代码可以看到在替换各个变量时需要将嵌套模型的变量一起替换掉,笔者之前训练时就因为仅仅替换了SmolVLMForConditionalGeneration而忘记替换SmolVLMModel中的image_token_id,导致语言模型接收不到图像特征,最后表现出来就是 loss 下降的极快且低,grad_norm 看起来也学到位了,一推理效果特别差,附上错误训练的损失图:
笔者最早没发现改动错误,先做完整微调(蓝色曲线)后发现损失下降很快达到了 0.1 以下,结果实际一推理发现模型完全没有图像理解能力,就补了一个冻结语言模型只微调视觉模型的实验(红色曲线),结果发现损失完全没下降,才定位到了视觉特征传入有问题。后续修复后正确的损失下降过程见黄色图像。
第三处改动:构建和替换特征映射层
这个相对较简单,只需要重新构建一个维度对齐的SmolVLMConnector即可。Qwen3 的 hidden_dim 是 1024,SigLip 的 hidden_dim 是 768,因此构建一个 768
1024 映射的SmolVLMConnector即可。代码如下:
···# 构建配置并且创建连接器@dataclassclassVisionConfig: hidden_size:int= 768@dataclassclassTextConfig: hidden_size:int= 1024@dataclassclassConnectConfig: scale_factor:int= 4 vision_config: VisionConfig = VisionConfig() text_config: TextConfig = TextConfig()new_connector_config = ConnectConfig()# 替换 SigLit 到 LLM 的 connector 层new_connector = SmolVLMConnector(new_connector_config).to(device).to(torch.bfloat16)smolvlm2_02B_model.model.connector = new_connector···
- 微调数据集构建
笔者最初计划寻找中文多模态数据集,但发现相关的资料比较少。因此决定先用英文的多模态数据集凑合一下。之后再考虑通过数据合成的方式将部分数据翻译为中文。关于数据合成和配比的问题将在之后的博客讨论。
这里为了方便本项目直接使用 HuggingFace 团队整合的多模态数据集 the Cauldron 数据集,Cauldron 翻译成中文类似于煮东西的 “釜”,不知道 HF 团队是不是玩“炼丹” 的梗。这个数据集整合了 50 个视觉微调任务数据集的训练集,用于微调 Huggingface 发布的多模态模型 Idefics2 模型。这 50 多个数据集都被处理成了一致的格式(见下图),共有 1,880,992 条数据,完整下载约 169G,非常方便使用。
不过可惜数据集的文本都是英文内容,且绝大多数数据集的回复非常短,只有一个词,这也给后面模型训练带来了麻烦。本篇博客暂时不讨论关于数据构建和配比的问题,后续有时间了专门做相关的实验。本博客先以为 Qwen3 模型带来视觉能力为核心目标。
数据集的下载链接如下,国内推荐用 modelscope 下载:
-
HuggingFace Hub:
-
ModelScope:
笔者在实际测试时发现 “mimic_cgd”, “localized_narratives”,“okvqa”,“ocrvqa”,“clevr_math” 这几个子数据集加载有点异常,建议使用此数据集训练的读者手动处理下,社区也有用户反馈这几个数据可以在原始来源处额外下载,未来笔者将会补全这几个数据集重新上传一次完整版的 the Cauldron 数据集。
- 微调方法与代码实现
冻结模型参数微调
整体微调方法采用了 CLM 模型通常的 Teacher Forcing 的学习方法,损失就是标准的交叉熵损失。考虑到此次本教程的目标是先确保模型具备中文多模态能力(优化模型性能等之后撰写其他博客),因此为了实验效率,在对齐微调阶段采用冻结视觉模型与文本模型,仅微调特征映射器和语言模型头的方法。
冻结模型参数的核心代码如下:
deffreeze_model(qwen_smvl): for_, paraminqwen_smvl.model.text_model.named_parameters(): param.requires_grad =False for_, paraminqwen_smvl.model.vision_model.named_parameters(): param.requires_grad =False returnqwen_smvl
冻结后训练参数、模型总参数、与占比如下:
trainableparams:12.00M ||allparams:662.87M || trainable%:1.81
文本长度,损失掩码和截断策略
文本长度
由于视觉特征需要占据大量的文本长度,笔者简单测试了下 the_cauldron 图像占 0.8K 到 1.3K 左右的 token。而数据集中大多数文本 token 数在 200-500 左右,极少情况会有 3-4K 的情况。因此笔者统一采用 2K 的文本长度,超出部分截断处理。
这里有一个不同于文本微调的细节要注意,文本截断长度不能小于图像 token,否则会导致模型在进行特征拼接时报错(当然图像特征如果被截断了,这条训练数据也就没意义了)。因此对于显存不足 64G 的同学如果需要适当缩短文本长度(不建议低于 1.5K),最好连同图像分辨率也缩小些。在后面的博客我们会专门增加对减少图片 token 占用的研究。
同样由于文本长度受限,且图像特征没法截断,我们也没使用 “packing dataset” 的方法提升模型的训练效率。
考虑到部分数据集存在多张图片的情况,考虑到本次训练仅采用 2k 的文本长度(与之对比 HF 在训练 SmolVLM-256M 版本采用的是 8K 的文本长度,2.2B 版使用了 16K 的文本长度)。针对单条数据中存在多张图片的情况仅仅选用第一张。
损失掩码
在采用 Teacher Forcing 的学习方法时,文本微调中损失掩码有两种策略:
-
对包含 “用户问题” 和“模型回复”的完整文本进行微调优化
-
仅对 “模型回复” 部分进行微调优化
这两种策略的对比如下图:
通常来说使用 “仅微调模型回复部分” 的策略模型更容易泛化(这点与 HF 在 SmolVLM2 的论文提到的 trick)。然而笔者为了提高训练效率选择了完整文本微调。可以在后续博客中增加消融实验做进一步对比。
值得注意的是,在进行完整文本微调时,需要单独屏蔽 Image Token 以防止对图像占位 token 计算损失,影响模型表现。
关键代码如下:
defdata_collate_fix2k(examples, processor, device, max_length=2048): batch_text = [] batch_image = [] forexampleinexamples: images = example["images"][:1] # 只允许一张图,不然显存压力太大 batch_image.append(images) image_num =len(images) chat_texts = example["texts"][0] messages = [ { "role":"user", "content": [{"type":"image"}] * image_num + [{"type":"text","text": chat_texts["user"]}], }, { "role":"assistant", "content": [{"type":"text","text": chat_texts["assistant"]}], }, ] text = processor.apply_chat_template( messages, enable_thinking=False, add_generation_prompt=False ) batch_text.append(text) batch = processor( text=batch_text, images=batch_image, max_length=max_length, return_tensors="pt", padding="max_length", truncation=True, ) labels = batch["input_ids"].clone() labels[labels == processor.tokenizer.pad_token_id] = -100 labels[labels == processor.image_token_id] = -100 batch["labels"] = labels returnbatch.to(device, dtype=torch.bfloat16)
微调超参数设置
学习率
由于仅仅针对特征映射层(connector)进行训练,且 conntector 由于要对齐 Qwen3 的维度因此参数为随机初始化(理论上可以采用一些独特的初始化策略提升性能,但考虑到模型较小因此笔者没关注初始化策略)。因此学习率设置为 lora 中较为流行的 1e-4 学习率策略。
为了保障有效收敛,学习率衰减基本是必备的 trick,采用的是社区比较流行的 cosine 学习率衰减,衰减至 0。warm up 为整体步长的 10%(在超过 1000k step 的情况下固定为 50)。
batch size
Batch size 通常来说越大越好,然而由于 VLM 模型的文本长度太大,因此采用每卡 1 batch 和 4 梯度累加(grad accelerate),在 8 卡训练中等效 32 Batch size。
训练参数设置代码
training_args = TrainingArguments( seed=42, data_seed=42, max_steps=200, # num_train_epochs=1, # 训练1个epoch 约1k steps per_device_train_batch_size=1, gradient_accumulation_steps=4, dataloader_pin_memory=False, warmup_ratio=0.1, learning_rate=1e-4, lr_scheduler_type="cosine", weight_decay=0.01, logging_steps=5, eval_strategy="steps", eval_steps=0.125, save_strategy="steps", save_steps=0.125, save_total_limit=8, optim="adamw_torch", bf16=True, output_dir=f"./model/freeze_except_connector_cocovqa", overwrite_output_dir=False, report_to="swanlab", run_, remove_unused_columns=False, gradient_checkpointing=False,)
训练环境
微调代码基于沐曦曦云 C500 通用 GPU 实现,显存为 64G。
各位读者在尝试本项目代码时可以采用 Nvidia 显存 40G 以上的显卡运行本教程。
训练环境的话除了安装 GPU 对应的驱动和 pytorch 外,本教程需要额外安装 Huggingface 全家桶,如下:
torch # 推荐版本>=6.0torchvisiontransformers>=4.53.0acceleratedatasetsnum2words # SmolVLM2需要
额外补充一句,如果采用沐曦 GPU 训练的话,需要在沐曦官方文档处寻找沐曦版 torch 的安装方式进行下载。其他 HF 环境和 NV 基本一样。附赠一个沐曦查看 GPU 的命令:
mx-smi
效果如下:
=================== MetaX System Management Interface Log ===================Timestamp : Sat Jul 12 14:58:51 2025Attached GPUs : 8+---------------------------------------------------------------------------------+| MX-SMI 2.1.12 Kernel Mode Driver Version: 2.12.13 || MACA Version: 2.29.0.19 BIOS Version: 1.22.3.0 ||------------------------------------+---------------------+----------------------+| GPU NAME | Bus-id | GPU-Util || Temp Pwr:Usage/Cap | Memory-Usage | ||====================================+=====================+======================|| 0 MetaX C500 | 0000:0e:00.0 | 0% || 36C 69W / 350W | 5680/65536 MiB | |+------------------------------------+---------------------+----------------------+| 1 MetaX C500 | 0000:0f:00.0 | 0% || 38C 70W / 350W | 4986/65536 MiB | |+------------------------------------+---------------------+----------------------+| 2 MetaX C500 | 0000:10:00.0 | 0% || 37C 69W / 350W | 4986/65536 MiB | |+------------------------------------+---------------------+----------------------+| 3 MetaX C500 | 0000:12:00.0 | 1% || 37C 71W / 350W | 4986/65536 MiB | |+------------------------------------+---------------------+----------------------+| 4 MetaX C500 | 0000:35:00.0 | 0% || 37C 70W / 350W | 4986/65536 MiB | |+------------------------------------+---------------------+----------------------+| 5 MetaX C500 | 0000:36:00.0 | 1% || 36C 68W / 350W | 4986/65536 MiB | |+------------------------------------+---------------------+----------------------+| 6 MetaX C500 | 0000:37:00.0 | 0% || 39C 73W / 350W | 4986/65536 MiB | |+------------------------------------+---------------------+----------------------+| 7 MetaX C500 | 0000:38:00.0 | 0% || 38C 71W / 350W | 4986/65536 MiB | |+------------------------------------+---------------------+----------------------++---------------------------------------------------------------------------------+| Process: || GPU PID Process Name GPU Memory || Usage(MiB) ||=================================================================================|| 0 3496691 python3.10 4066 || 0 3496692 python3.10 102 || 0 3496693 python3.10 102 || 0 3496694 python3.10 102 || 0 3496695 python3.10 102 || 0 3496696 python3.10 102 || 0 3496697 python3.10 102 || 0 3496698 python3.10 170 || 1 3496692 python3.10 4154 || 2 3496693 python3.10 4154 || 3 3496694 python3.10 4154 || 4 3496695 python3.10 4154 || 5 3496696 python3.10 4154 || 6 3496697 python3.10 4154 || 7 3496698 python3.10 4154 |+---------------------------------------------------------------------------------+
训练代码实现
在构建训练代码时,笔者使用 HuggingFace Transfomers 框架的 Trainer 类来完成训练代码。Trainer 类实现的训练逻辑基本能完成大部分微调任务。这里唯一需要提到的是笔者使用了 Qwen3-0.6B 而非通常此类任务该使用的 Qwen3-0.6B-Base 模型,Qwen3-0.6B 相比于 Qwen3-0.6B-Base 模型经过了指令遵从微调、对齐等,能实现聊天问答功能。
通常来说对经过微调的模型进行持续训练会一定程度带来性能损失,然而此次微调时笔者冻结了 LLM 参数,因此需要选用经过微调的模型来实现多模态问答能力。
笔者在训练过程中使用的是 bfloat16 精度,相比于 float16 来说 bfloat16 增加了尾数位数,训练过程中精度会更高些。
在前期进行方案验证阶段笔者采用的是 cocoqa 数据集,并且进行 200steps 的微调训练。在确定方案可行后笔者计划使用完整数据集进行微调训练,然而考虑到训练数据量仅仅只有整个模型的 12M,因此笔者按参数量与训练 Token 的比值为 1:10 采样数据集,即总共从数据集中采样出 60K 条数据用于实际训练(文本长度按照 2k 计算,实际上有 padding 部分因此实际参与 token 数小于 120M)。笔者认为参与训练的数量是足以令模型收敛的,后续实验也证明了模型确实能达到我们所期望的效果。
训练关键代码实现
代码比较长是因为增加了断点续训的能力
################# 开启训练################last_checkpoint =None# load last checkpoint if availableif( os.path.isdir(training_args.output_dir) andnottraining_args.overwrite_output_dir): last_checkpoint = get_last_checkpoint(training_args.output_dir) iflast_checkpointisNoneandlen(os.listdir(training_args.output_dir)) >0: raiseValueError( f"Output directory ({training_args.output_dir}) already exists" ) print( f"Checkpoint detected, resuming training at{last_checkpoint}." )# Init Trainertrainer = Trainer( model=qwen_smvl, args=training_args, train_dataset=raw_data["train"], eval_dataset=raw_data["test"], data_collator=collate_fn,)trainer.train(resume_from_checkpoint=last_checkpoint)qwen_smvl.save_pretrained(training_args.output_dir)
- 微调训练 & 结果展示
代码准备与环境安装
pipinstall -r requirements.txt
数据集和模型下载
笔者附上自动下载脚本,注意该脚本使用魔塔社区完成模型与数据集的下载
bashdownload_resource.sh
小批量微调训练
为了进行快速验证,笔者首先使用 cocoqa 数据集并且进行了 200steps 的训练,所有参数与前文所述一致。通过运行实验命令如下,推荐使用 8 卡进行训练,在 8 张沐曦 GPU 卡上预计需要使用 20min
# 单GPU训练CUDA_VISIBLE_DEVICES=0python train.py ./cocoqa_train.yaml# 8GPU训练accelerate--num_process8train.py ./cocoqa_train.yaml
注意,本项目使用 SwanLab 进行训练日志记录与分析,如果未登陆 SwanLab 需要使用swanlab login进行登陆。运行后看到如下结果即代表实验成功开启:
下面是笔者完成小批量微调训练的训练损失、测试损失结果图
模型在完成训练后会自动使用一张狗狗图片配合问题 “图中有什么动物?” 让模型根据图片进行推理,推理结果如下:
当时看到模型对着三只狗的图片回答 “兔子” 时笔者一时认为炼丹失败了,当然如果实际炼丹失败后模型是不会输出动物类型的,而是输出一些乱码或者告诉用户并没有看到图片。识别错误的原因实际上是由于训练步数过少导致的。后续加大训练步数与数据量后模型能正常识别出狗狗并且能准确的说出有三只狗。
PS: 作者公开了在 SwanLab 上的训练结果,感兴趣的读者可以自己查看,SwanLab 也支持 Clone 作者的训练日志,大家可以在自己训练时 clone 笔者的项目去做对照。
完整微调训练结果展示
运行实验命令如下,推荐使用 8 卡进行训练,在 8 张沐曦曦云 C500 GPU 上预计需要使用 1.5h
# 单GPU训练CUDA_VISIBLE_DEVICES=0python train.py ./full_train.yaml# 8GPU训练accelerate--num_process8train.py ./full_train.yaml
下图展示了使用完整微调数据对比于小批量训练,可以看到全量数据微调时 loss 变得更为抖动,这是由于数据类型的丰富给模型的学习带来了一定的挑战。
进一步对比完整训练和小批量训练的训练和测试损失,可以看到完整训练的模型训练损失达到了 0.61,远低于仅仅使用 cocoqa 模型的效果,评估损失也远低于前者,维持在 0.58 左右。
这里值得一提的是,由于我们选用的测试集比较小(仅有 64 条数据),因此训练损失和测试损失的差距并不能直接理解为过拟合的证据。实际上在大模型训练上,如果数据集足够大的情况下,通常可以认为训练损失等同于评估损失。
此外,模型通过分析 1k 步之后的训练损失、平均梯度范数(Grad Norm)变化。此时训练任务已过半,且学习率开始快速衰减。如下图,可以看到学习率快速衰减的情况下模型损失并没有明显的进一步下降,这说明模型已经实现了充分训练。
在训练效率方面,可以看到我们仍没有充分榨干 GPU 的性能,当然这也是由于多模态任务的网络本身架构上比较复杂,其中包含许多对图像、文本的拼接工作,这也导致了 GPU 性能没法完全利用。
同样在完成训练后使用狗狗图进行了测试,这次模型能理解图片、中文以及给出正确的回复。更为关键的是模型完全保留了 Qwen3-0.6B 原有的全部能力,包括函数调用、推理等。在此基础上,仅仅增加了 0.09B 参数量的情况下为模型带来了图像理解能力!
模型推理与效果分析
- 代码及数据集链接汇总
微调用 The Cauldron 数据集下载链接:
-
HuggingFace Hub:
-
ModelScope:
Qwen3-0.6B 模型下载:
-
HuggingFace Hub:
-
ModelScope:
本实验完整代码 GitHub 链接:
本实验 SwanLab 日志:
-
SwanLab 训练过程查看:
参考资料
- Huggingface SmolVLM2 技术报告:https://arxiv.org/pdf/2504.05299













