陈少宏 2025-07-27 22:01 浙江
Datawhale干货
推荐人:宋志学,来源:SwanLab
💡 **模型拼接实现**:该技术核心在于将SmolVLM2的视觉模块(0.09B)与Qwen3-0.6B模型进行“拼贴”。具体操作包括:调整SmolVLM2的上下文格式以兼容Qwen3;将SmolVLM2的文本部分替换为Qwen3-0.6B的tokenizer、词嵌入和语言模型;并重新初始化特征映射层的MLP,将其从768维映射到1024维,以实现视觉特征与文本特征的对齐。整个流程在保留SmolVLM2的图文处理逻辑基础上,整合了Qwen3的中文理解能力。
🔧 **关键代码与配置调整**:实现模型拼接需要对多个关键部分进行代码修改。首先,需要处理Tokenizers,将SmolVLM2的特殊图像令牌替换为Qwen3兼容的令牌,并调整chat_template以匹配Qwen3的复杂上下文策略,如包含图像特征的插入点、模型思考过程等。其次,需替换模型的核心组件,用Qwen3-0.6B的模型替换SmolVLM2的文本模型和语言模型头,同时调整词表大小、图像Token ID和生成停止符等配置,确保模型内部参数和配置的一致性。最后,需要构建并替换特征映射层(connector),使其能够正确地将SigLip(768维)的视觉特征映射到Qwen3(1024维)的文本特征空间。
📊 **微调策略与训练**:为了验证拼接思路的可行性,作者使用了英文多模态数据集“the_cauldron”进行微调。在微调阶段,为提高效率,作者采取了冻结视觉模型和文本模型参数,仅微调特征映射器和语言模型头的方法。训练过程中,文本长度统一设置为2K,并对多张图片的情况仅选用第一张。损失计算采用“仅微调模型回复部分”的策略(文中提到为提高效率选择了完整文本微调,但后续对比实验可能需关注此细节),并需要屏蔽图像Token以避免计算损失。训练超参数设置为1e-4的学习率,cosine学习率衰减,并使用4梯度累加模拟更大的Batch Size。
🚀 **训练环境与结果**:实验在沐曦曦云C500 GPU(64G显存)上完成,并建议使用Nvidia 40G以上显卡进行复现。训练过程中使用了bfloat16精度。通过SwanLab记录了训练过程,并展示了错误训练(损失快速下降但无实际效果)与正确训练(损失平稳下降)的对比。最终,通过微调,成功实现了将SmolVLM2的视觉能力注入Qwen3模型,使其具备了中文多模态理解能力,为小型多模态中文模型的开发提供了有效的实践路径。
陈少宏 2025-07-27 22:01 浙江
Datawhale干货
推荐人:宋志学,来源:SwanLab
⚠️关于算力的注意:本教程涉及VLM微调训练,对算力要求较高,需要40G及以上的GPU显存才能运行本教程的训练代码。目录
SmolVLMVisionTransformer
类<image>
被切分为<
、image
、>
三块。幸运的是,Qwen3本身在Tokenizers中预留了未来用于多模态的特殊特殊令牌<|image_pad|>
。因此读者直接使用了<|image_pad|>
代替了<image>
。用于在文本中预留图像特征的插入点。注意Qwen3上下文是没有预留图像位置的,但相比于一般的LLM和VLM多了一个用于插入模型思考过程的<|im_start|>user
你的名字是什么?<|im_end|>
<|im_start|>assistant
<think>
</think>
我的名字是Qwen<|im_end|>
<think><\think>
,以及包含额外的函数调用控制文本。为了便于读者理解,读者在在下面举了一个函数调用的例子。这些函数调用上下文用于控制模型调用外部函数、API或者MCP接口和接收其返回的信息。考虑到篇幅限制,本文就不粘贴带函数调用、推理、思考等一系列上下文的信息了(笔者打印了下发现实在太长了)。感兴趣的读者可以在Qwen3的官方文处了解详细设计看起来非常乱,是因为有大量的<|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的风格和复用特殊令牌。这样能够使得后续拼接的Qwen3-0.6B模型不至于受到上下文差异过大带来的性能损耗。实际上在设计微调上下文时应尽量与模型先前训练的任务接近,以减少微调带来的性能损失。transformers实现模型上下文格式控制的代码并非python语言,而是一种前端文本格式控制的语言Jinja。这个语言的变量作用域设计简直可以说是有魔法在里面。配合上Qwen3功能丰富且复杂的上下文策略,让笔者花了2个小时用于修改chat_teamplate。这里笔者不赘述如何修改chat_template,感兴趣的读者可以去文末代码链接寻找<|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|>
chat_template.jinja
文件,笔者专门将chat_template模版拿出来,并且做了格式化方便读者阅读。未来有时间了笔者专门写一篇模型上下文控制与jinja语言的博客。第二处改动:替换SmolVLM2的SmolLM2模型为Qwen3-0.6B替换模型这块没什么复杂的,主要是需要处理Transformers比较复杂的嵌套逻辑。Tranformers通常建议模型将预训练模型backbone和下游任务分开来。改动逻辑图如下:Qwen3Model
,仅仅包含embedding层、各个Decoder层,最后输出的是所有输入token的hidden state。负责下游任务的Qwen3提供了包括:用于因果语言序列生成的Qwen3ForCausalLM
,也就是大家常用的语言生成。负责句子分类的Qwen3ForSequenceClassification
,使用最后一个生成的token输入到一个单层MLP做序列级分类,做句子情绪分类等可以用这个下游模型;Qwen3ForTokenClassification
用于做Token级分类,比如语言实体抽取任务可以使用这个下游模型。
Qwen3ForQuestionAnswering
则是专门做抽取式问答任务的模型,核心思想是输入(问题,参考文本)让模型从参考文本中找到与问题最相关的一段,这类任务由于RAG系统的出现没那么流行了,未来笔者专门出一个系列的教程阐述除了因果语言序列生成以外的任务则怎么微调。关键代码如下接下来比较复杂的是替换所有的关键变量,比如模型内用于在文本序列中为图像特征预留的占位符from transformers import (
AutoProcessor,
AutoModelForImageTextToText,
AutoTokenizer,
AutoModelForCausalLM
)
# 替换text模型和head
smolvlm2_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.model
smolvlm2_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_size
smolvlm2_02B_model.model.vocab_size = qwen3_06b_model.vocab_size
smolvlm2_02B_model.config.vocab_size = qwen3_06b_model.vocab_size
smolvlm2_02B_model.config.text_config.vocab_size = qwen3_06b_model.vocab_size
smolvlm2_02B_model.model.config.vocab_siz = qwen3_06b_model.vocab_size
smolvlm2_02B_model.model.config.text_config.vocab_size = qwen3_06b_model.vocab_size
# 替换图像token
smolvlm2_02B_model.image_token_id = 151655
smolvlm2_02B_model.model.image_token_id = 151655
smolvlm2_02B_model.config.image_token_id = 151655
smolvlm2_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看起来也学到位了,一推理效果特别差,附上错误训练的损失图:SmolVLMConnector
即可。Qwen3的hidden_dim是1024,SigLip的hidden_dim是768,因此构建一个768➡️1024映射的SmolVLMConnector
即可。代码如下:4. 微调数据集构建笔者最初计划寻找中文多模态数据集,但发现相关的资料比较少。因此决定先用英文的多模态数据集凑合一下。之后再考虑通过数据合成的方式将部分数据翻译为中文。关于数据合成和配比的问题将在之后的博客讨论。···
# 构建配置并且创建连接器
class VisionConfig:
hidden_size: int = 768
class TextConfig:
hidden_size: int = 1024
class ConnectConfig:
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
···
冻结后训练参数、模型总参数、与占比如下:def freeze_model(qwen_smvl):
for _, param in qwen_smvl.model.text_model.named_parameters():
param.requires_grad = False
for _, param in qwen_smvl.model.vision_model.named_parameters():
param.requires_grad = False
return qwen_smvl
trainable params: 12.00M || all params: 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的学习方法时,文本微调中损失掩码有两种策略:微调超参数设置学习率由于仅仅针对特征映射层(connector)进行训练,且conntector由于要对齐Qwen3的维度因此参数为随机初始化(理论上可以采用一些独特的初始化策略提升性能,但考虑到模型较小因此笔者没关注初始化策略)。因此学习率设置为lora中较为流行的1e-4学习率策略。为了保障有效收敛,学习率衰减基本是必备的trick,采用的是社区比较流行的cosine学习率衰减,衰减至0。warm up为整体步长的10%(在超过1000k step的情况下固定为50)。batch sizeBatch size通常来说越大越好,然而由于VLM模型的文本长度太大,因此采用每卡1 batch和4梯度累加(grad accelerate),在8卡训练中等效32 Batch size。训练参数设置代码def data_collate_fix2k(examples, processor, device, max_length=2048):
batch_text = []
batch_image = []
for example in examples:
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
return batch.to(device, dtype=torch.bfloat16)
训练环境微调代码基于 沐曦曦云C500通用GPU 实现,显存为64G。各位读者在尝试本项目代码时可以采用Nvidia显存40G以上的显卡运行本教程。训练环境的话除了安装GPU对应的驱动和pytorch外,本教程需要额外安装Huggingface全家桶,如下: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_name="freeze_except_connector_cocovqa",
remove_unused_columns=False,
gradient_checkpointing=False,
)
额外补充一句,如果采用沐曦GPU训练的话,需要在沐曦官方文档处寻找沐曦版torch的安装方式进行下载。其他HF环境和NV基本一样。附赠一个沐曦查看GPU的命令:torch # 推荐版本>=6.0
torchvision
transformers>=4.53.0
accelerate
datasets
num2words # SmolVLM2需要
mx-smi
效果如下:训练代码实现在构建训练代码时,笔者使用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)。笔者认为参与训练的数量是足以令模型收敛的,后续实验也证明了模型确实能达到我们所期望的效果。训练关键代码实现代码比较长是因为增加了断点续训的能力=================== MetaX System Management Interface Log ===================
Timestamp : Sat Jul 12 14:58:51 2025
Attached 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 |
+---------------------------------------------------------------------------------+
完整代码见代码及数据集链接汇总,或者直接由完整项目GitHub地址。6. 微调训练&结果展示代码准备与环境安装可以在GitHub仓库地址处找到实验的完整代码。使用git clone后使用如下命令安装环境################
# 开启训练
################
last_checkpoint = None # load last checkpoint if available
if (
os.path.isdir(training_args.output_dir)
and not training_args.overwrite_output_dir
):
last_checkpoint = get_last_checkpoint(training_args.output_dir)
if last_checkpoint is None and len(os.listdir(training_args.output_dir)) > 0:
raise ValueError(
f"Output directory ({training_args.output_dir}) already exists"
)
print(
f"Checkpoint detected, resuming training at {last_checkpoint}."
)
# Init Trainer
trainer = 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)
pip install -r requirements.txt
数据集和模型下载笔者附上自动下载脚本,注意该脚本使用魔塔社区完成模型与数据集的下载bash download_resource.sh
小批量微调训练为了进行快速验证,笔者首先使用cocoqa数据集并且进行了200steps的训练,所有参数与前文所述一致。通过运行实验命令如下,推荐使用8卡进行训练,在8张沐曦GPU卡上预计需要使用20min注意,本项目使用 SwanLab 进行训练日志记录与分析,如果未登陆SwanLab需要使用# 单GPU训练
CUDA_VISIBLE_DEVICES=0 python train.py ./cocoqa_train.yaml
# 8GPU训练
accelerate --num_process 8 train.py ./cocoqa_train.yaml
swanlab login
进行登陆。运行后看到如下结果即代表实验成功开启:PS: 作者公开了在SwanLab上的训练结果,感兴趣的读者可以自己查看,SwanLab也支持Clone作者的训练日志,大家可以在自己训练时clone笔者的项目去做对照。完整微调训练结果展示运行实验命令如下,推荐使用8卡进行训练,在8张沐曦曦云C500 GPU上预计需要使用1.5h
下图展示了使用完整微调数据对比于小批量训练,可以看到全量数据微调时loss变得更为抖动,这是由于数据类型的丰富给模型的学习带来了一定的挑战。# 单GPU训练
CUDA_VISIBLE_DEVICES=0 python train.py ./full_train.yaml
# 8GPU训练
accelerate --num_process 8 train.py ./full_train.yaml
这里值得一提的是,由于我们选用的测试集比较小(仅有64条数据),因此训练损失和测试损失的差距并不能直接理解为过拟合的证据。实际上在大模型训练上,如果数据集足够大的情况下,通常可以认为训练损失等同于评估损失。此外,模型通过分析1k步之后的训练损失、平均梯度范数(Grad Norm)变化。此时训练任务已过半,且学习率开始快速衰减。如下图,可以看到学习率快速衰减的情况下模型损失并没有明显的进一步下降,这说明模型已经实现了充分训练。
一起“点赞”三连↓
AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。
鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑