0. 简介

RoboTTT 面向的是机器人基础模型的一个结构性缺陷:以 VLA 为代表的主流策略在做单次决策时,只能看到当前一帧观测或两三帧的短历史,而真实的多阶段装配任务动辄持续数分钟,策略因此无法判断自己走到了哪一步、上一次尝试是否失败过。这篇工作没有沿用"把更多帧拼进上下文"或"外挂记忆库"的路线,而是把测试时训练(Test-Time Training)做成序列建模机制:策略的循环状态不是一个向量,而是一小组快权重,每来一个时间步就用梯度下降把新信息写进这组权重,历史被压缩进参数空间而不是缓存起来。配合序列动作强制与截断时间反向传播两项训练配方,视觉运动上下文被推到 8K 时间步——按 30 Hz 控制频率约合五分钟,比现有策略长三个数量级,而推理延迟保持恒定。真机结果上,RoboTTT 平均任务完成分 79%,比单步上下文基线高 87%,是唯一能完整走通那个十阶段、五分钟的 Gear Bot 装配任务的方法;8K 上下文预训练比 1K 预训练高 63%,且到 8K 仍未饱和。

图 1:测试时训练的技术谱系。从 1987 年的快权重设想到 2026 年 RoboTTT,四代工作各自补上了一块(作者自绘,配 Gemini 提示词)

1. 健忘症是架构问题,不是数据问题

1.1 三秒钟上下文能做什么,不能做什么

当前绝大多数机器人基础模型的决策依据是单帧观测,少数会拼 2 到 8 帧连续观测。这个窗口对抓取放置足够了——桌上有个杯子,看见就去抓,不需要知道三分钟前发生过什么。但一旦任务变成十个阶段的装配,短窗口立刻塌掉,塌的方式还相当具体。

论文报告的第一种失效叫状态混淆(state aliasing):多阶段装配里视觉上高度相似的阶段会互相混淆,策略分不清自己是在装第一个齿轮还是第三个,于是做出错误动作或者干脆跳过某个阶段。第二种是缺乏策略性重试:Pup Go Car 任务里电钻如果没对准车顶螺丝,短上下文基线会当作已经拧好了直接进入下一阶段,因为它看不到自己刚才失败过。第三种是部分可观测下的精度损失:目标物体被手臂遮挡时,短窗口策略失去了它的位置信息,而长上下文策略能用几秒前的观测补上。

在这里插入图片描述

直觉理解(为什么这三种失效同源):它们全部指向同一件事——策略没有"我刚做了什么"的概念。这就像让一个人闭眼装家具,每睁眼一秒钟就重新失忆一次,他能拧螺丝,但不知道这颗螺丝是不是刚才拧过的那颗。问题不在于视觉能力不够,而在于时间维度上根本没有状态。

1.2 三条现成的路都不通

要把上下文做长,直觉上有三条路,论文逐条给出了否定理由。值得注意的一点是,这三条路各自都有相当成熟的代表工作,它们失败的方式也各不相同——一条会倒退,一条跑不动,一条压不住,正好对应三种不同的架构约束。逐条看下来会发现,它们并不是"做得不够好",而是各自撞上了一个结构性的天花板。

第一条是拼接更多历史帧。这条路不仅收益有限,还可能倒退——GR00T N1.7 Hist.(多加一帧历史)在 Pup Go Car 上得 39.5%,比无历史的 GR00T N1.7 的 57% 更低。原因是拼接历史会引入虚假相关(spurious correlation):过去的动作隐式编码在过去的观测里,策略学会了"照抄上一步动作"这个捷径,而推理时一旦偏离训练分布就崩。这个现象在模仿学习里有专门的名字,叫因果混淆和 copycat 问题。

第二条是全注意力自回归处理整段历史。这条路建模能力最强,但用 KV 缓存的解码延迟随上下文长度线性增长,长时程真机部署根本跑不动。控制频率是 30 Hz,留给单次推理的时间只有 33 毫秒,缓存翻十倍延迟就翻十倍。这条路的代表是 Gato、VIMA、RoboCat 这批把整段历史自回归处理的工作,它们在建模能力上确实最强,问题纯粹出在部署侧。

第三条是传统 RNN。LSTM 这类架构推理复杂度确实恒定,但缩放性质明显不如全注意力,向量值的隐状态容量太小,压不进数千步的历史。这一点在语言建模的缩放律研究里早有结论,机器人这边只是继承了同样的性质。而 GDN 这类现代线性循环架构虽然缓解了缩放问题,后面会看到它仍然卡在更新规则上。

1.3 缺口的精确表述

把三条路的失败合起来看,缺口可以写得很精确:**需要一个状态大小固定(推理恒定成本)、但表达能力远超向量状态(能压进数千步)、且能主动筛掉冗余而非机械累积(对抗虚假相关)的序列机制。**这三条约束不是论文明写的,而是从它对三条路的否定里反推出来的,但把它们并列写出来之后,方法的形态基本就被确定了。

前两个要求指向"某种循环结构",第三个要求把普通 RNN 排除了——因为普通 RNN 的更新规则是人工设计的固定形式,它不会学"该记住什么"。这三个约束同时满足的答案,就是快权重。接下来两节会分别拆解快权重是什么、以及它怎么被塞进一个已经预训练好的 VLA 里而不破坏原有能力。

图 2:RoboTTT 模型架构、训练与推理。TTT 层加在 DiT 动作头的注意力层之后,注意力在单时间步内运算,TTT 层跨时间步运算

2. 快权重:把历史压进参数而不是缓存

2.1 慢权重与快权重的分工

RoboTTT 把网络参数切成两类,这个切分是整篇工作的地基。慢权重是传统意义上的模型参数,预训练时学到、推理时冻结,承载通用知识。快权重是一小组独立参数,在训练和推理过程中都通过梯度下降更新,充当可快速写入的压缩记忆缓冲区。这个二分本身不新,快权重的概念可以追到线性 Transformer 与快权重编程器那条线;新的地方在于把它用作机器人策略的时间维状态。

这里的关键是快权重不是一个向量,而是一个小神经网络的参数。论文里这个小网络叫快模型(fast model),记作 f W ( ⋅ ) : R d → R d f_W(\cdot): \mathbb{R}^d \rightarrow \mathbb{R}^d fW​(⋅):Rd→Rd,实际实现是一个两层 MLP 带 GeLU 激活。换句话说,循环状态从"一串数字"升级成了"一个函数"。这个升级是整套机制表达力的来源,也是它与 GDN 这类线性循环状态的分野所在。

难点提示(状态是函数意味着什么):普通 RNN 的隐状态是一个 512 维向量,你能往里塞的信息量被这 512 个数字卡死。RoboTTT 的隐状态是一个两层 MLP 的全部权重,容量高出一个量级,而且它是非线性的——读取时不是简单查表,而是过一遍前向传播。类比一下:RNN 的记忆像一张便签,写满就得擦;快权重的记忆像一个可训练的小助手,你不断给它上课,它自己组织知识结构。

2.2 更新与应用两步走

形式化地看,给定 d d d 维 token 序列 X X X,经投影矩阵 θ Q , θ K , θ V \theta_Q, \theta_K, \theta_V θQ​,θK​,θV​ 得到查询、键、值序列, Q t , K t , V t Q_t, K_t, V_t Qt​,Kt​,Vt​ 表示第 t t t 步的投影。在时间步 t t t,快权重通过一步梯度下降更新,目标是让快模型学会把 K t K_t Kt​ 关联到对应的 V t V_t Vt​,写成下式。这条更新规则的形式与自注意力的键值检索是对应的,差别在于自注意力把所有键值对都存着、查询时逐一比对,而这里是把键值关联训练进一个小网络的参数里。

W t ← W t − 1 − η ∇ W L F W ( f W t − 1 ( K t ) ,   V t ) W_t \leftarrow W_{t-1} - \eta \nabla_W \mathcal{L}_{\mathrm{FW}}\bigl(f_{W_{t-1}}(K_t),\, V_t\bigr) Wt​←Wt−1​−η∇W​LFW​(fWt−1​​(Kt​),Vt​)

其中 L F W ( v ^ , v ) = ∥ v ^ − v ∥ 2 \mathcal{L}_{\mathrm{FW}}(\hat{v}, v) = \lVert \hat{v} - v \rVert^2 LFW​(v^,v)=∥v^−v∥2 是均方误差, η \eta η 是可学习的内层学习率。更新完之后立刻应用(apply),用刚更新的快权重算出输出,写成下式。注意这里用的是 W t W_t Wt​ 而不是 W t − 1 W_{t-1} Wt−1​,也就是说输出用的是已经吸收了当前 token 的那份权重,这个次序不能反。

O t = f W t ( Q t ) O_t = f_{W_t}(Q_t) Ot​=fWt​​(Qt​)

这套"先更新再应用"的操作在训练和推理时完全一致,没有训练推理不一致的问题。直观理解是,更新步把上下文信息编码进快模型的参数空间,应用步把它取出来供下游预测使用。这本质上是一次极小规模的自监督学习,只是它每个时间步都发生一次。

2.3 三种序列层的状态对比

论文项目主页给了一张很清晰的三方对比,值得展开说。自注意力保留全部历史的键值缓存,状态随上下文线性增长,表达能力最强但推理成本失控。循环层保留一个向量值状态,更新规则人工设计(比如门控加加权和),成本恒定但容量与灵活性都受限。TTT 层把权重当状态,更新规则是梯度下降,成本恒定而容量与非线性都在。

机制状态形式更新规则上下文成本能否元学习更新动态
自注意力KV 缓存无(全量保留)随上下文线性增长不适用
循环层(LSTM / GDN)向量人工设计的固定形式恒定否
TTT 层小模型的权重自监督损失上的梯度下降恒定是

解读:最后一列是这张表里最容易被忽略但最要紧的一项。GDN(Gated DeltaNet)与 RoboTTT 都维持固定大小状态,差别只在更新规则;而论文的实验证据显示,正是这个差别决定了能否从更长的预训练上下文里获益。能被元学习的更新规则,才能随训练序列变长而变好。

2.4 一段参考实现

论文没有开源代码,下面这段是按公式 1 与公式 2 写出的最小参考实现,用来说清"每步一次梯度下降"在代码层面是什么样子。注意快权重的梯度是手工计算并立即应用的,不进优化器状态。这段代码不追求性能,只求把机制写得能读懂;真实实现会做算子融合与分块并行,形态会复杂得多。

# 参考实现:TTT 层单步 update-then-apply(按论文 Eq.1 / Eq.2)
import torch
import torch.nn as nn
import torch.nn.functional as F


class TTTLinearFastModel(nn.Module):
    """快模型:两层 MLP,其权重就是 TTT 层的隐状态。"""

    def __init__(self, dim, hidden):
        super().__init__()
        # W1/b1/W2/b2 合起来构成快权重 W;这里用 Parameter 只是为了拿梯度,
        # 真实训练里它们由外层损失元学习出初始值 W_0。
        self.W1 = nn.Parameter(torch.randn(dim, hidden) * dim ** -0.5)
        self.b1 = nn.Parameter(torch.zeros(hidden))
        self.W2 = nn.Parameter(torch.randn(hidden, dim) * hidden ** -0.5)
        self.b2 = nn.Parameter(torch.zeros(dim))

    def forward(self, x, fw=None):
        W1, b1, W2, b2 = fw if fw is not None else (self.W1, self.b1, self.W2, self.b2)
        return F.gelu(x @ W1 + b1) @ W2 + b2

这段只是把快模型定义清楚:它的四组参数就是隐状态本身。下一段才是核心——把一个时间步的 token 写进这组参数。注意函数签名里 fw 既是入参也是返回值,这个进去一份、出来一份的形态是循环状态的标准写法。进一步看,这个函数是整套机制里唯一真正做"记忆写入"的地方,其余全部是它的调用者。

# 参考实现:单时间步的 update-then-apply
def ttt_step(fast_model, fw, k_t, v_t, q_t, inner_lr):
    """fw: 上一时刻快权重元组 (W1,b1,W2,b2);返回 (输出 O_t, 新快权重 W_t)。"""
    fw = tuple(p.detach().requires_grad_(True) for p in fw)

    # ---- update:在自监督重建损失上走一步梯度下降(Eq.1)----
    v_hat = fast_model(k_t, fw=fw)
    loss_fw = F.mse_loss(v_hat, v_t)
    grads = torch.autograd.grad(loss_fw, fw, create_graph=True)  # create_graph 供外层元学习
    fw_new = tuple(p - inner_lr * g for p, g in zip(fw, grads))

    # ---- apply:用刚更新的快权重读取上下文(Eq.2)----
    o_t = fast_model(q_t, fw=fw_new)
    return o_t, fw_new

三处细节值得注意。第一,create_graph=True 是元学习的关键——外层损失需要对内层梯度再求梯度,也就是论文说的"通过梯度的梯度元学习 W 0 W_0 W0​"。第二,inner_lr 在论文里是可学习的,叠在 0.1 的常数基础学习率之上,而不是固定超参。第三,这里没有优化器:更新是一行减法,不涉及动量或二阶矩,所以单步开销极小,这是整套方案能实时跑的前提。

3. 怎么塞进一个已有的 VLA

3.1 注意力管空间,TTT 管时间

RoboTTT 实例化在预训练好的 GR00T N1.7 上,后者由 Eagle 视觉语言模型作为骨干、一个 Diffusion Transformer 作为动作头组成。改动的位置很克制:在 DiT 的 16 个层里,每层的自注意力与交叉注意力之后各加一个 TTT 层。换句话说基座的计算图结构一点没动,只是在每个残差流上多接了一条支路,这也是为什么论文说这套方法适用于广泛的骨干模型。

分工是明确的:注意力处理单时间步内部的信息,TTT 层处理跨时间步的信息。具体来说,DiT 的输入是一段跨 T T T 个时间步的机器人轨迹,展开写成下式。这个展开式看着长,但结构很规整:每个时间步贡献一组四类 token,四类各有明确分工。这里的关键是四类 token 中只有三类会跨时间流动,这个筛选下一小节展开。

[ R 1 , Φ 1 , q 1 , A ~ 1 , … , R T , Φ T , q T , A ~ T ] [R_1, \Phi_1, q_1, \tilde{A}_1, \ldots, R_T, \Phi_T, q_T, \tilde{A}_T] [R1​,Φ1​,q1​,A~1​,…,RT​,ΦT​,qT​,A~T​]

其中 Φ t \Phi_t Φt​ 是 VLM 输出的视觉语言 token, q t q_t qt​ 是编码后的本体感受 token, A ~ t \tilde{A}_t A~t​ 是加噪的动作 token, R t R_t Rt​ 是每个时间步前置的 N N N 个可学习寄存器 token(register token),它们注意到该时间步的所有其他 token。注意力层在 R t R_t Rt​、 q t q_t qt​、 A ~ t \tilde{A}_t A~t​ 上运算并交叉注意到 Φ t \Phi_t Φt​;随后各时间步的注意力输出沿时间维拼接成

X = [ R 1 , q 1 , A ~ 1 , … , R T , q T , A ~ T ] X = [R_1, q_1, \tilde{A}_1, \ldots, R_T, q_T, \tilde{A}_T] X=[R1​,q1​,A~1​,…,RT​,qT​,A~T​]

再送进 TTT 层做快权重更新与输出。这个先分时间步做注意力、再沿时间维展平过 TTT 的两段式安排,是整个架构里最要紧的一处设计决定——它把空间关系与时间关系交给了两种不同的机制去处理,而不是让一个注意力层同时承担两者。换句话说,架构层面把"这一刻的画面里有什么"和"这几分钟里发生了什么"彻底解耦了。

3.2 为什么视觉语言 token 不过 TTT 层

上面那个展开式里有个刻意的缺席: Φ t \Phi_t Φt​ 不进 TTT 层。原因是计算效率——视觉语言 token 数量庞大,全部过 TTT 会把每步的内层梯度计算撑爆。论文的替代方案是靠数量小得多的寄存器 token( N = 16 N = 16 N=16)来承载视觉语言信息跨时间传递。这个取舍在参数量上省得非常多,但它也意味着跨时间可用的视觉语义被压到了 16 个向量的带宽里。

工程价值(寄存器 token 的角色):寄存器 token 最早是为了解决 ViT 里的注意力伪影而提出的,在这里被赋予了新任务——当信息瓶颈。它们在每个时间步内部注意到所有 token,把该时刻的视觉语言内容压成 16 个向量,只有这 16 个向量跨时间流动。这是一个典型的"用有损压缩换恒定成本"的取舍,而消融实验显示这个压缩层是必要的:加入寄存器 token 带来 18% 相对提升,而单独给 GR00T N1.7 加同样数量的寄存器 token 则毫无帮助。

3.3 tanh 门控保护预训练能力

RoboTTT 从预训练 VLA 的权重初始化,因此每个插入的 TTT 层必须不能一上来就干扰基座模型的计算。论文的做法是给 TTT 分支加一个可学习的 tanh 门控:这个技巧最早出现在 Flamingo 那类给冻结语言模型加视觉支路的工作里,目的完全一致——让新加的分支从完全不存在开始,逐渐学会自己该出多少力。

O = tanh ⁡ ( α ) ⊙ O T T T + O a t t n O = \tanh(\alpha) \odot O_{\mathrm{TTT}} + O_{\mathrm{attn}} O=tanh(α)⊙OTTT​+Oattn​

其中 α ∈ R d \alpha \in \mathbb{R}^d α∈Rd 每层各学一份,初始化在接近零的位置(0.001)。训练开始时 tanh ⁡ ( α ) ≈ 0 \tanh(\alpha) \approx 0 tanh(α)≈0,TTT 分支等于静音,预训练模型原样运行;门只在测试时训练确实有帮助的方向上、按需要的幅度打开。用 tanh ⁡ \tanh tanh 而不是 sigmoid 或裸标量的好处是输出被约束在 [ − 1 , 1 ] [-1,1] [−1,1] 内,门既能打开也能反向,且梯度在零附近最大,初期学得快。

# 参考实现:tanh 门控注入(按论文 Eq.3)
class GatedTTTBlock(nn.Module):
    def __init__(self, dim, hidden):
        super().__init__()
        self.fast_model = TTTLinearFastModel(dim, hidden)
        self.to_qkv = nn.Linear(dim, dim * 3, bias=False)   # theta_Q / theta_K / theta_V
        self.inner_lr = nn.Parameter(torch.tensor(0.1))     # 可学习内层学习率
        # 门控初始化在 0.001,使 tanh(alpha) ≈ 0,训练初期 TTT 分支静音
        self.alpha = nn.Parameter(torch.full((dim,), 1e-3))

    def forward(self, x_seq, attn_out, fw):
        """x_seq: [T, dim] 沿时间维展平的序列;fw: 进入本段的快权重状态。"""
        outs = []
        for t in range(x_seq.shape[0]):
            q, k, v = self.to_qkv(x_seq[t]).chunk(3, dim=-1)
            o_t, fw = ttt_step(self.fast_model, fw, k, v, q, self.inner_lr)
            outs.append(o_t)
        o_ttt = torch.stack(outs, dim=0)
        # 残差流上按门控幅度汇入,保护预训练注意力通路
        return torch.tanh(self.alpha) * o_ttt + attn_out, fw

这段代码里有个容易忽略的返回值:fw 被一路带出去。它不是块内的临时变量,而是要跨块、跨段、跨整条轨迹传递的状态,这一点在下一节的 TBPTT 里会变成关键约束。具体来说,正是因为它要跨段传递,才需要在段边界做那个看起来不起眼的 detach 操作。

3.4 参数量与部署配置

原始 DiT 是 538M 参数,每个 TTT 层约加 10M,16 层加完总共 690M。增量约 28%,不算轻,但也远不到重训一个基座的量级。 位置编码用 RoPE, θ r o p e = 10000 \theta_{\mathrm{rope}} = 10000 θrope​=10000。测试时优化器就是标准梯度下降,论文明确把 Muon 这类更复杂的内层优化器留给未来工作。这个留白其实是个不小的空间——内层优化器直接决定了每步能把多少信息压进快权重,而标准梯度下降只是最朴素的一个选择。

部署侧的数字更能说明这套方案的现实性:真机是 YAM 双臂桌面机器人,四台 RealSense D405 相机(顶部、底部、左腕、右腕)输出 480p RGB,推理跑在一张 NVIDIA RTX 5090 上,控制频率 30 Hz。一张消费级显卡能以 30 Hz 跑一个 8K 上下文的 690M 动作头,这是"推理延迟不随上下文增长"这句话最直接的兑现。

4. 序列训练:让快权重在训练时就动起来

4.1 训练时也跑 TTT,才能学到好的初始值

一个容易走偏的实现是:训练时按普通方式训,只在推理时启用快权重更新。RoboTTT 不这么做。它在机器人轨迹序列上训练,快权重在每条训练序列内部就开始更新,这样模型能同时学到两样东西——一个合适的快权重初始值 W 0 W_0 W0​,以及填充它的更新动态。

具体机制是:TTT 投影矩阵 θ Q , θ K , θ V \theta_Q, \theta_K, \theta_V θQ​,θK​,θV​ 与快权重初始值 W 0 W_0 W0​ 都是普通可学习参数。给定一条训练序列,内层循环跑 TTT(用快权重损失 L F W \mathcal{L}_{\mathrm{FW}} LFW​),外层在每个时间步计算任务损失,然后按平均损失优化整个模型。投影矩阵直接从外层任务梯度学,而 W 0 W_0 W0​ 是通过梯度的梯度被元学习出来的,这条路径与 MAML 同源。

数据集 D = { ξ ( i ) } i = 1 N \mathcal{D} = \{\xi^{(i)}\}_{i=1}^N D={ξ(i)}i=1N​ 里每条轨迹是 ξ = { ( l , o t , q t , A t ) } t = 1 T \xi = \{(l, o_t, q_t, A_t)\}_{t=1}^T ξ={(l,ot​,qt​,At​)}t=1T​ 的元组序列, l l l 是整条轨迹共享的语言指令。序列损失写成下式。注意语言指令在整条轨迹里只有一份,这意味着它无法承载阶段级信息,阶段判别完全依赖快权重。这个设计与后面一次性模仿实验里"所有构型共用一句提示"的做法是一脉相承的。

L f m ( ξ ; W 0 ) = 1 T ∑ t = 1 T ℓ t ( ( l , o t , q t , A t ) ;   W t − 1 ) \mathcal{L}_{\mathrm{fm}}(\xi; W_0) = \frac{1}{T}\sum_{t=1}^{T} \ell_t\bigl((l, o_t, q_t, A_t);\, W_{t-1}\bigr) Lfm​(ξ;W0​)=T1​t=1∑T​ℓt​((l,ot​,qt​,At​);Wt−1​)

其中 W t − 1 W_{t-1} Wt−1​ 是进入时间步 t t t 的快权重状态,在 TTT 层内部被更新为 W t W_t Wt​。每个优化步同时更新常规模型权重与快权重初始值。两类参数在同一个反向传播里被更新,只是路径深度不同——常规权重走一阶梯度, W 0 W_0 W0​ 走二阶。这里的关键是二阶项只在段内存在,跨段之后就被 TBPTT 切断了。

难点提示(梯度的梯度是怎么回事):内层的一步更新是 W 1 = W 0 − η ∇ L F W ( W 0 ) W_1 = W_0 - \eta \nabla \mathcal{L}_{\mathrm{FW}}(W_0) W1​=W0​−η∇LFW​(W0​),而外层任务损失依赖 W 1 W_1 W1​,所以对 W 0 W_0 W0​ 求导时会碰到 ∇ L F W \nabla \mathcal{L}_{\mathrm{FW}} ∇LFW​ 里已经含有的一次求导,形成二阶项。这就是为什么前面参考实现里必须写 create_graph=True。类比一下:你不是在教模型"答案是什么",而是在教它"从什么起点开始自学,学得最快"——教的是起跑姿势,不是终点位置。

4.2 序列动作强制:一条不做就训不动的配方

RoboTTT 的动作头用流匹配训练,学的是把 A ~ t = A t τ = τ A t + ( 1 − τ ) ϵ \tilde{A}_t = A_t^\tau = \tau A_t + (1-\tau)\epsilon A~t​=Atτ​=τAt​+(1−τ)ϵ 去噪回来, τ ∈ [ 0 , 1 ] \tau \in [0,1] τ∈[0,1] 是流匹配时间步, ϵ ∼ N ( 0 , I ) \epsilon \sim \mathcal{N}(\mathbf{0}, \mathbf{I}) ϵ∼N(0,I)。在序列训练里,论文发现噪声水平必须为序列中每个动作块独立采样,这个技巧叫序列动作强制。这个名字里的"强制"借自 teacher forcing 的说法,指的是训练时对每个位置施加独立的监督条件,而不是让整条序列共享一份。

不这么做的后果是训练直接不稳定。论文给的解释是:整条序列共享一个噪声水平(也就是全序列扩散)会让整条序列要么统一容易学(低噪声)、要么统一难学(高噪声),梯度信号在序列之间剧烈波动。带上序列动作强制之后完整目标写成下式,其中 v θ v_\theta vθ​ 是 DiT 动作头, Φ t \Phi_t Φt​ 是该步的视觉语言 token。

L f m ( ξ ; W 0 ) = 1 T ∑ t = 1 T E τ t , ϵ [ ∥ v θ ( Φ t , A t τ t , q t ;   W t − 1 ) − ( A t − ϵ ) ∥ 2 ] \mathcal{L}_{\mathrm{fm}}(\xi; W_0) = \frac{1}{T}\sum_{t=1}^{T} \mathbb{E}_{\tau_t, \epsilon}\Bigl[\bigl\lVert v_\theta\bigl(\Phi_t, A_t^{\tau_t}, q_t;\, W_{t-1}\bigr) - (A_t - \epsilon)\bigr\rVert^2\Bigr] Lfm​(ξ;W0​)=T1​t=1∑T​Eτt​,ϵ​[ ​vθ​(Φt​,Atτt​​,qt​;Wt−1​)−(At​−ϵ) ​2]

每个 τ t \tau_t τt​ 独立采样,采样方式是 τ t = s ( 1 − u ) \tau_t = s(1-u) τt​=s(1−u), u ∼ B e t a ( 1.5 , 1 ) u \sim \mathrm{Beta}(1.5, 1) u∼Beta(1.5,1), s = 0.999 s = 0.999 s=0.999。Beta(1.5, 1) 这个分布偏向较小的 u u u,因此 τ \tau τ 偏向较大值,也就是采样更偏向低噪声区间,这与流匹配的常见做法一致。这意味着模型在训练里见到的多数样本是"已经接近干净"的动作块,去噪任务偏向精修而非从零生成。

消融数据说明这条配方不是可选项:去掉序列动作强制之后闭环性能大幅下降,动作不准到机器人根本无法取得有意义的进展。 一条只有半页篇幅的训练技巧,决定了整套方法能不能训出来。论文把这个发现与 Diffusion Forcing 那篇的结论相互印证,说明它不是本工作特有的现象,而是序列扩散训练的一个普遍陷阱。

# 参考实现:序列动作强制 vs. 全序列共享噪声(按论文 Eq.5)
def sample_tau(shape, device):
    """tau = s(1-u), u ~ Beta(1.5, 1), s = 0.999"""
    u = torch.distributions.Beta(1.5, 1.0).sample(shape).to(device)
    return 0.999 * (1.0 - u)


def build_noised_chunks(action_chunks, per_chunk=True):
    """action_chunks: [T, H, action_dim]。per_chunk=True 即序列动作强制。"""
    T = action_chunks.shape[0]
    eps = torch.randn_like(action_chunks)
    if per_chunk:
        tau = sample_tau((T,), action_chunks.device)          # 每个块独立
    else:
        tau = sample_tau((1,), action_chunks.device).expand(T)  # 全序列共享,训练会不稳
    tau_b = tau.view(T, 1, 1)
    noised = tau_b * action_chunks + (1.0 - tau_b) * eps
    target = action_chunks - eps                              # 流匹配速度场目标
    return noised, target, tau

这段代码的全部区别只在 per_chunk 这个布尔量上——一个 shape 是 (T,),另一个是广播出来的常量。**改动量小到几乎看不见,效果差异却是"能训"与"训不出来"。**这类"改一行代码决定生死"的细节,是复现这类工作时最容易漏掉、也最难自己debug出来的部分。直观理解是,训练不稳定的表象是损失曲线抖动,但根因藏在一个采样 shape 里,很难从症状反推回来。

4.3 TBPTT:让上下文长度与显存脱钩

长序列全量反向传播(BPTT)要存下每个时间步的激活,显存随序列长度线性增长,8K 步根本存不下。RoboTTT 改用截断时间反向传播:把输入序列切成固定长度的段,梯度只在段内流动。这个技巧在传统 RNN 时代就是标准做法,但在 TTT 这里有一处必须特别处理的地方,就是快权重与它的梯度要分开对待。

关键的不对称在这里:快权重跨段边界继续携带,所以 TTT 在整条序列上不间断地跑;但它们的梯度在边界处被切断。 于是显存由段长决定而非总长决定,训练上下文可以在固定显存预算下任意加长。论文还补了一句要紧的说明: W 0 W_0 W0​ 仍然通过第一段接收梯度,因为第一段的更新直接源自 W 0 W_0 W0​。

图 3:TBPTT 示意。梯度在段边界被截断,快权重跨段携带,因此 TTT 在整条序列上持续运行

# 参考实现:TBPTT 主循环(按论文 Fig.4 的语义)
def tbptt_train_step(model, trajectory, segment_len, optimizer):
    """快权重跨段携带(值保留),梯度在段边界 detach(图断开)。"""
    fw = model.init_fast_weights()      # 从元学习出的 W_0 起步
    total_loss = 0.0

    for seg in trajectory.split(segment_len):
        # 段内正常建图反传;W_0 只通过第一段拿到梯度
        loss, fw = model.forward_segment(seg, fw)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        total_loss += loss.item()

        # 关键一行:值往下传,梯度不往下传
        fw = tuple(p.detach() for p in fw)

    return total_loss

一句话理解:fw = tuple(p.detach() for p in fw) 这一行就是 TBPTT 的全部实质——记忆继续往前走,责任追溯只到段边界为止。这跟人类学习长任务的方式其实很像:你会记得上一小时做了什么,但不会为了理解现在这一步而把上一小时的推理过程重新走一遍。

5. 损失掩码:同一套机制的两种用法

5.1 解耦快慢权重更新的那把钥匙

RoboTTT 有一个设计上极为省力的性质:快权重更新与慢权重更新是解耦的。在选定的时间步上把流匹配损失掩掉,这些时间步就变成纯上下文——它们更新快权重,但不提供模仿目标。这个性质之所以成立,是因为快权重更新用的是自监督的键值重建损失,与外层的流匹配动作损失是两套独立的目标。

这一个开关同时决定了两件事:什么在给策略提供条件,以及什么在监督策略。把这两者错开,就能从同一套配方里长出两种完全不同的能力。下面两小节分别是这个错位的两种排法,一种把外部演示放进上下文,一种把自己的失败放进上下文。

5.2 用法一:从上下文人类视频做一次性模仿

第一种用法是把人类视频序列 ξ v i d e o \xi^{\mathrm{video}} ξvideo 前置到机器人轨迹 ξ r o b o t \xi^{\mathrm{robot}} ξrobot 之前,视频部分的损失被掩掉、只更新快权重,动作损失只在机器人轨迹上计算。于是模型被迫学会一件事:从视频里提取任务信息,因为只有这样它才能在后半段给出正确动作。这里要厘清的是,模型学的不是"模仿人手的动作",而是"从人手动作里读出目标构型"。

训练数据的采集方式很直接:在 Circuit 任务上额外录人类演示视频,机器人保持静止,由人手完成电路装配。训练集里每个装配构型采 5 到 20 条人类视频,初始布局各异。训练时采样同一构型的一条人类视频与一条机器人轨迹,拼成单条训练序列。注意这两段来自完全不同的具身——一段是人手,一段是双臂机器人,中间没有任何显式的对应关系标注。

这里最关键的一个实验设计是:所有构型都用同一句任务提示"assemble circuit",所以目标构型只能从上下文视频里辨认出来,语言里没有任何信息。这就把"模型是否真的在用上下文"这件事变成了可判定的——如果它不看视频,就必然装错。这种把信息通道刻意堵死的实验设计,比在提示词里加一句"参考上下文"要严格得多。

图 4:从上下文人类视频做一次性模仿。连续录制:人类演示一个未见构型(1-3 帧),场景重置(第 4 帧),RoboTTT 复现装配(第 2 行)

5.3 用法二:DAgger 蒸馏,把失败当上下文

第二种用法把角色对调,这是全篇最巧妙的一处设计。它要解决的问题是:DAgger 这类带人工纠正的数据里,机器人自己那些次优动作到底该怎么用。这个问题在标准 DAgger 流程里被默认回答为"丢掉",而论文给出了一个完全不同的答案。而这个答案的实现成本几乎为零,只是把损失掩码的排法换了一下。

先看标准 DAgger 是怎么做的:机器人出错时人工介入给出纠正动作,得到一条轨迹 ξ D A g g e r \xi^{\mathrm{DAgger}} ξDAgger,其中每个执行的动作块要么是机器人动作 A t R A_t^{\mathrm{R}} AtR​、要么是人类纠正 A t H A_t^{\mathrm{H}} AtH​。标准做法是只在人类纠正上微调,把次优的机器人动作丢掉。论文对此的评论很到位——恰恰是这些被丢掉的动作,揭示了每个纠正是在响应什么失败。

RoboTTT 两者都用,但角色不对称:序列训练时快权重在完整交互历史上更新——不只是执行的纠正,也包括那些次优的机器人动作本身——而流匹配损失只掩到人类纠正上。这个安排把同一条轨迹的两部分放到了两个不同的角色上,而不是把它们混成一堆监督信号。

难点提示(这个不对称到底蒸馏了什么):标准 DAgger 学的是"人在这个状态下会怎么做",是一个状态到动作的映射。DAgger 蒸馏学的是"在刚犯了某个错误之后,该怎么纠正",是一个失败到纠正的映射。差别在于前者把纠正孤立地模仿,后者把纠正放回它所响应的那个失败的语境里。论文把这看作算法蒸馏在机器人领域的一次实例化——人工介入所诱导的那个改进过程,被蒸馏成了策略自己的快权重适应过程。

推理时的行为闭合得很漂亮:模型在线执行这些纠正,无需人工介入;而它自己的纠正随后进入历史、被吸收进快权重,与训练时人类纠正被吸收的方式完全一致。训练与推理在这一点上是同构的,这也是为什么它能在没有人的情况下持续自我纠正而不逐步漂移。

图 5:DAgger 蒸馏。序列训练时所有执行的动作都更新快权重,但流匹配损失只在人类纠正上计算

# 参考实现:损失掩码同时支撑两种条件方式
def sequence_loss_with_mask(model, seq, loss_mask, fw):
    """loss_mask[t] = 1 表示该步计入动作损失;= 0 表示该步只更新快权重。

    用法一(视频演示):人类视频段 mask=0,机器人轨迹段 mask=1
    用法二(DAgger 蒸馏):机器人次优动作 mask=0,人类纠正 mask=1
    """
    losses, n_supervised = [], 0
    for t, step in enumerate(seq):
        # 无论 mask 取值,前向都会推进快权重——这是"纯上下文"的实现方式
        pred, fw = model.forward_step(step, fw)
        if loss_mask[t]:
            losses.append(F.mse_loss(pred, step.flow_target))
            n_supervised += 1
    if n_supervised == 0:
        return torch.zeros((), device=seq.device), fw
    return torch.stack(losses).mean(), fw

这段代码里 loss_mask 是唯一的可变量,而它取不同模式就给出不同能力。一次性模仿与在线自我改进在实现层面共用同一个函数,区别只在于掩码怎么排,这是论文方法论上最经济的一处。对想复用这个想法的人来说,这也意味着实现成本极低——你不需要为两种能力各写一套数据管线。

5.4 DAgger 蒸馏:从同一批数据里多榨出 24 个百分点

…详情请参照古月居

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐