做机器人操作的朋友应该都有过这种体验:一个简单的“把红色方块放到篮子里”,写传统控制代码可能要一整天,感知模块、坐标转换、轨迹规划、抓取判定各写一版,最后还要为了某个边界情况反复调参。后来看到VLM(视觉语言模型)在图片问答上的表现,我就在想:既然模型能看懂画面里的物体和空间关系,能不能让机器人直接“看着画面、听着指令”把活干了?Show-Harness就是围绕这个想法搭的一套框架,核心只有一句话:用单个VLM智能体完成从视觉理解到动作决策的整条链路,不再为每个任务单独写控制逻辑。这篇文章会把这套方案的架构、复现步骤和我在实测中踩过的坑完整讲清楚,适合正在做机器人操作研究、准备入门具身智能,或者想用低成本方式验证VLM控制效果的工程师。
1. 为什么敢用一个VLM包办机器人操纵
1.1 传统流程的瓶颈在哪里
经典的机器人操作流程,基本是模块串联:视觉模块先识别目标物体,输出像素坐标;坐标变换模块把像素坐标转到机械臂基座坐标系;轨迹规划模块负责生成一条无碰撞路径;底层控制器再去执行这条路径。听起来很顺,但每一环都在累积误差。
我印象最深的一次,是做一个抓取项目。目标检测模型在测试集上准确率挺高,但换了一个光源方向,框的位置偏了4到5个像素。就这么点误差,换算到机器人坐标系里已经超过了夹爪的容差,抓取直接落空。后面检查发现,手眼标定矩阵也有将近2毫米的偏差。一个模块一个问题,调到最后甚至不知道该信谁。
这种模块化方案的另一个痛点,是任务边界特别脆。你要让机器人“把红色方块放到篮子里”,就得单独训练一个红色方块检测器,还要定义什么算“放到篮子里”,篮子被遮挡怎么办,红色方块被其他物体压住怎么办。每个新任务都要新增规则,规则之间还会打架。说到底,传统流程把大量精力花在了把真实世界“翻译”成预定义状态上。
1.2 VLM恰好补上了什么
VLM能补上的,恰恰是这种“翻译”能力。它不是在识别一个固定类别,而是在用海量图文预训练中学到的世界常识来理解画面:这是杯子,那是篮子,杯子在篮子左边,红色方块离机械臂更近。这些对人类来说顺理成章的判断,传统视觉方案要专门建模。
从技术上拆开看,VLM一般由一个视觉编码器和一个语言模型组成。视觉编码器把图像切成类似token的patch,映射到语言模型能处理的向量空间,然后语言模型根据任务指令预测输出。所以它天然具备两个能力:一是理解图像里的物体和空间关系,二是把理解结果组织成文本或结构化指令。
Show-Harness实际用起来也是一样。我只需要把机械臂末端的相机画面传进去,再问一句“请把红色方块放到篮子里”,它就能输出一步动作。这种交互方式,等于把“写死规则”变成了“用自然语言描述目标”,对非专业开发者友好得多。
1.3 适用边界:它不是万能控制器
但我得先把丑话说在前面,VLM不是万能的,更不能替代底层伺服控制。它擅长的是任务级决策:识别场景、判断目标、规划大致动作序列。真正执行时,关节伺服、动力学控制、力控制依然要靠传统控制器完成。
我在实测中碰到的典型场景是,VLM能准确说出“现在应该往左移动2厘米”,但如果让它生成机械臂每个关节的力矩值,那就完全失控了。它没有动力学模型,也不会计算惯性补偿。所以Show-Harness的正确打开方式是:VLM输出高层动作意图,底层控制器负责平滑执行。两个层级各干各的,系统才稳。
这个边界想清楚之后,很多问题就不会纠结了。比如VLM一个动作推理要1到3秒,用来做实时力控肯定不行,但用来做“观察-决策-执行”的离散步骤,时间完全够用。
2. Show-Harness的架构拆解:从像素到关节指令的完整链路
2.1 感知输入怎么设计
Show-Harness的输入端不复杂,核心是RGB图像加一段任务描述。如果机械臂上有深度相机,我也会把关键目标的深度值提取出来,换算成文字放进提示词里,而不是把深度图直接塞给VLM。原因很简单,大多数开源VLM对深度图的先验几乎为零,你给它一张深度图,它很容易当成普通灰度图理解,反而产生误导。
相机视角需要固定。我早期测试时把顶置相机换成了45度斜视角,VLM给出的目标坐标立刻乱掉。后来我在提示词里明确标注了“frontview”和“topview”两个视角,并让模型只基于frontview输出动作,效果才稳定。如果你只有一个相机,尽量固定安装,不要频繁动它。
图像分辨率也不是越高越好。模型输入是一串patch,分辨率太高会占掉大量上下文窗口,留给动作生成的token就少了。我一般把相机画面缩放到640x480,重点区域可以再做一次中心裁剪。这样推理速度快,也不容易丢失关键信息。
2.2 决策层:VLM如何输出动作
VLM输出动作的方式,决定了整套系统的可靠程度。最省事的做法是让它输出自然语言:“向左移动两厘米,然后放下夹爪。”但自然语言解析起来很痛苦,同一个意思它能换一百种说法,你总不能每种都写一个正则。
Show-Harness的做法是强制输出JSON格式的动作原语。我在提示词里给它定义一个结构,模型只会输出这样一个JSON:
{ "action": "move", "delta_position": [0.0, -0.02, 0.01], "gripper": "open", "speed": 0.2 }这里的action是动作类型,可以是move、pick、place、wait;delta_position是相对于当前末端位置的平移量;gripper控制夹爪;speed限制执行速度。用JSON的好处是解析简单,而且利用语言模型的指令遵循能力,正确率可以做到95%以上。
如果模型输出了非法JSON,常见情况是多了尾逗号或者中文字符,我不会让程序崩溃,而是重新采样一次。大概两三次内都能拿到合法结构。
2.3 动作翻译与底层控制器
VLM输出JSON只是第一步,要让机械臂真正动起来,还需要一个动作翻译层。在我的实现里,这个翻译层做了三件事:坐标变换、轨迹插值、安全校验。
坐标变换最关键。如果VLM输出的是绝对坐标,它必须知道坐标系原点在哪、每个轴怎么定义,但语言模型对绝对数字的感知很弱,让它直接回归[0.42, 0.31, 0.15]这种坐标,误差很大。所以我改成了输出相对位移,让模型判断“向左2厘米、向上1厘米”,实际目标位置由程序计算出来。VLM对相对方向的理解,远比对绝对坐标的理解靠谱。
轨迹插值和安全校验则完全是工程问题。计算得到目标位置后,我会用MoveIt或自定义的直线插补生成一段平滑轨迹,再把轨迹点交给底层速度控制器。安全校验是为了防止VLM突发奇想输出一个大跳跃动作,超出工作空间。可见,决策层可以交给模型,但执行层必须握在程序员手里。
2.4 为什么强调“只需一个智能体”
最近很多框架喜欢做多智能体编排,一个模型负责规划,一个模型负责感知,一个模型负责执行,中间再用一个调度器串联。听起来很灵活,实际跑起来经常崩。每多一个模型,就多一层token传输、多一份上下文不一致的风险。
Show-Harness选择单智能体的理由很朴素:所有信息都放到同一个上下文窗口里,感知和决策天然共享同一个视觉表示。模型在生成动作时,既能看到图像patch,也能看到任务指令和失败反馈,不需要额外通信。
单智能体也有明显的代价,就是单点故障。一旦模型理解错了场景,整个任务就错了,没有其他模型来纠偏。所以我在后面会讲一个很重要的机制:失败反馈闭环。用环境反馈把错误信息再喂给同一个智能体,让它自己修正,这才是单智能体方案能活下去的关键。
3. 从零复现一套可运行的VLM操纵环境
3.1 运行环境与模型选型
如果你也想上手试,我建议先在仿真环境里跑通,再考虑真机。我目前在用的组合是robosuite仿真器加一款开源VLM,硬件是一张RTX 4090,显存24GB。表里这几类模型都可以考虑:
| 模型 | 参数量 | 显存占用参考 | 适用场景 |
|---|---|---|---|
| Qwen2-VL-7B | 7B | 约16GB | 通用视觉语言理解,中文支持好,文本动作输出 |
| LLaVA-1.6-7B | 7B | 约14GB | 轻量,方便做LoRA微调 |
| OpenVLA | 7B | 约24GB以上 | 机器人预训练,输出动作token,不是传统VLM |
| 商业API模型 | 不确定 | 零本地显存 | 快速验证,但反馈延迟和成本要留意 |
我的默认选择是Qwen2-VL-7B,因为它对中文指令和结构化JSON输出的支持都不错,量化之后显存占用可以压到10GB左右,一张消费级显卡就能跑。如果你要做精细调优,LLaVA系列更容易做LoRA微调。
系统依赖大概这样:Ubuntu 22.04、Python 3.10、PyTorch 2.x、transformers、robosuite。仿真器不需要太高的显卡要求,CPU也能跑,但VLM推理必须GPU。
3.2 核心代码骨架
下面是一个简化版的核心循环,去掉了一堆异常处理,但整体结构足够看清Show-Harness是怎么运转的:
import json import torch from transformers import AutoProcessor, Qwen2VLForConditionalGeneration import robosuite as suite from robosuite.controllers import load_controller_config # 1. 加载VLM model_id = "Qwen/Qwen2-VL-7B-Instruct" processor = AutoProcessor.from_pretrained(model_id) model = Qwen2VLForConditionalGeneration.from_pretrained( model_id, device_map="auto", torch_dtype=torch.float16 ) # 2. 创建仿真环境 config = load_controller_config(default_controller="JOINT_VELOCITY") env = suite.make( "Lift", robots="UR5e", grippers="Robotiq85Gripper", has_renderer=False, has_offscreen_renderer=True, use_camera_obs=True, camera_names="frontview", camera_heights=480, camera_widths=640, controller_configs=config, ) def build_prompt(task, image, history): return { "system": "你是一个机械臂操作员。" "只按给定JSON格式输出下一步动作,不要解释。", "user": f"当前相机画面:<image>\n任务:{task}\n" f"历史反馈:{history}\n" "可选动作:move/pick/place/wait。" "输出格式:{\"action\":\"...\"," "\"delta_position\":[dx,dy,dz]," "\"gripper\":\"open/close\"}", } def parse_action(raw_text): text = raw_text.strip().replace("```json", "").replace("```", "") return json.loads(text) obs = env.reset() history = "" task = "把红色方块放到篮子里" for step in range(30): image = obs["frontview_image"] inputs = processor.build_inputs( build_prompt(task, image, history), images=[image], return_tensors="pt" ).to(model.device) output = model.generate( **inputs, max_new_tokens=128, do_sample=True, top_p=0.9, temperature=0.6, ) raw_text = processor.batch_decode(output, skip_special_tokens=True)[0] action = parse_action(raw_text) # 3. 把JSON动作翻译成环境可执行的低层指令 env_action = action_to_env_action(env, action) obs, reward, done, info = env.step(env_action) # 4. 用简单的视觉判断任务是否完成 if check_success(obs, task): print("任务完成") break这里的action_to_env_action是最容易被忽略的部分:delta_position是相对于当前夹爪位置的笛卡尔平移量,但robosuite的底层控制器接收的往往是关节速度或目标末端位置,所以需要先经过逆运动学换算。仿真器里可以直接调控制器接口,真机则要经过ROS和MoveIt。
3.3 仿真器和真实机器人的接入差异
仿真环境最大的好处是状态完全可观测,你能直接拿到夹爪是否闭合、物体是否被拿起。但这也掩盖了真实世界的一堆麻烦:相机畸变、标定误差、物体摆放偏差、控制延迟。
我搬真机之后遇到的第一件事就是,仿真里抓得稳稳的方块,真机上每次都偏几毫米。原因就是仿真里的相机内参和手眼矩阵都是理想值,而真机的一连串标定误差叠在一起,直接让VLM输出的相对位移失效。后来我重新做了手眼标定,又在每次任务开始前加了一步“对刀尖”校准,成功率才回来。
建议是先把仿真成功率跑到80%以上,再上真机;不要一上来就用真机调VLM,失败原因会复杂到根本没法归因。
4. 任务提示词与交互闭环:让模型稳定干活的关键
4.1 提示词里必须写清楚的信息
VLM不是程序,不会自动明白你的坐标系、动作空间和禁止事项。提示词里少写一个约束,模型就可能放飞一次。我总结下来,提示词至少要包含五部分:角色设定、任务目标、坐标系定义、可用动作列表、输出格式。
我常用的模板长这样:
系统提示词: 你是一个机械臂操作员,控制一台UR5e机械臂。 机械臂当前末端位置定义为原点,x轴向前,y轴向左,z轴向上。 你只能使用动作原语:move、pick、place、wait。 move表示移动到相对位置,pick表示闭合夹爪尝试抓取, place表示打开夹爪放下物体,wait表示等待。 只输出一个JSON对象,不要输出任何解释。 用户提示词: 当前相机画面:<image> 任务:把红色方块放到篮子里。 如果上一次动作失败,反馈是:夹爪闭合但未夹到物体,请调整位置重试。 请给出下一步动作。这套模板看起来啰嗦,但每句话都有用。坐标系定义能帮模型理解delta_position的方向;动作列表限制了模型的行为空间;输出格式约束让解析不再头疼。我遇到的大部分格式错误,都是因为少了其中某一段。
4.2 失败重试闭环
VLM单次预测不可能永远正确,但错误不一定是致命的,只要你告诉它错了,它常常能自己纠正。这就是失败反馈闭环存在的意义。
具体做法是每次执行完动作后,先判断是否成功。成功可以靠两种方式判断:一种是视觉前后对比,抓取前后各截一张图,看物体有没有离开桌面;另一种是读取夹爪状态和末端高度,判断夹爪内是否有障碍物。把判断结果拼接成文本,作为历史反馈加进下一轮提示词。
比如模型第一次输出“向左移动2厘米”,执行后夹爪闭合却什么都没夹到。我会把“夹爪未检测到物体,物体可能还在原位置,建议向上移动0.5厘米后重新靠近”这段反馈放进上下文,再让模型生成下一步。实测这套闭环大概能把任务成功率提高三成。模型更像是“看着结果慢慢摸索”,而不是一次性蒙一个动作。
4.3 采样次数与置信度判断
很多教程默认用贪心解码,但VLM做动作生成时,贪心解码容易陷入重复或死板的输出。我建议使用带采样的解码,同一个画面生成多个候选动作,再挑一个最合理的执行。
Show-Harness里我通常设置n=5次采样。如果五次里四次都是同一个动作,那这个动作大概率可信;如果动作五花八门,说明模型对当前场景很困惑,这时候我会选择等待一步,或者让模型重新表述任务。你甚至可以在JSON里加一个confidence字段,让模型自己给动作打个分,虽然这个分数并不是真正的概率,但它在排序候选项时确实有用。
采样也会带来一个副作用:偶尔有一两次采样会输出离谱动作。所以无论采样几次,动作校验都不能省。最大位移超限、目标位置在工作空间之外、夹爪指令冲突,这些都要在解析层拦住。
4.4 少量示例的作用
如果模型对JSON输出的格式总出错,或者频繁选择错误动作,最快解决方法是给它一两条few-shot示例。示例不是让它背答案,而是让它理解“期望的输出长什么样”“哪些行为是禁止的”。
我会在提示词末尾补一段:
示例1: 用户:把杯子推到桌子右边。 助手:{"action": "move", "delta_position": [0.0, -0.05, 0.0], "gripper": "open", "speed": 0.2}示例不宜多,一到两条足够,多了反而会占用上下文窗口,拖慢推理速度。如果换成新的机械臂或者新任务,记得把示例里的坐标系和动作语义一并更新,否则模型会模仿错误示例。
5. 实测翻车现场与调优经验
5.1 相机位置一变就失灵
我最早测试时用的是顶置相机,效果不错,物体识别率高,相对位移判断也很准。某天想把相机挪到机械臂侧面,结果同一个任务的成功率从八成直接掉到三成。原因不复杂:VLM对空间关系的理解本质上是二维像素层面的,它看到“方块在图像左侧”,并不意味着知道“方块在机器人坐标系的哪个方向”。
这个问题我最后用两个办法解决。一是固定相机安装,不做随意调整,这样模型面对的观察分布不会漂移。二是在提示词里同时提供顶置视角和侧视角两张图,让模型交叉验证空间关系。加了一个视角后,成功率基本恢复。
5.2 动作抖动与安全限位
连续执行时,VLM输出的相对位移不稳定。比如模型第一次输出向左2厘米,第二次可能输出向左1.5厘米,第三次又变成向上0.3厘米,末端执行器在这种交替指令下会明显抖动。直接执行不仅难看,还有撞到工件的风险。
我给执行层加了两层保护。第一层是滑动平均,用一个长度为5的窗口对每一维位移做平滑;第二层是硬限位,任何单步位移超过0.05米就截断到0.05米,任何目标位置超出机械臂工作空间的直接丢弃。这样牺牲了一点动作灵活度,换来的却是稳定性和安全性。
尤其注意,在仿真里撞一下只是画面难看,在真机上可能就是夹爪和工件受损。安全限位不是可选项,是必选项。
5.3 长指令多步骤任务怎么拆
VLM的上下文窗口虽然大,但让它一次性输出一整条长任务的每步动作,大概率会丢步骤。比如“把红色方块放到篮子里,再把篮子推到桌子边缘”,它常常只做完第一步就停了。
Show-Harness的做法不是硬撑,而是把长任务切成多轮短任务。每一轮只让VLM做一个动作,但用户提示词里维护一个“已完成步骤”列表。模型看到“已完成:物体已经放入篮子;当前目标:把篮子从当前位置推到桌子边缘”,就能继续干活而不会重复。
这个“已完成步骤”列表本质上就是最轻量的记忆机制,不需要额外的状态存储,只靠文本拼接就能实现,而且效果显著。
5.4 一份实测踩坑清单
| 现象 | 可能原因 | 常规解法 |
|---|---|---|
| JSON解析失败 | 模型输出了尾逗号或中文标点 | 做字符串清理,或重采样2至3次 |
| 模型声称抓取成功但实际没抓到 | 视觉幻觉,缺少成功验证 | 增加夹爪状态和图像前后对比检查 |
| 推理延迟高,任务卡顿 | 图像分辨率太大,上下文过长 | 缩到640x480,减少few-shot示例 |
| 换场景后成功率骤降 | 过度依赖旧场景先验 | 重新设计提示词,补充新场景示例 |
| 连续动作抖动 | 采样随机性叠加 | 滑动平均加最大步长限制 |
| 坐标方向反了 | 坐标系定义没写清 | 在提示词里明确x、y、z轴方向和原点 |
这些坑几乎每个做VLM控制的人都会遇到。建议你把自己的失败案例也记录下来,攒成一张自己的避坑清单,比任何现成教程都更有价值。
6. 这类方案能走多远:几个值得继续投入的方向
6.1 数据回流与微调
通用VLM确实能开箱做不少任务,但如果你要长期在一个固定场景里运行,微调是绕不开的。Show-Harness的执行日志本身就是一个数据金矿:每一步的相机图像、任务指令、模型输出、执行结果,全部可以被整理成训练样本。
我在项目中期收集了大概几百条成功轨迹,用LoRA对VLM做了一次轻量微调,格式错误率明显下降,对特定工具的识别也更稳定。这里要提醒一句,微调数据必须包含失败样本,否则模型会变成“只报喜不报忧”的嘴硬选手,明明没抓到也说自己抓到了。
6.2 多模态记忆与长期任务
单智能体方案的局限在于没有长时记忆。模型不会记得十分钟前它已经完成了哪些步骤,除非你把历史信息写进提示词。这个用文本列表就能解决,但任务一旦复杂到几十步,文本列表会变得很长,挤占图像token的空间。
我目前在做的一个改造,是把已完成步骤摘要化,只保留关键状态,比如“物体在夹爪中”“篮子位置已移动”,而不是完整对话历史。这就像你做事前会先在便利贴上记一个待办清单一样,模型的便利贴就是那段摘要文本。
6.3 从单机械臂到移动操作
从固定机械臂扩展到移动底盘加机械臂,是一个自然的方向。固定机械臂只有一个操作空间,VLM只需要关心当前工作台;移动操作则要额外处理全局导航和局部避障。
如果只靠一个VLM硬扛全局路径规划,效果会很差,因为导航需要的是稠密里程计和地图信息,这不是VLM擅长的。我个人建议保留移动底盘原本的导航方案,让VLM专注于机械臂抓取和放置决策。导航到一个目标点后再启用VLM操作,两套系统各司其职,比硬塞一个模型里更可靠。
我在实际运行中最深的一点体会是,Show-Harness这类方案把机器人开发的调试重心从“写控制代码”变成了“设计提示词和反馈机制”。你花在观察模型为什么犯错上的时间,会远远超过写代码的时间,而收获也恰恰在这里:每一次失败反馈,都在帮同一个智能体变得更好。拿到一个新任务,我现在会先跑五十次,统计成功率和失败模式,再针对最常见的失败场景修改提示词。这套循环走顺之后,你手里就真的多了一个能跟着自然语言指挥干活的机器人。