写给零基础读者:读这篇之前先搞懂几个名词

AlpaMayo-R1 的代码不长,但它把好几个「听起来很唬人」的概念揉在了一起。你要是直接冲进源码,多半会被一堆缩写劝退。所以我们先花点时间,把会反复出现的黑话全部翻译成大白话。你现在不用全记住,有个印象,等下看代码时回头对照就行。

  • VLA(Vision-Language-Action,视觉语言动作模型):给模型一张图(有时再加一句话指令),它直接吐出「车该怎么开」。它的核心思路是:把开车这件事,当成一个「语言建模」问题来做——图像当输入,动作当输出,中间用一个已经很会「说话」的大模型(VLM)串起来。把里面那根大模型底座叫做「VLM 骨干」。

  • VLM(Vision-Language Model,视觉语言模型):一个「能看图说话」的多模态大模型。它本来的看家本领是「看一张图,用自然语言回答关于这张图的问题」,比如 Qwen2.5-VL、GPT-4V。AlpaMayo-R1 在它上面做改造,让它不光会回答问题,还会「开车」。

  • token(离散词):大模型眼里的世界不是连续的,而是一个个「词」。它把一句话拆成一串编号(比如「你」=101,「好」=102),每个编号就是一个 token。模型的工作说白了就是「猜下一个 token 是几号」。理解这个对 AlpaMayo-R1 尤其重要:它不光把文字拆成 token,还把历史轨迹、未来动作全拆成 token——开车就是说话,轨迹就是文字

  • CoC(Chain of Causation,因果推理链):一种结构化的推理格式。普通 CoT(思维链)是自由写的,比如「前面有车,我要减速」。CoC 更严格,每个推理样本拆为三个结构化组件:

组件定义是否闭集
驾驶决策 (Driving Decision)当前时刻的纵向+横向操控意图闭集(8纵x8横,必须选一个)
关键因素 (Critical Components)影响决策的场景元素(目标、红绿灯、道路事件等)开集(自由描述)
CoC 推理链把决策和关键因素串成一段自然语言推理动态生成

其中「驾驶决策」不是随意填的,而是从闭集(固定选项)里选。纵向决策包括 Set speed tracking(定速巡航)、Lead obstacle following(跟车)、Yield(让行)、Stop(停止)等 8 种;横向决策包括 Lane keeping(保持车道)、Lane change(变道)、Turn(转弯)、Pull-over(靠边)等 8 种。这种设计确保了推理链直接锚定到具体驾驶行为,杜绝了「be cautious」这类模糊描述。

CoC 和普通 CoT(思维链)的核心区别:

维度CoT(思维链)CoC(因果推理链)
推理形式自由格式,想到哪写到哪结构化,必须包含决策+因素+推理
决策锚定松散,不绑定具体驾驶行为闭集驾驶决策,固定选项
因果约束无显式要求显式因果链接(因素->决策->动作)
可监督性难评估,自由度太高易评估,可做自动规则检查

简单说:CoT 是自由写作文,CoC 是必须用「三段论」格式写议论文——必须有论点(决策)、论据(关键因素)、论证(推理过程)。

下面举几个 CoC 推理链的实际例子,感受一下模型输出长什么样:

场景 1:前方行人横穿

驾驶决策:Yield(让行)+ Lane keeping(保持车道) 关键因素:前方 15 米处有行人从右向左横穿,行人已踏上人行横道,当前车速 45km/h,无法安全绕行 推理链:前方行人已进入人行横道且距离仅 15 米,继续直行将与行人发生冲突。因此我选择减速让行,待行人通过后再恢复正常行驶速度。

场景 2:主路汇入

驾驶决策:Gap-searching(寻找空档)+ Merge(汇入主路) 关键因素:当前位于匝道末端 100 米,主路左侧车道有连续车流,最近空档约 40 米、相对速度 +5km/h 推理链:匝道即将结束,我需要汇入主路。左侧车道有一处 40 米空档可以安全插入,目标车辆相对速度 +5km/h,加速匹配即可完成汇入。因此我选择加速至目标车道车速并汇入。

场景 3:红灯停车

驾驶决策:Stop(停止)+ Lane keeping(保持车道) 关键因素:前方 50 米处交通信号灯为红色,已知红灯持续时间约 30 秒,自车当前速度 60km/h 推理链:前方红灯且距离足够(50 米),以当前速度 60km/h 正常减速即可在停止线前平稳停车。因此我选择匀速减速至停止,在停止线前等待绿灯。

场景 4:弯道减速

驾驶决策:Speed adaptation(弯道调速)+ Lane keeping(保持车道) 关键因素:前方 80 米处为右转弯道、曲率半径约 30 米,当前速度 70km/h 推理链:右转弯道曲率半径仅 30 米,以 70km/h 入弯将超过横向加速度安全阈值。我需要提前减速至 35km/h,入弯后保持匀速通过。因此我选择在弯道前逐步减速。

场景 5:跟车巡航

驾驶决策:Lead obstacle following(跟车)+ Lane keeping(保持车道) 关键因素:本车道前方有一辆轿车,相对距离 40 米,前车速度 50km/h,自车速度 55km/h,跟车时距约 2.6 秒 推理链:前车以 50km/h 匀速行驶,当前跟车时距 2.6 秒在安全范围内(>2 秒),无需急加减速。我选择微调速度至 50km/h 以保持稳定跟车距离。

每个 CoC 推理链都严格包含三要素:明确说「我要做什么」、给出「为什么」的关键因素、把两者串成因果逻辑。模型训练时,这些推理链作为监督信号让 VLM 学会「先想明白、再开车」的决策习惯。

  • GRPO(Group Relative Policy Optimization,组相对策略优化):一种不需要价值网络的强化学习算法。对同一个场景采样多个候选轨迹,用组内奖励的均值和标准差做基线,比平均好的给正信号、比平均差的给负信号。省掉了 PPO 里那个和策略一样大的 critic 网络。DeepSeek-R1 就用它。

  • Flow Matching(流匹配):一种生成模型。和扩散模型(DDPM)类似,Flow Matching 也是从纯噪声逐步变成数据。但 DDPM 是「弯曲路径」(随机微分方程 SDE),Flow Matching 是「直线路径」(常微分方程 ODE)。直线意味着更少的生成步数——AlpaMayo-R1 只用了 10 步。

  • DDPM(Denoising Diffusion Probabilistic Model,去噪扩散概率模型):另一种生成模型。通过给数据慢慢加噪声、再训练网络去噪来生成新样本。步骤多(100~1000 步),但效果好。

  • Expert(动作专家模块):AlpaMayo-R1 里独立于 VLM 的一个轻量 Transformer 网络,专门负责处理轨迹 token。VLM 生成 CoC 推理链,Expert 接收 VLM 的上下文(KV cache)并迭代去噪生成轨迹。两者分工明确:VLM 管「想」,Expert 管「动」。

  • 单轮动力学模型(Unicycle Model):一个最简化的车辆运动模型。它把车的运动分解为两个控制量:加速度(踩油门/刹车)和曲率(打方向盘)。给定这两个量,用简单的物理公式就能积分出车的位置 (x, y) 和朝向。好处是模型不需要直接预测位置,而是预测更底层、更平滑的控制量。

  • Fourier 编码(傅里叶特征编码):一种把连续数值映射到高频空间的技术。直觉上,神经网络很难直接感受「x=0.5」这种小数的小变化,但如果把它变成 [sin(ω₁x), cos(ω₁x), sin(ω₂x), cos(ω₂x), ...] 这么一组信号,网络就能通过不同频率的响应来捕捉细微变化。这个技巧在 NeRF(神经辐射场)里很出名。

  • KV cache(键值缓存):Transformer 生成时,每次只生成一个新 token,但需要「回顾」所有之前生成的 token。KV cache 就是把之前所有 token 的注意力键和值存起来,避免每步都从头算一遍。AlpaMayo-R1 里 VLM 生成的 KV cache 被直接传给 Expert 复用。

  • Euler 积分:一种最朴素的数值积分方法。给定一个速度 v 和时间步 dt,新位置 = 旧位置 + dt × v。在 Flow Matching 里,每一步去噪就是 x = x + dt × v,像沿着一条直线向前迈一小步。

  • action space(动作空间):模型输出的是轨迹吗?其实不是。AlpaMayo-R1 模型输出的不是 (x,y) 坐标,而是加速度和曲率这对控制量。然后通过单轮动力学模型积分出轨迹。这么做的原因是:控制量比坐标更平滑、更物理、更容易学。

  • mode collapse(模式坍缩):扩散模型的一个常见问题——不同随机噪声经过扩散模型的去噪后,往往收敛到相似的轨迹。比如让模型生成 10 条不同轨迹做候选,结果 10 条都差不多。这会严重降低规划的多样性。

如果这些词你都有个模糊印象了,下面读代码会顺畅很多。我们开始。

为什么要讲 AlpaMayo-R1 的代码

这是「自动驾驶代码讲解」系列的第 10 篇。前面我们已经走过了 UniAD / VAD(端到端回归范式)、DiffusionDrive(扩散轨迹生成)、SparseDriveV2(稀疏检索式规划)、AutoVLA(动作 token 化 + GRPO)等。

AlpaMayo-R1(NVIDIA,2025,arXiv:2511.00088)把这条线推到了一个新的高度。它是第一个把「结构化因果推理」和「GRPO 强化学习后训练」整合进 VLA 的开源模型。10B 参数,真车路测延迟仅 99ms,在 NAVSIM 长尾场景上规划准确率 +12%、近距离冲突率 -35%。

更值得讲的是它的代码设计——不是把所有逻辑塞进一个大模型里,而是拆成了三个清晰独立、可插拔的组件:VLM(负责看和想)+ Expert(负责迭代去噪)+ Diffusion(负责采样调度)。这种模块化设计让它很容易被复用和扩展。

这篇不推公式,直接顺着仓库把关键实现讲清楚。

一句话结论:AlpaMayo-R1 = VLM(Qwen3-VL-8B 背板,自回归生成 CoC 推理链)+ Expert(轻量 2B Transformer,在 VLM 的 KV cache 上迭代去噪)+ Diffusion(Flow Matching,10 步 Euler 积分从噪声生成动作)+ 单轮动力学(把加速度/曲率解码成轨迹点)。

AlpaMayo-R1 框架总览

这是论文中的端到端架构图:

AlpaMayo-R1 端到端架构

从上图可以看到完整的数据流:

  • 输入:多相机多帧图像 + 历史自车轨迹
  • VLM 编码:图像 → ViT 编码(支持 Triplane/Flex 压缩)→ 与文本 token 一起送入 Qwen3-VL
  • 推理:VLM 自回归生成 CoC 推理链(慢)→ 遇到 <traj_future_start> 停止
  • 扩散生成:Expert 模块利用 VLM 的 KV cache,配合 Flow Matching 迭代去噪
  • 动作解码:单轮动力学把 (accel, kappa) 转成 (x, y, yaw) 轨迹点

架构总览:先看地图

AlpaMayo-R1 代码结构总览

在钻进任何一个文件之前,先把整条链路在脑子里画一遍。AlpaMayo-R1 的一次「决策」大致是这样走的:

  • 输入端:把 4 个相机 × 4 帧的图像 + 历史 1.6s 自车轨迹一起读进来。图像通过 VLM 的视觉编码器变成视觉 token,历史轨迹通过 DeltaTokenizer 编码成离散 token。
  • VLM 自回归生成 CoC:模型的 VLM 骨干(Qwen3-VL-8B)开始逐 token 生成——先产生一段文本推理链(CoC),比如「前方有行人横穿,因此我选择减速让行」。生成到 <traj_future_start> 这个特殊 token 时停止。
  • Expert + Diffusion 去噪:VLM 的 KV cache(包含图像特征 + CoC 文本上下文)被传给 Expert 模块。Expert 是一个轻量 Transformer(约 2B 参数),它和 Flow Matching 采样器配合,从随机噪声出发,用 10 步 Euler 积分生成动作。
  • 动作解码:生成的 (accel, kappa) 动作通过单轮动力学积分成 (x, y, yaw) 轨迹点,完成从「推理」到「开动」的全流程。

AlpaMayo-R1 推理流程

训练分三步走(但本次开源的推理代码只包含前两步的训练后模型):

  • Stage-1(动作模态注入):让 VLM 学会输出驾驶动作 token。用标准的 next-token prediction 训练。
  • Stage-2(推理能力激发):用 CoC 数据集做 SFT,让模型学会输出推理链 + 轨迹 token。
  • Stage-3(RL 后训练):用 GRPO 做强化学习,同时优化推理质量 + 推理-动作一致性 + 轨迹质量。(该阶段权重未开源)

关键认知:AlpaMayo-R1 里没有一个「专门的规划器」。规划、推理、决策,全部被分配给了两个清晰分工的模块——VLM 负责「想明白为什么这么开」,Expert 负责「把它开出来」。这种解耦是它最优雅的设计。

项目结构:先厘清边界

读任何开源代码,第一件事是搞清楚「哪些是作者自己写的核心、哪些是白嫖的外部库」。AlpaMayo-R1 的边界很清楚:

  • 基座(外部):Qwen3-VL-8B(VLM 骨干,来自 HuggingFace transformers),Cosmos-Reason 微调版。
  • AlpaMayo-R1 自己写的核心
    1. VLA 模型组装:怎么在 Qwen3-VL 上挂 Expert 和 Diffusion 模块(alpamayo_r1.pybase_model.py
    2. 动作空间:怎么用单轮动力学把轨迹编成 (accel, kappa) 再解码回来(action_space/
    3. Flow Matching:直线路径去噪采样器(diffusion/
    4. 动作投影:Fourier 编码 + MLP 把动作映射成 token(action_in_proj.py
    5. 推理入口和数据加载test_inference.pyload_physical_aiavdataset.py

用普通 Markdown 列一下仓库结构:

  • alpamayo/
    • src/alpamayo_r1/
      • config.py (配置类:AlpamayoR1Config,容纳 diffusion/action_space/expert 等全部配置)
      • helper.py (构造对话模板 + 初始化处理器)
      • test_inference.py (端到端推理入口,从 clip_id 到轨迹,不到 60 行)
      • load_physical_aiavdataset.py (从 Physical AI AV Dataset 加载多摄图像和自车轨迹)
      • models/
        • alpamayo_r1.py (主角:AlpamayoR1,整合 VLM + Expert + Diffusion + Action Space)
        • base_model.py (基类 ReasoningVLA + TrajectoryFusionMixin + 配置 + tokenizer)
        • action_in_proj.py (PerWaypointActionInProjV2:Fourier 编码 + MLP 投影)
        • token_utils.py (StopAfterEOS 停止条件 + 文本提取 + 填充替换)
        • delta_tokenizer.py (DeltaTrajectoryTokenizer:历史/未来轨迹的离散 token 编码)
      • action_space/
        • action_space.py (抽象基类 ActionSpace)
        • unicycle_accel_curvature.py (单轮动力学:accel + kappa ↔ traj 的双向映射)
        • discrete_action_space.py (离散轨迹 tokenizer:均匀量化 + 查表解码)
      • diffusion/
        • base.py (BaseDiffusion + StepFn Protocol)
        • flow_matching.py (FlowMatching:Euler 积分采样 + 训练数据构造)
      • geometry/ (旋转矩阵、坐标变换工具)
      • common/ (日志工具)

逐个用一句话说明它干嘛:

  • config.py:把所有组件的配置统一管理。diffusion_cfg 配 Flow Matching,action_space_cfg 配单轮动力学,expert_cfg 配 Expert 结构,action_in/out_proj_cfg 配投影模块。全部用 hydra.utils.instantiate 从 dict 注入。
  • helper.py:构建 VLM 的对话模板——把图像和文本占位 token 拼成 [system, user, assistant] 三段式消息。同时负责初始化处理器(processor)。
  • test_inference.py:最简推理入口。加载数据 → 构造消息 → 调 processor → 调 model.sample_trajectories → 打印 CoC + 算 minADE。
  • load_physical_aiavdataset.py:按 clip_id 从 NVIDIA 的 Physical AI AV Dataset 加载一个场景。包含坐标变换(世界系→自车 t0 局部系)。
  • alpamayo_r1.py:主角文件。定义 AlpamayoR1 类——继承 ReasoningVLA,挂接 Expert、ActionSpace、Diffusion、action_in/out_proj。核心方法是 sample_trajectories_from_data_with_vlm_rollout,包含 VLM 自回归生成 + Expert 扩散去噪的全流程。
  • base_model.py:基类 ReasoningVLA。负责 VLM 加载、tokenizer 初始化(加入轨迹特殊 token)、权重注册(AutoConfig.register + AutoModel.register 让模型可从 HuggingFace 一键加载)。
  • action_in_proj.py:FourierEncoderV2 + MLPEncoder 做动作投影。输入是噪动作 (accel, kappa) + 时间步 t,输出是 Expert 能理解的 embedding。
  • token_utils.py:StopAfterEOS(遇到 <traj_future_start> 停止生成)+ extract_text_tokens(从输出 token 中提取 CoC 文本)+ replace_padding_after_eos(清理填充)。
  • delta_tokenizer.py:把轨迹 (x,y,yaw) 编码成离散 token——对 Δxyz 做均匀量化(num_bins=1000),支持往返编码/解码。
  • unicycle_accel_curvature.py:ActionSpace 的具体实现。traj_to_action(轨迹 → 加速度+曲率,训练用)和 action_to_traj(accel+kappa → 轨迹点,推理用)的双向映射。核心运动学方程基于单轮车模型。
  • discrete_action_space.py:DiscreteTrajectoryTokenizer,用均匀量化把连续动作变成离散 token,供 VLM 做 next-token prediction。
  • flow_matching.py:Flow Matching 采样器。推理用 Euler 积分(x = x + dt × v),训练用 construct_training_data 构造加噪样本 + compute_loss_from_pred 算 MSE。
  • geometry/common/:辅助工具——旋转矩阵、角度化简、日志分层。

边界提醒:下面所有代码都是简化后的示意伪代码,用来讲清楚思路,不是逐字符照抄仓库。真实代码会有更多工程细节,但主干逻辑就是这些。代码块里我用纯英文(变量名、符号),中文解释都放在块外。

一、推理入口:不到 60 行跑通全流程

1.1 test_inference.py:从 clip_id 到轨迹

AlpaMayo-R1 的推理入口简单到令人惊讶——全部逻辑只有约 50 行有效代码:

clip_id = "030c760c-ae38-49aa-9ad8-f5650a545d26"
data = load_physical_aiavdataset(clip_id, t0_us=5_100_000)
messages = helper.create_message(data["image_frames"].flatten(0, 1))

model = AlpamayoR1.from_pretrained("nvidia/Alpamayo-R1-10B", dtype=torch.bfloat16).to("cuda")
processor = helper.get_processor(model.tokenizer)

inputs = processor.apply_chat_template(messages, tokenize=True, ...)
model_inputs = {
    "tokenized_data": inputs,
    "ego_history_xyz": data["ego_history_xyz"],
    "ego_history_rot": data["ego_history_rot"],
}
with torch.autocast("cuda", dtype=torch.bfloat16):
    pred_xyz, pred_rot, extra = model.sample_trajectories_from_data_with_vlm_rollout(
        data=model_inputs, top_p=0.98, temperature=0.6,
        num_traj_samples=1, max_generation_length=256, return_extra=True,
    )
print("CoC:\n", extra["cot"][0])
min_ade = eval_minADE(pred_xyz, data["ego_future_xyz"])
print("minADE:", min_ade, "meters")

逐行说人话:

  • clip_id:指定要加载的数据片段 ID,从 vla_golden.parquet 这类索引文件里可以查到。
  • load_physical_aiavdataset(clip_id, t0_us=5_100_000):加载这个片段在 第 5.1 秒 的那一帧。历史 16 帧(1.6s 历史)和未来 64 帧(6.4s 预测)都以这一帧为锚点。为什么是 5.1 秒?因为片段的前几秒通常是车辆静止/起步阶段,5.1s 后才有丰富的驾驶行为。
  • helper.create_message(data["image_frames"]):把 4 个相机 × 4 帧的图像拼成一个对话模板——告诉模型「你是驾驶助手,请根据场景输出推理和轨迹」。
  • AlpamayoR1.from_pretrained("nvidia/Alpamayo-R1-10B"):从 HuggingFace 一键加载 22GB 的模型权重。能这么直接用,是因为代码里把自定义模型注册进了 transformers 的 AutoModel 体系(AutoConfig.register + AutoModel.register)。
  • processor.apply_chat_template:把对话模板转成模型能吃的 token 张量——图像变成 ViT patch embedding,文本变成 token ID。
  • sample_trajectories_from_data_with_vlm_rollout:核心推理方法。先让 VLM 自回归生成 CoC 推理链,再用 Expert + Diffusion 去噪生成轨迹。
  • extra["cot"]:模型输出的 CoC 推理链文本——不是人类标注的,是模型自己生成的。
  • minADE:最小平均位移误差——衡量生成的轨迹里最好那条和真值轨迹有多接近。
参数作用典型值
t0_us采样锚点时刻(微秒)5.1M µs(第 5.1 秒)
num_traj_samples每个场景采样多少条轨迹1(推理用,调高可看多样性)
max_generation_lengthVLM 最多生成多少 token256
top_p / temperature文本生成的随机性控制0.98 / 0.6

1.2 数据加载:世界坐标系→自车坐标系

load_physical_aiavdataset.py 的核心工作不是「读取数据」,而是坐标系变换。它把车在世界坐标系中的位置,变换到「以当前时刻自车为原点」的局部坐标系:

# 核心:世界系 → 自车 t0 局部系
t0_xyz = ego_history_xyz[-1]                    # t0 时刻世界坐标
t0_rot = spt.Rotation.from_quat(ego_history_quat[-1])  # t0 时刻朝向
t0_rot_inv = t0_rot.inv()

# 历史轨迹:平移 + 旋转
ego_history_xyz_local = t0_rot_inv.apply(ego_history_xyz - t0_xyz)
# 未来轨迹:平移 + 旋转
ego_future_xyz_local  = t0_rot_inv.apply(ego_future_xyz - t0_xyz)

为什么一定要转到自车局部坐标系? 想象一下,如果保持世界坐标系,同一个「左转弯」动作,在十字路口 A 和十字路口 B 对应的 (x,y) 数值完全不同。模型必须额外学一个「当前位置在哪」的隐式编码。而在自车局部坐标系里,所有「左转弯」的轨迹都大致是「x 正方向 + y 正方向」,大大简化了学习难度。

数据加载的输出:

张量形状含义
image_frames(4, 4, 3, H, W)4 个相机 × 4 帧(t-0.3s ~ t0)
ego_history_xyz(1, 1, 16, 3)历史轨迹 1.6s @10Hz,局部坐标
ego_future_xyz(1, 1, 64, 3)未来轨迹 6.4s @10Hz,局部坐标
ego_history_rot(1, 1, 16, 3, 3)历史朝向(旋转矩阵)
camera_indices(4,)相机索引 [0,1,2,6] 按固定排序

4 个相机按固定顺序排列:[cross_left, front_wide, cross_right, front_tele]。这保证了模型每次看到的数据顺序一致,不需要额外处理。

1.3 helper.py:消息模板构造

def create_message(frames):
    num_traj_token = 48  # 历史轨迹占位符数量
    hist_traj_placeholder = (
        f"<|traj_history_start|>{'<|traj_history|>' * num_traj_token}<|traj_history_end|>"
    )
    return [
        {"role": "system", "content": [
            {"type": "text", "text": "You are a driving assistant that generates safe and accurate actions."}
        ]},
        {"role": "user", "content": [
            {"type": "image", "image": frame} for frame in frames  # 16 张图(4cam×4帧)
        ] + [
            {"type": "text", "text": f"{hist_traj_placeholder} output the chain-of-thought reasoning, then output the future trajectory."}
        ]},
        {"role": "assistant", "content": [
            {"type": "text", "text": "<|cot_start|>"}  # 预告:推理从这里开始
        ]},
    ]

说人话:

  • 模板就是三段对话:system 告诉模型「你是司机」、user 给模型看图 + 放历史轨迹占位符 + 让模型输出推理和轨迹、assistant 用一个 <cot_start> token 预告「推理即将开始」。
  • 那 48 个 <traj_history> 占位符后面会被 fuse_traj_tokens() 替换为真实的历史轨迹 token。为什么是 48 个?因为历史轨迹 16 个点 × 3 维(Δx, Δy, Δyaw)= 48 个离散 token。
  • 图像有 16 张(4 相机 × 4 帧),每张都被 VLM 的视觉编码器压缩成 patch token。

processor 要做的事就是把这些「对话模板」(里面有文字、有图像)转成模型能吃的 token 张量。这一步由 HuggingFace 的 AutoProcessor 完成——它把图像 resize 成固定大小、切成 patch、投影成 token embedding,最后和文本 token 拼成一个长序列。


二、核心模型:VLM + Expert + Diffusion 三组件

2.1 模型初始化:三组件组装

alpamayo_r1.py 里定义的 AlpamayoR1 类,其 __init__ 做的就是组装三组件

class AlpamayoR1(ReasoningVLA):
    def __init__(self, config, pretrained_modules=None, original_vocab_size=None):
        super().__init__(config, ...)  # 加载 VLM 骨干 + tokenizer

        # 组件1:Expert——轻量 Transformer,只处理动作 token
        expert_config = copy.deepcopy(self.vlm.config.text_config)
        self.expert = AutoModel.from_config(expert_config)
        del self.expert.embed_tokens  # Expert 不自己做 embedding

        # 组件2:Action Space——单轮动力学 (accel, kappa)
        self.action_space = hyu.instantiate(config.action_space_cfg)

        # 组件3:Diffusion——Flow Matching 采样器
        self.diffusion = hyu.instantiate(config.diffusion_cfg,
                                          x_dims=self.action_space.get_action_space_dims())

        # 动作投影模块
        self.action_in_proj = hyu.instantiate(config.action_in_proj_cfg, ...)
        self.action_out_proj = hyu.instantiate(config.action_out_proj_cfg, ...)

三个组件各自的职责:

组件用什么实现的输入输出参数量管什么
VLM(骨干)Qwen3VLForConditionalGeneration图像 + 文本CoC 文本 + KV cache~8B理解场景、生成推理
Expert(动作专家)AutoModel.from_config(text_config)动作 token embedding + KV cache预测的速度场 v~2B迭代去噪、精修轨迹
Diffusion(采样器)FlowMatching随机噪声 + step_fn去噪后的动作0(无参数)调度去噪步数

Expert 为什么可以独立出来? 这是 AlpaMayo-R1 最巧妙的设计。Expert 直接从 VLM 的 text_config 实例化——它的结构和你 Qwen3-VL 里那个语言模型一模一样,但它不要自己的 embedding 层(删掉了 embed_tokens),因为它的输入不是文字 ID,而是 action_in_proj 投影好的向量。这就意味着 Expert 可以「听懂」VLM 的上下文(通过 KV cache)——VLM 说「前面有行人,应该减速」,Expert 在这个语义背景下做去噪,生成符合这个推理的动作。

2.2 参数总量

整个模型 10B 参数,分布:

  • VLM 骨干(Qwen3-VL-8B):约 8B 参数,负责视觉编码 + 文本生成
  • Expert(轻量 Transformer):约 2B 参数,负责动作去噪

可训练的参数只有 62.8M(指 SFT 阶段),因为 VLM 骨干也参与了训练。在推理阶段,全部 10B 参数都被使用。

2.3 推理采样:两阶段流水线

sample_trajectories_from_data_with_vlm_rollout 是整个推理管线的灵魂。它把整个过程分为清晰的两个阶段:

def sample_trajectories_from_data_with_vlm_rollout(self, data, ...):
    # ========== 阶段1:VLM 自回归生成 CoC 推理链 ==========
    input_ids = data["tokenized_data"]["input_ids"]
    traj_data_vlm = {"ego_history_xyz": ..., "ego_history_rot": ...}
    input_ids = self.fuse_traj_tokens(input_ids, traj_data_vlm)  # 历史轨迹→token

    vlm_outputs = self.vlm.generate(
        input_ids=input_ids,
        generation_config={"top_p": 0.98, "temperature": 0.6, ...},
        stopping_criteria=StopAfterEOS(eos_token_id=traj_future_start_id),
        logits_processor=ExpertLogitsProcessor(...),  # 屏蔽离散轨迹 token!
    )
    # 此时 vlm_outputs 包含:CoC 文本 + KV cache

    # ========== 阶段2:Expert + Diffusion 去噪生成轨迹 ==========
    def step_fn(x, t):
        # 把噪声动作投影成 Expert 能吃的 embedding
        future_token_embeds = self.action_in_proj(x, t)
        # Expert 在 VLM 的 KV cache 上做前向
        expert_out = self.expert(
            inputs_embeds=future_token_embeds,
            past_key_values=prompt_cache,  # 复用 VLM 的 KV cache
            ...
        )
        last_hidden = expert_out.last_hidden_state[:, -n_diffusion_tokens:]
        pred = self.action_out_proj(last_hidden)
        return pred  # 预测的速度场 v

    sampled_action = self.diffusion.sample(
        batch_size=total_batch, step_fn=step_fn, ...
    )
    # 解码:动作 → 轨迹
    pred_xyz, pred_rot = self.action_space.action_to_traj(sampled_action, ...)
    return pred_xyz, pred_rot

阶段1——VLM 自回归生成,详细来看每一小步:

  • fuse_traj_tokens:把输入 token 序列里那 48 个 <traj_history> 占位符替换成真的历史轨迹 token——这些是 DeltaTrajectoryTokenizer 把历史轨迹 (x,y,yaw) 编码成的一串离散编号。
  • vlm.generate(...):标准的 HuggingFace 自回归生成。模型看图 + 看历史轨迹 → 逐 token 输出 CoC 推理链。
  • ExpertLogitsProcessor:一个非常重要的细节——它把离散轨迹 token(<i0> ~ <i767>)的 logit 全部 mask 成 -inf。这意味着 VLM 在生成时只能生成文本 token,不能生成轨迹 token。轨迹 token 留给 Expert 去处理。这是「VLM 管推理、Expert 管动作」分工的结构保障。
  • StopAfterEOS:遇到 <traj_future_start> 这个特殊 token 就停止生成。这个 token 是「推理完成,接下来该生成轨迹了」的信号。
  • 生成完成后,VLM 的 past_key_values(KV cache)——里面包含了图像 token、CoC 文本 token 的注意力键值——被直接传给 Expert

ExpertLogitsProcessor 的设计意图:离散轨迹 token(<i0> ~ <i767>)的 logit 被 mask 为 -inf,强制 VLM 只生成文本 token。这保证了 CoC 推理链是纯文本,不与轨迹 token 混淆。推理链和轨迹生成完全解耦,但通过 KV cache 共享上下文——KV cache 共享正是「推理链约束轨迹生成」的实现机制。Expert 做去噪时,它的注意力可以「看到」VLM 生成的 CoC 文本,从而让推理内容直接影响轨迹生成。

阶段2——Expert + Diffusion 去噪,每一小步:

  • action_in_proj(x, t):把当前噪声动作 x(形状 [B, 64, 2],64 个 waypoint × (accel, kappa))和时间步 t 投影成 Expert 的 hidden_size 维 embedding。方法是用 Fourier 编码 + MLP(下一节详细讲)。
  • self.expert(inputs_embeds=future_token_embeds, past_key_values=prompt_cache):Expert 在 VLM 的 KV cache 上做前向。Expert 的输入不是 token ID,而是 action_in_proj 输出的向量。它的作用是「在当前场景上下文下,预测这个噪声动作需要往哪个方向修正」。
  • self.action_out_proj(last_hidden):Expert 最后一层的隐藏状态投影回动作维度 (64, 2),得到预测的速度场 v。
  • self.diffusion.sample(...):Flow Matching 的 Euler 积分。从随机噪声出发,step_fn 每步给出速度场 v,x = x + dt × v,10 步后得到干净的动作。
  • self.action_space.action_to_traj(...):把 (accel, kappa) 解码成 (x, y, yaw) 轨迹点。

为什么 VLM 和 Expert 要分工?为什么不直接让 VLM 自己生成轨迹? 因为 VLM 是自回归的——逐 token 生成,每个 token 依赖之前所有的 token。这对文本没问题(文本就是逐字生成的),但对轨迹来说太慢且不够灵活。Expert 的并行去噪可以在 10 步内同时处理 64 个 waypoint,而且可以通过多步迭代逐步精修,比一步到位的自回归更稳定。

2.4 TrajectoryFusionMixin:历史轨迹注入

base_model.py 里定义了一个混入类 TrajectoryFusionMixin,核心方法就是 fuse_traj_tokens

def fuse_traj_tokens(self, input_ids, traj_data):
    if traj_data is None: return input_ids

    # 把历史轨迹 (xyz, rot) 编码为离散 token
    hist_idx = tokenize_history_trajectory(
        self.hist_traj_tokenizer, traj_data,
        self.hist_token_start_idx
    )
    # 替换占位符
    input_ids = replace_pad_token(
        input_ids, hist_idx,
        self.config.traj_token_ids["history"]
    )
    return input_ids

说人话:VLM 看到的 token 序列里,本来有 48 个 <traj_history> 占位符。现在把它们替换成真正的轨迹 token。HistTrajectoryTokenizerDeltaTrajectoryTokenizer 把历史 16 帧的 (x,y,yaw) 编码为 48 个离散 token(每帧 3 个 Δ 量)。这样 VLM 在生成推理时,就能看到「这辆车过去 1.6s 是怎么开的」。

2.5 模型注册:一键加载的魔法

为什么能 AlpamayoR1.from_pretrained("nvidia/Alpamayo-R1-10B")?因为代码在文件末尾做了注册:

class AlpamayoR1(ReasoningVLA):
    config_class = AlpamayoR1Config

AutoConfig.register("alpamayo_r1", AlpamayoR1Config)
AutoModel.register(AlpamayoR1Config, AlpamayoR1)

这两行代码把自定义的模型类和配置类注册进了 HuggingFace transformers 的 AutoModel 体系。从此 transformers 就知道:当你看到 "nvidia/Alpamayo-R1-10B" 这个模型名,它的 config 是 AlpamayoR1Config,模型结构是 AlpamayoR1。这是那 22GB 权重能一键加载的技术基础。

2.6 配置系统:Hydra 注入与可插拔设计

所有组件都是可插拔的,通过 Hydra + config dict 注入。AlpamayoR1Config 统一管理全部子模块的配置:

class AlpamayoR1Config(ReasoningVLAConfig):
    def __init__(self,
                 diffusion_cfg=None,        # → hyu.instantiate 创建 FlowMatching
                 action_space_cfg=None,     # → hyu.instantiate 创建 UnicycleAccelCurvatureActionSpace
                 action_in_proj_cfg=None,   # → hyu.instantiate 创建 PerWaypointActionInProjV2
                 action_out_proj_cfg=None,  # → nn.Linear
                 expert_cfg=None,           # → 覆盖 text_config 属性
                 expert_non_causal_attention=True,
                 keep_same_dtype=True,
                 ...):

每个 _cfg 参数都是一个 dict,用 hydra.utils.instantiate 从 dict 创建具体对象。一个典型的 YAML 配置片段:

action_space_cfg:
  _target_: "alpamayo_r1.action_space.UnicycleAccelCurvatureActionSpace"
  accel_mean: 0.0
  accel_std: 1.0
  curvature_mean: 0.0
  curvature_std: 1.0
  dt: 0.1
  n_waypoints: 64
配置键实例化目标作用
diffusion_cfgFlowMatching采样器(步数、x_dims)
action_space_cfgUnicycleAccelCurvatureActionSpace动作空间(dt、标准化参数)
action_in_proj_cfgPerWaypointActionInProjV2动作投影(hidden_size、Fourier 维度)
action_out_proj_cfgnn.Linear反向投影(输出维度)
expert_cfg覆盖 text_configExpert 结构(层数、head 数)

这意味替换任意组件不需要改模型代码——换 Diffusion 采样器只需改 YAML 里 _target_ 路径,换动作空间只需替换 action_space_cfg 的配置块。这种设计是后续 SFT 和 RL 训练复用的基础,也是「三组件」能独立演进的结构保障。


三、动作空间:为什么用加速度和曲率

3.1 动作空间 vs 轨迹空间

AlpaMayo-R1 的模型不直接输出轨迹点 (x,y),而是输出一对控制量 (accel, kappa)——即加速度和曲率。然后通过单轮动力学模型积分出轨迹。

表示方式模型输出优点缺点
原始 waypoint (x, y)直接回归坐标直观对噪声敏感、轨迹可能不物理
加速度+曲率 (accel, kappa)控制量物理合理、平滑、数值稳定需要额外解码步骤
航向角+速度 (θ, v)控制量直观积分漂移问题

UnicycleAccelCurvatureActionSpace 实现了这个双向映射。我们先看它的核心——正向反向两个方向。

3.2 正向:动作→轨迹(推理用)

推理时,模型预测出 (accel, kappa),需要转成 (x, y, θ) 才能用:

def action_to_traj(self, action, traj_history_xyz, traj_history_rot, t0_states=None):
    accel, kappa = action[..., 0], action[..., 1]

    # Step 1:反标准化(模型输出是标准化后的,需要转回物理值)
    accel = accel * accel_std + accel_mean      # 范围 ≈ [-9.8, +9.8] m/s²
    kappa = kappa * kappa_std + kappa_mean      # 范围 ≈ [-0.2, +0.2] m⁻¹

    # Step 2:从历史状态估计 t0 时刻速度
    if t0_states is None:
        t0_states = self.estimate_t0_states(traj_history_xyz, traj_history_rot)
    v0 = t0_states["v"]  # t0 时刻的初速度

    # Step 3:单轮动力学积分(核心)
    dt = 0.1  # 时间步长 0.1s

    velocity = v0 + cumsum(accel * dt)        # v(t) = v0 + ∫a·dt
    theta    = cumsum(kappa * velocity * dt)   # θ(t) = ∫κ·v·dt
    x        = cumsum(velocity * cos(θ) * dt)  # x(t) = ∫v·cos(θ)·dt
    y        = cumsum(velocity * sin(θ) * dt)  # y(t) = ∫v·sin(θ)·dt

    return traj_future_xyz, traj_future_rot

说人话——这条积分链在干什么:

  • v(t) = v0 + ∫a·dt:速度 = 初速度 + 加速度的累加。踩油门(a > 0)速度增加,踩刹车(a < 0)速度减小。
  • θ(t) = ∫κ·v·dt:朝向角 = 曲率 × 速度的累加。曲率 κ 表示「方向盘的转动程度」——κ 大意味着急转弯,κ 小意味着直行。
  • (x,y) = ∫v·cos(θ)·dt, ∫v·sin(θ)·dt:位置 = 速度在 x/y 方向上的投影积分。车头的方向(θ)决定了速度是往左还是往右。

这三个方程组就是经典的单轮车运动学模型。它的假设是:车辆像一辆自行车,后轮驱动前轮转向。虽然简单,但对 6.4s 的规划来说是够用的。

3.3 反向:轨迹→动作(训练用)

训练时,我们有真值轨迹 (x,y,θ),需要转成 (accel, kappa) 做监督信号:

def traj_to_action(self, traj_history_xyz, traj_history_rot,
                   traj_future_xyz, traj_future_rot):
    full_xy = concat(history_xyz[-1:], future_xyz)  # 拼起来
    dxy = diff(full_xy, dim=-2)                      # 位置差分→速度
    theta = theta_smooth(future_rot, ...)            # 平滑化航向角
    v = dxy_theta_to_v(dxy, theta, v0, ...)          # 从位置差分估计速度
    accel = finite_diff(v)                           # 速度差分→加速度
    kappa = dtheta / (v * dt)                        # 航向变化率→曲率
    return normalize(accel, kappa)                   # 标准化到 0 均值 1 方差

说人话:训练时,我们有「人类司机怎么开」的真值轨迹,我们要反过来求出对应的 (accel, kappa),作为模型需要学习的目标。这个反算使用了正则化最小二乘solve_xs_eq_y,即解一个带正则化的线性方程组 Ax = y),来保证求出的速度是平滑合理的。

3.4 输入标准化

UnicycleAccelCurvatureActionSpace 有一个非常重要的设计:模型的输入输出都是标准化的(均值 0,方差 1)。

accel = (accel - accel_mean) / accel_std  # 标准化
kappa = (kappa - kappa_mean) / kappa_std  # 标准化

这意味着:

  • 模型预测的 accel/kappa 范围大约在 [-3, +3] 之间(而不是物理值 ±9.8 和 ±0.2)
  • 进入 action_to_traj,要先反标准化(乘 std 加 mean)才能得到物理值
  • 进入 traj_to_action,先算物理值,再标准化后才作为模型的学习目标

为什么要标准化?因为神经网络的激活函数(比如 SiLU)在 [-3, +3] 范围之外会饱和。如果你让模型直接预测 [-9.8, +9.8] 这么大的数值,梯度信号会很差,模型几乎学不动。

3.5 离散轨迹 Tokenizer

除了连续动作空间,AlpaMayo-R1 还有一个离散版本的 tokenizer,供 VLM 做 next-token prediction(在 Stage-1 训练中使用):

class DiscreteTrajectoryTokenizer:
    def encode(self, hist_xyz, hist_rot, fut_xyz, fut_rot):
        action = self.action_space.traj_to_action(hist_xyz, hist_rot, fut_xyz, fut_rot)
        # 均匀量化到 num_bins=1000 个离散值
        action = (action - dims_min) / (dims_max - dims_min) * (num_bins - 1)
        return action.round().long().reshape(batch_size, -1)

    def decode(self, hist_xyz, hist_rot, tokens):
        action = tokens.float() / (num_bins - 1)
        action = action * (dims_max - dims_min) + dims_min
        return self.action_space.action_to_traj(action, hist_xyz, hist_rot)

一条轨迹有 64 步 × 2 维 = 128 个离散 token,每一步的 accel 和 kappa 分别被量化为 0~999 之间的整数。这些离散 token 和文本 token 一起,在 VLM 的自回归生成过程中被预测(但注意推理时这些离散 token 被 ExpertLogitsProcessor mask 了,实际由 Expert 处理连续版本)。


四、Flow Matching 扩散解码器

4.1 为什么是 Flow Matching 而不是 DDPM

Flow Matching 和 DDPM 都是生成模型,但关键区别在于生成路径

维度DDPMFlow Matching
生成路径弯曲的随机微分方程 SDE直线的常微分方程 ODE
步数需求20~1000 步10 步即可
采样方式Langevin 动力学Euler 积分(x += dt × v)
训练目标预测噪声 ε预测速度场 v

Flow Matching 的「直线路径」意味着:从纯噪声到干净数据的路径是一条直线,你只要沿着斜率(速度场 v)走固定步数就能到达。DDPM 的路径是弯曲的,每一步的噪声分布在变化,需要更小的步长。

4.2 FlowMatching 采样

class FlowMatching(BaseDiffusion):
    def sample(self, batch_size, step_fn, device, inference_step=10):
        # 起点:高斯噪声
        x = torch.randn(batch_size, *self.x_dims, device=device)
        # 时间步:0 → 1 的线性插值
        time_steps = torch.linspace(0.0, 1.0, inference_step + 1, device=device)

        for i in range(inference_step):
            dt = time_steps[i+1] - time_steps[i]                # 步长
            v = step_fn(x=x, t=time_steps[i])                    # 模型预测速度场
            x = x + dt * v                                       # Euler 积分

        return x  # 去噪后的动作 (accel, kappa)

步数 inference_step=10 ——注意这里 10 不是训练 epoch 数,而是一条轨迹的生成步数。10 步从噪声到数据,比 DDPM 的几百步快了数十倍。

4.3 训练数据构造

训练时,Flow Matching 的损失是这样构造的:

def construct_training_data(self, x):
    # Beta 分布采样时间步(偏重于高噪声端)
    t = self.beta_dist.sample((batch_size,))      # Beta(1.5, 1.0)
    t = self.beta_scale_constant - t * self.beta_scale_constant

    noise = torch.randn_like(x)
    noisy_x = t * x + (1 - t) * noise  # 线性插值:t=0→纯噪声,t=1→干净数据
    return {"noisy_x": noisy_x, "timesteps": t, "noise": noise, "x": x}

def compute_loss_from_pred(self, training_data, pred):
    x = training_data["x"]
    noise = training_data["noise"]
    target = x - noise  # 速度场方向 = 数据 - 噪声
    return F.mse_loss(target, pred)

说人话:

  • noisy_x = t * x + (1-t) * noise:这是 Flow Matching 的核心——不像 DDPM 那样逐步加噪,而是直接做线性插值。t 接近 0 时,noisy_x ≈ 纯噪声;t 接近 1 时,noisy_x ≈ 干净数据。
  • target = x - noise:速度场的方向就是从噪声指向数据的向量。模型要学的是这个方向。
  • Beta(1.5, 1.0) 时间步采样:偏重于 t 接近 1 的区域(即更靠近干净数据的那一侧),让模型更关注「精修」阶段的预测。

五、动作投影模块:Fourier 编码 + MLP

5.1 PerWaypointActionInProjV2

action_in_proj.py 实现的 PerWaypointActionInProjV2 模块,负责把「当前噪声动作 (accel, kappa) + 时间步 t」映射成 Expert 能理解的 embedding:

class PerWaypointActionInProjV2(nn.Module):
    def __init__(self, in_dims, out_dim, ...):
        # 2 个 Fourier 编码器,分别对 accel 和 kappa 编码
        self.sinus = nn.ModuleList([
            FourierEncoderV2(dim=20, max_freq=100.0) for _ in range(2)
        ])
        self.timestep_fourier_encoder = FourierEncoderV2(dim=20, max_freq=100.0)
        self.encoder = MLPEncoder(num_input_feats=..., out_dim=out_dim)

    def forward(self, x, timesteps):
        # 对 accel 和 kappa 分别做 Fourier 编码
        accel_feat = self.sinus[0](x[:, :, 0])  # accel → 20 维高频信号
        kappa_feat = self.sinus[1](x[:, :, 1])  # kappa → 20 维高频信号

        # 时间步也做 Fourier 编码
        t_feat = self.timestep_fourier_encoder(timesteps)

        # 拼接 + MLP + LayerNorm
        x = torch.cat([accel_feat, kappa_feat, t_feat], dim=-1)  # ~60 维
        return self.norm(self.encoder(x))  # → Expert hidden_size

5.2 FourierEncoderV2 的原理

class FourierEncoderV2(nn.Module):
    def __init__(self, dim, max_freq=100.0):
        half = dim // 2
        # 对数间隔的频率:ω = [10^0, 10^0.2, ..., 10^log10(max_freq)]
        freqs = torch.logspace(0, math.log10(max_freq), steps=half)
        self.freqs = freqs[None, :]

    def forward(self, x):
        arg = x[..., None] * self.freqs * 2 * torch.pi
        # 输出 = [sin(ω₁x), ..., sin(ω_half x), cos(ω₁x), ..., cos(ω_half x)]
        return torch.cat([torch.sin(arg), torch.cos(arg)], -1) * math.sqrt(2)

为什么需要 Fourier 编码? 直觉上,神经网络很难区分 x=0.45 和 x=0.46 这种微小差异——在 32 位浮点数里,这两个数只差不到 0.1%。但如果用 Fourier 编码,0.45 和 0.46 在 100Hz 频率上的响应(sin(100×0.45×2π) vs sin(100×0.46×2π))会产生显著不同的数值。这相当于给了网络一把「显微镜」,让它可以感知到连续数值的细微变化。这个技巧在 NeRF(神经辐射场)中第一次被广泛使用。

5.3 反向投影

action_out_proj 就更简单了——就是一个 nn.Linear

self.action_out_proj = nn.Linear(expert_config.hidden_size, 2)
# 输入:Expert 最后一层隐藏状态 (B, 64, hidden_size)
# 输出:速度场 v (B, 64, 2) 即每个 waypoint 的 (Δaccel, Δkappa)

六、Token 工具函数

token_utils.py 里提供了几个关键工具,虽然代码很少但很重要:

6.1 StopAfterEOS

class StopAfterEOS(StoppingCriteria):
    def __init__(self, eos_token_id):
        self.eos_token_id = eos_token_id
        self.eos_found = None

    def __call__(self, input_ids, scores, **kwargs):
        batch_size = input_ids.shape[0]
        if self.eos_found is None:
            self.eos_found = torch.zeros(batch_size, dtype=torch.bool, device=input_ids.device)
        if self.eos_found.all():
            return True  # 全部生成完了
        last_tokens = input_ids[:, -1]
        current_has_eos = last_tokens == self.eos_token_id
        self.eos_found = self.eos_found | current_has_eos
        return False  # 还要继续

这个停止条件的特殊之处在于:它不是在遇到 EOS token 时就停止,而是在遇到后多生成一个 token 再停。因为 VLM 的 KV cache 是在生成下一个 token 后才更新的,如果遇到 EOS 立刻停,KV cache 里就没有包含 EOS token 自身的信息。

6.2 ExpertLogitsProcessor

class ExpertLogitsProcessor(LogitsProcessor):
    def __init__(self, traj_token_offset, traj_vocab_size):
        self.traj_token_offset = traj_token_offset
        self.traj_vocab_size = traj_vocab_size

    def __call__(self, input_ids, scores):
        # 把离散轨迹 token 的 logit 设为 -inf
        scores[:, self.traj_token_offset:self.traj_token_offset + self.traj_vocab_size] = float("-inf")
        return scores

这个 processor 在 VLM 自回归时,把编号 <i0> ~ <i767> 对应的 logit 全部设成 -inf,让 VLM 「不会输出」这些离散轨迹 token。VLM 只能输出文本 token。那些被 mask 掉的轨迹 token 留给 Expert 模块去处理——这是分工的结构保障。

6.3 extract_text_tokens

def extract_text_tokens(tokenizer, output_tokens):
    decoded_batch = tokenizer.batch_decode(output_tokens, skip_special_tokens=False)
    extracted_text = {}
    for token in ["cot", "meta_action", "answer"]:
        extracted_text[token] = extract_between_special_tokens(decoded_batch, token)
    return extracted_text

从 VLM 的完整输出中,提取 <cot_start>...<cot_end> 之间的 CoC 推理链文本。这些文本就是模型「认为」自己为什么这么开车的理由。


七、把整条链路串起来

到这里所有零件都讲完了,我们把一次完整的推理用一张数据流图串起来,再钉死每个环节的关键信息。

下面这张图追踪从用户输入到轨迹输出的每一个步骤——它不包含新信息,但能帮你把前面六节的内容在脑子里串成一条完整的线:

用户输入: clip_id + t0_us[5.1s]
    |
    v
load_physical_aiavdataset()
    |-- 加载 4 相机 x 4 帧图像         -> image_frames (4,4,3,H,W)
    |-- 加载自车历史轨迹 (1.6s)         -> ego_history_xyz (1,1,16,3)
    |-- 加载自车未来轨迹 (6.4s)         -> ego_future_xyz  (1,1,64,3)
    +-- 坐标变换: 世界系 -> 自车 t0 局部系
    |
    v
helper.create_message()
    +-- 构造 [system, user, assistant] 对话模板
    |
    v
processor.apply_chat_template()
    +-- 图像 -> ViT patch embedding; 文本 -> token ID
    |
    v
AlpamayoR1.sample_trajectories_from_data_with_vlm_rollout()
    |
    |-- Stage 1: VLM 自回归生成
    |   |-- fuse_traj_tokens(): 历史轨迹 -> 离散 token -> 替换 placeholder
    |   |-- vlm.generate() with ExpertLogitsProcessor
    |   |   |-- 图像 token + 历史轨迹 token + 文本 prompt
    |   |   |-- -> 自回归生成 CoC 推理链文本
    |   |   |-- -> 遇到 <|traj_future_start|> 停止
    |   |   +-- -> 得到: 推理链文本 + KV cache (含图像&文本上下文)
    |   |
    |   +-- 构建 Expert 输入
    |       |-- position_ids 对齐 (去除填充, 加上 rope_delta)
    |       +-- attention_mask 只关注 KV cache 的有效部分
    |
    +-- Stage 2: Expert + Diffusion 去噪
        |-- init: x = randn(B, 64, 2)  # 高斯噪声 (accel, kappa)
        |
        +-- for t in [0.0, 0.1, ..., 1.0] x 10 steps:
            |-- action_in_proj(x, t)
            |   |-- Fourier 编码 (accel_i, kappa_i, t)
            |   +-- MLP -> Expert hidden_size
            |-- expert(inputs_embeds, past_key_values=KV_cache)
            |   +-- Expert 在 VLM 上下文上做前向 -> 预测速度场 v
            |-- action_out_proj(last_hidden)  # hidden -> (accel, kappa)
            +-- x = x + dt * v  # Euler 积分
            |
            v
        sampled_action (accel, kappa) -> (64, 2)
        |
        v
    action_space.action_to_traj()
        |-- 反标准化: normed_accel -> 物理 accel (+-9.8)
        |-- 单轮动力学积分: (accel, kappa) -> (x, y, yaw)
        +-- 输出: pred_xyz (64,3) + pred_rot (64,3,3)
        |
        v
    额外输出: extra["cot"] = CoC 推理链文本

用一张对照表钉死每个环节的关键信息:

环节文件输入输出一句话
数据加载load_physical_aiavdataset.pyclip_id图像+轨迹张量加载并转到局部坐标
对话构造helper.py图像帧对话模板告诉模型「你是司机」
VLM 生成alpamayo_r1.pytoken + KV cacheCoC 文本自回归吐推理链
Expert 去噪alpamayo_r1.py噪声动作 + KV cache速度场 v在 VLM 上下文上修正轨迹
Diffusion 采样flow_matching.py随机噪声干净动作10 步 Euler 积分
动作解码unicycle_accel_curvature.py(accel, kappa)(x, y, yaw)单轮动力学积分
投影模块action_in_proj.py动作+时间步Expert embeddingFourier 编码 + MLP

和本系列其他文章的关系

AlpaMayo-R1 不是孤立的,它在端到端自动驾驶的演化线上有明确的坐标:

  • 对比 UniAD / VAD(端到端回归):那两条是「网络直接回归轨迹坐标」的路线,没有大模型、没有生成式模型。AlpaMayo-R1 则引入了 10B 的 VLM 骨干和扩散生成式解码器。这是从「回归范式」到「生成式 VLA」的跨越。

  • 对比 DiffusionDrive(扩散轨迹生成):DiffusionDrive 用截断 DDPM 从 anchor 出发 2 步去噪生成轨迹,依赖预定义 anchor。AlpaMayo-R1 用 Flow Matching 从纯噪声出发 10 步去噪,不依赖 anchor。且 AlpaMayo-R1 多了一个 CoC 推理链——它不只是「生成轨迹」,而是「先想明白为什么,再生成轨迹」。

  • 对比 AutoVLA(动作 token + GRPO):两者都用 GRPO 做后训练,都是 VLA 架构。但核心差异在于动作表示——AutoVLA 把动作离散化成 codebook token 让 VLM 直接「说」出来;AlpaMayo-R1 把动作表示为连续的(accel, kappa),用一个独立的 Expert 模块做扩散生成。一个走「离散 token + 统一生成」,一个走「连续动作 + 分模块生成」。这两篇是理解「VLA 动作表示」两条路线的关键对照。

  • 通向 pi0:AlpaMayo-R1 告诉你「推理链 + 分模块 Expert + Flow Matching」是一种可行的 VLA 架构。而 pi0 会告诉你另一种可能——用 Flow Matching 做统一的动作生成,不需要独立 Expert,也不需要推理链。两篇合起来,正好是 VLA 架构设计空间的两个极端。

个人思考

1. AlpaMayo-R1 最让我欣赏的设计是「VLM 管推理,Expert 管动作」的解耦哲学。 这看起来只是一个工程的模块划分,但它的影响很深远:VLM 的推理链可以单独被评估和调试(它输出的是自然语言),Expert 的去噪过程也可以单独优化(调整步数、换采样器)。如果有天出现了更好的扩散采样器,换掉 flow_matching.py 就行,VLM 完全不用动。这种「不像一个大黑盒,而像三个螺丝拼接的精密仪器」的架构,才是能持续演进的系统。

2. 单轮动力学动作表示是一个被低估的工程细节。 很多端到端规划工作直接回归 (x,y) 坐标,然后花大量精力在轨迹平滑上。AlpaMayo-R1 选择从物理模型出发——用 accel 和 kappa 做中间表示,自然地保证了轨迹的平滑性和运动学可行性。这个做法的启发是:与其在输出后加一大堆后处理让轨迹看起来平滑,不如让模型生来就在「平滑的空间」里工作。这就像写文章时用语法正确的语言写,而不是写完了再用自动纠错改一遍。

3. KV cache 共享是「推理链约束轨迹生成」的实现基础。 推理链不是一句空话——Expert 做去噪时,它的注意力可以「看到」VLM 生成的 CoC 文本。这意味着 Expert 知道「模型刚说了要减速让行」,所以它生成的轨迹也会偏向减速。这个因果链是训练出来的(通过 GRPO 优化推理-动作一致性),但 KV cache 的共享是它的物理基础——没有这个共享,推理链和轨迹就分别活在两个孤立的模块里,所谓「因果」就只剩文本层面的装饰了。

4. 10B 参数推理只用了 10 步扩散,这种「大模型 + 少步生成」的搭配很值得研究。 直觉是:模型越大,每步去噪的质量越高,所以需要的步数越少。AlpaMayo-R1 用 10B 骨干换来了 10 步去噪,而小模型可能需要 20 步以上才能达到同样质量。这个 trade-off(参数量 vs 推理步数)对未来模型设计有指导意义——也许「大模型 + 少步共识」比「小模型 + 多步精修」在延迟和效果上都更优。

5. 一点反思:CoC 推理链到底「帮助」了规划多少? 论文报告了 +12% 的规划准确率提升,但这个提升有多少来自于推理链本身、多少来自于更大的模型和更多的训练数据?如果能做一个消融实验——同样的模型架构、同样的训练数据,只是把推理链从生成序列中移除——看指标下降多少,会更令人信服。不过话说回来,从产品角度看,推理链即使对指标提升贡献有限,它提供的可解释性也足够有价值——一个能解释「为什么在这个路口减速让行」的自动驾驶系统,比一个开得更好但说不清理由的系统,在安全审计和用户信任上都有不可替代的优势。


📖 代码讲解系列第 10 篇。本文基于 github.com/NVlabs/alpamayo commit 17 个(main 分支)的代码分析。SFT 和 RL 后训练代码在 alpamayo-recipes