0. 简介

Harness VLA 出自清华大学于超教授团队与正行创新、无问芯穹的合作(arXiv:2607.08448,代码 github.com/RLinf/RPent)。它处理的是一个已被基准数字掩盖的事实:在 LIBERO 130 个任务上训到 96% 成功率的 VLA,换到只引入目标重绑定与位置交换两类扰动的 LIBERO-Pro 上,成功率跌到 50%。这篇工作的做法是不动 VLA 的任何一个权重,而是在它外面加一层执行组织系统:把冻结的 VLA 封装成名为 VLA_ACT 的单个原语,配上六个脚本化的解析原语,由一个能读写记忆的 Agentic Planner 决定谁在什么状态下被调用。LIBERO-Pro 上成功率从 50% 提到 82.4%,同期 NVIDIA Cap-X 是 18.2%、Berkeley RATS 是 43.8%;RoboCasa365 的三项子任务分别从 60.0%、21.3%、5.0% 提到 91.6%、56.3%、15.0%;RoboTwin 双臂跨场景迁移从 47.9% 提到 58.4%。github链接在这里https://harnessvla.github.io/
在这里插入图片描述

1. 一个被漂亮数字掩盖的问题

过去两年,Vision-Language-Action(VLA)模型几乎把机器人操作的标准 Benchmark 做到了饱和。在 LIBERO 的 130 个任务上训练出来的模型,平均成功率能到 96%:抓取、搬运、开抽屉、按按钮,绝大多数任务都能稳定完成。从曲线上看,这个方向似乎已经解决得差不多了,剩下的只是把模型继续做大、把数据继续堆多。

但这些数字回避了一个更现实的问题:机器人离开标准测试环境之后,还能不能正常工作。把同一个模型放到 LIBERO-Pro 环境里,情况立刻变了。LIBERO-Pro 做的事情并不激进,它只是在原有任务上引入两类扰动:一类是目标重绑定(instruction-redirection),指令换成让机器人操作场景里的另一个物体;另一类是位置交换(position-swap),把物体和容器的空间位置对调。任务本身的动作难度没有任何提升,抓取还是抓取,放置还是放置。

结果是 96% 掉到了 50%。更要紧的是失败的样子:机械臂动作依然流畅,靠近、闭合、抬起、移动,每一段局部运动单独看都没有明显错误,不抖动也不卡死。它只是把这一整套漂亮的动作,用在了错误的物体上,或者在错误的任务阶段执行了正确的动作。这不是运动控制的失败,是任务理解和执行组织的失败。

这个现象把 VLA 的核心短板暴露得很清楚:模型能完成训练过的任务,却很难稳定适应真实环境里的细微变化。而真实部署环境永远在变——家具会被挪动,指令会被换一种说法,同一类物体会有两三个摆在一起。如果每一次这样的变化都要重新收集数据、重新微调模型,机器人就永远走不出实验室。

直觉理解:把 96% 到 50% 这个落差理解成「模型退化」是错的。动作能力一点没丢,丢的是把动作用在正确对象、正确阶段上的组织能力。一个熟练工人被换到镜像布局的车间,手上的技术不会变差,但如果没人告诉他零件位置换了,他仍然会一次次拿错料。VLA 缺的正是这个「有人告诉他」的环节。

2. 扩大模型不是唯一的答案

面对泛化不足,最直觉的思路是继续 scaling:更大的模型、更多的数据、更广的场景覆盖,指望模型自己学出足够强的泛化能力。这条路当然有效,但它有一个很少被摆到台面上的隐含假设——所有能力都必须内化到模型权重里。一旦接受这个假设,应对每一类新的分布偏移就都变成同一件事:补数据、再训练、再验证,周期以周甚至月计。扰动的种类却几乎是无穷的,光是「物体和容器位置对调」这一种就能在任意场景里生成无数变体,靠穷举数据去覆盖它们并不划算。

数字智能领域最近两年的发展提供了另一个答案。以 Claude Code、Codex 为代表的新一代 Coding Agent,能力提升并不主要来自语言模型本身变大。真正的变化发生在模型之外:多了一层执行组织系统,通常被称为 Harness Layer。语言模型负责生成,Harness 负责维护上下文、调用工具、检查结果、处理失败,并决定模型在什么时候、以什么方式参与任务。同一个模型,套上一层成熟的 Harness,在真实工程任务上的完成率可以有数倍差距。

这里的关键是:系统能力不只取决于模型有多强,也取决于模型如何被组织和调用。一个能写出正确代码的模型,如果没有人告诉它当前文件长什么样、上次编译报了什么错、哪一步已经做完了、哪个测试还是红的,它依然会反复失败在同一个地方。Harness 补的正是这一层,而这一层的内容并不在权重里,它是系统结构、上下文管理和反馈回路。换句话说,能力的上限由模型决定,能力的兑现率由 Harness 决定。

那么对机器人来说,VLA 是否也需要属于自己的 Harness Layer?清华大学于超教授团队联合正行创新、无问芯穹提出的 Harness VLA,第一次把 Harness Layer 引入具身智能,并给出了明确的验证结果:在 LIBERO-Pro 扰动评测中,成功率从 50% 提升到 82.4%,显著超过 NVIDIA Cap-X(18.2%)和 Berkeley RATS(43.8%)。整个过程中,底层 VLA 全程冻结,一个权重都没有改动。
RPent / Harness VLA 框架总览(官方配图)

3. Harness VLA 做了什么:把 VLA 降格为一个原语

3.1 固定的原语词汇表

Harness VLA 的核心设计可以用一句话概括:不训练更大的端到端模型,而是让冻结的 VLA 只做它最擅长的接触密集操作,再用一层 Harness 学会如何组织、调用和复用它。这句话里「降格」两个字是关键——在端到端范式里 VLA 是整个系统,在这里它退化成词汇表中的一个条目,和 MOVE_TO 这样的脚本函数平级,接受同样的调用协议、返回同样格式的结果、接受同样的成功判定。

这个设计的第一步是重新划分职责。系统维护一套固定的原语词汇表(Primitive Vocabulary),分两类。第一类是 VLA_ACT Primitive,把底层 VLA 整体封装成一个可调用的工具,负责抓取、放置、按按钮、开抽屉这类难以用解析方法建模的接触密集行为。这些行为涉及复杂的接触力学和视觉伺服,写规则写不出来,正是学习型模型的优势区间。

第二类是 Analytic Primitives,包括 MOVE_TO(末端伺服到世界坐标)、ROTATE_WRIST(绕世界 Z 轴转腕)、ROTATE_PITCH(绕世界 X 轴俯仰)、MOVE_POSE(位置与姿态同时伺服)、SET_GRIPPER、RELEASE。这些都是确定性的脚本化控制,不调用任何神经网络,负责稳定的空间移动、位姿调整、运输和释放。它们不聪明,但极其可靠、可重复、可预测。

3.2 Planner 学的是组织方式,不是新技能

形式化地写,系统的动作空间不再是连续的关节指令或末端速度,而是一个固定的离散原语集合。Planner 每一步只做一件事:从这个集合里选一个条目,并给出它需要的参数。这个改写把原本高维连续的控制问题降成了一个带参数的离散选择问题,而离散选择恰好是语言模型擅长的形式:

P = { VLA_ACT } ⏟ 冻结神经网络 ∪ { MOVE_TO ,   MOVE_POSE ,   ROTATE_WRIST ,   ROTATE_PITCH ,   SET_GRIPPER ,   RELEASE } ⏟ 确定性脚本 \mathcal{P} = \underbrace{\{\texttt{VLA\_ACT}\}}_{\text{冻结神经网络}} \cup \underbrace{\{\texttt{MOVE\_TO},\ \texttt{MOVE\_POSE},\ \texttt{ROTATE\_WRIST},\ \texttt{ROTATE\_PITCH},\ \texttt{SET\_GRIPPER},\ \texttt{RELEASE}\}}_{\text{确定性脚本}} P=冻结神经网络 {VLA_ACT}​​∪确定性脚本 {MOVE_TO, MOVE_POSE, ROTATE_WRIST, ROTATE_PITCH, SET_GRIPPER, RELEASE}​​

这个集合在探索阶段和部署阶段都保持不变,规模始终是 ∣ P ∣ = 7 |\mathcal{P}| = 7 ∣P∣=7,论文明确禁止在部署时新增原语。于是 Planner 学到的东西全部体现在如何在时间维度上把这七个条目排成序列、每次带什么参数,而不是往集合里添加新条目。写成策略的形式,它的输入是四元组,输出是一个原语加一组参数:

π harness :   ( ℓ ,   o t ,   s t ,   M )   ⟼   ( p t ,   θ t ) , p t ∈ P \pi_{\text{harness}}:\ (\ell,\ o_t,\ s_t,\ \mathcal{M})\ \longmapsto\ (p_t,\ \theta_t),\qquad p_t \in \mathcal{P} πharness​: (ℓ, ot​, st​, M) ⟼ (pt​, θt​),pt​∈P

其中 ℓ \ell ℓ 是任务语言, o t o_t ot​ 是当前 RGB-D 观测, s t s_t st​ 是机器人本体状态, M \mathcal{M} M 是两层记忆, θ t \theta_t θt​ 是这次调用的参数(目标坐标、prompt 文本、夹爪目标开度等)。真正的关键不是把这两类原语放在一起,而是让 Agentic Planner 在闭环交互中学习它们的使用边界。哪些阶段该交给 Analytic Primitive,哪些局部接触该交给 VLA_ACT,调用 VLA_ACT 之前需要把视角和预接触位姿调整到什么状态,一次接触失败之后应该如何重新 Staging 再尝试。这些都不是预先写死的流程,而是在与环境交互中逐步学到的组织知识。

在这里插入图片描述

从这个意义上说,Harness VLA 学到的不是新的底层机器人技能,也不是对 VLA 权重的更新,而是如何更可靠地使用已有技能。底层 VLA 始终冻结,但 VLA 被调用的时机、条件和上下文在持续被优化。这个区别在工程上很实际:换一个更强的 VLA checkpoint 进来,之前学到的组织知识大部分仍然有效,因为那些知识描述的是「什么状态下该调用接触原语」,与具体是哪个模型在执行接触无关。

一句话理解:VLA_ACT 在这套系统里的地位,相当于一个你没有源码、不能改、但调用起来很有效的第三方库函数。你能做的只有两件事——把入参调到它能正常工作的范围,以及检查返回值再决定下一步。Harness 学的正是这两件事。

4. Frozen VLA 如何被封装成一个可判定的工具

4.1 成功判据来自本体感知,不来自模型自述

要把 VLA 当工具用,先得解决一个基础问题:怎么知道这次调用成功了。端到端范式下这个问题不存在——模型一直跑到 episode 结束,成功由环境判定,中间过程没有人需要知道。但在 Harness 架构里,Planner 需要在每次调用之后立刻拿到一个可判断的结论,才能决定下一步是继续推进、重新 Staging 还是换目标。缺了这个结论,闭环就退化成开环,Planner 只能盲目地把预设序列发完。

RPent 的做法是不相信 VLA 的自我报告,而是完全从机器人本体感知里独立推断。以抓取原语 pi0_pick 为例,它的成功判据只用末端高度和夹爪开度这两个物理量,与 VLA 内部的置信度、注意力或任何中间表示无关。写成判据的形式,它是三个条件的合取,三者缺一不可:

success    =    ( z start − z min ⁡ ≥ 0.10 ) ⏟ 确实下探过    ∧    ( z > min ⁡ peak − z min ⁡ ≥ τ lift ) ⏟ 触底后回升    ∧    ( g < τ grip ) ⏟ 夹爪已闭合 \text{success} \;=\; \underbrace{(z_{\text{start}} - z_{\min} \ge 0.10)}_{\text{确实下探过}} \;\wedge\; \underbrace{(z^{\text{peak}}_{>\min} - z_{\min} \ge \tau_{\text{lift}})}_{\text{触底后回升}} \;\wedge\; \underbrace{(g < \tau_{\text{grip}})}_{\text{夹爪已闭合}} success=确实下探过 (zstart​−zmin​≥0.10)​​∧触底后回升 (z>minpeak​−zmin​≥τlift​)​​∧夹爪已闭合 (g<τgrip​)​​

其中 τ lift = 0.05 \tau_{\text{lift}} = 0.05 τlift​=0.05 米、 τ grip = 0.06 \tau_{\text{grip}} = 0.06 τgrip​=0.06,而 z > min ⁡ peak z^{\text{peak}}_{>\min} z>minpeak​ 特指触底之后出现的最高点。这个下标是整段逻辑的要害所在,实现里为它专门维护了一个会在出现更深最低点时被重置的变量。代码中那条注释解释的正是这件事,也是这段判据里最容易写错的一处,下面把完整实现贴出来看:

def pi0_pick(
    self,
    prompt: str,
    *,
    max_chunks: int = 24,
    lift_thresh: float = 0.05,
    gripper_closed_thresh: float = 0.06,
) -> dict:
    """闭环 Pi0.5 抓取,以 prompt 作为 VLA 的子指令。

    成功判据 := 末端在触底后回升 >= lift_thresh 且夹爪开度 < gripper_closed_thresh。
    """
    start_z = self._last_obs_eef_z
    min_z = start_z
    # 关键:只看 |peak - min| 会在下降到最低点时就误判为"已抬起",
    # 真正的抬升信号是"先触底、再回升",所以要单独跟踪触底之后的峰值。
    post_min_peak_z = start_z
    descent_done = False
    success = False

    for c in range(max_chunks):
        self._vlm_chunk(instr)          # 一次模型前向 + chunk_size 个环境步
        z = self._last_obs_eef_z
        grip = self._last_obs_gripper

        if z < min_z:
            min_z = z
            post_min_peak_z = z          # 出现更深的最低点,重置回升基准
        else:
            post_min_peak_z = max(post_min_peak_z, z)

        if (start_z - min_z) >= 0.10:    # 下降超过 10cm,认为已经进入抓取动作
            descent_done = True

        ascended = (post_min_peak_z - min_z) >= lift_thresh
        closed = grip < gripper_closed_thresh
        if descent_done and ascended and closed:
            success = True
            break

难点提示:为什么不能直接用 z peak − z min ⁡ ≥ τ lift z^{\text{peak}} - z_{\min} \ge \tau_{\text{lift}} zpeak−zmin​≥τlift​ 这个更简单的式子?因为在机械臂正在下降的过程中,历史峰值就是起始高度, z peak − z min ⁡ z^{\text{peak}} - z_{\min} zpeak−zmin​ 会随着下降单调增大,下探到 5 厘米深时判据就被满足了,此时物体根本还没被夹住。抬升信号的本质是顺序而非幅度,必须先确认触底、再从触底点重新计算回升量。

这段代码里那条注释值得单独说一句。最朴素的判据是「末端峰值高度减去最低高度超过阈值就算抬起」,但这个量在机械臂下降到最低点的瞬间同样会被满足,于是抓取还没发生就被判成功了。真正的抬升信号是「先下降触底,再回升」这个顺序结构,所以实现里要在观察到新的最低点时重置回升基准。这类细节不来自论文的公式,来自反复调试中踩过的坑,而它决定了 Planner 收到的反馈是否可信。

4.2 返回诊断量,而不只是一个布尔值

进一步看返回值的设计。原语并不只返回一个 success 布尔值,而是把整段可用于归因的诊断信息一并交回。这个选择的动机很直接:布尔值只告诉 Planner「这次没成」,而没成有好几种完全不同的成因,对应完全不同的补救动作,分不清就只能盲目重试同一套流程。返回结构里每个字段都对应一个可判别的物理量:

return {
    "name": "pick",
    "success": success,
    "chunks_used": chunks_used,
    "peak_lift_m": post_min_peak_z - min_z,   # 触底后的实际回升量
    "min_gripper_opening": min_grip,
    "libero_terminated": self.env.episode_done,
    "diagnostics": {
        "descent_m": round(start_z - min_z, 4),
        "post_min_ascent_m": round(post_min_peak_z - min_z, 4),
        "descent_done": descent_done,
        ...
    },
}

这是 Harness 架构和普通函数调用的区别。Planner 拿到 success: false 之后需要知道失败在哪一环:如果 descent_done 为假,说明 VLA 根本没往下走,问题在预接触位姿太远;如果下降了但 min_gripper_opening 接近全闭,说明夹爪闭合到底却什么都没夹到,抓空了;如果夹爪有间隙但没抬起来,可能是物体太重或者卡住了。三种失败对应三种完全不同的恢复动作,只有把诊断量交出去,Planner 才有可能做出正确判断。

对非抓取类的接触操作,系统提供 pi0_doubled,用于转旋钮、开关炉灶、短距离推动这类动作。它的实现刻意保守:不设私有的物体位姿判据,success 只镜像官方的任务终止信号。这个设计在文档注释里写得很直白——中间过程的接触技能返回 success: false 并不代表接触失败,Planner 必须自己去看图像和状态证据。宁可交回一个不确定的结论,也不给出一个可能误导的确定结论。

5. 感知隔离:故意不告诉 Planner 物体在哪

5.1 一行 state.pop 定义了整个实验的性质

Harness VLA 的实验设计里有一个容易被忽略但非常重要的选择:Planner 拿不到物体的真实坐标。这不是实现上的疏漏,而是主动施加的约束,它决定了报告出来的成功率提升能不能被当作真实能力来看待。如果 Planner 能读到真值坐标,那么位置交换这类扰动对它就完全不构成挑战——读数变了,照着新读数走就行,测出来的是仿真器的诚实度而不是系统的泛化能力。

LIBERO 底层的 robosuite 环境其实把所有物体的世界坐标都放在观测里,plate_1_pos、akita_black_bowl_1_pos 这些字段随手可得。如果直接把它们交给 Planner,任务会简单很多——直接读坐标、直接 move_to,连感知都不需要。但那样得到的成功率毫无意义,因为真实机器人没有这种特权信息。

这里要强调的是,剥离动作发生在状态导出这一层,而不是靠提示词要求 Planner「不要使用坐标」。前者是物理上拿不到,后者只是一句约定,模型完全可能在某一步偷偷用回去,而且事后很难从日志里查出来。RPent 选的是前者,实现只有三行,但这三行是整套评测可信度的基础:

state = primitives.get_privileged_state()
# 强制感知:丢掉物体的世界坐标,Planner 必须通过 depth + camera_meta 自己定位。
# 保留物体 NAMES(场景里有什么、目标是哪个)+ 机器人本体感知 —
# 名字不是坐标信息,而且任务语言里本来就暗示了它们。
objs = state.get("objects", {})
state["object_names"] = sorted(objs.keys())
state.pop("objects", None)

这一行 state.pop("objects", None) 把整个实验的性质定了下来。Planner 每一步能拿到的只有:机器人末端位姿、夹爪开度、场景里有哪些物体的名字、多路相机的 RGB 图像、对应的深度图和相机内外参。要知道目标在哪,必须自己走一遍感知流程——分割、反投影、验证。保留物体名字这个选择也有讲究:名字告诉 Planner 场景里存在什么、任务指的是哪一类,但不携带任何空间信息,而且任务语言本身已经暗示了这些名字,保留它不构成额外泄漏。

5.2 从掩码到世界坐标:为什么用中位数

配套的感知工具有两个,分别覆盖「按语义找物体」和「按像素查坐标」两种需求。segment 调用 SAM 3.0 做开放词汇分割,输入一段文本描述或者一个正样本点,输出二值掩码;然后把掩码内的像素通过预计算的世界坐标图投影出去,对每个坐标轴分别取中位数,得到物体的世界坐标估计:

x ^ world    =    ( median ⁡ ( u , v ) ∈ Ω   X u v ,    median ⁡ ( u , v ) ∈ Ω   Y u v ,    median ⁡ ( u , v ) ∈ Ω   Z u v ) , Ω = { ( u , v )   :   M u v = 1 ∧ depth valid } \hat{\mathbf{x}}_{\text{world}} \;=\; \Big(\operatorname{median}_{(u,v)\in \Omega}\, X_{uv},\ \ \operatorname{median}_{(u,v)\in \Omega}\, Y_{uv},\ \ \operatorname{median}_{(u,v)\in \Omega}\, Z_{uv}\Big),\qquad \Omega = \{(u,v)\,:\, M_{uv}=1 \wedge \text{depth valid}\} x^world​=(median(u,v)∈Ω​Xuv​,  median(u,v)∈Ω​Yuv​,  median(u,v)∈Ω​Zuv​),Ω={(u,v):Muv​=1∧depth valid}

对应的实现里,有效性过滤和中位数聚合是两个独立步骤,缺任何一个都会出问题:不过滤会让 NaN 污染整个结果,不用中位数则会被掩码边缘的背景像素系统性带偏。函数还额外设了一个有效像素数下界,样本太少时宁可返回失败也不给一个不可靠的坐标:

def _mask_to_world(mask, world_map, min_valid: int = 10) -> dict:
    ys, xs = np.where(mask)
    pts = world_map[ys, xs].astype(np.float64)
    # 过滤掉深度无效的像素(NaN、全零)
    valid = np.isfinite(pts).all(axis=1) & (np.abs(pts).sum(axis=1) > 1e-6)
    pts = pts[valid]
    if pts.shape[0] < min_valid:
        return {"world_xyz": None,
                "world_error": f"too few valid depth pixels ({pts.shape[0]})"}
    # 用中位数而非均值:掩码边缘常常吃到背景像素,均值会被拉偏
    return {"world_xyz": [round(float(np.median(pts[:, i])), 4) for i in range(3)]}

用中位数不用均值是个小而关键的选择。分割掩码的边缘几乎总会包含一圈背景像素,这些像素的深度属于桌面或者更远的墙面,在数值上是重尾的离群点。均值对离群点没有抵抗力,会被系统性地拉向背景,误差方向还是固定的(总是偏远),于是 MOVE_TO 会稳定地停在物体后方几厘米处;中位数的崩溃点是 50%,只要边缘背景像素不超过掩码的一半就基本不受影响。这类选择在论文里通常一句都不提,但它直接决定预接触位姿准不准。

在这里插入图片描述

另一个工具 back_project 更直接,把指定像素通过世界坐标图查出对应的三维点,同时提供区域模式:给一个像素窗口和一个高度带,返回窗口内有效点的中心。区域模式的存在同样源自实际问题——要往容器里放东西,需要的是容器空腔的中心,而单像素或者掩码中位数往往落在容器边缘的凸起上,导致释放位置偏出去。

这里值得单独说明,Planner 并不被允许自己写反投影的数学。工具描述里明确要求使用 back_project,除非是在调试工具本身的故障。这里体现的是 Harness 设计的一个原则:把容易出错的坐标变换固定在工具里,用一次、验证一次,而不是让模型每次即兴推导。相机元数据的注释里就记着一个典型的坑:image_NN.png 是按 Pi0 约定旋转过 180 度的,和深度图、内参矩阵不在同一个坐标系,用它来做反投影会得到完全错误的结果;要做反投影必须用 image_cam_NN.png。这种一次踩过就应该永久固化的知识,写在工具描述里比写在论文里有用得多。

6. 两层记忆:把成功经验变成可复用的结构

6.1 探索自举与部署评估两个阶段

Harness VLA 的生命周期分成两个阶段,这是它区别于普通任务调度器的地方——调度器只按既定规则执行,不会因为跑过一遍而变得更好,而 Harness 会把跑通的过程沉淀下来供下次使用。两个阶段之间的界线很清楚:探索阶段允许反复试错、可以失败很多次,部署阶段只读记忆不写记忆,且不允许针对新场景重新探索。

第一个阶段是探索式自举(Exploratory Bootstrapping Phase)。在每个任务的 Seed 0 上,Planner 通过环境反馈不断试错,直到找到一条能够成功的原语组合轨迹。第二个阶段是部署评估(Deployment Evaluation Phase),把学到的东西应用到同一任务的其他 Seed 或者扰动版本上。这个流程和人的学习过程很像:先在少量样本上摸索到方法,然后独立作业。

6.2 第一层:任务级 recipe

从探索阶段沉淀下来的东西分两层。第一层是任务级记忆(Task-Specific Memory),运行结束时自动导出,只保留真正推进了任务的原语命令序列。这里的过滤规则是双重的:读文件、看状态、做分割这类不改变环境的调用不进入记录,返回了 error 的调用也不进入记录。用集合表示,导出的 recipe 是全部事件序列的一个子序列:

R    =    ⟨   c t    :    c t . action ∈ P act    ∧    ¬   err ( c t )   ⟩ t = 1 T , P act = P ∖ { 感知类 } \mathcal{R} \;=\; \big\langle\, c_t \;:\; c_t.\text{action} \in \mathcal{P}_{\text{act}} \;\wedge\; \neg\,\text{err}(c_t) \,\big\rangle_{t=1}^{T},\qquad \mathcal{P}_{\text{act}} = \mathcal{P}\setminus\{\text{感知类}\} R=⟨ct​:ct​.action∈Pact​∧¬err(ct​)⟩t=1T​,Pact​=P∖{感知类}

导出只在最终 libero_terminated = True 的运行上发生,也就是说记忆的准入条件是环境给出的客观成功信号,而不是 Planner 自己声称完成。这道门槛避免了一类很难排查的污染:Planner 误判成功并把错误流程写进记忆,之后每次检索都会被这条错误经验带偏,而且越用越确信。导出函数的过滤逻辑如下:

def write_recipe_from_states(output_dir: str, recipe_tag: str) -> str:
    """从 states.json 里提取一条能让 libero_terminated=True 的命令序列。"""
    primitive_actions = {
        "move_to", "pi0_pick", "pi0_doubled", "release",
        "set_gripper", "rotate_wrist", "rotate_pitch", "move_pose",
    }

    command_events = []
    for step_idx, entry in enumerate(states):
        command = entry.get("command")
        if command is None:
            continue
        if command.get("action") not in primitive_actions:
            continue          # 只留原语,过滤掉感知/读文件类调用
        result = entry.get("result")
        if isinstance(result, dict) and result.get("error"):
            continue          # 出错的调用不进 recipe
        command_events.append(((step_idx, -1), command))

    # 成功的 segment 调用也一并导出,保留 Planner 当时是怎么定位目标的
    for artifact in (Path(output_dir) / "segments").glob("segment_*.json"):
        segment = json.load(artifact.open())
        if segment.get("error"):
            continue
        ...

6.3 第二层:全局记忆笔记

第二层是全局记忆(Global Memory),以 Markdown 笔记的形式组织,一个 MEMORY.md 作索引,下面挂若干子笔记,记录跨任务通用的操作要点、参数范围和常见失败模式。这一层不针对具体任务,而是沉淀经验性的知识。两层的关系可以这样理解:recipe 回答「这个任务按什么顺序做」,全局记忆回答「做这类动作时有哪些坑、参数该取多少」。前者高度特化且不可跨任务复用,后者通用但不含具体步骤,单独用任何一层都不完整。

两层记忆的分工在提示词里被说得非常明确,而且措辞相当强硬:recipe 只提供命令顺序,真正把它适配到新场景要靠 memory 笔记里的技巧、参数范围和失败模式;只读 recipe 不读 memory 是「只看到了一半」,规划前必须两个都查。系统甚至要求 Planner 在最终的 strategy_notes 里写清读了哪几个 memory 文件,或者明确声明「没找到匹配的任务记忆」,让记忆的使用可被审计。

这条约束背后是一个真实存在的失败模式:如果 Planner 只拿到 recipe,它最容易做的事就是照抄里面的坐标。而在位置交换的扰动任务里,历史坐标恰恰是最危险的信息——物体已经不在那里了。提示词因此反复强调,Planner 参考的应该是步骤顺序和策略,坐标必须根据当前画面重新感知计算,不得照搬。

全局记忆里究竟存什么,从 RPent 仓库里已经固化下来的这批关键参数可以看出一个大概。它们的共同特点有三个:都能写成一句人话,都带明确的适用条件(哪类物体、哪个场景、哪个高度区间),而且都是某次具体失败的直接产物,不是从超参数搜索里扫出来的。下面这些是提示词与工具描述里能直接读到的:

- 单步 xy 位移必须控制在 ±0.30 以内,否则 OSC 控制器会翻转 IK 解,长距离要拆成多个中间路点
- lift_thresh:平底物体 0.05,易滑的高瓶 0.08
- step_clip:空盒 0.025,罐类 0.015,高瓶 0.012
- 参考坐标系:末端 z ≈ 0.68(客厅)/ 1.17(厨房)/ 0.26(桌面物体)
- 放碗到盘子上:末端 y = 盘子 y + 0.045
- 高瓶搬运:抬到 z=0.30,直接释放,不要下降
- 接近策略:先抬高再垂直下降;失败后重新抓取,不要悬停微调

这些数字不是超参数搜索的结果,是一次次失败换来的经验。它们的价值在于可迁移:换一个 Seed、换一个布局,物体坐标全变了,但「高瓶要用更小的 step_clip」「放碗要在盘心 y 方向偏 4.5cm」这些规律依然成立。这正是把执行知识从模型权重里剥离出来、放到系统层的好处——它以人类可读、可审计、可增量修改的形式存在,而不是被编码进一堆无法解释的参数里。

记忆本身托管在 Hugging Face 数据集(RLinf/RPent-memory)上,每次运行时增量同步。设置 HF_HUB_OFFLINE=1 就跳过同步只用本地副本;某个环境如果没有对应记忆,或者同步整体失败,运行也会照常继续,只是退回到零样本模式。记忆是可选增强,不是硬依赖,这个设计让系统在离线机房和没有历史积累的新环境里都能启动,代价是成功率会掉到后文零样本实验给出的水平。

工程价值:把执行知识放在 Markdown 笔记里而不是权重里,带来的是可审计性。「高瓶用 step_clip=0.012」这条经验,工程师能看懂、能改、能判断它在新场景是否仍然成立,出问题时也能定位到具体哪一条误导了 Planner。同样的知识如果被编码进一次微调的权重更新里,就既看不见也改不动,只能靠再训一次来覆盖。

7. 82.4% 从哪来:目标、状态、分工

Harness VLA 的设计建立在一个关键洞察上:许多部署失败并不是 VLA 不会抓取、放置或操作,而是它在错误的目标、错误的状态或错误的任务阶段,继续执行了一段局部上看似合理的动作。所以 Harness 提升的不是 Frozen VLA 单次动作的能力,而是让这些已有能力被用在更正确的地方。这件事可以拆成三层来看。

7.1 第一层:让 VLA 作用在正确的目标上

当指令重新指定目标,或者物体与容器交换位置之后,端到端 VLA 仍然可能延续训练时熟悉的目标和空间关系。它的动作依然流畅——靠近、抓取、放置一气呵成,但服务的对象已经错了。这种失败从动作质量上完全看不出来,因为从一开始选错目标,后面每一步都是"正确地做错误的事"。

Harness VLA 的处理方式是由 Agentic Planner 根据当前任务指令和 RGB-D 观测重新判断该操作谁、目标在哪,再据此组织后续动作。这里的关键是一条硬约束:目标必须由空间关系确定,不能靠名字猜。提示词里对这一点写得相当细,连怎么用世界坐标做消歧都给了具体判据:

Rule 2c — 用空间关系锚定目标,不要用名字。
  当任务描述包含关系("饼干盒上的碗"、"盘子左边的杯子"),
  目标就是场景中满足该关系的那个物体,靠感知去找,不要猜是 _1 还是 _2。
    • "在饼干盒上" ⇒ 那个被抬高的碗(比桌面高约 0.03–0.06m),
      用 back_project 拿到的世界 z 来和桌面高度的碗区分。
    • "在 X 左边/右边/前面/后面" ⇒ 比较反投影得到的世界 xy。
  完全根据物体实际在哪来选目标。

为什么要专门强调这一点?因为场景里经常有两个完全同名的物体(比如两个 akita_black_bowl_*),名字里不携带任何可感知的差异,后缀编号 _1 和 _2 与它们在画面中的位置也没有稳定对应关系,靠名字根本无法区分。真正能消歧的是关系:哪个被垫高了,哪个在左边。这类信息只能从深度图和反投影里拿到,而这恰恰是感知隔离设计要逼出来的能力。

还有一个更隐蔽的坑,提示词里单独列了一条规则:放置目标必须先在 RGB 里做语义分类,再去定位。原因是深度图和世界坐标图能告出一个平的圆盘在哪,但说不出它是什么——盘子、灶眼、木柜顶面、锅盖,在反投影坐标里都是「桌面高度的一个平圆盘」,只有在 RGB 里才能分开。厨房场景里经常同时存在一个灶眼和一个盘子,高度几乎一致,如果按 z 扫描找到第一个平圆盘就往上放,有相当概率放错。提示词给出的判据是外观级的:盘子是陶瓷质感、通常发白、有干净的凸起边缘;灶眼是深灰金属、带盘管或格栅纹路、坐在灶具上。这条规则还附了一句很实用的诊断经验——如果释放之后任务判定没有触发,先怀疑是不是放到了外观相似的另一个面上,再去怀疑抓错了物体,因为判定不触发是「放错面」的概率和「抓错物」一样高。

7.2 第二层:让 VLA 在合适的状态中被调用

即使目标识别正确,如果让 VLA 从任意距离、视角或机械臂姿态直接接管,它仍然可能处在训练分布之外的状态里。这也是分段解决长程任务时的典型策略衔接问题:每一段策略都只在自己熟悉的初始状态集合上可靠,而上一段的终止状态未必落在下一段的可行域内。用集合的语言说,衔接成立的条件是 s end ( k ) ∈ S init ( k + 1 ) s_{\text{end}}^{(k)} \in \mathcal{S}_{\text{init}}^{(k+1)} send(k)​∈Sinit(k+1)​,而 Staging 做的正是把状态推进这个集合。

Harness VLA 的关键在于,它不是简单地把任务切成若干固定步骤,而是通过探索和记忆逐渐学习 VLA 的适用边界:什么时候应该先调整空间关系,什么时候 VLA 已经处在可接管的局部操作区间,什么时候一次接触失败后需要重新 Staging 再尝试。这个边界没有解析表达式,只能通过反复试探把它的轮廓摸出来,然后以参数范围和失败模式的形式写进记忆。

调用之前,Planner 用 Analytic Primitives 把机器人送到接近 VLA 优势分布的位置,这个过程就是 Staging。这个环节有一条来自控制器物理特性的硬约束,被直接写进工具描述而不是留给模型自己发现,因为它一旦被违反整个 run 就没救了——IK 解翻转之后机械臂的位形已经错了,后续所有观测都不可信:

move_to:绝不要单次下发 |Δxy| > 0.30 的移动指令 —
  OSC 控制器会翻转 IK 解,整个 run 会被污染;
  长距离搬运必须拆成 2-3 个中间路点,在搬运高度上过渡。

这类约束是 Analytic Primitive 存在的直接理由。VLA 面对长距离移动时并不知道有这个物理边界,而脚本化原语可以把它固化成规则。同类的还有 move_pose:它同时伺服位置和姿态,注释里说明了动机——面对柜门前方的低位姿态,如果用「先 move_to 移动、再 rotate_pitch 转腕」的解耦方式,位置伺服会把腕关节推进奇异位形然后卡死;只有让 xyz 和腕部倾角每一步同时变化,才能像 VLA 那样走出一条弯曲的伸入轨迹。

调用之后,Planner 根据反馈决定是否再次调用。这里的要点是:这不是让 Frozen VLA 在相同条件下机械重复。每次调用之后 Planner 都会根据新观测判断任务进展,决定是继续执行、用 Analytic Primitives 重新移动和 Staging,还是再次调用 VLA_ACT。所以第二次调用面对的通常已经不是第一次调用时的状态——机械臂可能重新调整了位姿、夹爪状态或与目标的相对位置。

判断抓取是否真的成功,同样不看 VLA 的自我报告,而是走两个互相独立、都不依赖物体真值坐标的信号做交叉验证。一个来自本体感知的夹爪关节角,一个来自腕部相机的图像,两者同时支持才继续推进搬运。两个信号的失效模式不重叠,这是交叉验证有意义的前提:

Rule 1b — 从感知判断抓取,不要从名字判断。抓取之后用两个无坐标信号决定
  "我到底抓到目标了吗":
    • 夹爪(本体感知):state.robot0_gripper_qpos —
      手指闭合但没有完全合死(约 0.01–0.05 的间隙)⇒ 夹住了东西;
      完全闭合(约 0.0)⇒ 抓空了。
    • 腕部相机:抬起后读 image_wrist_hi_NN.png。目标应该已经被提到夹爪里,
      它原来所在的位置应该空了。
  pi0_pick.success 只是提示,不是证据 —— 搬运之前一定用腕部相机确认。

「夹爪完全闭合等于抓空」这个判据非常朴素但极其有效。抓到物体时手指会被物体撑住,留下一个和物体厚度相当的间隙;抓空时手指会一路合到底。一个标量就能区分两种情况,而这个标量来自本体感知,完全不依赖视觉,也不依赖模型的自我评估。

关于重试次数,论文给出了一个值得注意的实验结果。横轴是每个 Episode 允许调用 VLA_ACT 的次数上限,纵轴是累计成功率。三个 Benchmark 上的曲线都在最初几次调用后快速上升,然后逐渐逼近完整 Harness VLA 的性能。也就是说,由 Planner 完成 Staging 之后的重复调用确实能提升成功率,但实际需要的调用是稀疏的,并不依赖大量反复尝试。

这个结果排除了一种很自然的质疑:性能提升是不是仅仅因为多试了几次。这个质疑值得认真对待,因为它有一个可以被证伪的形式——如果各次尝试真的独立同分布、每次成功率都是同一个 p p p,那么累计成功率应该服从几何累积,随次数单调上升且没有平台:

P iid ( k )    =    1 − ( 1 − p ) k (随  k  单调上升,无饱和平台) P_{\text{iid}}(k) \;=\; 1 - (1-p)^k \quad\text{(随 } k \text{ 单调上升,无饱和平台)} Piid​(k)=1−(1−p)k(随 k 单调上升,无饱和平台)

而论文里观测到的曲线在很小的 k k k 处就逼近渐近值 P harness ( ∞ ) P_{\text{harness}}(\infty) Pharness​(∞),形状明显偏离上式。差别的来源在于各次尝试并不独立——Planner 在两次调用之间用 Analytic Primitives 改变了机械臂的初始状态,所以第二次调用面对的条件已经不同,其条件成功率远高于首次的基础成功率:

P ( success at  k + 1 ∣ fail at  k ,  re-staged )    ≫    p P(\text{success at } k{+}1 \mid \text{fail at } k,\ \text{re-staged}) \;\gg\; p P(success at k+1∣fail at k, re-staged)≫p

…详情请参照古月居

Logo

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

更多推荐