The Checkpoint Loading Trap: A 60-Point Mystery, Part 2
根因是一个时间差问题,两个发布物各自冻结在不同的时刻:
transfuser_proj.{weight,bias}bev_encoder_proj.{weight,bias},官方发布时的代码(如 commit 278fa5a)已是新名形状完全一致——权重矩阵都是 ,偏置都是 维——改的只有名字。装载器按名字匹配 key,旧名在模型里查无此层,这个张量被跳过;模型侧的新名等不到数据,层保持随机初始化。一层之差,60 分。
由此还能推出一个旁证:官方 README 报的 87.34 分必然产自改名之前的内部 ckpt——如果用 release 的这份权重配 release 的代码真跑过一遍 220 全量,官方自己也会看到 27.5 这个量级的分数。release 物之间自洽性从未被端到端验证过。
权重文件和代码是两条独立的版本线,改名、重构、模块拆分都只发生在代码一侧;ckpt 一旦发布就永久冻结,里面的 key 名是打包那一刻代码状态的快照。任何发布之后发生的重命名,都会在新代码 + 旧权重的组合上埋下失配。shape 一致反而让故障更隐蔽:连”维度对不上”这种最低级的告警都不会触发。
防御分两侧:发布侧在改名时应同步发布 key 迁移脚本或重新打包 ckpt;使用侧在装载后断言 missing/unexpected 为空(详见”教训一:missing 当硬错误”)。本案修复走的是使用侧:装载器里加一张改名表 _CKPT_KEY_RENAMES(详见”修复:key 重映射”)。
改名失配本身不罕见,罕见的是它全程静默地毁了 60 分。复盘装载路径,会发现防护是分三层布置的,而每一层都恰好放过了这一层。
流式装载器遍历 ckpt 文件里的 key,逐个去模型的 state dict 里查同名条目。transfuser_proj 在当代模型里查无此名,被计入 unexpected 列表——记一笔,继续走。这个设计服务于”部分加载”的正当需求(比如只加载主干、跳过任务头),代价是任何意外的失配也被同样容忍。
反过来,模型期待的 bev_encoder_proj.{weight,bias} 在文件侧没有对应物,计入 missing 列表。装载器把这个列表打印到日志,不抛异常、不返回非零退出码。这两行输出混在几百个 key 的正常装载信息里,前面后面全是成功记录——不专门 grep,肉眼扫过去毫无异常感。
而 missing 层的命运是:保持构造函数里的随机初始化值。层还在,shape 还对,前向传播照跑,输出全是有限数值——一切”能跑”的表征全部满足,只有数值内容是垃圾。
装载路径里其实有一道硬保险:BEV 主干那 1146 个 bev_encoder.* key 是单独提取出来、用 strict=True 装载的,任何 missing/unexpected 当场抛异常,零警告通过。但投影层叫 bev_encoder_proj——注意,前缀是 bev_encoder,它是 bev_encoder_proj——不在 strict 段的管辖范围。最强的一道防护覆盖的是事故层的邻居,不是事故层本身。
三道保险各自都是合理设计:容忍部分加载、日志告知、关键子网严格校验。失效的是它们的组合——没有一道负责回答”全模型最终是否每个参数都来自 ckpt”这个全局问题。按名匹配的装载体系里,这个全局正确性只能靠装载后显式检查 missing/unexpected 两个列表来闭环,缺了这一步,前面所有校验都只是局部保证。
这层整层只有两个张量:权重矩阵 和偏置 ,做的是一次仿射变换:
输入 是 BEV 编码器输出的几何特征(LiDAR + RGB 融合),输出 落在 Qwen3-VL 的嵌入空间(embedding space,大模型内部表示 token 的向量空间)维度上。参数量 M——在 5.6B 总参数里占比不到千分之一。
它的功能是跨模态对齐:BEV 编码器(几何特征空间)和大模型(语义嵌入空间)是独立训练的两个表征体系,维度和统计特性都不同,投影层学的就是两个空间之间的映射。这类 “encoder → projector → LLM” 的三段式结构是多模态大模型的标准做法(LLaVA 系列的视觉投影层是同一范式)。
直觉上”几百层坏一层”似乎可以靠冗余兜住,这个直觉在这里失效,原因是结构位置:
类比:给大模型递了一张用乱码写的地图。地图格式完好(64 个 token、每个 2560 维),内容全错,而读地图的人不知道自己读的是乱码——他会照样按图决策,比如坚信前方通畅。
每一帧推理都会执行(inference.py:185):
packed_sequence_fast[packed_bev_token_indexes] = bev_encoder_proj(x)
语义:BEV 编码器输出 ,经投影层变换后,原位覆盖写进 LLM 输入序列里预留的 64 个槽位。packed_sequence_fast 是把多模态输入打包好的 token 序列——文本、图像 token、BEV token 各有固定位置;packed_bev_token_indexes 就是 BEV 特征的那 64 个槽位的索引。同一层在 automot.py:506 的训练侧路径同样出现。
两个细节决定了故障的静默程度:
bev_encoder_feature 是推理必需输入(inference.py:263),不是可选增强——64 个 BEV token 承载的是障碍规避所依赖的几何感知于是每一帧,LLM 的输入序列里都有 64 个垃圾 token,而整条流水线——序列拼接、注意力、轨迹解码、PID 控制——没有任何一环察觉异常。
对比一下两种故障形态:如果 BEV 特征是可选输入、缺失时跳过拼接,装载失败会立刻表现为显式的输入缺失,容易查。而原位覆盖的设计让垃圾数据和正常数据走完全相同的路径,唯一的区别是数值内容——这正是权重级静默故障最难查的原因:所有结构层面的健康检查(shape、dtype、有限性、时序)全部通过,坏的只有语义。
取证时的推论也因此直接:既然链路结构无异常而行为全毁,嫌疑必然落在”数值内容的来源”上——也就是权重装载。
评测 harness 的 viz 存档为每一帧记录了 (throttle, brake, speed) 三个数。用阈值规则把每一帧归入三类:
| 类别 | 判据 | 语义 |
|---|---|---|
stuck_fullthr | throttle ≈ 1 且 speed ≈ 0 | 满油门零位移:想走,走不动 |
stop_brake | brake = 1 且 speed = 0 | 主动刹停:礼让、红灯、堵车 |
moving | speed > 4 m/s | 正常行驶 |
这套规则的力量在于把决策意图(油门/刹车,模型输出经控制链)和物理结果(车速,仿真世界)拆成两个独立通道分别观测。两个通道的组合直接区分故障层:
stuck_fullthr,占比 98.5%——整条路线就是一次原地空转stuck_fullthr修复前后用同一把尺子量同一条路线,是这组数字有证明力的原因——分类规则本身不变,变的是被测对象。
DS 是路线级的聚合结果,一条路线跑 10–20 分钟才出一个分;逐帧分类在失败路线的前几十帧就能给出主导模式。故障定性从”等 18 条路线出分再猜”变成”看 200 帧就定位”。这套三分类后来成了项目的标准诊断动作:任何闭环异常,先跑逐帧分类确定故障层,再决定排查方向——行为数据是取证现场,比分数早得多,也具体得多。
_CKPT_KEY_RENAMES修复只动评测侧的装载器 automot_utils.py(load_safetensors_weights_streaming),核心逻辑:
_CKPT_KEY_RENAMES = {"transfuser_proj.": "bev_encoder_proj."}
model_key = key # 文件里的原始名
for _old, _new in _CKPT_KEY_RENAMES.items():
if model_key.startswith(_old): # 前缀命中改名表
model_key = _new + model_key[len(_old):]
target = model_state.get(model_key) # 用新名查模型
tensor = f.get_tensor(key) # 用原名读文件
设计要点:
"transfuser_proj." 带句点前缀,一次规则同时覆盖 weight 和 bias,也对未来同类改名有扩展性修复后装载日志从 Streamed 1154 tensors, missing 2, unexpected 2 变为 Streamed 1156 tensors, 0 missing, 0 unexpected——账本里的最后 2 个 key 各就各位。
理论上也可以离线把 ckpt 里的 key 改名重新打包。不这么做的原因:
补丁 ≠ 修复完成。装载侧修复后接三级验证:离线 mock 模型断言逐位相等(证明装载正确)→ 3 路线冒烟(DS 25.42/58.55/12.96 → 三个 100,零违章)→ 220 全量 v2 重启(前 10 条 meanDS 96.0,回到官方 87/89 水位)。每一级失败都止损在对应成本上,不会把 30 小时扔进一个未验证的补丁。
修补丁的第一版自己先崩了:改完名之后,拿新名去读文件——f.get_tensor("bev_encoder_proj.weight")。可 ckpt 文件里根本没有这个 key(文件冻结在改名之前,里面只有 transfuser_proj.*),safetensors 直接抛 SafetensorError。
这个 bug 的本质是把两个不同的命名空间当成一个:
| 命名空间 | 由谁定义 | 有效 key |
|---|---|---|
| 文件侧 | ckpt 打包那一刻的代码(2026-05-17) | transfuser_proj.* |
| 模型侧 | 当前代码仓 | bev_encoder_proj.* |
改名表 _CKPT_KEY_RENAMES 是两个空间之间的翻译器,翻译只发生在跨界的那一刻。正确写法是两个变量各管一段:
tensor = f.get_tensor(key) # key = 原名,文件命名空间
target = model_state.get(model_key) # model_key = 新名,模型命名空间
错误写法 f.get_tensor(model_key) 把翻译后的名字拿回原文空间去查——就像拿着译名去查原文词典,必然查无此词。
写重映射补丁时,人脑里的自然叙述是”把旧名改成新名然后加载”,代码里 key 和 model_key 又只差一个前缀,变量名相似、作用域相邻,混用是最顺手的写法。这类”补丁的补丁”事故概率极高,对抗方法不是更小心,而是离线验证先行:用 Tiny mock 模型在分钟级成本下把 loader 跑一遍,让 SafetensorError 在本地炸掉,而不是在 30 小时闭环的前夜炸掉。
更一般的教训:修 bug 的补丁本身也是新代码,和所有新代码一样需要验证。区别只在于验证成本——loader 补丁的验证可以是纯 CPU、纯离线、分钟级的,没有任何理由跳过。
验证一个装载补丁有两个选项:
选项之间是几个数量级的成本差,而它们的判别力在装载层面是等价的——装载正确性不依赖仿真、不依赖 GPU、不依赖驾驶行为,只依赖 key 映射和张量拷贝这两件事。凡是能用便宜实验回答的问题,绝不用贵实验回答。
tools/t13_remap_check.py 的思路:
bev_encoder_proj.{weight,bias}load_safetensors_weights_streaming(含 _CKPT_KEY_RENAMES 补丁)从真实 ckpt 装载missing == []、重映射 1:1 命中bev_encoder_proj 的值与 ckpt 里 transfuser_proj 的值逐位相等key 重映射是纯改名,不涉及任何数值变换——没有 dtype 转换、没有 reshape、没有归一化。因此装载的正确性标准就是位级一致:模型侧张量的每一个字节都应等于文件侧张量的对应字节。这个断言强到没有模糊空间:要么全等(映射正确),要么不等(映射或拷贝有 bug)。用 torch 写就是:
assert torch.equal(model_state["bev_encoder_proj.weight"],
ckpt_tensor) # 位级相等,非 allclose
注意用 torch.equal(精确相等)而不是 torch.allclose(容差近似)——纯拷贝路径下出现任何数值差异都意味着存在预期之外的变换,应当视为 bug 而非误差。
离线验证只是第一级。完整序列是:mock 断言(分钟,CPU)→ 3 路线冒烟(小时级,验证行为翻转)→ 220 全量(30 小时,验证分数水位)。每一级都假设上一级已通过——冒烟不需要再怀疑装载,全量不需要再怀疑单路线行为。把最便宜的验证放在最前面,是这次 40 分钟定位根因、一次修复即翻盘的方法论底座。
复现场景(目标是逐位复刻官方权重行为)里,装载完成后的正确姿势是立即断言,不匹配就当场失败:
missing, unexpected = load_checkpoint(model, ckpt_path)
if missing or unexpected:
raise RuntimeError(
f"ckpt 装载不完整: missing={missing}, unexpected={unexpected}"
)
# 走到这里 = 全模型每个参数都来自 ckpt
一行断言的价值对比:有它,改名失配在评测起跑那一刻炸掉,报错信息直接给出涉案 key 名;没它,同一故障表现为 30 小时后的 meanDS 27.5,加 40 分钟的五嫌疑排查,加一整晚的作废算力。fail fast(快速失败)原则在权重装载这个场景的回报率极高,因为故障信号(missing/unexpected 列表)本来就存在,缺的只是”把它当错误”这个动作。
不是所有 missing 都同样致命。危险等级可以这样排:
第一类的特征是”动静最小、破坏最大”:missing 列表里只有 2 个 key,占总 key 数的千分之一不到,行为损失却是 60 分。所以断言不该只对”大量 missing”报警——任何非空都该失败,数量不构成赦免理由。
Streamed N tensors 的 N 应与 key 审计总数对账(本案 2302 = 1146 strict + 1156 流式,修复后口径)