8. 权重适配:加载官方 Qwen3.5 权重
第 6 章讲了 MiniQwen 的架构。本章讲怎么把官方 Qwen3.5-0.8B-Base 的预训练权重直接加载到 MiniQwen——跳过我们 0.3B 数据的小规模预训练(效果有限),直接获得官方模型的全部能力。
两条适配线:本章讲 Base 权重 → MiniQwen(
miniqwen/adapter.py,模型 IDQwen/Qwen3.5-0.8B-Base)。后训练成品Qwen/Qwen3.5-0.8B(SFT→DPO→GRPO 之后的官方成品)有独立适配脚本posttrain/adapter.py,映射逻辑完全相同,只是模型 ID 与输出目录不同(checkpoints/qwen35_posttrain_adapter)——它是第 9/10 章真实后训练链路的默认起点。
8.1 为什么需要适配
个人预训练是不现实的,没有那么多资源。
官方 Qwen3.5 vs MiniQwen 的结构差异
| 官方 Qwen3.5 | MiniQwen | |
|---|---|---|
| 模型类 | Qwen3_5ForConditionalGeneration | MiniQwenForCausalLM |
| 文本模型路径 | model.language_model.* | model.* |
| 视觉模型 | model.visual.* | ❌ 不实现 |
| MTP | mtp.* | ❌ 不实现 |
| DeltaNet 层名 | linear_attn | linear_attn ✅ 相同 |
| Attention 层名 | self_attn | self_attn ✅ 相同 |
| MLP 层名 | mlp | mlp ✅ 相同 |
关键发现:层内部的权重名称完全一致,只需要修改路径前缀。
权重映射规则
官方 MiniQwen
─────────────────────────────────────────────────
model.language_model.embed_tokens → model.embed_tokens
model.language_model.layers.N.* → model.layers.N.*
model.language_model.norm → model.norm
lm_head → lm_head
model.visual.* → 跳过
mtp.* → 跳过8.2 适配流程
adapter.py
│
├── 1. download_official_model()
│ └── 从 ModelScope 下载 Qwen3.5-0.8B-Base → checkpoints/qwen35_official_raw
│
├── 2. adapt_weights()
│ ├── 加载 safetensors 文件
│ ├── 名称映射 (去掉 language_model. 前缀)
│ ├── 跳过 visual.* 和 mtp.*
│ └── 处理 lm_head 权重共享(官方 tie,从 embedding 克隆)
│
├── 3. verify_weights()
│ ├── 对比 MiniQwen 期望的权重名
│ └── 测试加载到模型
│
└── 4. save_checkpoint()
└── 保存 model.safetensors + config.json + miniqwen_config.pt + tokenizer4 个函数的实际代码
download_official_model(output_dir):
from modelscope import snapshot_download
print(f"Downloading Qwen3.5-0.8B-Base to: {output_dir}")
path = snapshot_download(MODELSCOPE_OFFICIAL_MODEL_ID, local_dir=output_dir)MODELSCOPE_OFFICIAL_MODEL_ID = "Qwen/Qwen3.5-0.8B-Base",下载到 qwen35_official_raw。
adapt_weights(official_dir) -> dict:
def adapt_weights(official_dir: str) -> dict:
safetensor_files = sorted([f for f in os.listdir(official_dir) if f.endswith(".safetensors")])
official_state = {}
for fname in safetensor_files:
tensors = load_file(os.path.join(official_dir, fname))
official_state.update(tensors)
print(f" Official: {len(official_state)} tensors")
miniqwen_state = {}
skipped_vision = skipped_mtp = 0
for key, value in official_state.items():
if key.startswith("model.visual."): # 跳视觉
skipped_vision += 1; continue
if key.startswith("mtp."): # 跳 MTP
skipped_mtp += 1; continue
new_key = key.replace("model.language_model.", "model.", 1) # 去前缀
miniqwen_state[new_key] = value
# 官方 tie_word_embeddings=True,lm_head 不在权重文件,从 embedding 克隆
if "lm_head.weight" not in miniqwen_state and "model.embed_tokens.weight" in miniqwen_state:
miniqwen_state["lm_head.weight"] = miniqwen_state["model.embed_tokens.weight"].clone()
# safetensors 不支持同一 tensor 出现多次,clone 共享权重
seen_ptrs = set()
for key in list(miniqwen_state.keys()):
ptr = miniqwen_state[key].data_ptr()
if ptr in seen_ptrs:
miniqwen_state[key] = miniqwen_state[key].detach().clone()
seen_ptrs.add(ptr)
print(f" Adapted: {len(miniqwen_state)} tensors")
print(f" Skipped: vision={skipped_vision}, mtp={skipped_mtp}")
return miniqwen_stateverify_weights(miniqwen_state):对比 model.state_dict().keys() 和 miniqwen_state.keys() 的 missing/extra,理想情况都为空;再 model.load_state_dict(..., strict=False) 测试加载。
save_checkpoint(miniqwen_state, output_dir):
- 写
model.safetensors(主权重) - 复制
tokenizer.json/tokenizer_config.json(从仓库tokenizer/,与官方逐字节一致) - 写
config.json(MiniQwenConfig().to_dict(),权威构造参数,含rope_theta=1e7——minivllm 直接读这个) - 写
miniqwen_config.pt(pickle,向后兼容旧加载器)
权重统计
| 类别 | 数量 | 说明 |
|---|---|---|
| 官方总权重 | 488 | 包含文本 + 视觉 + MTP |
| 视觉权重 | 153 | 跳过(ViT 编码器) |
| MTP 权重 | 15 | 跳过(Multi-Token Prediction) |
| 适配后 | 321 | 纯文本模型权重 |
| MiniQwen 期望 | 321 | ✅ 完美匹配 |
8.3 实际操作
使用方法
cd /qwen35-reproduce
python miniqwen/adapter.py --output ./checkpoints/qwen35_adapter不传 --model_path 时自动从 ModelScope 下载官方权重到 checkpoints/qwen35_official_raw。
输出文件
checkpoints/qwen35_adapter/
├── model.safetensors # 适配后的权重 (约 1.9 GB)
├── config.json # MiniQwenConfig 全部构造参数(含 rope_theta=1e7)
├── miniqwen_config.pt # MiniQwen 配置对象(pickle,向后兼容旧加载器)
├── tokenizer.json # Tokenizer
└── tokenizer_config.json # Tokenizer 配置格式注意:适配产物是
model.safetensors+config.json,不是model.pt+config.pt。inference.py默认读model.pt/config.pt(那是预训练产物checkpoints/miniqwen_pretrain/miniqwen_final/的格式),想用inference.py直接加载适配权重会走到"随机权重"分支。正确方式是miniqwen/eval/pretrain_adapter/eff_official_vs_native.py那样load_file("checkpoints/qwen35_adapter/model.safetensors")加载。
检验标准
若终端输出如下则表明加载完毕:
Downloading Qwen3.5-0.8B-Base...
Loading official weights...
Official: 488 tensors
Adapted: 321 tensors
Skipped: vision=153, mtp=15
Verifying weights...
✅ All 321 tensors match perfectly!
✅ Weights loaded successfully
Total params: 752,393,024
Saved: checkpoints/qwen35_adapter/model.safetensors (1920.1 MB)8.4 加载适配后的权重
from safetensors.torch import load_file
from miniqwen.model.config import MiniQwenConfig
from miniqwen.model.modeling_miniqwen import MiniQwenForCausalLM
# 创建模型
model = MiniQwenForCausalLM(MiniQwenConfig())
# 加载适配后的权重
state = load_file("checkpoints/qwen35_adapter/model.safetensors")
model.load_state_dict(state)
# 推理
model = model.cuda().bfloat16().eval()8.5 eval 验证:行为等价 + 能力基线
完整套件在
miniqwen/eval/,6 个脚本覆盖了推理引擎评测(吞吐/C-Eval/数学/native 对齐/DeltaNet 微基准)。本节只展示"权重适配"相关的两个核心结果——这套适配产物的权重真的能复现官方行为吗?能力基线是什么水平?
Part 1:行为等价(同一份权重的官方 vs MiniQwen 行为一致吗?)
eff_official_vs_native.py 用同一份适配后的 model.safetensors 分别在官方 transformers (Qwen3_5ForConditionalGeneration) 和手写 MiniQwen 上跑 full forward,对比 logits。
4 段测试输入的逐位一致性:
| 输入 | max-abs-diff | top1 一致率 | 等价 |
|---|---|---|---|
深度学习是机器学习的一个分支... (len=11) | 0.00e+00 | 100% | ✅ |
请用三句话介绍你自己。 (len=6) | 0.00e+00 | 100% | ✅ |
Python 和 JavaScript 有什么区别? (len=8) | 0.00e+00 | 100% | ✅ |
什么是注意力机制?... (len=13) | 0.00e+00 | 100% | ✅ |
Part 2:效率差距(行为对齐了,但实现效率差多少?)
16 case 矩阵(prompt_len ∈ {64,256,1024,4096} × max_new ∈ {32,128} × batch ∈ {1,4})的代表性数据(完整表见 results/):
| case | engine | prefill(s) | decode tok/s | total(s) | 峰显存(MB) |
|---|---|---|---|---|---|
| p1024/n32/b1 | 官方(KV cache) | 0.20 | 37.55 | 1.03 | 3219 |
| p1024/n32/b1 | native(naive) | 0.18 | 4.99 | 6.40 | 3182 |
| p4096/n128/b1 | 官方 | 0.43 | 37.45 | 3.82 | 3599 |
| p4096/n128/b1 | native | 0.42 | 2.25 | 56.80 | 3491 |
| p4096/n128/b4 | 官方 | 0.68 | 35.84 | 4.22 | 5002 |
| p4096/n128/b4 | native | 0.70 | 1.42 | 90.38 | 4753 |
结论:
- 行为对齐(Part 1:logits 位一致)
- 效率差距(Part 2:官方 ~37 tok/s 稳定 vs native 1.4~5 tok/s 随上下文劣化)
native 慢的原因是没有 KV cache、每步全量重算(
Part 3:C-Eval 能力基线(这套适配产物能答对多少?)
eval_ceval.py 用 5-shot greedy 在 C-Eval val 集 10 学科 255 题上测试(完整分学科数据见 results/eval_ceval/summary.md):
| 维度 | 官方实现 | 我们的 MiniQwen |
|---|---|---|
| 总分(10 学科 255 题) | 108/255 = 42.35% | 108/255 = 42.35% |
| 行为忠实度(answer match rate) | — | 100% (255/255) |
| 行为忠实度(score match rate) | — | 100% (255/255) |
分学科准确率(官方 = native = 一致):
| 学科 | 准确率 | 学科 | 准确率 |
|---|---|---|---|
| college_programming | 54.05% (20/37) | education_science | 48.28% (14/29) |
| logic | 50.00% (11/22) | operating_system | 47.37% (9/19) |
| high_school_physics | 47.37% (9/19) | computer_network | 42.11% (8/19) |
| accountant | 40.82% (20/49) | law | 33.33% (8/24) |
| middle_school_mathematics | 26.32% (5/19) | high_school_mathematics | 22.22% (4/18) |
解读:
- 42.35% vs 25% 随机基线 → +17.35%,0.8B Base 模型具备真实推理能力(非刷分、非无脑随机)
- 行为忠实度 100% 证明官方和我们的 MiniQwen 输出逐位一致——再一次佐证 Part 1 的"权重适配正确"
- 42.35% 是未经指令微调的 Base 能力基线——第 9-10 章 SFT/DPO/GRPO 等后训练会进一步提升
8.6 总结
权重适配的核心要点
| 要点 | 说明 |
|---|---|
| 名称映射 | model.language_model.xxx → model.xxx |
| 跳过视觉 | model.visual.* 共 153 个权重 |
| 跳过 MTP | mtp.* 共 15 个权重 |
| lm_head 共享 | 官方 tie_word_embeddings=True,需要手动 clone |
| 验证 | 对比权重名和形状,确保 321/321 匹配 |
下一步可操作
- 后训练起点:用适配后的 Base 权重(或
qwen35_posttrain_adapter的后训练成品)做 SFT/DPO/GRPO 等(第 9-10 章) - 推理加速:用 minivllm 框架补 KV cache,把 decode 吞吐从 native 的 1.4~5 tok/s 提到 ~37 tok/s(第 11-13 章)