如果目标是把 LingBot-VLA 2.0 接到机器人上做控制,部署顺序建议反过来推:先把单帧推理跑通,再让它在真实数据流上稳定输出,最后才把动作发给执行器。原因很实际——环境、权重、输入格式和控制回路这四类问题一旦同时存在,日志里看到的往往是同一个报错,很难定位。每一步都应该有一个可验证的产物:能打印的输出形状、能对齐的时间戳、能被裁剪的动作指令。
建议的路径是:确认环境依赖 → 单帧推理出结果 → 封装成可复用函数 → 只读接入传感器数据流 → 小幅度闭环动作。判断能否进入下一步,看日志里是否有稳定的输出形状、对齐的帧时间戳和可控的动作幅度,而不是看整套系统是否启动。环境未确认就调闭环,通常会把依赖问题和控制问题混在一起,排查成本反而更高。
确认运行环境与依赖版本
这类 VLA 模型一般同时依赖 PyTorch、CUDA 运行时和若干视觉、序列处理库,版本错配的表现常常是加载失败或前向报错,而不是给出清晰提示。建议放在独立虚拟环境里装,避免和机器上其他项目互相污染。先把本地版本打出来:
python `--version`
python -c 'import torch; print(torch.__version__, torch.version.cuda, torch.cuda.is_available())'
python -c 'import torchvision, numpy; print(torchvision.__version__, numpy.__version__)'
nvidia-smi
nvcc `--version`
pip freeze > env_local.txt
把 env_local.txt 与仓库自带的依赖声明(一般是 requirements.txt、environment.yml 或 README 里的安装段落)逐项对照,重点检查三件事:PyTorch 与 CUDA 运行时是否匹配、显卡驱动是否支持该 CUDA 运行时、模型仓库额外要求的包是否装上。具体版本组合以仓库声明为准,不要照搬别处的搭配。
如果 torch.cuda.is_available() 返回 False,先解决环境问题再往下走。这一步不通,后面的推理现象都不可信。
加载模型并跑通一条静态样本的单帧推理
目标是证明权重能加载、前向能出结果,此时不接机器人。准备一条静态样本:一张固定图片、一句指令、一段本体状态,用零张量或仓库自带示例数据都可以。下面是与框架无关的骨架,函数名和字段名按仓库实际接口替换:
import torch
ckpt_path = 'path/to/lingbot-vla-2.0.ckpt' # 按仓库实际文件替换
device = 'cuda' if torch.cuda.is_available() else 'cpu'
state = torch.load(ckpt_path, map_location=device)
model = build_model_from_config(...) # 用仓库提供的构建入口
weights = state['model'] if isinstance(state, dict) and 'model' in state else state
missing, unexpected = model.load_state_dict(weights, strict=False)
print('missing', len(missing), 'unexpected', len(unexpected))
model.eval().to(device)
sample = {
'image': torch.zeros(1, 3, 224, 224, device=device),
'instruction': ['pick up the block'],
'state': torch.zeros(1, 8, device=device),
}
with torch.no_grad():
out = model(**sample)
if isinstance(out, dict):
for k, v in out.items():
print(k, getattr(v, 'shape', type(v)))
else:
print(type(out), getattr(out, 'shape', None))
预期输出通常是动作序列:可能是一个张量,形状类似 (batch, 动作步数, 动作维度),也可能是字典,键名包含 action 或 actions。这里不预设具体维度,按仓库接口确认即可。只要 batch 维度等于喂进去的样本数,说明前向链路是通的。missing 和 unexpected 的键名也值得看一眼,非空往往意味着检查点结构和当前代码不匹配。
把单帧推理封装成可重复调用的函数
单帧能出结果后,把它收进一个函数,后面的数据流和闭环都调这个函数,而不是复制推理代码。入参建议固定成一个观测字典:图像或图像列表、语言指令、本体状态、采集时间戳;出参建议是动作数组加推理耗时,必要时附上模型标识。上游换传感器、下游换执行器时,中间这层不用动。
def infer_once(obs):
# obs: {'image': ..., 'instruction': str, 'state': ..., 'timestamp': float}
# return: {'action': np.ndarray, 'latency_ms': float, 'timestamp': obs['timestamp']}
...
回归测试用一份固定样本:把第一次跑通的观测存成 .pt 或 .npz,之后每次改代码都重跑一遍,比对输出是否在容差内一致。
ref = torch.load('regress_sample.pt')
a1 = infer_once(ref)['action']
a2 = infer_once(ref)['action']
print('max_abs_diff', float(np.max(np.abs(np.asarray(a1) - np.asarray(a2)))))
更换设备、改 batch 大小、升级依赖之后都要重跑这个回归,否则很难区分是模型问题还是环境问题。
接入真实传感器数据流,但先不开控制
这一步只读不写:接真实相机和关节状态,照常推理,但不下发任何动作。要观察的是采集和推理的时序是否对齐。
import time
prev_cap = None
while True:
frame = read_sensor() # 相机/关节状态,按实际接口替换
t_cap = time.time()
if prev_cap is not None:
print('采集间隔(ms)', round((t_cap - prev_cap) * 1000, 2))
prev_cap = t_cap
t0 = time.perf_counter()
result = infer_once(obs_from_frame(frame))
t1 = time.perf_counter()
print('帧时间戳', frame.get('timestamp'),
'推理耗时(ms)', round((t1 - t0) * 1000, 2),
'动作形状', getattr(np.asarray(result['action']), 'shape', None))
# 此处不向执行器发送动作
重点看几件事:帧间隔是否忽大忽小、推理耗时是否超过传感器周期(超过就意味着下一帧到了上一帧还没算完)、时间戳是否单调递增、有没有重复或跳变。若中间加了队列,队列长度持续增长通常说明消费速度跟不上采集速度。用滑动窗口统计耗时中位数和 P95 便于前后比较,但不要把某个数字当成结论,不同机器和分辨率差别很大。
最后接入动作执行器,从小幅度动作开始
前三步稳定后再接执行器。第一次一定要限制幅度,并且先只记录不发送。
actuator:
dry_run: true # 先只记录指令,不真正下发
max_abs_action: 0.05 # 按实际关节量纲换算,从很小值开始
max_delta_per_step: 0.02 # 相邻帧之间的最大变化量
min_command_interval: 0.1
watchdog_timeout: 0.5
幅度限制一般做两层:一层把动作裁剪到安全范围,另一层限制相邻两帧的变化量,避免突跳。dry_run 先跑一段时间,把指令打印出来确认序列看起来合理,再关闭它真正下发。限位、看门狗超时断连和物理急停都保留,不要为了调试方便省掉。
记录要成对:每一帧写下发指令、下发前的实际状态、下发后的实际状态、时间戳和推理耗时,落盘到本地文件或 rosbag,方便回放。回看时重点看指令与实际状态的差值是否持续扩大,以及有没有触发看门狗超时。确认偏差收敛、没有异常振动或报错后,再逐步放宽幅度限制。任何一步出现不可解释的现象,退回单帧推理和固定样本回归,先排除环境与模型问题。