1. 一句话理解 MemER
    MemER 的核心思想是:

不要把机器人从开头到现在看到的所有画面都塞给模型,而是让一个高层视觉语言模型不断判断“现在该做哪个子任务”和“最近看到的哪些帧以后可能还要用”,再把这些被挑出来的关键帧作为长期视觉记忆喂回模型。

它解决的是长时程机器人任务里的“我刚才看见过,但现在画面里没有”的问题。例如:

机器人之前看过某个物体在右边箱子里,现在要找它时不应该重新翻左边箱子。
机器人已经舀了两勺花生,当前画面只看到勺子,不一定能知道已经舀了几勺。
机器人把架子上的物体拿走、擦完架子以后,需要记住物体原本放在哪一层。
普通策略如果只看当前帧,会缺记忆;如果直接看很长视频,会慢、贵、冗余,而且容易把无关历史当捷径。MemER 的做法是用“最近窗口 + 少量关键帧”代替完整历史。

  1. 方法整体架构
    MemER 是一个分层策略:

高层策略 pi_h:
输入: 总任务指令 + 长期关键帧记忆 K_t + 最近 N 帧 R_t
输出: 当前应执行的语言子任务 l’_t + 最近 N 帧中应保存的候选关键帧 J_t

低层策略 pi_l:
输入: 当前图像 I_t + 机器人状态 q_t + 高层给出的子任务 l’_t
输出: 未来一小段机器人动作 A_t
论文把整体策略写成:

pi(A_t | o_{0:t})
= pi_l(A_t | I_t, q_t, l’_t)
* pi_h(l’t, J_t | I{t-N+1:t}, K_t)
其中:

A_t 是未来动作 chunk。
I_t 是当前多相机图像。
q_t 是关节角、夹爪等 proprioception。
l_t 是用户给的总任务指令。
l’t 是当前要交给低层策略执行的子任务。
R_t = I
{t-N+1:t} 是最近 N 帧。
K_t 是过去被选中的长期关键帧。
J_t 是高层模型从最近窗口里新提名的候选关键帧。
当前公开代码主要覆盖高层策略:数据导出、Qwen3-VL 微调格式、结构化推理、记忆更新、离线 rollout 评估和在线部署封装。低层 pi0.5 的训练代码不在这个仓库里。

  1. 为什么需要“高层记忆策略”
    直接把历史全喂给策略有三个问题:

太贵:视频帧是高维输入,长历史会让 VLM/VLA 的 token 和显存成本快速上涨。
太慢:机器人闭环控制需要低延迟。论文里 Long History baseline 用 32 帧大约 16 秒历史,推理延迟已经接近闭环可忍受上限。
太脆:长历史里包含大量无关动作,模型可能学到示范轨迹中的顺序捷径。一旦部署时低层策略卡顿、重试、失败恢复,历史分布就变了,模型更容易误判当前进度。
MemER 的设计取舍是:

最近窗口保留局部动作上下文。
关键帧记忆保留长期必要信息。
关键帧由高层模型自己根据任务语义提名,不额外训练一个图像检索模型。
最后用一个很简单的时间聚类器去掉重复提名,让记忆稳定。
4. 数据构建:从一段视频到很多道训练题
MemER 的高层数据构建,最简单的理解是:

把一整段机器人演示视频,切成很多道给 VLM 做的训练题。每道题都问模型两件事:现在应该执行哪个子任务?最近看到的几帧里,有没有以后要记住的关键画面?

完整流程是:

原始机器人演示轨迹
-> 每帧都有图像、总任务、当前子任务标签、机器人状态、动作
-> 根据子任务标签和规则,找出整条轨迹里哪些帧值得记住
-> 对每个时间步 t 单独构造一条高层训练样本
-> 导出成 Qwen SFT 的 train.json + media/*.jpg
先把几个名字分清楚,后面就不会乱:

名字 它是什么 在训练样本里扮演什么角色
GT keyframes 整条演示轨迹里,规则认为应该记住的绝对帧号 离线生成监督信号
memory_indices 当前时间步以前、已经离开最近窗口的 GT keyframes 作为输入图片给模型看
context_indices 当前时间步附近的最近 N 个下采样帧 作为最近视频输入给模型看
keyframe_positions context_indices 里哪些位置应该被提名为新 keyframe 作为模型输出答案
一句话说:

memory_indices 是“过去已经该记住的帧”,放进输入;
keyframe_positions 是“最近刚出现、以后应该记住的帧”,放进输出。
这个区别是数据构建里最关键的点。MemER 不是让模型直接输出一个历史帧号,而是训练模型在“最近窗口”里指出哪些帧值得保存;等这些帧未来离开最近窗口后,它们才会成为长期 memory。

4.1 原始演示数据
论文里的每条机器人轨迹可以理解成一串时间步:

(I_t, q_t, l_t, l’_t, a_t)
含义是:

I_t:多相机图像,论文实验使用 wrist camera 和 third-person/base camera。
q_t:机器人自身状态。
l_t:总任务指令,例如“把架子上的物品拿下来,擦架子,再放回原位”。
l’_t:当前子任务标签,例如“dust bottom shelf”。
a_t:机器人动作。
子任务标签来自数据采集过程:操作者执行预先规定的子任务列表,完成一个子任务后按键切换到下一个子任务。因此每个时间步天然有当前子任务标签。

4.2 高层策略训练样本
高层策略不直接预测机器人动作,只预测:

{
“current_subtask”: “…”,
“keyframe_positions”: [1, 5]
}
其中:

current_subtask 是当前应该让低层策略执行的语言子任务。
keyframe_positions 是最近窗口中哪些帧值得保存,位置是 1-indexed,即 1 表示最近窗口第一帧,8 表示第八帧。
公开代码导出的 Qwen SFT 样本大致长这样:

{
“id”: “episode_000000_t000120”,
“image”: [
“media/episode_000000/frame_000015.jpg”,
“media/episode_000000/frame_000050.jpg”,
“media/episode_000000/frame_000085.jpg”,
“media/episode_000000/frame_000090.jpg”,
“… recent frames …”
],
“conversations”: [
{“from”: “system”, “value”: “…输出 JSON …”},
{“from”: “human”, “value”: “Task: … … …”},
{“from”: “gpt”, “value”: “{“current_subtask”: “…”, “keyframe_positions”: [7]}”}
],
“metadata”: {
“memory_indices”: [15, 50],
“context_indices”: [85, 90, 95, 100, 105, 110, 115, 120],
“answer”: {
“current_subtask”: “…”,
“keyframe_positions”: [7]
}
}
}
注意这里的图片顺序是:

先放 memory keyframes,再放 recent context frames
prompt 文本会告诉模型前几张是“从完整历史中选出的重要帧”,后几张是“最近动作视频”。

4.3 多相机图像怎样处理
公开代码支持单相机或多相机。多相机时,每个时间步的多个相机视角会被竖直拼成一张图:

stacked_frame_t =
[wrist image at t
exterior image at t]
默认 Panda/Franka 配置是:

{
“camera_keys”: [
“observation.images.wrist_left”,
“observation.images.exterior_1_left”
],
“view_width”: 320,
“view_height”: 180
}
因此最终单帧输入宽度是 320,高度是 180 * 相机数。这个拼接方式保证训练、评估、部署看到的视角顺序一致。

  1. 关键帧标签怎样得到
    MemER 并不是让人工逐帧标所有关键帧。论文和代码采用的是半自动规则:

先有每一帧的子任务标签。
把连续相同子任务看成一个 segment。
人工为每类 subtask 设定规则:取 segment 第一帧、最后一帧、两者都取,或不取。
规则固定后自动应用到所有演示轨迹。
比如:

Object Search:look inside the bin 取最后一帧,因为最后通常能看清箱子里有什么。
Counting:place a scoop of in the bowl 取最后一帧,因为这代表一次计数完成。
Dust & Replace:移走物体、擦完某层、放回物体等动作取最后一帧,因为这些帧记录了“完成后的状态”。
reset 类动作通常不取,因为它们不是长期记忆所需信息。
伪代码:

def build_rule_based_keyframes(labels, rules, default=“none”):
keyframes = [] # 存放最终选出的绝对帧索引
i = 0 # 当前扫描到的轨迹帧位置

while i < len(labels):
    label = labels[i]  # 当前 segment 的子任务名
    j = i + 1

    # 找到连续执行同一个子任务的区间 [i, j)
    while j < len(labels) and labels[j] == label:
        j += 1

    segment = [i, ..., j - 1]              # 这个子任务持续的帧区间
    select = match_rule(label, rules, default)  # 查规则:该子任务要取首帧、尾帧、都取还是不取

    if select == "first":
        keyframes.append(i)       # 记录子任务刚开始时的状态
    elif select == "last":
        keyframes.append(j - 1)   # 记录子任务完成后的状态,MemER 最常用这种
    elif select == "both":
        keyframes.extend([i, j - 1])  # 同时保留开始和结束状态
    elif select == "none":
        pass  # 该子任务不贡献长期记忆

    i = j  # 跳到下一个子任务 segment

return unique_preserve_order(keyframes)  # 去掉重复帧,同时保持时间顺序

这些 keyframes 是训练时的 ground truth。模型训练时并不知道规则本身,只看到由规则产生的监督答案。

  1. 训练样本怎样从轨迹切出来
    这一节是数据构建的核心。先不要急着看公式,先看一个具体例子。

假设现在要为 t = 120 这一帧构造一条训练样本,配置是:

frame_subsample = 5
recent_frames_length = 8
keyframes_length = 8
prediction_horizon = 2
再假设整条轨迹里,规则生成的 GT keyframes 是:

GT keyframes = [30, 60, 95, 140]
对 t = 120 这道训练题,MemER 会这样构造:

recent window = [85, 90, 95, 100, 105, 110, 115, 120]

memory_indices = [30, 60]
因为 30 和 60 已经早于 recent window,可以作为历史记忆输入。

keyframe_positions = [3]
因为 GT keyframe 95 正好在 recent window 里,而且是 recent 的第 3 张。

current_subtask = labels[130]
因为 prediction_horizon = 2,frame_subsample = 5,所以目标子任务向前看 10 帧。
最终这条样本可以白话理解成:

输入给模型:
总任务指令
frame_30.jpg、frame_60.jpg # 旧的长期记忆
frame_85.jpg … frame_120.jpg # 最近窗口

要求模型输出:
{
“current_subtask”: labels[130],
“keyframe_positions”: [3]
}
所以,训练数据不是“整段视频 -> 一个答案”,而是“一段视频里的每个时间步 -> 一道小题”。模型通过大量这样的小题学会:哪些旧图应该参考,最近窗口里哪些新图应该保存,现在该给底层 VLA 什么语言指令。

设:

原始时间步是 t。
frame_subsample = s,公开示例默认 5。
recent_frames_length = N,公开示例默认 8。
keyframes_length = M,公开示例默认 8。
prediction_horizon = h,公开示例默认 2。
6.1 最近窗口
对每个时间步 t,代码先根据下采样步长构造到当前为止的候选上下文:

start_offset = t % frame_subsample # 让采样序列的最后一帧刚好落在当前 t
all_context = range(start_offset, t + 1, frame_subsample)
recent = last_N(all_context) # 只保留最后 N 个下采样帧
这个 recent window 可以理解为“带当前帧的滑动采样窗口”。它不是永远取 [0, 5, 10, …] 这种固定网格,而是会随着当前时间步平移,确保最后一张 recent image 就是当前帧。

例如 frame_subsample = 5, N = 8:

t = 120 -> recent = [85, 90, 95, 100, 105, 110, 115, 120]
t = 122 -> recent = [87, 92, 97, 102, 107, 112, 117, 122]
模型因此总能看到“当前发生到哪里了”。这个窗口对应多少真实秒数取决于数据集原始帧率;论文实验描述为相机 15Hz、层级策略输入下采样到约 2Hz。

6.2 训练时的 memory keyframes
训练时 memory 来自 ground-truth keyframes 中已经早于最近窗口的部分:

memory_keyframes = keyframes in [start_offset, recent[0])
memory_keyframes = align_to_subsample_grid(memory_keyframes)
memory_keyframes = last_M(memory_keyframes)
也就是说,如果某个关键帧还在最近窗口里,它不会作为 memory 出现,而是应该通过 keyframe_positions 被模型提名;等它离开最近窗口后,才会作为长期记忆出现。

6.3 当前输出中的 keyframe_positions
训练目标里的 keyframe_positions 只允许指向最近窗口中的帧:

candidate_keyframes = keyframes in [recent[0], t + 1)
candidate_keyframes = align_to_subsample_grid(candidate_keyframes)
relative_positions = [recent.index(k) + 1 for k in candidate_keyframes]
因此模型学到的是:

在最近 8 帧里,如果某些帧刚好包含以后需要记住的信息,就输出它们在最近窗口里的位置。

6.4 current_subtask 为什么预测未来一点
代码默认使用:

target_idx = min(t + prediction_horizon * frame_subsample, total_steps - 1)
current_subtask = labels[target_idx]
也就是说,高层策略在时间 t 不一定预测 labels[t],而是预测稍微往前看的标签。这样做是为了弥补闭环系统中的反应延迟:高层模型推理、低层策略接收新语言指令、机器人实际动作执行之间都不是零延迟。提前一点预测可以让子任务切换更及时。

  1. 高层模型结构
    高层策略本质上是一个视觉语言模型,被 SFT 成结构化输出器。

论文实验中:

高层策略:Qwen2.5-VL-7B-Instruct。
低层策略:pi0.5。
训练时冻结视觉编码器和视觉投影层,只训练 LLM backbone。
还使用了 model merging,把预训练权重和微调权重线性插值。
当前公开代码中:

高层训练格式面向 Qwen3-VL。
README 提供 dusting 任务的 Qwen3-VL-4B checkpoint。
推理类使用 transformers.AutoModelForImageTextToText 和 AutoProcessor 加载 Qwen3-VL。
视觉编码器冻结、优化器等训练细节由官方 Qwen finetune 仓库执行,不在本仓库训练代码里实现。
7.1 输入格式
高层模型每次收到:

system prompt:
你是机器人程序,输出 JSON:

  • current_subtask
  • keyframe_positions

user prompt:
Task: <总任务指令>

Here are selected frames from the full video:
<memory image 1>
<memory image 2>

Here is a video of the most recent actions:
<recent image 1>


如果没有 memory,memory 部分会省略。图片本身以 Qwen chat template 支持的 image content 传入。

7.2 输出格式
模型输出必须能解析成 JSON:

{
“current_subtask”: “dust bottom shelf”,
“keyframe_positions”: [5]
}
推理代码会比较宽容:

可以从普通文本或 Markdown code fence 里提取第一个 JSON object。
current_subtask 也兼容旧字段名 current_primitive。
keyframe_positions 必须是 list,元素必须能转成整数。
如果解析失败,本步不更新 memory。
8. 高层训练 pipeline
公开可复现的高层训练流程是:

LeRobot v3 dataset with subtasks
-> generate_sft_data.py
-> train.json + media/*.jpg
-> 注册到 Qwen3-VL finetune 数据集
-> 用 Qwen 官方训练脚本 SFT
-> 得到高层 MemER checkpoint
8.1 数据导出
generate_sft_data.py 做这些事:

读取 LeRobot dataset metadata,拿到任务、子任务、相机键。
按 camera layout 导出每个时间步的 JPEG;多相机时先拼成 stacked image。
根据 subtask label 和 keyframe rules 生成全轨迹 ground-truth keyframe indices。
对每个时间步构造一个 Qwen SFT example。
写出:
output_dir/
train.json
media/
episode_000000/
frame_000000.jpg
frame_000001.jpg

8.2 Qwen3-VL 训练命令要点
公开训练文档给出的 reference 设置包括:

base model:Qwen/Qwen3-VL-4B-Instruct
训练 1500 steps
tune_mm_vision False
tune_mm_mlp False
tune_mm_llm True
bf16
model_max_length 8192
batch size per device 8,gradient accumulation 8
学习率 6e-5
cosine scheduler,warmup ratio 0.05
max_pixels 115200,min_pixels 50176
这和论文附录中的原始实验设定不同:论文报告 Qwen2.5-VL-7B 高层策略训练 4500 gradient steps,batch size 256,冻结 vision encoder 和 projection layer。

  1. Loss 定义
    9.1 高层策略 loss
    高层策略训练是标准自回归 SFT。给定 prompt x_t 和目标 JSON token 序列 y_t,优化目标是最大化目标 token 的条件似然:

L_high = - sum_i log p_theta(y_{t,i} | x_t, y_{t,<i})
其中 y_t 是类似下面的字符串:

{“current_subtask”: “dust bottom shelf”, “keyframe_positions”: [5]}
关键点:

代码里没有单独的分类 loss 来预测 subtask。
代码里也没有单独的多标签 loss 来预测 keyframe positions。
subtask 和 keyframe positions 都被序列化成 JSON 文本,统一通过 next-token cross entropy 学。
换句话说,current_subtask 和 keyframe_positions 的监督都藏在同一个语言建模 loss 里。

9.2 低层策略 loss
论文层面,低层策略学习:

pi_l(A_t | I_t, q_t, l’_t)
也就是给当前图像、机器人状态和子任务语言,预测未来动作 chunk。

当前仓库没有 pi0.5 低层训练代码,因此不能从源码确认具体实现 loss。方法层面可以写成 imitation learning 的负对数似然:

L_low = - log pi_phi(A_t^expert | I_t, q_t, l’_t)
如果某个具体低层 VLA 用连续动作回归、扩散、flow matching 或其他 action head,那么 L_low 会由该低层模型自己的训练框架定义。MemER 对低层策略的要求不是特定 loss,而是它能可靠执行语言子任务。

9.3 总体训练目标
论文方法是分开训练高层和低层,而不是端到端联合训练:

min_theta L_high(theta)
min_phi L_low(phi)
部署时两者组合:

高层输出 l’_t -> 低层用 l’t 生成动作
高层输出 J_t -> 记忆模块更新 K
{t+1}
10. 视觉记忆怎样构建
这是 MemER 最关键的机制。

高层模型每一步只输出最近窗口里的相对位置,比如 [7]。这些位置会被映射成真实时间索引:

absolute_index = context_indices[position - 1]
然后加入候选 keyframe 池。候选池保留重复票数,因为同一个帧或相邻帧被多次提名,说明模型反复认为这段时间重要。

之后对所有候选帧索引做一维 single-linkage clustering:

把所有候选索引排序,重复值不去掉。
相邻候选索引距离 <= d 就放进同一个 cluster。
每个 cluster 取中位数索引作为代表帧。
只暴露最近 memory_length 个代表帧给模型。
如果代表帧仍在最近窗口里,则暂时不作为 memory 暴露,避免同一帧在 memory 和 recent 中重复出现。
伪代码:

def build_visual_memory(candidate_indices, merge_distance, memory_length, current_context):
# candidate_indices 是模型历次提名过的绝对帧号;重复值代表多次投票
indices = sorted(candidate_indices) # 按时间排序,但保留重复投票
if not indices:
return [] # 还没有任何候选关键帧时,memory 为空

clusters = []              # 每个 cluster 表示一个被多次提名的“事件”
current = [indices[0]]     # 当前正在累积的时间簇

for idx in indices[1:]:
    # 如果相邻候选帧距离足够近,就认为它们描述同一个事件
    if idx - current[-1] <= merge_distance:
        current.append(idx)
    else:
        clusters.append(current)  # 距离太远,结束当前事件
        current = [idx]           # 开启新的事件簇
clusters.append(current)          # 别忘了收尾最后一个簇

representatives = []
for cluster in clusters:
    # 用中位数帧作为该事件的代表;重复投票会影响中位数位置
    representatives.append(median_low(cluster))

selected = representatives[-memory_length:]  # 只保留最近的 M 个长期记忆

# 如果代表帧仍在 recent context 里,就暂时不作为 memory 重复展示
visible = [idx for idx in selected if idx not in current_context]
return visible[-memory_length:]  # 返回本次 prompt 真正可见的 memory 帧索引

10.1 为什么要聚类,而不是直接保存模型输出的帧
模型在相邻时间步看到的最近窗口高度重叠。一个重要事件可能在多次推理中被提名:

t=100: 模型提名 frame 90
t=105: 模型提名 frame 90
t=110: 模型提名 frame 95
t=115: 模型提名 frame 95
如果直接保存,会得到很多重复或近重复图片。聚类后,它们会合并成一个事件,取中位数作为代表。这样 memory 更像“事件摘要”,而不是“模型每次投票的原始日志”。

10.2 merge_distance 的单位
论文里 d=5 是在下采样后的时间步意义上讲的。公开代码的 rollout/deploy 会把它换算成原始帧距离:

raw_merge_distance = merge_distance * frame_subsample
例如:

merge_distance = 5
frame_subsample = 5
raw_merge_distance = 25 原始帧
这样无论内部候选索引用原始帧号表示,聚类距离仍然等价于“下采样后 5 步以内算同一事件”。

  1. 推理流程
    11.1 在线部署流程
    在线部署由一个 stateful policy 维护:

所有已经收到的帧 _frames
episodic memory _memory
当前总任务 instruction
相机拼接配置
每次调用 step(obs):

def memer_step(obs):
frame = preprocess_and_stack_cameras(obs) # 把单/多相机观测转成一张 PIL 图
frames.append(frame) # 在线部署时持续累积整段观测历史
t = len(frames) - 1 # 当前时间步的绝对帧索引

# 从历史中取最近 N 个下采样帧,作为短期上下文
context_indices = build_recent_context_indices(
    timestep=t,
    frame_subsample=s,
    recent_frames_length=N,
)

# 从长期记忆中取当前 prompt 可见的关键帧,避免和 recent context 重复
memory_indices = memory.visible_indices(context_indices)

# prompt 只写占位符数量;图片会按 memory + recent 的顺序传给 VLM
prompt = build_prompt(
    instruction=instruction,
    memory_count=len(memory_indices),
    recent_count=len(context_indices),
)

images = [frames[i] for i in memory_indices + context_indices]  # 先长期记忆,再最近窗口

prediction = qwen.predict(prompt, images)  # 输出 current_subtask 和 keyframe_positions

if prediction.parse_ok:
    # 模型输出的是 recent window 中的 1-indexed 位置,需要映射回绝对帧号
    absolute_keyframes = map_relative_positions_to_absolute(
        prediction.keyframe_positions,
        context_indices,
    )
    memory.add_candidates(absolute_keyframes)  # 把候选帧加入投票池,供后续聚类

return prediction.current_subtask  # 交给低层策略执行的语言子任务

返回值中还包含调试信息:

当前 timestep
parse 是否成功
模型输出的相对 keyframe positions
映射后的绝对帧索引
推理前后的 memory indices
所有候选 keyframe votes
raw model text
11.2 和低层策略怎样配合
论文部署中:

高层策略约 1Hz 运行。
低层策略约 2Hz 运行。
低层策略输出 15 个 15Hz 动作,执行前 8 个动作后重新规划。
两个策略异步跑在不同 server 上。
直观上:

高层慢一点,但负责想清楚“现在做什么”
低层快一点,持续执行最近一次高层给出的 subtask
当高层还在推理下一条 subtask 时,低层继续使用最近一次已经预测出的 subtask。这可以减少等待,提高机器人动作连贯性。

11.3 离线 rollout 评估
离线评估不是简单地对每个训练样本独立测试,而是模拟部署时的记忆更新:

memory = EpisodicMemory(…) # 每个 episode 单独维护一份滚动视觉记忆

for timestep in episode:
context = recent frames up to timestep # 当前时刻能看到的短期上下文
memory_before = memory.visible_indices(context) # 当前时刻可见的长期关键帧
prediction = model(memory_before + context) # 用“长期记忆 + 最近窗口”预测子任务和新候选帧

compare prediction.current_subtask with target label  # 统计 subtask 准确率

if parse_ok:
    keyframes = map prediction.keyframe_positions to absolute indices  # 相对位置转绝对帧号
    memory.add_candidates(keyframes)  # 用本步预测更新 memory,影响后续时间步

这很重要,因为 MemER 的预测会影响之后的 memory。如果早期乱提名很多无意义帧,后面模型看到的 memory 就会变差。因此 rollout 评估比静态 teacher-forcing 更接近真实部署。

  1. 三个任务分别需要记住什么
    论文用三个真实机器人任务评估 MemER,每个任务考察一种不同的长期记忆。

12.1 Object Search
任务:三个不透明箱子里随机放 3-5 个物体,机器人依次收到三个目标物体,要找出并放到白色箱子。

需要记住:

已经看过哪些箱子。
每个箱子里有哪些物体。
如果目标已经见过,直接去对应箱子,不要重复搜索。
关键帧规则:

看箱子动作的最后一帧要记住。
抓取并放置物体的动作不需要记住。
12.2 Counting Scoops
任务:把指定数量的两种食材舀到指定颜色碗里。

需要记住:

每种食材已经舀了几勺。
当前是否应该继续舀、reset scooper,还是结束。
关键帧规则:

每次成功放入一勺的最后一帧要记住。
拿勺子、reset、放下勺子通常不记。
12.3 Dust & Replace
任务:拿走上下层架子上的物体,擦架子,再把物体放回原位置。

需要记住:

每个物体原本在上层还是下层。
哪一层已经擦过。
放回时应该放到哪里。
关键帧规则:

移走物体、擦完某层、放回物体等完成状态要记住。
reset duster、put down duster 这类动作不记。
13. Model merging
论文还报告了一个实用技巧:高层模型微调很多步后,虽然更会说正确 subtask,但可能损失预训练 VLM 对非理想行为的鲁棒性。因为训练数据主要是专家演示,部署时低层策略可能出现卡顿、重试、失败恢复,分布会不一样。

论文使用线性权重合并:

theta = (1 - alpha) * theta_pre + alpha * theta_ft
其中:

theta_pre 是原始 Qwen2.5-VL-Instruct 权重。
theta_ft 是 MemER 数据微调后的权重。
论文使用 alpha = 0.8。
直觉是:大部分采用任务微调能力,同时保留一部分预训练模型的开放域视频理解鲁棒性。

当前公开代码没有实现 model merging 脚本;它是论文方法中的训练后处理技巧。

  1. 为什么视觉记忆比文本记忆更好
    论文比较了三种记忆形式:

只用短历史。
短历史 + 文本 subtask 记忆。
MemER 图像关键帧。
MemER 图像关键帧 + 文本 subtask。
结果是视觉关键帧更稳。原因很直观:

文本 subtask 太粗。例如“look inside right bin”不能完整记录右箱子里所有物体。
文本可能错。一旦某次 subtask 预测错,把错误文本存进 memory 后会误导后续推理。
模型容易过度依赖文本 token,反而忽视视觉证据。
图像关键帧保留环境真实状态,尤其适合物体位置、数量、完成状态这些难以用短文本完整表达的信息。
所以 MemER 默认使用纯视觉 memory,而不是把历史 subtask 文本也一起塞进去。

  1. 一个完整例子
    以 Dust & Replace 为例:

总任务指令是:
remove the items from the shelves, dust the shelves, and place the items back
刚开始没有 memory,模型只看最近 8 帧,预测:
{“current_subtask”: “remove the object on the bottom shelf”, “keyframe_positions”: []}
低层策略执行拿走下层物体。

到动作完成附近,最近窗口里出现“下层物体被拿走后原位置可见”的画面。模型输出:

{“current_subtask”: “remove the object on the top shelf”, “keyframe_positions”: [7]}
7 被映射成某个绝对帧号,比如 t=140,加入候选池。

后续几步模型可能又提名 140, 145, 145。聚类器发现它们都很近,合成一个 cluster,取中位数 145 作为代表 keyframe。

当这些帧离开最近窗口后,代表帧 145 会作为 memory image 出现在之后的 prompt 里。

擦完架子后,机器人需要放回物体。当前画面可能只看到空架子和手里的物体,但 memory 里有原位置关键帧,所以高层可以预测:

{“current_subtask”: “place the panda plushie on the bottom shelf”, “keyframe_positions”: []}
这个流程体现了 MemER 的本质:把“曾经发生过的重要视觉状态”变成长期可见的少量图片,而不是让模型记住完整视频。

  1. 方法伪代码总览
    16.1 训练数据生成
    def generate_sft_examples(episode):
    frames = export_stacked_camera_images(episode) # 导出每个时间步的单图;多相机先竖直拼接
    labels = get_subtask_labels(episode) # 每帧对应的 ground-truth subtask
    instructions = get_task_instructions(episode) # 总任务指令,可来自数据集或命令行覆盖
    gt_keyframes = build_rule_based_keyframes(labels, rules) # 根据子任务规则生成 GT 关键帧

    examples = []
    for t in range(len(frames)):
    # 最近窗口:截至 t 的最后 N 个下采样帧
    recent = build_recent_context_indices(t, frame_subsample, N)

    memory = gt_keyframes_before(recent[0])  # 已经离开 recent window 的 GT 关键帧
    memory = align_to_subsample_grid(memory) # 对齐到下采样帧格点,和 recent 索引体系一致
    memory = last_M(memory)                  # prompt 里最多放 M 张长期记忆图
    
    candidates = gt_keyframes_inside(recent)     # 当前 recent window 内应被提名的 GT 关键帧
    candidates = align_to_subsample_grid(candidates)
    keyframe_positions = [recent.index(k) + 1 for k in candidates]  # 转成 1-indexed 位置
    
    # 预测稍微未来一点的 subtask,用来补偿高层推理和低层执行延迟
    target_t = min(t + horizon * frame_subsample, len(frames) - 1)
    answer = {
        "current_subtask": labels[target_t],
        "keyframe_positions": keyframe_positions,
    }
    
    # Qwen SFT 样本:输入图片顺序是 memory + recent,输出是 JSON 字符串
    examples.append(qwen_sft_record(
        instruction=instructions[t],
        images=memory + recent,
        answer=answer,
    ))
    

    return examples # 一个 episode 会产生 len(frames) 条高层 SFT 样本
    16.2 高层推理与记忆更新
    def rollout_high_level_policy(frames, instruction):
    memory = EpisodicMemory(merge_distance=d, memory_length=M) # 保存模型自己滚出来的视觉记忆

    for t, frame in enumerate(frames):
    context = build_recent_context_indices(t, frame_subsample, N) # 本步 recent window
    visible_memory = memory.visible_indices(context) # 本步可见的长期关键帧

    prediction = vlm_generate_json(
        instruction=instruction,
        images=frames[visible_memory] + frames[context],  # 图片顺序必须和 prompt 约定一致
    )
    
    subtask = prediction["current_subtask"]              # 给低层策略的语言命令
    relative_positions = prediction["keyframe_positions"] # recent window 中的 1-indexed 候选位置
    
    absolute_candidates = []
    for pos in relative_positions:
        # 丢弃越界位置;合法位置映射成轨迹中的绝对帧号
        if 1 <= pos <= len(context):
            absolute_candidates.append(context[pos - 1])
    
    memory.add_candidates(absolute_candidates)  # 加入候选投票池,下一步可能变成可见 memory
    
    yield subtask  # 当前时间步输出给低层控制器
    

16.3 和低层策略组合
latest_subtask = None # 低层策略会一直使用最近一次高层预测

while robot_is_running:
obs = get_robot_observation() # 当前相机图像和机器人状态

# 慢频率或异步线程中运行
if high_level_ready:
    # 高层只负责更新语言子任务和内部 memory,不直接输出关节动作
    latest_subtask = memer_high_level.step(obs).current_subtask

# 快频率运行
action_chunk = low_level_policy.predict(
    image=obs.image,          # 当前视觉输入
    robot_state=obs.q,        # 当前关节/夹爪状态
    language=latest_subtask,  # 高层给出的当前子任务
)
execute_first_actions(action_chunk)  # 执行动作 chunk 的前几步,然后重新规划
Logo

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

更多推荐