MemER 方法原理讲解

1. 一句话理解 MemER
MemER 的核心思想是:
不要把机器人从开头到现在看到的所有画面都塞给模型,而是让一个高层视觉语言模型不断判断"现在该做哪个子任务"和"最近看到的哪些帧以后可能还要用",再把这些被挑出来的关键帧作为长期视觉记忆喂回模型。
它解决的是长时程机器人任务里的"我刚才看见过,但现在画面里没有"的问题。例如:
- 机器人之前看过某个物体在右边箱子里,现在要找它时不应该重新翻左边箱子。
- 机器人已经舀了两勺花生,当前画面只看到勺子,不一定能知道已经舀了几勺。
- 机器人把架子上的物体拿走、擦完架子以后,需要记住物体原本放在哪一层。
普通策略如果只看当前帧,会缺记忆;如果直接看很长视频,会慢、贵、冗余,而且容易把无关历史当捷径。MemER 的做法是用"最近窗口 + 少量关键帧"代替完整历史。
2. 方法整体架构
MemER 是一个分层策略:
text
高层策略 pi_h:
输入: 总任务指令 + 长期关键帧记忆 K_t + 最近 N 帧 R_t
输出: 当前应执行的语言子任务 l'_t + 最近 N 帧中应保存的候选关键帧 J_t
低层策略 pi_l:
输入: 当前图像 I_t + 机器人状态 q_t + 高层给出的子任务 l'_t
输出: 未来一小段机器人动作 A_t
论文把整体策略写成:
text
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 的训练代码不在这个仓库里。
3. 为什么需要"高层记忆策略"
直接把历史全喂给策略有三个问题:
- 太贵:视频帧是高维输入,长历史会让 VLM/VLA 的 token 和显存成本快速上涨。
- 太慢:机器人闭环控制需要低延迟。论文里 Long History baseline 用 32 帧大约 16 秒历史,推理延迟已经接近闭环可忍受上限。
- 太脆:长历史里包含大量无关动作,模型可能学到示范轨迹中的顺序捷径。一旦部署时低层策略卡顿、重试、失败恢复,历史分布就变了,模型更容易误判当前进度。
MemER 的设计取舍是:
- 最近窗口保留局部动作上下文。
- 关键帧记忆保留长期必要信息。
- 关键帧由高层模型自己根据任务语义提名,不额外训练一个图像检索模型。
- 最后用一个很简单的时间聚类器去掉重复提名,让记忆稳定。
4. 数据构建:从一段视频到很多道训练题
MemER 的高层数据构建,最简单的理解是:
把一整段机器人演示视频,切成很多道给 VLM 做的训练题。每道题都问模型两件事:现在应该执行哪个子任务?最近看到的几帧里,有没有以后要记住的关键画面?
完整流程是:
text
原始机器人演示轨迹
-> 每帧都有图像、总任务、当前子任务标签、机器人状态、动作
-> 根据子任务标签和规则,找出整条轨迹里哪些帧值得记住
-> 对每个时间步 t 单独构造一条高层训练样本
-> 导出成 Qwen SFT 的 train.json + media/*.jpg
先把几个名字分清楚,后面就不会乱:
| 名字 | 它是什么 | 在训练样本里扮演什么角色 |
|---|---|---|
GT keyframes |
整条演示轨迹里,规则认为应该记住的绝对帧号 | 离线生成监督信号 |
memory_indices |
当前时间步以前、已经离开最近窗口的 GT keyframes | 作为输入图片给模型看 |
context_indices |
当前时间步附近的最近 N 个下采样帧 |
作为最近视频输入给模型看 |
keyframe_positions |
context_indices 里哪些位置应该被提名为新 keyframe |
作为模型输出答案 |
一句话说:
text
memory_indices 是"过去已经该记住的帧",放进输入;
keyframe_positions 是"最近刚出现、以后应该记住的帧",放进输出。
这个区别是数据构建里最关键的点。MemER 不是让模型直接输出一个历史帧号,而是训练模型在"最近窗口"里指出哪些帧值得保存;等这些帧未来离开最近窗口后,它们才会成为长期 memory。
4.1 原始演示数据
论文里的每条机器人轨迹可以理解成一串时间步:
text
(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 高层策略训练样本
高层策略不直接预测机器人动作,只预测:
json
{
"current_subtask": "...",
"keyframe_positions": [1, 5]
}
其中:
current_subtask是当前应该让低层策略执行的语言子任务。keyframe_positions是最近窗口中哪些帧值得保存,位置是 1-indexed,即1表示最近窗口第一帧,8表示第八帧。
公开代码导出的 Qwen SFT 样本大致长这样:
json
{
"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: ... <image> ... <image> ..."},
{"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]
}
}
}
注意这里的图片顺序是:
text
先放 memory keyframes,再放 recent context frames
prompt 文本会告诉模型前几张是"从完整历史中选出的重要帧",后几张是"最近动作视频"。
4.3 多相机图像怎样处理
公开代码支持单相机或多相机。多相机时,每个时间步的多个相机视角会被竖直拼成一张图:
text
stacked_frame_t =
[wrist image at t
exterior image at t]
默认 Panda/Franka 配置是:
json
{
"camera_keys": [
"observation.images.wrist_left",
"observation.images.exterior_1_left"
],
"view_width": 320,
"view_height": 180
}
因此最终单帧输入宽度是 320,高度是 180 * 相机数。这个拼接方式保证训练、评估、部署看到的视角顺序一致。
5. 关键帧标签怎样得到
MemER 并不是让人工逐帧标所有关键帧。论文和代码采用的是半自动规则:
- 先有每一帧的子任务标签。
- 把连续相同子任务看成一个 segment。
- 人工为每类 subtask 设定规则:取 segment 第一帧、最后一帧、两者都取,或不取。
- 规则固定后自动应用到所有演示轨迹。
比如:
- Object Search:
look inside the <LOCATION> bin取最后一帧,因为最后通常能看清箱子里有什么。 - Counting:
place a scoop of <OBJECT> in the <COLOR> bowl取最后一帧,因为这代表一次计数完成。 - Dust & Replace:移走物体、擦完某层、放回物体等动作取最后一帧,因为这些帧记录了"完成后的状态"。
- reset 类动作通常不取,因为它们不是长期记忆所需信息。
伪代码:
python
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。模型训练时并不知道规则本身,只看到由规则产生的监督答案。
6. 训练样本怎样从轨迹切出来
这一节是数据构建的核心。先不要急着看公式,先看一个具体例子。
假设现在要为 t = 120 这一帧构造一条训练样本,配置是:
text
frame_subsample = 5
recent_frames_length = 8
keyframes_length = 8
prediction_horizon = 2
再假设整条轨迹里,规则生成的 GT keyframes 是:
text
GT keyframes = [30, 60, 95, 140]
对 t = 120 这道训练题,MemER 会这样构造:
text
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 帧。
最终这条样本可以白话理解成:
text
输入给模型:
总任务指令
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,代码先根据下采样步长构造到当前为止的候选上下文:
python
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:
text
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 中已经早于最近窗口的部分:
python
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 只允许指向最近窗口中的帧:
python
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 为什么预测未来一点
代码默认使用:
python
target_idx = min(t + prediction_horizon * frame_subsample, total_steps - 1)
current_subtask = labels[target_idx]
也就是说,高层策略在时间 t 不一定预测 labels[t],而是预测稍微往前看的标签。这样做是为了弥补闭环系统中的反应延迟:高层模型推理、低层策略接收新语言指令、机器人实际动作执行之间都不是零延迟。提前一点预测可以让子任务切换更及时。
7. 高层模型结构
高层策略本质上是一个视觉语言模型,被 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 输入格式
高层模型每次收到:
text
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>
...
<recent image N>
如果没有 memory,memory 部分会省略。图片本身以 Qwen chat template 支持的 image content 传入。
7.2 输出格式
模型输出必须能解析成 JSON:
json
{
"current_subtask": "dust bottom shelf",
"keyframe_positions": [5]
}
推理代码会比较宽容:
- 可以从普通文本或 Markdown code fence 里提取第一个 JSON object。
current_subtask也兼容旧字段名current_primitive。keyframe_positions必须是 list,元素必须能转成整数。- 如果解析失败,本步不更新 memory。
8. 高层训练 pipeline
公开可复现的高层训练流程是:
text
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。
- 写出:
text
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 Falsetune_mm_mlp Falsetune_mm_llm Truebf16model_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。
9. Loss 定义
9.1 高层策略 loss
高层策略训练是标准自回归 SFT。给定 prompt x_t 和目标 JSON token 序列 y_t,优化目标是最大化目标 token 的条件似然:
text
L_high = - sum_i log p_theta(y_{t,i} | x_t, y_{t,<i})
其中 y_t 是类似下面的字符串:
json
{"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
论文层面,低层策略学习:
text
pi_l(A_t | I_t, q_t, l'_t)
也就是给当前图像、机器人状态和子任务语言,预测未来动作 chunk。
当前仓库没有 pi0.5 低层训练代码,因此不能从源码确认具体实现 loss。方法层面可以写成 imitation learning 的负对数似然:
text
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 总体训练目标
论文方法是分开训练高层和低层,而不是端到端联合训练:
text
min_theta L_high(theta)
min_phi L_low(phi)
部署时两者组合:
text
高层输出 l'_t -> 低层用 l'_t 生成动作
高层输出 J_t -> 记忆模块更新 K_{t+1}
10. 视觉记忆怎样构建
这是 MemER 最关键的机制。
高层模型每一步只输出最近窗口里的相对位置,比如 [7]。这些位置会被映射成真实时间索引:
python
absolute_index = context_indices[position - 1]
然后加入候选 keyframe 池。候选池保留重复票数,因为同一个帧或相邻帧被多次提名,说明模型反复认为这段时间重要。
之后对所有候选帧索引做一维 single-linkage clustering:
- 把所有候选索引排序,重复值不去掉。
- 相邻候选索引距离
<= d就放进同一个 cluster。 - 每个 cluster 取中位数索引作为代表帧。
- 只暴露最近
memory_length个代表帧给模型。 - 如果代表帧仍在最近窗口里,则暂时不作为 memory 暴露,避免同一帧在 memory 和 recent 中重复出现。
伪代码:
python
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 为什么要聚类,而不是直接保存模型输出的帧
模型在相邻时间步看到的最近窗口高度重叠。一个重要事件可能在多次推理中被提名:
text
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 会把它换算成原始帧距离:
python
raw_merge_distance = merge_distance * frame_subsample
例如:
text
merge_distance = 5
frame_subsample = 5
raw_merge_distance = 25 原始帧
这样无论内部候选索引用原始帧号表示,聚类距离仍然等价于"下采样后 5 步以内算同一事件"。
11. 推理流程
11.1 在线部署流程
在线部署由一个 stateful policy 维护:
- 所有已经收到的帧
_frames - episodic memory
_memory - 当前总任务 instruction
- 相机拼接配置
每次调用 step(obs):
python
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 上。
直观上:
text
高层慢一点,但负责想清楚"现在做什么"
低层快一点,持续执行最近一次高层给出的 subtask
当高层还在推理下一条 subtask 时,低层继续使用最近一次已经预测出的 subtask。这可以减少等待,提高机器人动作连贯性。
11.3 离线 rollout 评估
离线评估不是简单地对每个训练样本独立测试,而是模拟部署时的记忆更新:
python
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 更接近真实部署。
12. 三个任务分别需要记住什么
论文用三个真实机器人任务评估 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 对非理想行为的鲁棒性。因为训练数据主要是专家演示,部署时低层策略可能出现卡顿、重试、失败恢复,分布会不一样。
论文使用线性权重合并:
text
theta = (1 - alpha) * theta_pre + alpha * theta_ft
其中:
theta_pre是原始 Qwen2.5-VL-Instruct 权重。theta_ft是 MemER 数据微调后的权重。- 论文使用
alpha = 0.8。
直觉是:大部分采用任务微调能力,同时保留一部分预训练模型的开放域视频理解鲁棒性。
当前公开代码没有实现 model merging 脚本;它是论文方法中的训练后处理技巧。
14. 为什么视觉记忆比文本记忆更好
论文比较了三种记忆形式:
- 只用短历史。
- 短历史 + 文本 subtask 记忆。
- MemER 图像关键帧。
- MemER 图像关键帧 + 文本 subtask。
结果是视觉关键帧更稳。原因很直观:
- 文本 subtask 太粗。例如"look inside right bin"不能完整记录右箱子里所有物体。
- 文本可能错。一旦某次 subtask 预测错,把错误文本存进 memory 后会误导后续推理。
- 模型容易过度依赖文本 token,反而忽视视觉证据。
- 图像关键帧保留环境真实状态,尤其适合物体位置、数量、完成状态这些难以用短文本完整表达的信息。
所以 MemER 默认使用纯视觉 memory,而不是把历史 subtask 文本也一起塞进去。
15. 一个完整例子
以 Dust & Replace 为例:
- 总任务指令是:
text
remove the items from the shelves, dust the shelves, and place the items back
- 刚开始没有 memory,模型只看最近 8 帧,预测:
json
{"current_subtask": "remove the object on the bottom shelf", "keyframe_positions": []}
-
低层策略执行拿走下层物体。
-
到动作完成附近,最近窗口里出现"下层物体被拿走后原位置可见"的画面。模型输出:
json
{"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 里有原位置关键帧,所以高层可以预测:
json
{"current_subtask": "place the panda plushie on the bottom shelf", "keyframe_positions": []}
这个流程体现了 MemER 的本质:把"曾经发生过的重要视觉状态"变成长期可见的少量图片,而不是让模型记住完整视频。
16. 方法伪代码总览
16.1 训练数据生成
python
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 高层推理与记忆更新
python
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 和低层策略组合
python
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 的前几步,然后重新规划