AutoMoT Architecture: Async Dual Experts and a BEV World Model, Part 1
双专家设计的理论原型是认知科学里的双过程理论(dual-process theory):慢系统(System 2)负责需要逐步推理的深思熟虑,快系统(System 1)负责毫秒级的直觉反应。开车恰好同时需要两者:路口让行决策是一条多步推理链(有行人、有对向车、谁先走),而前车急刹的响应不允许任何”思考时间”。
视觉语言模型(VLM)生成推理链是自回归(autoregressive)的:每输出一个 token 都要完整前向一次, 个 token 就是 次串行前向,延迟随推理链长度线性增长,无法压缩到控制周期以内。
控制侧的账是硬的。车辆以速度 行驶、决策延迟为 时,延迟期间车是”盲开”的,盲开距离
(50 km/h)时,100 ms 的额外延迟就是 1.39 m 的盲开——足以决定一次行人避让的成败。因此控制环通常要求 10 Hz 以上的更新率,这与 VLM”逐 token 生成一段自然语言推理”的节奏天然冲突。
与其让单模型在两种节奏间硬扛(SimLingo 的路线:每帧都完整跑一遍 VLM),AutoMoT 把两种节奏拆给两个专家:
两个时钟之间靠 KV 缓存传递语义(机制见本 part B08 的深读),慢推理的一次开销被摊薄到其后的若干帧上。
多头注意力(Multi-Head Attention)把隐向量切成 个头,每个头维度 ,分别做缩放点积注意力再拼回:
AutoMoT 的文本塔基于 Qwen3-VL,隐维度 2560,理解(understanding, und)侧为 32 头,故 。
混合 token Transformer(Mixture of Tokens, MoT)的关键不是”两个小模型串联”,而是同一个解码层里挂两套 QKV 投影,按 token 类型分流。官方源码 qwen3vl_navit.py 的 PackedAttentionMoT.__init__:
# 大专家(理解 token)沿用父类的 q_proj/k_proj/v_proj: 32 头 / 32 KV 头
# 小专家(动作/生成 token)独立的一套投影:
self.q_proj_mot_gen = nn.Linear(hidden_size, self.mot_num_heads * self.mot_head_dim, ...) # 2560 -> 16×80
self.k_proj_mot_gen = nn.Linear(hidden_size, self.mot_num_key_value_heads * self.mot_head_dim, ...) # 2560 -> 4×80
self.v_proj_mot_gen = nn.Linear(hidden_size, self.mot_num_key_value_heads * self.mot_head_dim, ...) # 2560 -> 4×80
self.o_proj_mot_gen = nn.Linear(self.mot_num_heads * self.mot_head_dim, hidden_size, ...) # 16×80 -> 2560
配置默认值(同文件 Qwen3VLTextConfig):mot_num_attention_heads=16、mot_num_key_value_heads=4、mot_head_dim = hidden_size // num_attention_heads = 80——头维度两边相同,小专家只是头更少。KV 侧 16 头共享 4 组键值,即分组查询注意力(Grouped-Query Attention, GQA),每组 个查询头共用一对 K/V。
只算注意力投影(不含 MLP),每层:
小专家的注意力参数量约为大专家的 。MoT 另有独立的 MoT MLP 分支(mot_intermediate_size),但层归一化、位置编码、层间连接结构全部共享——这是它与”双塔双专家”(两个完整网络)的本质区别:显存省一半左右,还保留了共享底座带来的表征对齐。
AutoMoT 的文本塔采用 NaViT 式打包(packing):一个 batch 里多条样本的 token 首尾相接拼成一条长序列,用块稀疏注意力掩码(block mask)隔离样本,省掉 padding 浪费。代价是序列里什么位置是什么 token,全靠索引数组标注。
MoT 分流就建立在这套索引上:进层前有序列里两个下标集合——packed_und_token_indexes(理解 token)与 packed_gen_token_indexes(动作/生成 token)。前向是”取出—各算各的—写回”三步,官方源码 qwen3vl_navit.py 的 PackedAttentionMoT.forward_train:
packed_sequence_und = packed_sequence[packed_und_token_indexes] # gather:取出理解 token
packed_sequence_gen = packed_sequence[packed_gen_token_indexes] # gather:取出动作 token
und_q = self.q_proj(packed_sequence_und) # 大专家投影(32 头)
gen_q = self.q_proj_mot_gen(packed_sequence_gen) # 小专家投影(16 头)
packed_query_states[packed_und_token_indexes] = und_q # scatter:写回原位
packed_query_states[packed_gen_token_indexes, :self.mot_num_heads, :self.mot_head_dim] = gen_q
两个专家头数不同(32 vs 16),却要在同一次 batched 注意力里算完。代码的处理是把 Q/K/V 张量按两者的最大形状分配,小专家的结果只写进前 mot_num_heads 个头、前 mot_head_dim 维,其余补零。softmax 后零项不参与有效输出的读出(输出投影只取小专家自己的头),所以填充不改变数学结果——这是工程对齐手段,不是模型结构的一部分。
训练时两类 token 一次打包前向,损失同时回传两个专家;推理时则可以只喂 gen token、复用 und token 留下的 KV 缓存,让快专家单独跑——分流信息(索引数组)与计算(投影)彻底解耦,专家才被拆得开。分拣机制是”能拆”,KV 桥接是”拆完还能协作”(见本 part B08)。
自回归解码时,第 个 token 的注意力需要此前所有 token 的键值对 。不重算、把它们存下来,就是 KV 缓存(KV cache)。设上下文长 、隐维度 :全量重算每个新 token 的代价是 ,用缓存则只需算新 token 自己的 Q/K/V、再与旧缓存做一次注意力,。上下文越长,省的越多。
通常 KV 缓存只是同一序列内的提速手段。AutoMoT 把它升级成两个专家、两个时钟之间的接口:慢专家在时刻 看图、看 BEV、生成推理链,这一路 token 的 KV 留在缓存里;接下来若干帧,快专家每帧只编码新的视觉/BEV/query token 并追加进缓存,注意力直接读到慢专家的旧键值——相当于”接着上次想完的继续开”,而不是每帧重想。
官方推理器(Automot/evaluation/inference.py 的 InterleaveInferencer)把这个节奏写得很直白:
def kv_cache_inference(self, ..., frame_idx: Optional[int] = 0, slow_update_interval: int = 2):
if frame_idx % slow_update_interval == 0 or not hasattr(self, "_cached_gen_context"):
... # 慢专家:完整前向,刷新 gen_context 里的 past_key_values / kv_lens / ropes
# 快专家路径:只准备新 token 的增量,与旧缓存拼接
generation_input, kv_lens, ropes = self.model.prepare_fast_kvcache(...)
slow_update_interval=2 即默认每 2 帧才让慢专家”重新想一次”,其余帧快专家全在复用旧缓存。
SimLingo 官方代码没有 KV 缓存,本项目为其打了提速补丁(SIMLINGO_FASTPATH:kv-cache + driving 复用 cache),性质是纯提速——同一时钟内少算重复前缀,不改变任何语义流。对照实验口径见项目实测:split20 子集 fast DS=94.39 vs 原始 92.54(噪声带内),墙钟 2.24×。AutoMoT 的缓存则是架构原生部件,承载的是跨专家、跨时刻的上下文传递——同为 KV cache,角色完全不同。
鸟瞰图(Bird’s-Eye-View, BEV)编码器是 AutoMoT 的”世界模型”前段,双分支结构承自 TransFuser 一脉:
两条分支层级数对齐(各 4 级),这是逐阶段融合的前提。
官方源码 bev_encoder.py 的 BEVEncoderBackbone.forward,核心就是一个循环:
# Loop through the 4 blocks of the network.
for i in range(4):
image_features = self.forward_layer_block(image_layers, ..., image_features) # 图像分支前进一级
lidar_features = self.forward_layer_block(lidar_layers, ..., lidar_features) # 激光分支前进一级
image_features, lidar_features = self.fuse_features(image_features, lidar_features, i) # 交换一次信息
fuse_features 内部是每个 stage 各一座小型 Transformer(代码里的 GPT 模块):把对方分支的特征作 key/value、自己作 query 做交叉注意力,再残差加回。于是信息交换发生在 4 个不同尺度上——从 1/4 分辨率的纹理级细节到 1/32 分辨率的目标级语义。
对照做法是”最后拼接”(late fusion):两分支各自跑完,只在最高层拼一次特征。问题在于 CNN/Transformer 的层级特性——浅层提取边缘、纹理、局部几何,深层才是语义;只在最深层融合,低层级的跨模态对齐(“这条边缘对应点云里这堵墙”)就永远没机会发生。逐阶段融合让每个尺度都有自己的跨模态校正机会,是融合网络从 TransFuser 一路验证下来的老经验:缝得越早、越频繁,两模态的特征越能互相 grounding。
四阶段融合之后,BEV 编码器输出的世界是一张特征图:形状 —— 为 batch,1512 维通道, 空间网格。把它嵌进语言序列只需三步,官方源码 automot.py:
# bev_encoder_feature: [B, 1512, 8, 8]
assert C == 1512, f"expect 1512 channels, got {C}"
assert H * W == 64, f"expect 8x8=64 spatial positions"
x = x.flatten(2).transpose(1, 2) # [B, 1512, 8, 8] -> [B, 64, 1512] 展平空间维
bev_tok = x.reshape(-1, 1512) # [B*64, 1512]
bev_tok = self.bev_encoder_proj(bev_tok) # 线性投影 1512 -> 2560,对齐 LLM 隐维度
packed_sequence[packed_bev_indexes] = bev_tok # 按索引写回打包序列
一层 nn.Linear(1512, 2560) 完成”几何特征到语言空间”的翻译,之后这 64 个 token 与文字、视觉 token 走完全相同的注意力——没有额外适配器,没有特殊位置编码分支。
LiDAR 直方图覆盖自车周围 ±32 m(网格分辨率见 ep11p2 B06 的深读), 网格意味着每个 token 概括约 的一块地面。用 64 个 token 表征整个俯视世界,在序列预算里几乎免费——对比之下一张图经 ViT 切块动辄数百 token。token 越省,慢专家生成推理链时的注意力上下文越短,异步架构的摊薄收益才成立。
代码里的两行 assert(通道必须 1512、空间位置必须 64)也值得注意:这类”形状断言”是长链路系统的廉价保险——本项目 T1.3 的 ckpt 键改名事故里,BEV 投影层随机初始化后形状仍全对、assert 全过,车照样满油门顶墙。形状对,不代表权重对。
横向控制的输入是 20 个路径点。官方源码 automot.py 的 RouteHead:
self.query = nn.Parameter(0.02 * torch.randn(1, future_waypoints, hidden_size)) # 20 个可学习查询
self.mlp = nn.Sequential(
nn.Linear(hidden_size, mlp_dim * 2), nn.SiLU(True),
nn.Linear(mlp_dim * 2, mlp_dim), nn.SiLU(True),
nn.Linear(mlp_dim, 2, bias=False),
)
def forward(self, features):
route = self.mlp(features).cumsum(dim=1) # 网络输出增量,沿 20 点维累加得绝对坐标
return route
网络实际回归的是相邻点之间的位移增量 ,绝对路径由累加得到:
为什么差分更稳:绝对坐标的数值随距离增大(远处点几十米),误差也在远处被放大且逐点相关;增量的数值范围小、各点近似独立,回归目标的分布更紧凑。代价是误差沿累加链累积——末点误差是前 19 个增量误差之和,这正与”越远的点本来就越不重要”的控制需求相匹配。
WaypointsHead 结构同形,6 个查询、输出 6 个轨迹点。agent 侧(mot_b2d_agent.py)注释写明口径:6 点、间隔 0.5 s、共 3 s 的时空轨迹,期望速度直接从相邻点位移推出:
# MoT trajectory: 6 points, 0.5s interval each, total 3s
desired_speed = np.linalg.norm(speed_waypoints_np[one_second_idx] - speed_waypoints_np[half_second_idx]) * 2.0
# 相邻点(0.5 s 间隔)的欧氏距离 × 2 = m/s
velocity_head 是两层 MLP(Linear(2560,2560) → LeakyReLU → Linear(2560,3)),输出 3 维速度决策;训练侧的损失按 1 s / 2 s / 3 s 三档分别计 L2 误差(vad_traj_loss 的 n_1s=2, n_2s=4, n_3s=6)——决策序列的三档输出就来自这里。
感知决策交给网络,执行交给不可学习的控制器——端到端骨干 + PID 执行器的典型搭配。
同一道考题(Bench2Drive 闭环驾驶)的两种解法,逐维度对照:
| 维度 | SimLingo(主模型,CVPR’25) | AutoMoT(第二模型,ICML’26) |
|---|---|---|
| 底座 | InternVL2-1B | Qwen3-VL,4B 理解专家 + 1.6B 动作专家(共 5.6B) |
| 传感器 | 单前视相机,纯视觉 | 前视相机 + LiDAR(64 个 BEV token) |
| 节奏 | 单时钟:每帧完整跑一遍 VLM | 双时钟:慢专家低频想、快专家高频做 |
| KV 缓存 | 官方无;本项目后打补丁(SIMLINGO_FASTPATH),纯提速 | 架构原生:跨专家、跨帧传语义 |
| 速度输出 | 10 点轨迹回归出速度 | 3 维速度头 + 6 点(3 s)轨迹 |
| 官方 DS/SR | 85.9/66.8 | README 87.34/70.00;HF 卡 89.42/74.09 |
| 5090(32GB) 微调 | 可:LoRA 可训练参数 1759.6 万(占 2.72%);全量微调 bs=6 峰值 19.6 GiB | 不可:官方训练默认单卡 80 GB,只能推理 |
渲染敏感度不对称。 SimLingo 的感知输入 100% 来自渲染画面,渲染变化的影响可归因;AutoMoT 的 64 个 BEV token 是几何量、不随渲染变,渲染效应被结构性稀释。这是项目选 SimLingo 当主模型、把含 LiDAR 方案一票否决为主模型的原因(稀释问题详见 ep11p2 B12)。
可训练性不对称。 SimLingo 在 32 GB 的 5090 上可以 LoRA 乃至全量微调,支撑项目后半程的”UE5 数据微调”实验;AutoMoT 训练需 80 GB,角色被锁定为 UE4 基线 + UE5 zero-shot——它的微调档在本系列里不存在,不是没做,是物理上做不了。
文本 tokenizer 只认离散词表,速度(一个浮点数)、目标点(一个二维坐标)这类连续量没有对应的”字”。VLM 的通用解法:用一个微型 MLP 把低维连续值升到模型隐维度,伪装成一个 token。AutoMoT 官方源码 automot.py 里的两件套:
# 目标点编码器 WaypointInputAdaptor: 2 -> 256 -> 512 -> 2560
self.mlp = nn.Sequential(
nn.Linear(2, hidden_size), nn.ReLU(True),
nn.Linear(hidden_size, hidden_size2), nn.ReLU(True),
nn.Linear(hidden_size2, token_size) # token_size = 2560
)
# 速度编码器: 1 -> 256 -> 512 -> 2560
self.velocity_encoder = nn.Sequential(
nn.Linear(1, 256), nn.ReLU(True),
nn.Linear(256, 512), nn.ReLU(True),
nn.Linear(512, self.hidden_size),
)
升维后的向量按索引写进打包序列(packed_sequence[v_indexes]、packed_sequence[target_point_indexes]),与文本 token、ViT token、64 个 BEV token 平起平坐,共享同一套注意力。
把标量拼到某个特征向量后面当然也行,但那需要为它单独设计融合位置与归一化;“一切皆 token”的做法零特殊分支——新增一种输入 = 新增一个小编码器 + 一组序列索引,模型主体不动。这与 SimLingo 的占位符机制(在文本提示里留 <target_point> 占位、用编码向量替换其 embedding)是同一哲学的两种实现。
中间维度 256/512 没有理论讲究,是”足够宽的瓶颈”经验值:输入只有 1–2 维,三层 MLP 的表达力绰绰有余,参数量不到 200 万。
AutoMoT 不靠 prompt 里的文字指令触发推理,而是在输入序列里插入一组可学习的 embedding——训练出来的”请开始分析”信号。官方源码 automot.py:
self.reasoning_queries = nn.Embedding(
num_embeddings=self.reasoning_query_tokens, # 一组可学习向量
embedding_dim=self.reasoning_query_dim,
)
self.reasoning_projector = MLPconnector(self.reasoning_query_dim, self.hidden_size, ...) # 升到 2560
推理时这些查询经投影写进序列的指定位置(inference.py):
packed_sequence_fast[packed_reasoning_token_indexes] = self.model.reasoning_projector(
self.reasoning_query_tokens)
模型在这些位置之后自回归生成的内容就是推理链与决策文本。同理,route 和 waypoint 输出各有自己的查询组(route_projector / waypoint_projector),模型在对应查询位置的隐状态被读出头解码成坐标。
这套手法源自 DETR 的目标查询(object queries):检测任务没有”第 个框该预测什么”的天然顺序,DETR 用 个可学习向量作查询,每个查询通过交叉注意力自己”学会”负责图像的某个区域。驾驶里同构的问题:想什么、输出什么,都用可学习 query 到序列里去取——推理、路径、轨迹各占一组查询位,互不串扰,增删输出头不动模型主体。
与传统”特殊 token”(如 [CLS])的区别:特殊 token 位置固定、数量一个;可学习查询是一组带独立参数的向量,每个位置有自己的”提问方式”,由训练分配分工。