最近在整理抓取数据集时一个明显感受是LeRobot 的 v2 和 v3 数据格式在读写逻辑、索引方式、并发能力上差别非常大如果没搞清楚就切到 v3训练脚本里那一堆dataset[i]、episode_id、video相关的处理基本都要改一遍。这篇文章就围绕 lerobot数据格式v2和v3的比较把我实际用下来看到的差异讲清楚也会给一些从 v2 迁到 v3 时的适配思路和注意点。先说适用范围这篇文章适合做模仿学习、机器人操作数据采集、以及准备把 LeRobot 数据接到自己训练/评估管线的朋友。无论你是在复现公开数据集还是在自己搭采集流程理解这两代格式的底层组织方式都能避免后期在数据加载上反复踩坑。1. v2 格式的底子Parquet 行表、EpisodeIndex 与图像边车1.1 v2 数据集的物理结构LeRobot 的 v2 格式简单说就是“以 Parquet 行表为主干视频文件作为边车”。你从 Hugging Face Hub 拉下来一个 v2 数据集目录结构大概是这样的dataset/ train/ data/ episode_000000.parquet episode_000001.parquet ... videos/ chunk-000/ episode_000000.mp4 ... info.json episodes/ episode_000000.json ...info.json是入口记录了robot_type、env_name、codebase_version、total_episodes、total_frames、format这些元信息。真正存放时间序列数据的是每个episode_*.parquet而图像帧没有直接塞进 parquet而是编码成 mp4 放到videos/目录parquet 里只保存视频帧的索引信息。我最初看到这个设计时也愣了一下为什么不直接把图像存成一张张 jpg后来想通了一张 640x480 的 rgb 图如果逐帧存成 jpeg 文件百万级帧数会让文件系统 inode 暴增训练前拷贝、移动都慢。用 mp4 把整段 episode 的图像编码成一个流既能压缩又能让随机访问变得可控。代价就是哪怕你只想要某一帧也得先把对应视频帧解码出来不能像读取普通 jpeg 那样零成本跳转。1.2 一行数据代表什么字段语义v2 的每个 parquet 文件一行代表一帧或者说一个 time step。常见的列有observation.state机器人当前状态通常是关节角joint positions、末端位姿等维度取决于机械臂比如 19 维。observation.images.*多个相机各自一列比如observation.images.top、observation.images.wrist。这一列存的不是原始图像而是一个“帧引用”。action控制指令通常是目标关节角或末端 delta 位姿。episode_index当前帧属于哪一集。frame_index该帧在 episode 内的序号。timestamp时间戳。加载时LeRobotDataset 会扫描这些 parquet把episode_index的变化当成 episode 边界。你访问dataset[i]它返回一个字典包括observation.state、observation.images.top这些键其中图像是被解码成 tensor 的帧。1.3 EpisodeIndex 是 v2 的灵魂v2 中随机访问一帧的路径是遍历或维护一个 episode 索引知道episode_000000.parquet从第几行开始。在这个 parquet 里取到目标行的episode_index,frame_index。根据episode_index找到对应的videos/chunk-000/episode_000000.mp4。用视频解码器比如 PyAVseek 到frame_index那一帧解码。也就是说v2 的数据访问严格分两层状态/动作走 parquet 列扫描图像走视频帧定位。优点是比较直观缺点则是“按 episode 读取”没有特别优化——如果你想取某一集连续 200 帧仍然要先通过 parquet 行表一步步定位再逐帧解码。1.4 v2.0 与 v2.1 的区别v2 自身也经历了小版本演进。v2.0 时代episode 的边界信息基本靠扫描 parquet 里的episode_index才能获得v2.1 开始增加了episodes/目录每个 episode 单独一个 json记录该集的任务tasks、帧数、文件路径等信息。这样加载时不用把所有 parquet 打开一遍再算 episode 边界效率明显提升。很多社区数据集都是 v2.1。如果你拿到一个老数据卡episodes/目录缺失训练代码在统计 episode 长度时就会慢不少。我的习惯是拿到 v2 数据先看info.json和episodes/是否齐全不全的话赶紧跑一遍索引重建否则后面按集训练会很难受。2. v3 格式从哪来多集并发写入与按集访问的推力2.1 v2 在真实任务中的痛点v2 格式在大规模采集场景下有两个痛点最明显。第一写入慢。LeRobot 官方采集流程一边遥操作一边 append 数据最终要写 parquet、写 mp4、更新索引。多进程并行采集时如果所有 worker 都往同一个数据集目录写文件锁和 parquet writer 的串行化会拖慢整体速度。我在多卡采集时明显感觉到v2 的 append 模式一旦数据量大起来吞吐会快速下降。第二按集消费不友好。模仿学习在做 episode 级采样时经常需要“把这集的连续帧作为一个 chunk”拿出来做数据增强或拼接。v2 的接口虽然能举出episode_index但要拿到一集的全部帧仍然要走 parquet mp4 两层定位。数据量一大这个开销会被放大。2.2 v3 的核心设计取向episode 粒度的组织v3 格式在设计上把重心从“行表”移到了“episode”上。数据物理上仍然有 parquet 和视频文件但多了一层显式索引按集组织得更清楚。比如从公开仓库里能看到 v3 数据集会有index.jsonl或类似结构来记录每个 episode 的元数据、帧数、分片shard位置。加载时不再需要无脑扫描全部 parquet而是先读这个索引直接定位到某一个 episode 对应的存储分片。另一个明显变化是并发写入能力。v3 允许不同 worker 分别写各自的分片文件写完再通过索引统一合并。这样一来多进程采集时的瓶颈会小很多每集写入的原子性也更强。为什么改用 jsonl 而不是继续让 parquet 当索引呢因为 jsonl 是按行追加的文本索引便于增量更新写坏一行不会影响整个索引文件parquet 是列式存储适合大规模统计学分析但用来做“快速定位某一条 episode”反而有点杀鸡用牛刀。v3 把“索引”和“数据”彻底解耦读取时可以先读小而轻的索引再按需加载数据分片这正好契合远程下载 本地缓存的场景。2.3 v3 带来的处理管线变化v3 里的数据访问不再是拿一个dataset[i]就完事更常见的做法是先拿到某个 episode 的EpisodeData再从这个对象里取帧、取状态、取动作。接口上更像“集数据容器”而不只是“帧表”。这会带来一系列连锁反应。以前写训练 pipeline只需要dataset[i]然后读observation.state、observation.images.top、action三个键到了 v3你得先搞清楚当前取的是“帧数据”还是“episode 数据”。如果直接按 v2 的写法去跑一个 v3 数据集大概率会报 KeyError 或者拿到一个奇怪的嵌套对象。另外v3 对图像的处理更灵活。v2 基本是统一的 mp4 视频流v3 允许更细粒度地控制采样压缩和存储格式比如某些集可以直接存成 jpeg 序列某些集保留 mp4。好处是采样时可以只解码自己需要的那一帧坏处是不同 shard 的解码路径可能不同兼容层要写得更健壮。3. 两代格式逐项对比存储结构、读写路径与接口变化3.1 核心差异对照表v2 和 v3 的关键差别可以用一张表说清楚对比项v2 格式v3 格式索引组织parquet 行表 episodes jsonjsonl 索引 分片shard管理数据单元帧为一行episode 是隐含边界episode 是显式单元可从索引直达图像存储统一 mp4 视频边车parquet 存引用mp4 或 jpeg 序列按 shard 管理写入方式单 writer 串行 append多进程压力大多进程写多个分片写完合并索引读取方式dataset[i] 返回单帧字典按 episode 取 EpisodeData再取帧按集访问需要扫描 parquet seek 视频直接通过索引定位 episode 分片流式加载支持但随机访问开销大按分片流式加载随机访问更友好成熟度稳定生态大社区工具链齐全beta 阶段部分工具仍在迭代上面这个表是基于我自己接触的 v2/v3 数据集整理的不是官方文档搬过来的所以具体字段名可能在不同版本里有小差异但设计取向是稳定的v2 以帧为中心v3 以集为中心。3.2 随机访问与流式加载的实验对比我做过一个非常简单的测试取一个大约 2000 集、每集 200 帧左右的数据集分别用 v2 和 v3 的接口去“读取第 500 集的第 30 帧”。v2 的路径是先扫描/加载 parquet 索引定位 episode 500 的起始行然后在这一集对应的 mp4 中 seek 第 30 帧。整个过程依赖两层操作耗时不稳定特别是在 v2.0 那种没有 episodes json 的情况下你可能要先把整个 parquet 扫一遍才能知道第 500 集从哪行开始。v3 的路径是读index.jsonl直接找到第 500 集的 shard 和帧偏移然后解码对应帧。因为少了“扫描 parquet 算边界”这一步速度会快很多。尤其当你只需要访问少数几个 episode 时v3 的优势是数量级的。流式加载也有差异。v2 用 Hugging Facedatasets的 streaming 模式可以直接拿到数据但如果你反复随机访问某几集底层还是要不停拉取远程 parquet 分块缓存的命中率不高。v3 因为索引显式存在可以先下载一个轻量索引文件再按需要下载对应 shard 的分片文件整体网络开销小很多。3.3 图像压缩策略对训练的影响这个点很多人会忽略。v2 的 mp4 是整集一个编码流优点是压缩率高缺点是你要取某一帧就不得不处理关键帧I-frame和解码若干帧。v3 如果允许 jpeg 序列那么单帧的随机访问变得廉价数据增强时不用反复解码视频流。代价是磁盘占用上涨。mp4 连续编码比一张张 jpeg 的压缩率通常高很多尤其是画面变化小的操作场景。所以 v3 不是无脑比 v2 好如果你的数据主要在本地、训练时整天做帧级随机增强v3 更合适如果追求极致压缩、存储很紧张v2 的视频流方案仍有价值。4. 从 v2 迁到 v3我踩过的坑和兼容技巧4.1 第一关datasets 库版本与 info.json 识别从 v2 切到 v3最直接的问题是LeRobotDataset加载时会读info.json里的format字段。旧版的加载代码可能直接判断format是否等于V2遇到V3或v3.0就会抛错。我的建议是迁移第一步不是改业务代码而是把“格式探测”做对。你在写数据加载层时不要假设只有 v2要写成import json from pathlib import Path def detect_format(dataset_path: str) - str: root Path(dataset_path) info_candidates list(root.glob(*/info.json)) list(root.glob(info.json)) if not info_candidates: return unknown info json.loads(info_candidates[0].read_text()) fmt info.get(format, ) if str(fmt).upper().startswith(V3): return V3 if str(fmt).upper().startswith(V2): return V2 return unknown就这么一个小函数能避免后期大量踩坑。因为很多公开数据集明明已经是 v3但加载代码里的判断还只在 v2/v2.1 分支里打转。4.2 读取图像的大坑视频路径、jpeg 序列与返回类型v2 里dataset[i][observation.images.top]通常返回一个 tensor。v3 里如果你调的是 episode 级加载返回的可能是一个“帧加载器”对象必须再调某个方法才能解码出真正的 tensor。代码风格变化很大。比如我的兼容层写法大概是这样的def get_image_at(dataset, episode_data, frame_idx, camera_key): # 这里 dataset 可能是 v2 的帧级接口也可能是 v3 的 episode 级接口 if hasattr(dataset, get_episode_data): # v3: 从 EpisodeData 取 return episode_data[camera_key][frame_idx] # v2: 从全局帧索引取 global_idx episode_data.frame_index_to_global_idx[frame_idx] return dataset[global_idx][camera_key]核心思想是在业务代码层统一暴露get_image_at内部再区分 v2/v3 的实现。这样训练脚本里就不用到处写 if/else只在你自己的数据接口层做一次适配。4.3 训练缓存失效与 map 卡死另一个实际遇到的坑是缓存。datasets库会缓存处理后的数据但 v3 的索引一旦变化比如你往数据集里追加了一集缓存容易失效导致训练时长时间卡在加载阶段。这种情况我一般先清缓存目录或者在加载时直接传download_modeforce_reload来避免读了旧缓存。但要注意force_reload会导致每次重新下载全部数据网络开销很大。如果数据集在本地可以先把全部文件拷贝到本地磁盘再设置HF_DATASETS_OFFLINE1来跳过远程检查。配合 v3 的按需下载这个组合能保证训练时不会莫名其妙地卡网络。4.4 多进程读取时的资源竞争v3 支持多进程并行读取分片但多进程同时解码视频时如果数据集还开启了 streaming 模式容易出现文件句柄耗尽或解码器崩溃。我通常在 DataLoader 里把num_workers设为 0 或 4同时关掉 streaming用本地分片 按索引读取这样最稳。如果你必须用 streaming尽量让每个进程只处理固定的 episode 分片避免所有进程争抢同一个网络文件。这种分区读取思路在 v3 下实现起来比 v2 容易得多因为索引已经按 episode 拆好了。4.5 一个简单的兼容数据加载器下面是一个可用的思路把 v2/v3 统一成“按 episode 迭代”的形式class EpisodeIterator: def __init__(self, dataset): self.dataset dataset self.is_v3 hasattr(dataset, get_episode_data) or hasattr(dataset, episode_data_index) def __len__(self): return len(self.dataset.episode_data_index) if self.is_v3 else len(self.dataset) def __getitem__(self, episode_idx): if self.is_v3: return self.dataset.get_episode_data(episode_idx) # v2 fallback手动取该 episode 的连续帧 indices self.dataset.episode_data_index[episode_idx] return {k: self.dataset[i][k] for i in range(indices.from_idx, indices.to_idx)}这个兼容层不是官方写法但足够支撑大多数训练脚本从 v2 平滑过渡到 v3。你只需要保证训练代码里用的是episode_iter[ep_idx][observation.state]这种抽象接口不用关心底层是 v2 还是 v3。5. 选型建议什么时候继续用 v2什么时候切 v35.1 继续留在 v2 的理由如果你主要是复现社区公开数据集或者跑论文里的对比实验v2 目前仍然是最稳的选择。原因很简单公开数据集的生态大部分还是 v2工具链最成熟遇到问题随便搜一下就有答案。v2 的缓存机制和dataset[i]接口也被大多数模仿学习框架测试过放在训练管线上几乎不会出幺蛾子。还有一个关键点是v2 的按帧索引对细粒度采样非常友好。当你要做帧级随机增强、稀疏采样、跨帧拼接时v2 的“一个索引拿一帧”的模式更直接。v3 强在按 episode 组织但如果你只是在一个 episode 内部乱序抽样两者的性能差异反而不大。5.2 优先切到 v3 的场景如果你的场景是自建数据采集流程且同时在多台设备上并发采集v3 的多分片写入价值就很大了。除此之外远程数据集的随机访问、增量追加、按集下发数据这类需求v3 的设计也更合理。我建议的切换策略不是一步到位。先把采集端切到 v3观察产出数据的索引是否稳定训练端暂时保留 v2 接口兼容层跑通后再逐步迁移。这样即使 v3 某个子版本出现问题也能快速回退到 v2 管线继续干活。5.3 我自己对两代格式的一句态度说了这么多精简成一句话v2 是经过了大量验证的稳定数据格式适合做研究复现和框架对接v3 是朝着“生产化数据管理”演进的新一代格式适合自建采集、增量更新和多端点部署。如果你的团队人手不够、没有专职数据工程师先别急着切 v3把 v2 的数据管理做扎实收益更大。从数据格式演进这件事上我最大的体会是数据管线看似只是“把数据存下来”实际上直接影响着整个模仿学习项目的迭代效率。v2 和 v3 的比较背后其实是两种数据组织哲学的取舍——你是愿意接受“帧级扁平、生态成熟”的现状还是选择“集级显式、并行友好”的未来。搞清楚自己的场景选哪一代格式都说得通。