从0到1搭建具身智能数据飞轮:机器人多模态数据闭环实战
技术栈:Ray + Kafka + LanceDB + Milvus (Zilliz Cloud) + MinIO + Iceberg
在调研具身智能领域的过程中,我发现一个被严重低估的瓶颈------不是模型不够大,不是算力不够强,而是数据管道根本跟不上迭代速度。
具身智能的场景数据天然是多模态的:RGB-D 图像、LiDAR 点云、IMU 序列、关节位姿、轨迹路径、ROS Bag......研发团队每天产生数百 GB 原始数据,但三个月后再想找"那个机器人在仓库拐角撞到货架的场景",往往要在成百上千个 Bag 文件里翻找一整天。
带着这个问题,我用几天时间搭了 RobotLoop------一个面向具身智能的机器人多模态数据闭环平台技术验证原型。目标很清晰:验证"从数据采集到特征存储到语义检索"的完整链路是否能在开源技术栈上跑通。
全文围绕三个核心问题展开:
- 数据怎么进来? ------ 事件驱动,设备端零耦合
- 数据怎么处理? ------ Ray 分布式流水线,Actor 模型解耦
- 数据怎么检索? ------ 向量召回 + 结构化过滤,双引擎混合
一、架构全景:11 个服务的编排逻辑
整个系统通过 docker-compose 一键启动,共 11 个服务。下面是简化后的编排核心:
yaml
services:
# 基础设施
zookeeper / kafka / minio / iceberg-rest
# 初始化任务(一次性)
kafka-init / minio-init / model-init
# 计算层
ray-head / ray-worker
# 业务服务
model-api / api / jupyter
# 监控
prometheus / grafana
几个关键设计决策:
| 设计点 | 实现方式 | 原因 |
|---|---|---|
| 事件驱动 | MinIO Bucket Notification → Kafka | 设备端只需上传文件,零消息客户端依赖 |
| 初始化分离 | kafka-init / minio-init / model-init |
用 condition: service_completed_successfully 保证启动顺序 |
| 模型管理 | CLIP 权重存 MinIO models bucket |
Worker 启动时自动下载缓存,版本化管理 |
| 存储隔离 | Iceberg 使用独立 iceberg-warehouse bucket |
避免写入触发 Kafka 通知,防止事件循环 |
MinIO 的通知配置是事件驱动的核心:
yaml
minio:
environment:
MINIO_NOTIFY_KAFKA_ENABLE_robotloop: "on"
MINIO_NOTIFY_KAFKA_BROKERS_robotloop: kafka:9092
MINIO_NOTIFY_KAFKA_TOPIC_robotloop: "raw-data-ingest"
设备端上传 scene_metadata.json 到 robotloop-data bucket,MinIO 自动推送事件到 Kafka raw-data-ingest topic。Ray Worker 消费这个消息,触发完整的数据处理流水线。
二、Ray 计算层:Actor 模型解耦三阶段
数据处理的核心在 ray_pipeline.py。我没有把逻辑写成一个单体脚本,而是用 Ray Actor 把三个职责拆成了独立角色:
2.1 三个 Actor 的职责划分
python
@ray.remote
class SceneProcessor:
"""场景处理器:调用模型 API 标注 + CLIP 向量化"""
@ray.remote
class LanceDBWriter:
"""列式存储写入器:批量追加到 LanceDB"""
@ray.remote
class MilvusIndexer:
"""向量索引器:插入 Milvus 集合"""
为什么用 Actor 而不是普通 Task?
每个 Actor 内部需要持有状态(CLIP 模型句柄、数据库连接、Milvus 客户端),如果用普通 Task,每次调度都要重新初始化,开销巨大。Actor 在创建时完成一次初始化,后续通过 .remote() 调用复用状态。
2.2 SceneProcessor:标注 + 向量化
python
@ray.remote
class SceneProcessor:
def __init__(self):
# 每个 Actor 实例加载一次 CLIP 模型
self.model = _load_clip_model(
MODEL_CACHE_DIR, S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY
)
def process(self, scene: Dict) -> Dict:
# 1. 调用模型 API 获取场景描述和质量分
resp = requests.post(
f"{self.model_api_url}/annotate",
json={"scene_id": scene["scene_id"]},
timeout=30,
)
annot = resp.json()
scene["scene_description"] = annot.get("description", "")
scene["quality_score"] = annot.get("quality", 0.0)
# 2. CLIP 文本编码 -> 512 维语义向量
desc = scene.get("scene_description", "")
emb = self.model.encode(desc).astype(np.float32).tolist()
scene["scene_embedding"] = emb
# 3. 模拟轨迹编码 -> 256 维(生产可替换为 TCN/Transformer)
traj = np.random.randn(256).astype(np.float32)
traj = traj / np.linalg.norm(traj)
scene["trajectory_embedding"] = traj.tolist()
return scene
CLIP 模型的加载策略值得展开:
python
def _load_clip_model(cache_dir: str, endpoint: str, key: str, secret: str):
model_dir = os.path.join(cache_dir, "clip-ViT-B-32")
if not os.path.exists(model_dir):
# 首次启动:从 MinIO models bucket 下载
s3 = boto3.client("s3", endpoint_url=endpoint, ...)
objects = s3.list_objects_v2(Bucket="models", Prefix="clip-ViT-B-32/")
for obj in objects["Contents"]:
s3.download_file("models", obj["Key"], target_path)
else:
# 后续启动:直接读取本地缓存
pass
return SentenceTransformer(model_dir)
模型权重通过 MinIO 版本化管理,Worker 首次启动自动下载,后续重启直接走本地缓存。这意味着扩缩容新节点时,模型自动分发,不需要手动拷贝或挂共享存储。
2.3 MilvusIndexer:向量集合的自动管理
python
@ray.remote
class MilvusIndexer:
def __init__(self):
self.client = MilvusClient(uri=MILVUS_URI, token=MILVUS_TOKEN)
self._ensure_collection()
def _ensure_collection(self):
"""集合不存在则自动创建,schema 不匹配则重建"""
if self.client.has_collection(self.collection_name):
info = self.client.describe_collection(self.collection_name)
fields = {f["name"] for f in info.get("fields", [])}
if "scene_id" in fields and "embedding" in fields:
self.client.load_collection(self.collection_name)
return
else:
self.client.drop_collection(self.collection_name)
schema = MilvusClient.create_schema(auto_id=False, enable_dynamic_field=False)
schema.add_field(
field_name="scene_id", datatype=DataType.VARCHAR,
is_primary=True, max_length=128
)
schema.add_field(
field_name="embedding", datatype=DataType.FLOAT_VECTOR, dim=512
)
self.client.create_collection(
collection_name=self.collection_name,
schema=schema,
metric_type="COSINE",
)
这里做了防御性设计:集合存在但 schema 不匹配时自动重建。这在迭代开发阶段非常实用------改了字段定义后不需要手动删集合再重启。
2.4 流水线的主循环
python
def main():
init_ray()
# 初始化三个 Actor
processor = SceneProcessor.remote()
lance_writer = LanceDBWriter.remote()
milvus_indexer = MilvusIndexer.remote()
# Kafka 消费者
consumer = KafkaConsumer(
"raw-data-ingest",
bootstrap_servers=KAFKA_BOOTSTRAP,
group_id="ray-worker",
auto_offset_reset="earliest",
)
for msg in consumer:
event = msg.value
key = event["Records"][0]["s3"]["object"]["key"]
if key == "scene_metadata.json":
# 1. 从 MinIO 下载元数据
scenes = ray.get(download_metadata.remote(bucket, key))
# 2. 并行处理(每个场景一个 Ray Task)
processed = ray.get([
processor.process.remote(s) for s in scenes
])
# 3. 三路并行写入
ray.get([
lance_writer.write.remote(processed),
milvus_indexer.index.remote(processed),
sync_to_iceberg.remote(processed),
])
三步全部异步并行:download_metadata → processor.process(批量并行)→ 三路写入并行。1000 条场景数据在单机上的处理时间约 2-3 分钟。
2.5 Iceberg 同步:面向数仓的前瞻设计
python
@ray.remote
def sync_to_iceberg(scenes: List[Dict]):
catalog = load_catalog("rest", **{
"type": "rest",
"uri": ICEBERG_CATALOG_URI,
"warehouse": ICEBERG_WAREHOUSE,
"s3.endpoint": S3_ENDPOINT,
"s3.path-style-access": "true",
})
# 按小时分区,支持时间范围查询
partition = PartitionSpec(
PartitionField(
source_id=2, field_id=1000,
transform=HourTransform(), name="ts_hour"
)
)
table.append(arrow_data)
Iceberg 的引入是为了对接 BI 工具(Superset、Metabase)。数据科学家可以通过标准 SQL 分析场景分布、质量趋势、标注覆盖率,不需要了解底层存储细节。
三、混合检索 API:向量召回 + 结构化过滤
检索是整个数据飞轮的"出口"。api/main.py 实现了混合检索,核心逻辑分两个阶段:
3.1 接口设计
python
@app.post("/search")
def search_scenes(
text_query: str = Query(None), # 语义查询文本
quality_min: float = Query(0.0), # 质量分阈值
scene_type: str = Query(None), # 场景类型过滤
top_k: int = Query(10), # 返回数量
):
3.2 Phase 1:Milvus 向量召回
python
if text_query:
# CLIP 编码查询文本 -> 512 维向量
query_emb = model.encode(text_query).tolist()
# Milvus ANN 搜索,扩大 10 倍范围避免过滤后无结果
results = client.search(
collection_name=COLLECTION_NAME,
data=[query_emb],
limit=top_k * 10,
output_fields=["scene_id"],
)
hit_ids = [hit["pk"] or hit["id"] for hit in results[0]]
3.3 Phase 2:LanceDB 精确过滤
python
# 读取整个表为 Arrow Table
full_arrow = table.to_arrow()
# PyArrow 布尔掩码构建
mask = pc.field("scene_id").isin(hit_ids)
if quality_min > 0:
mask = mask & (pc.field("quality_score") >= quality_min)
if scene_type:
mask = mask & (pc.field("scene_type") == scene_type)
# 应用过滤
filtered = full_arrow.filter(mask)
# 按 Milvus 返回的相似度顺序排序
id_order = {sid: idx for idx, sid in enumerate(hit_ids)}
order_arr = pa.array([id_order[sid] for sid in filtered.column("scene_id").to_pylist()])
indices = pc.sort_indices(order_arr)
sorted_table = filtered.take(indices[:top_k])
为什么用 PyArrow 而不是 SQL?
LanceDB 底层就是 Arrow 格式,to_arrow() 是零拷贝读取,过滤操作在 Arrow 的列式内存结构上直接执行,比走 SQL 解析层更快。PyArrow 的表达式 API 也让代码可读性很好。
3.4 调试信息设计
python
debug_info = {
"input_params": {...},
"table_status": f"initialized, rows={table.count_rows()}",
"milvus_status": f"success, hits={len(hit_ids)}",
"lancedb_filter": "scene_id in [...] AND quality>=0.8 AND type=warehouse",
"lancedb_status": f"success, filtered_count={len(filtered)}",
}
每个环节的状态和参数都回传到 debug 字段,定位问题时一目了然。这在开发阶段非常实用------前端调用失败时,看一眼 debug 就知道是 Milvus 没数据还是 LanceDB 过滤条件太严。
四、部署验证
docker-compose up -d 一键启动,首次初始化约 2-3 分钟。验证指标:
| 验证项 | 指标 | 说明 |
|---|---|---|
| 端到端流水线 | ~2-3 min | 1000 条场景数据从上传到全部存储写入完成 |
| LanceDB 写入吞吐 | > 5000 条/秒 | 批量列式追加,P99 < 50ms |
| Zilliz 向量插入 | ~2000 条/秒 | 含网络往返 |
| 混合检索 P95 延迟 | < 120ms | 10 万数据集,top_k=20 |
| 内存占用 | ~6GB | 全部服务运行中(64GB PC 实测) |
各组件访问地址:
- Ray Dashboard :
http://localhost:8265 - MinIO Console :
http://localhost:9001 - Jupyter Notebook :
http://localhost:8888(无密码) - API Docs (Swagger) :
http://localhost:8000/docs - Prometheus :
http://localhost:9090 - Grafana :
http://localhost:3000
五、从原型到生产:已知局限与演进
作为技术验证原型,有几个明确的扩展方向:
| 方向 | 当前状态 | 演进路径 |
|---|---|---|
| 多模态处理 | 仅文本向量 + 模拟图像路径 | NVENC GPU 解码、SAM 分割、图像 CLIP 编码、PointNet 点云 |
| 轨迹编码 | 随机向量模拟 | TCN / Transformer 编码器 |
| VLM 场景描述 | HTTP Mock 返回模拟描述 | Qwen-VL、LLaVA 真实模型替换 |
| 分布式部署 | Ray 本地模式、Kafka 单节点 | K8s + Kafka 多副本 + Ray Autoscaler |
Ray 的 GPU 资源调度语义(@ray.remote(num_gpus=1))已经为多模态扩展预留了接口,模型 API 的接口设计也保持兼容,替换成本很低。
六、写在最后
RobotLoop 是一个从实际问题出发的技术验证项目。在具身智能这个快速发展的领域,数据基础设施的建设往往被算法的光环所掩盖,但我越来越确信:谁掌握了高质量、可检索、可复用的数据闭环,谁就能在迭代速度上赢得数量级优势。
这个项目让我把流处理、分布式计算、向量数据库、列式存储等技术串起来实践了一遍,也让我对"AI 基础设施"这个赛道有了更切身的理解。如果你也在关注具身智能数据平台方向,欢迎交流。
项目代码已开源在 GitHub,包含完整的 Docker Compose 配置和 Jupyter 演示 Notebook:
GitHub : github.com/pftn/robotl...
目前正在关注具身智能 / 机器人数据平台方向的架构与开发机会,如果你所在团队正好在搭建类似的数据基础设施,非常期待深入交流。