The Checkpoint Loading Trap: A 60-Point Mystery, Part 1
AutoMoT(ICML 2026)是一种端到端(End-to-End, E2E)自动驾驶模型,核心结构是异步双专家:一个 Qwen3-VL 视觉语言模型(Vision-Language Model, VLM)专家负责场景理解,一个 BEV 编码器(Bird’s-Eye-View Encoder,鸟瞰图编码器)专家负责几何感知,两路特征在序列层面汇合后由动作分支输出规划轨迹。总参数 5.6B。
| 通道 | 输入 | 作用 |
|---|---|---|
| VLM 专家 | 前视相机 CAM_FRONT(1024×512,FOV 110°),是 VLM 唯一的前视视觉输入 | 语义理解、交通规则、场景推理 |
| BEV 编码器专家 | 激光雷达(LiDAR)+ RGB 融合的几何特征 | 障碍物定位、规避,承载几何感知 |
| 俯视 BEV 相机(512²,FOV 50°,高 50 m) | 仅供可视化(viz)与渲染存档 | 不进模型 |
关键点:动作分支的输入含 LiDAR 通道。这意味着 AutoMoT 不是纯视觉模型——做”渲染保真度对 E2E 模型的影响”这类实验时,结论必须注明 LiDAR 对渲染变量的稀释:相机画面再差,几何通道还在兜底。反过来,本案的事故恰好证明了这条通道有多重要:BEV 特征进大模型的唯一入口(一个投影层)坏掉,行为立刻全毁。
Oscar-Huang/AutoMoT,commit fc79b630,lastModified 2026-05-17model.safetensors 单文件 13,441,415,278 B(约 12.5 GiB),双专家 + BEV 编码器合一份,共 2302 个张量 keybev_encoder.* 前缀 1146 个 key(约 106.5M 参数,推理时全冻结)model.safetensors.index.json,引用两个并不存在的分片文件——装载器优先走单文件,index 永远不会被读,保留原样即可驾驶分数(Driving Score, DS)是 CARLA leaderboard / Bench2Drive 的主指标,定义为路线完成度乘以违章罚分系数的连乘积:
其中 是路线完成度(Route Completion,实际驶过的路线比例), 是第 类违章的罚分系数, 是该类违章的次数。每发生一次违章,总分就被对应系数乘一次——违章是乘性惩罚,不是扣分,所以一次撞车可能打掉几十分。
以 CARLA leaderboard 公开实现中的系数为例:撞行人 0.50、撞车 0.60、撞静态物 0.65、闯红灯 0.30、闯 stop 标志 0.20。乘性结构意味着 DS 对重违章极度敏感:RC 跑满 100% 但撞两次车,。
注意什么不在罚分乘积里:低速行驶(MinSpeedTest)只影响路线状态判定(可能触发 AgentBlocked 终止),不直接进 DS 的乘积项。本项目实测 AutoMoT 修复前后都有 MinSpeedTest FAILURE,属于模型固有的谨慎驾驶特征,不需要为它”修”什么。
同一个官方 ckpt 有两个参考分数:README 报 87.34、HF 模型卡报 89.42,都是 Bench2Drive 220 条路线全量口径。两个数字差 2 分,本身就提示了该评测的噪声量级——随机种子、背景交通采样的波动足以让同一模型浮动一两分。因此复现判据定为 ±3:落在锚点附近 3 分内,或者给出书面解释。
三条独立的证据方向一致:
均值的数学性质也支持这个判断:DS 是几十上百条路线的均值,单路线随机性会被平均掉。均值级别的崩塌,只能来自每一条路线都共同携带的系统性缺陷。
220 条路线全量评测要跑约 30 小时,CARLA server 又是出名的易崩进程,无人值守过夜必须解决三个问题:怎么发现卡住、怎么自动恢复、怎么判断该放弃。本项目用三层机制分别覆盖。
| 层 | 实现 | 职责 |
|---|---|---|
| cron 巡检 | 每 30 分钟一班(:13/:43) | 读进度游标与分数走势,做”要不要继续”的决策层判断 |
| 停滞看门狗 | tools/t12_eval_guardian.sh | 评测日志超过 25 分钟无写入 → 杀全部匹配进程,触发下层自愈 |
| harness 自愈 | 外层 bash while 循环 + server 自动重启 | 评测器崩溃/被杀后拉起续跑,server 崩溃后重启 |
分工逻辑:harness 只会”重启”,看门狗负责”判定停滞并制造重启的契机”,cron 巡检负责需要理解的判断(比如”分数持续走低”这种看门狗看不懂的模式)。机器班兜底死锁类硬故障,需要语义判断的异常留给巡检班次。
pgrep -f 会匹配发起 kill 的 shell 自身:复合命令里的模式串会出现在自己的命令行里,不先排除就会误杀自己。杀之前先 pgrep -af 人工确认目标。head -1 只杀一个:conda run 包装进程和真正的 evaluator 是两个进程,只杀包装层,真 evaluator 漏杀、tee 管道不关,harness 会卡在管道上永远等不到退出。必须杀掉全部匹配,且按顺序:外层 run_eval bash → conda run 包装 → 真 evaluator,漏杀外层 bash 会导致无限重启。leaderboard 评测器每跑完一条路线就落一个 eval_<id>.json,续跑时跳过已有结果的路线——所以中途停车不损失任何已完成进度,这是 07:14 敢于主动刹车的前提。
但 leaderboard 的 --resume 参数是 type=bool 的经典 Python 坑:argparse 的 bool 类型对任何非空字符串都解析为 True,传 --resume=False 依然是续跑。首轮起跑必须整个省略这个参数,续跑时才显式传 --resume=True。官方 run_evaluation.sh 恒传 --resume=${RESUME}(默认 “False”),等于恒续跑,自写脚本不要照抄这一点。
leaderboard 评测里,一条路线的结局不止”成功/失败”两种,终态本身携带信息:
| 终态 | 含义 | 典型根因 |
|---|---|---|
| Completed | 路线跑完 | — |
| TickRuntime | 路线的仿真时间预算耗尽 | 车开得太慢、堵死、原地不动 |
| AgentBlocked | 低速检测持续触发,被判卡死终止 | 长时间不动或蠕动 |
| 碰撞/违章类失败 | 罚分把 DS 打到极低或触发终止 | 决策激进、感知漏检 |
同样的低分,终态分布完全不同:开得野的模型死于碰撞类,开不动的模型死于 TickRuntime / AgentBlocked。
18 条路线:Completed 只有 4 条,失败以 TickRuntime 占多数,路线完成度普遍只有 35–58 个百分点。三个数字指向同一个方向——车在爬行,把时间预算磨蹭耗尽,而不是撞出去的。
签名分析的关键是同构性。如果问题是”这批路线恰好难”,失败方式应该多样化:窄路会车撞了、施工区绕不过去、路口被堵死……各种终态散布出现。而 14/18 的失败共享同一个终态、同一个行为模式(满油门零位移),说明所有路线命中的是同一个缺陷——这个缺陷在模型或链路内部,不在路线难度里。
同一套仿真器、同一个时段,SimLingo 侧的同类超时只是零星个位数,这边是批量出现。链路全程无报错、无崩溃,说明管道”能跑”,只是跑得极差。这把嫌疑范围从”环境坏了”收窄到”这个模型(或它的装载)坏了”。
取证上的方法论价值:先看失败签名的分布形状,再决定往哪查。签名同构 → 系统性缺陷,查模型侧;签名异构 → 路线/场景问题,查环境侧。这一步只需要读评测结果的 JSON,成本几乎为零,却能砍掉一半排查空间。
AutoMoT 评测的传感器由 agent 的 sensors() 方法自报,harness 按声明挂接。与官方文档逐项核对的结果:
| 传感器 | 关键参数 | 用途 |
|---|---|---|
LiDAR(sensor.lidar.ray_cast) | 位姿 x=0, y=0, z=2.5 m,偏航 −90° | BEV 编码器的几何输入 |
| CAM_FRONT | 1024×512,FOV 110° | VLM 唯一前视视觉输入 |
| 俯视 BEV 相机 | 512×512,FOV 50°,安装高度 z=50 m | 仅供 viz/渲染,不进模型 |
| 所有传感器 | sensor_tick 与 20 Hz 同步假设一致(tick = 0.05 s) | 帧对齐 |
视场角(Field of View, FOV)决定针孔相机的内参。水平 FOV 为 、图像宽 像素时,等效焦距
前视相机 、 时 像素。FOV 差 10°,同一个物体在图像里的投影尺寸和位置就系统性偏移——模型的输入分布直接漂移。
传感器配置错误的危害模式与本案症状同款:不报错、能跑、行为系统性走样。错装 5° 的偏航、差一档的分辨率、错位 0.5 m 的安装高度,任何一项都让模型输入偏离训练分布(distribution shift),而神经网络对分布外输入没有告警机制——它照常输出,只是输出的是错的东西。
排查时每项都要对到数值和符号:LiDAR 偏航 −90° 这种带符号的约定,漏掉负号就是”旋转 180° 的世界”,模型不会崩,只会绕不开障碍。
512×512 像素、FOV 50°、高 50 m 的俯视相机,地面覆盖范围边长约 m,即每像素约 9 cm——够看清车辆轮廓和车道线,做 BEV 可视化刚好,但不承担模型输入。
模型输出规划轨迹后,由 PID 控制器(比例-积分-微分控制器,Proportional-Integral-Derivative Controller)把轨迹跟踪误差翻译成油门/刹车/方向盘。纵向控制的标准形式:
其中 是期望速度与当前车速的误差, 映射到油门(正)或刹车(负)。核对点有三个:PID 增益参数、20 Hz 的控制频率假设(积分/微分项对步长敏感,频率错了整个控制器就错)、期望速度公式。三项全部与代码注释一致——控制链没有坏。
viz 存档的逐帧数据给出了本案最有信息量的一个画面:throttle ≈ 1.0 且 speed = 0,而且持续几百帧。
这个组合排除了控制链,因为控制链的行为完全正常:
控制器忠实地执行了一个疯狂的决策。决策层坚信前方一马平川,物理世界里车头却顶着障碍物。矛盾只能来自决策层的输入:它看不见那个障碍物。故障在感知侧,不在执行侧——一次”排除”反而把嫌疑指向了感知的输入链路。
工程上”车不动”有两类常见根因,取证时必须分开:
本案两者都不是:输出(满油门)与模型”以为的场景”自洽,只是那个场景是假的。这第三种模式——感知失明但决策自洽——是权重级故障的指纹。
怀疑官方 checkpoint(ckpt,模型权重文件)是”另一代架构”时,可以按成本从低到高做三层审计,每层都能独立排除一类问题。
Hugging Face 的大文件走 Git LFS(Large File Storage):仓里存的是指针文件,真正的二进制在对象存储。指针文件里写着
oid sha256:58517de6afd215e29c9ea5e1c5ab0da77395230895cbea9b9e3e06f50c3047a3
下载完成后对本地文件算 sha256,与这个 oid 逐字符比对。一致即证明传输无损——13 GB 文件经镜像站下载,这一步是必做的,不算过度谨慎。本案核验通过:下载无损。
config.json / bev_config.json 描述模型的架构超参(层数、维度、注意力头数等)。与当前代码仓同一 release 的 config 逐字段一致,说明”官方发布的权重”和”官方发布的代码”在架构声明层面是配套的。
safetensors 格式的 header 是 JSON,记录全部张量名、shape、dtype。用 safe_open 只读 header 就能枚举 key 清单,不需要把 12.5 GiB 权重载进内存:
from safetensors import safe_open
with safe_open("model.safetensors", framework="pt") as f:
keys = list(f.keys()) # 只读 header,秒级完成
# 2302 个 key,其中 bev_encoder.* 前缀 1146 个
本案审计结果:2302 个 key 中,装载日志确认 1156 个载入后架构 100% 匹配——排除”整份 ckpt 是旧架构”。
注意三层审计共同的边界:它们验证的是文件与架构声明,不是”每个张量是否真的进了模型”。strict 装载只覆盖了 BEV 主干那 1146 个被单独提取的 key;剩下的流式装载段按名匹配、只打印警告。三层审计全绿,和”有一层没装上”可以同时成立——本案正是如此。完整性审计的最后一公里,只能靠装载后检查 missing/unexpected 列表来补,那是嫌疑①的领地。
PyTorch 的 load_state_dict 本质是按 key 名字对表。模型侧有一份期望的 key 清单,ckpt 文件里有一份实际的 key 清单,两张表做集合运算:
关键在默认行为:当 strict=False(或任何自定义的流式装载器),这两个列表只是返回值或日志输出,不抛异常、不中断执行。PyTorch 官方文档对 strict 的描述是:为 True 时两个列表都必须为空,否则抛 RuntimeError;为 False 时任何不匹配都被静默容忍。这是为”部分加载预训练权重”这个正常需求设计的——比如微调时换个分类头,missing 一个 head 是预期行为。但对”全量复现官方权重”的场景,missing 一个层就是事故。
装载日志里一直躺着:
Streamed 1154 tensors
missing: ['bev_encoder_proj.weight', 'bev_encoder_proj.bias']
unexpected: ['transfuser_proj.weight', 'transfuser_proj.bias']
读法:ckpt 里有两个叫 transfuser_proj.* 的张量没人认领(unexpected);模型期待的两个叫 bev_encoder_proj.* 的张量没等到货(missing)。两个 missing 配两个 unexpected,名字还明显是同一对东西的两种叫法——这就是改名失配的教科书签名。missing 和 unexpected 数量相等、形状互补、名字语义相近时,第一反应应该是查改名史,而不是当作无害噪声。
这两行混在几百个 key 的装载输出里,前后都是正常信息,不 grep 专门找根本不会注意。更麻烦的是它不崩:missing 的层保持随机初始化,前向传播照常跑,shape 全对,数值全是有限值,单元测试都未必抓得到——只有行为在下游缓慢而彻底地崩坏。静默故障(silent failure)是所有故障里取证成本最高的一类,因为它不产生任何”该停下来看看”的信号。
模型权重文件共 2302 个张量 key,每个 key 在装载时只有三种归宿:进 strict 装载段、进流式装载段、漏载。对账:
| 分录 | 数量 | 说明 |
|---|---|---|
bev_encoder.* 单独提取 | 1146 | BEV 主干,strict 装载,零警告,约 106.5M 参数全冻结 |
| 主流式装载段载入 | 1154 | 日志:Streamed 1154 tensors |
| 流式段漏载(unexpected) | 2 | transfuser_proj.weight / transfuser_proj.bias,无人认领 |
| 合计 | 2302 | 1146 + 1154 + 2,一个不多一个不少 |
等式两边严丝合缝,说明这份清单是完备的——不存在第四类去向,不存在未统计的 key。
对账的价值不在数字本身,而在它支撑的两个排他性结论:
bev_encoder_proj.*)。其余 2300 个 key 全部各就各位。全模型几百层里,随机初始化的恰好且仅是这一个投影层。BEV 主干那 1146 个 key 享受的是最强保护:单独提取、strict=True 装载,任何不匹配当场抛异常。这个保护通过了——零警告。但投影层 bev_encoder_proj 不属于 bev_encoder.* 前缀(注意名字:一个是 bev_encoder,一个是 bev_encoder_proj),不在 strict 段的管辖范围。最强的一道保险恰好没有覆盖案发层,剩下的流式段又只打印不报错。对账把这一点暴露出来:防护强度最高的区段和事故区段不重叠。
这类对账不需要跑模型,safe_open 读 header 枚举 key(秒级),加上装载日志里的 Streamed N tensors 行和 missing/unexpected 列表,三处来源互相印证即可。2302 = 1146 + 1154 + 2 这个等式写进诊断报告,后续任何人复查都能在分钟内复算。