SimLingo Anatomy: When a Language Model Learns to Drive, Part 1
InternVL2-1B 是多模态模型 InternVL2 系列的最小成员,采用”视觉塔 + 投影层 + 语言塔”的标准 VLM(视觉语言模型,Vision-Language Model)三段式:
| 组件 | 模型 | 参数量 | 职责 |
|---|---|---|---|
| 视觉塔 | InternViT-300M-448px | ~0.3B | 把 448×448 图块编码成视觉特征 |
| 投影层 | 两层 MLP | 很小 | 把视觉特征从 ViT 隐空间映射到语言模型隐空间 |
| 语言塔 | Qwen2-0.5B | ~0.5B | 统一处理视觉 token 与文字 token |
三件套合计约 0.8–0.9B 参数——“1B”是系列命名,不是精确参数量。做容量估算、显存预算时按实测值算,别按名字算。语言塔常被转述成”Qwen2.5-0.5B”,实测装载日志显示 InternVL2-1B 内嵌的实为 Qwen2-0.5B(MySim T1.2b profiling 报告确认)。
视觉塔输出特征的维度是 InternViT 的隐层宽度,语言塔的嵌入层期望 Qwen2 的隐层宽度(896)。两者维度不同、分布也不同,直接拼接会让语言模型看到”域外输入”。投影层 ( 为激活函数)把视觉特征线性平移加非线性变换到语言空间,训练时与语言塔对齐。这类”对齐投影”是 LLaVA 以来 VLM 的通用件。
SimLingo 不重训底座:InternVL2-1B 整体冻结,训练信号只进两处——语言塔上的 LoRA 低秩补丁(约占 2.72% 参数)和新加的驾驶头/适配层。冻结的动机有两层:
代价是能力上限被底座锁死:驾驶头能学到的东西,受限于冻结特征里”还有多少可榨的驾驶相关信息”。
InternViT-300M 的输入分辨率钉死在 448×448,而相机画面是 1024×512 的宽银幕(宽高比 2:1)。直接 resize 到 448×448 会把横向信息压扁一半——对需要看清远处车道线的驾驶场景不可接受。InternVL2 的解法是动态分块:按原图宽高比把图切成若干接近正方形的子块,每块独立缩放到 448×448 过视觉塔,可选再加一张整图缩略图保住全局视野。
1024×512 宽高比恰好 2:1,最自然的切法是左右两个 512×512 子块。SimLingo 设 max_num=2:最多两块,不再追加缩略图。这是速度与视野的折中——分块数每加一,视觉 token 数和视觉编码耗时都线性加一份。
InternViT 的 patch 大小是 14 像素:
InternVL2 在投影前做一次 pixel shuffle( 相邻 patch 合并),把 token 数压到 。两块图就是:
这个数字能在 SimLingo 的代码里直接对上:prompt 编码时预留 512 个 <IMG_CONTEXT> 占位 token,视觉特征算出来后原位顶替。整段输入序列(系统提示 + 图像占位 + 文字 prompt)约 546–660 token,视觉 token 占了八成。
MySim T1.2b 的离线 profiling 实测:两块 448² 图块过 InternViT + MLP 投影约 17.8 ms,只占每帧模型耗时的 ~1%。视觉侧不是瓶颈——瓶颈在语言塔的逐 token 生成(占 ~93%)。但 token 数有连带成本:512 个视觉 token 全部进入语言模型每一层的注意力计算,序列越长每次前向越贵,max_num 从 2 调到 6 会把整个语言段也拖慢,不只是视觉塔那 17.8 ms 乘三。
闭环推理时,agent 拿到的是 CARLA 渲染出的无损像素,却要主动做一次 JPEG 编码→解码的往返,人为引入有损压缩伪影,再喂给模型。原因很朴素:SimLingo 的训练数据当年是按 JPEG 落盘的,模型学到的视觉分布里天然带着 JPEG 伪影。测试时如果喂无损图,输入分布就悄悄偏移了——这叫协变量偏移(covariate shift),模型没崩,但每一帧都在它没见过的干净数据上做外推。
训练/测试预处理必须逐位同款,这是离线学出来的模型进闭环的第一条铁律。MySim 的 profiling 报告里这段 jpeg roundtrip 被明确标注”训练/测试一致性所需,不能去”——它花 7 ms CPU 时间,买来的却是分布一致性。
agent 还会裁掉画面底部约 30% 的引擎盖区域(评测链固定行为),因为那部分是车体不是环境。SimLingo 官方训练配置里的对应开关是 cut_bottom_quarter: True(裁底部四分之一)。
这个”约 30% vs 25%“的微小差异后来在 MySim 微调实验(M4)里成为嫌疑犯:v1/v2 两版微调训练时没有裁底(0%),LoRA 在底部 30% 区域学到的特征,评测时被裁剪整个抹除——训练/评测管线的隐式预处理约定成了隐形雷区。v4 修复假设之一就是训练侧对齐裁剪。教训的形式化表述:
训练数据的每一个像素级预处理,必须与评测链逐位同源;差一个裁剪比例,就是一次隐性域偏移。
做 UE4 vs UE5 渲染对比时,这段预处理(jpeg 往返 + 裁底)在两侧一字不改。它是预处理层的”常量”,保证两侧唯一的变量是渲染器输出的像素分布本身。
导航目标点 target_point 是模型方向盘的唯一指向依据,它的生产链分四步:
其中 是路线点的世界坐标, 是它在自车坐标系下的坐标( 朝车头, 朝左)。模型只看到这个相对坐标,永远不知道自己在世界地图的哪里。
坐标变换本身不难,难的是语义对齐:规划器”下一个点”的定义、距离阈值、队列状态如何跨帧演进,训练数据和评测链必须用同一套算法。MySim M4 微调失败的第一实锤就埋在这里:
RoutePlanner 是有状态的:每帧贪心弹出距自车 7.5 m 以内的点,目标点取剩余队列的第 1 个(约 7.5–10 m 外),状态随车辆前进逐帧演进。LoRA 把模型掰向了错误的 target_point 分布;评测时喂回官方分布,模型行为崩溃(16/16 路线超时,油门满+舵打死持续饱和)。修复方式是把官方 RoutePlanner 类原样 import 进采集管线重算 55 万帧——同源,不是近似。
语言模型的输入是离散 token 序列,二维坐标 是连续数值,两者不能直接拼。SimLingo 的解法是占位符替换:提示词文本里放特殊 token 占位,编码阶段用数值算出来的向量把占位位置的 embedding 换掉。
关键在替换发生在 embedding 层,不是 token 层。tokenizer 把 prompt 切成 token 后,每个 token 查表得到一个隐向量(hidden_size = 896);占位 token 位置的查表结果被丢弃,换成 WaypointInputAdaptor 的输出。语言模型从第一层开始看到的就是”一句话里嵌着两个导航向量”,后续注意力一视同仁。
SimLingo 源码(simlingo_training/models/adaptors/adaptors.py)里的实现:
class WaypointInputAdaptor(nn.Module):
"""输入 [B, N, 2],输出 [B, N, token_size]"""
def __init__(self, token_size=258, hidden_size=64, hidden_size2=128, norm_layer=None):
super().__init__()
self.norm_layer = norm_layer
self.mlp = nn.Sequential(
nn.Linear(2, hidden_size), nn.ReLU(True), # 2 → 64
nn.Linear(hidden_size, hidden_size2), nn.ReLU(True), # 64 → 128
nn.Linear(hidden_size2, token_size), # 128 → 语言模型隐维
)
def forward(self, x):
if self.norm_layer is not None:
x = self.norm_layer(x) # 坐标先做 min-max 归一化
return self.mlp(x)
三点值得注意:
norm_layer(NormZeroOne)把坐标按预设的 min/max 区间压到 [0,1],避免数值范围随地图尺度漂移——CARLA 各镇的坐标量级差异不小。同一个机制用了两次:视觉侧是 512 个 <IMG_CONTEXT> 占位 token 被 ViT 特征顶替,导航侧是路点占位 token 被 adaptor 输出顶替。VLM 的一切模态输入最终都归约成”隐向量序列里的一段”,这是”语言模型没有特殊输入”这句话的工程实现。
SimLingo 的输出侧是 DrivingAdaptor,核心是一组可学习查询向量(learnable queries)——这是 DETR(Detection Transformer)在目标检测里开创的范式:不让模型”顺便”输出结果,而是给每个想要的输出配一个专属 query token,让它在注意力里主动收集所需信息。
源码(adaptors.py)里的定义:
# route 头:20 个 query,每个产出一个路径点
self.future_waypoints = 20
self.query_embeds_wps = nn.Parameter(0.02 * torch.randn((1, 20, hidden_size)))
self.route_head = 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)) # 最终输出 (x, y)
# speed 头:10 个 query,产出带速度信息的点
self.future_speed_waypoints = 10
self.query_embeds_speed = nn.Parameter(0.02 * torch.randn((1, 10, hidden_size)))
30 个 query 拼在输入序列末尾,语言模型做完注意力后,取末尾 30 个位置的输出特征,各过一个小 MLP 头。
get_predictions 里藏着一行容易读漏的代码:
prediction = self.heads[input_type](feature).cumsum(1)
MLP 头直接输出的不是路径点坐标,而是相邻点之间的位移增量,cumsum 沿时间维累加后才得到绝对轨迹:第 个点 。预测增量的好处是数值范围稳定(每步位移在米级,绝对坐标可能几十米),误差结构也更平滑;坏处是误差沿 cumsum 累积——MySim 的数值验证里就观察到 bf16 舍入差异”经 cumsum head 放大”,单步 0.1 m 级的差到第 20 点可以放大到米级。
训练损失是 smooth L1(Huber)逐点回归真值路径,同样作用在 cumsum 之后的预测上。
query 不直接过 ViT 特征,而是拼进语言模型序列做完整注意力,意味着每个 query 能同时看到图像 token、文字 prompt、速度文本和导航向量——路径预测条件于全部输入,包括那句”当前速度 3.0 米每秒”。这是 VLM 驾驶方案区别于”ViT 特征接个回归头”的关键:语言条件真正进了决策路径,后续”慢一点""靠左”这类指令跟随才有落点。
SimLingo agent 的传感器定义在 team_code/config_simlingo.py:
# 只启用 0 号相机
self.num_cameras = [0]
# 安装位:车顶后侧,x=-1.5m(靠后) y=0 z=2.0m(高),无旋转平视
self.camera_pos_0 = [-1.5, 0.0, 2.0]
self.camera_rot_0 = [0.0, 0.0, 0.0] # Roll Pitch Yaw,单位度
self.camera_width_0 = 1024 # 宽,像素
self.camera_height_0 = 512 # 高,像素
self.camera_fov_0 = 110 # 水平视场角,度——广角
相机之外挂三个非视觉传感器:IMU(惯性测量单元,给航向角)、GNSS(全球导航卫星系统,给经纬度)、速度计。它们的读数不进视觉网络,分别以罗盘角、路线规划输入、提示词文本的形式进模型。
普通乘用车前视相机的水平视场角约 60–90°,110° 是明显的广角:同样 1024 像素横向要覆盖更宽的场景,远处目标的有效像素密度更低,但换来的是路口场景能看到两侧来车——Bench2Drive 大量场景(无保护左转、汇流)的成败就在侧向视野里。广角畸变不靠标定校正,模型直接从带畸变的像素里学。
整套模型输入里只有这一条 1024×512 视频流与渲染器相关:
这是主模型选纯视觉方案(而非相机+LiDAR 的融合方案)的核心理由:多一个几何传感器,渲染结论就要多打一个折扣。对照组 AutoMoT 的动作分支含 LiDAR BEV 特征,其渲染相关结论因此必须注明”被几何传感器部分稀释”。
模型 speed 头输出 10 个未来路径点(点间距 0.5 s 游戏时间)。期望速度不直接回归,而是从点的几何间距读出来:
轨迹是 cumsum 出来的(见驾驶头一篇),所以相邻点间距本来就是模型”打算在这半秒里走多远”的直接表达。
纵向控制的判据顺序(config_simlingo.py 里的真实参数):
self.brake_speed = 0.4 # 期望速度低于 0.4 m/s 视为"想停" → 刹车
self.brake_ratio = 1.1 # 当前速度超过期望 10% → 刹车
self.clip_delta = 1.0 # 送进 PID 的速度差裁剪到 ±1 m/s
self.max_throttle = 1 # 油门输出上限
两条刹车判据任意一条命中就踩刹车,否则速度误差 (先裁剪到 ±1)进 PID 出油门:
纵向 PID 增益为 (speed_kp/ki/kd),误差历史缓冲 20 帧(speed_n)。
clip_delta = 1.0 把速度差限幅在 ±1 m/s:期望速度 8 m/s 而车静止时,误差是 8,不限幅的话 直接顶穿油门上限,积分项还会疯狂累积(积分饱和,integral windup),接近期望速度时大幅超调。限幅让加速过程变成一个”误差恒为 1”的平缓爬升,代价只是起步稍保守。这是把学习型轨迹接进经典控制器时的标准保护:网络输出不可信,控制器要对它有免疫力。
route 头输出的 20 点路径相邻间隔约 0.5–1 m,直接追踪太糙。控制前先插值到每 0.1 m 一个点——横向控制要的是一条平滑密集的参考线,否则瞄准点选择会在稀疏点上跳变,方向盘跟着抖。
这是纯追踪(pure pursuit)类控制器的核心参数:在参考线上取距自车 远的点作为瞄准点, 叫前视距离(lookahead/aim distance)。真实配置(config_simlingo.py)是三档,比”快七米慢两米”更细:
self.aim_distance_very_fast = 7.0 # 车速 > 15 m/s
self.aim_distance_fast = 3.0 # 5.5 ~ 15 m/s
self.aim_distance_slow = 2.25 # < 5.5 m/s
self.aim_distance_threshold = 5.5 # 慢/快分界,m/s
self.aim_distance_threshold2 = 15 # 快/极快分界,m/s
前视距离的作用类似一阶低通: 小则跟踪紧、过弯切内但易振荡; 大则平滑稳定但过弯”切弯心”不足。高速用 7 m 换稳定,低速用 2.25 m 保转弯精度——和人开车”快看远、慢看近”同理。
瞄准点与车头的横向偏差作为误差信号,进横向 PID 出方向盘:
self.turn_kp = 3.25 # 比例
self.turn_ki = 1.0 # 积分
self.turn_kd = 1.0 # 微分
self.turn_n = 20 # 误差历史缓冲,帧
输出裁剪后作用于转向。源码注释写明 “these were the values for LB1”——这组增益是 CARLA Leaderboard 1.0 时代调的,沿用了几代模型没动过。
“大脑可训、小脑不可训”:轨迹预测是开放问题,值得用 1B 模型;而”已知一条参考路径,求方向盘转角”是被经典控制论解决了几十年的闭问题。PID 不需要数据、不会域偏移、行为可解释可审计——在 UE4/UE5 对比实验里它还是一个常量:两侧控制器完全相同,行为差异才能归因给上游的渲染-模型链路。工程上把学习系统包在确定性控制器里,出的问题都好定位:轨迹对而车不对,查 PID;轨迹本身就歪,查模型。
闭环评测一跑就是上百条路线,必须有无人值守的兜底。机制在 config_simlingo.py:
self.stuck_threshold = 800 # 车速 < 0.1 m/s 连续 800 帧 → 判定卡死
self.creep_duration = 15 # 蠕动持续 15 帧
self.creep_throttle = 0.4 # 蠕动期间强制油门 0.4
逻辑:连续 800 帧(40 s 游戏时间)车速低于 0.1 m/s,判定车辆卡死;触发蠕动模式,接下来 15 帧无视模型输出,强制给 0.4 油门,试图把车顶出死局。15 帧后交还模型;若仍卡死,再走下一轮检测。
这组参数在 0.04 倍实时的推理速度下会发生严重的墙钟膨胀(MySim T1.2b 对 25845 号路线的解剖):
该路线日志末尾连续每帧 force_move: 14——卡死计数早已破 800,蠕动反复触发,但车辆物理上动不了(MinSpeedTest 189% 佐证全程低速)。蠕动救得了”停住”,救不了”顶住”。
蠕动模式的盲区是行为性死锁:MySim T3.0 实测 SimLingo 在 (72,24) 路口绿灯时满刹+死舵卡死(路线 10000/10007 同点位复现),force_move 强制油门也无效——模型持续输出刹车与蠕动的油门相互抵消,物理上就是不走。这类 case 的根因在模型权重(对特定路口几何的决策缺陷),不在管线。
工程兜底与模型弱点的区分标准很简单:蠕动后速度能起来的是前者,起不来的是后者。统计成绩时前者是噪声,后者是模型能力的真实扣分项——这也是为什么闭环评测必须保留失败路线明细,不能只看聚合 DS。