• 正文
  • 相关推荐
申请入驻 产业图谱

让Thor看懂OriginMan的眼睛——Cosmos 3 Reasoner端侧部署与RosClaw语义层集成

11/03 09:06
78
加入交流群
扫码加入
获取工程师必备礼包
参与热点资讯讨论

转载自公众号:敢敢AUTOHUB

1. 引言

上一篇我们把 OriginMan 和 Thor 接通了:机器人通过 rosbridge 开放 ROS2 话题,Thor 上的 OpenClaw 代理借 RosClaw 插件读取机器人状态,AprilTag 和巡线模式都跑得很稳。

问题在于,代理拿到的只是检测器的原始输出:tag_id=1、dominant_color=red、Line Center X: 281。前面那个红色的东西是不是要抓的方块,路口该往哪边走,它只能自己猜。

这篇在 Thor 上加一个视觉语言模型 Cosmos 3 Reasoner,具体用的是面向 Jetson 的 Cosmos3-Edge。它看相机画面,输出中文场景描述、物体列表和动作建议,再塞回 /originman/state 给代理读。改动只有一个 vLLM 服务、一个约 150 行的 ROS2 节点和一段适配器补丁。选它,是因为它专门为物理 AI 训练,4B 的 Edge 版官方就是给 Jetson Thor 准备的,vLLM 0.26 也已经内置支持。

图 1 总体架构。OriginMan 端只在适配器里加一段 scene 缓存(B.1 补丁),Thor 端新增 vLLM 服务和 cosmos_reason_node 节点,两台机器之间走 rosbridge WebSocket(端口 9090)。

2. Cosmos 3 Reasoner 是什么

NVIDIA 这两年在物理 AI 上做了一套 Cosmos 基础模型:Predict 根据当前画面生成未来几秒的视频,Transfer 把仿真画面转成逼真的训练数据,Reason 负责看懂当前场景并给出决策。2026 年 5 月发布的 Cosmos 3 把它们合进了一个模型,7 月又推出了面向端侧的 Edge 版。模型内部分两个分支:Reasoner 看图、看视频、输出文字,Generator 生成图像、视频和动作。本文只用 Reasoner。

Reasoner 延续了 Cosmos Reason 的训练思路。Cosmos-Reason1 的论文(arXiv:2503.15558)把训练分成两段:先做常规的多模态预训练,再用大量带物理常识标注的具身决策样本训练。比如同一段搬箱子的视频,通用数据只标"一个人在搬箱子",Cosmos 会标出"箱子重心偏右、人已经弯腰过度、再不调整会失去平衡"。所以它推理时会考虑物理约束,不只是描述像素。

规格项 Cosmos3-Edge Cosmos3-Nano Cosmos3-Super
参数量 4B 16B 64B
推荐硬件 Jetson AGX Orin / Thor / RTX PRO 6000 RTX PRO 6000 / H100 / B200 H200 / B200 / GB200
输入模态 文本 / 图像 / 视频 / 动作 同左 同左
输出模态 文本 / 图像 / 视频 / 动作 另加声音 另加声音
官方测试精度 BF16 — —
本文在 Thor 上实测 Reasoner 权重 4.67 GiB,单次推理 4~5 秒 — —
实测短板 偶发整句英文描述,会在文字里编造尺寸 — —

放到 Thor 上看,Edge 的 Reasoner 权重只占 4.67 GiB,KV cache 我们限制在 1 GiB,30 分钟长跑时整机内存在 15.9~16.3 GB 之间。Thor 有 128 GB 共享内存,再加上检测器、OpenClaw 网关也绰绰有余,这是它和 64 GB 的 AGX Orin 最大的差别。

和通用 VLM 相比,它有三点不同。一是物理常识:地面白纸上左绿右红两个方块,Edge 5 次都答对了左右,交换位置后也跟着变;同一批图里,原版颜色识别每次只输出一个 blue。二是能处理图像序列,从前后几帧判断目标往哪边动。三是可以用 <think> 展开推理过程,方便调试。本文关掉了 thinking,因为推理时间几乎都花在生成 token 上,展开思考只会更慢。

图 2 Cosmos 三件套的分工。Predict 和 Transfer 主要在云端,Reason 负责端侧推理。到了 Cosmos 3,三者合进同一个模型,4B 的 Edge 版可以直接放在 Thor 上跑。

3. 在 Thor 上把模型跑起来

3.1 硬件前提与系统层准备

硬件是 Jetson AGX Thor 开发者套件:Blackwell GPU、128 GB LPDDR5X 内存,标称 2070 FP4 TFLOPS,系统是 JetPack 7(Ubuntu 24.04、CUDA 13)。Thor 和 OriginMan 接在同一台交换机上,地址段 192.168.127.0/24,ping 平均 0.31 ms。建议先把 JetPack 和驱动升级到最新;如果要用 NVIDIA 官方的 vLLM 容器,镜像的 CUDA 版本不能高于驱动支持的版本(比如 26.01 版镜像基于 CUDA 13.1)。

vLLM 的依赖很重,我们单独建一个虚拟环境,不和系统 Python、ROS2 混在一起。vLLM 固定在 0.26.0,Quack 和 CUTLASS DSL 要和它配套,原因见第 8 章:

# Thor 端,工作目录建在 home
mkdir -p ~/cosmos_validation && cd ~/cosmos_validation

# 独立 venv,避免污染 ROS2 和系统 Python
python3 -m venv venv-cosmos3
venv-cosmos3/bin/python -m pip install 'pip==26.2.1' 'uv==0.13.0'

# 版本固定:vLLM 0.26.0 + CUDA 13 版 PyTorch,Quack 和 CUTLASS DSL 要配套
venv-cosmos3/bin/uv pip install --python venv-cosmos3/bin/python 
  --index-url https://pypi.tuna.tsinghua.edu.cn/simple --torch-backend cu130 
  'vllm==0.26.0' 'transformers==5.5.3' 
  'quack-kernels==0.6.1' 'nvidia-cutlass-dsl[cu13]==4.6.0'

# 验证版本
venv-cosmos3/bin/python -c "import vllm, torch; print('vllm', vllm.__version__, '| torch', torch.__version__)"
# vllm 0.26.0 | torch 2.11.0+cu130

权重在 Hugging Face 的 nvidia/Cosmos3-Edge,revision 固定在 344d602b,国内网络可以走 hf-mirror.com 镜像。Reasoner 只需要根目录的配置文件、transformer/ 的两个分片和 vision_encoder/model.safetensors,共 20 个文件、约 7.7 GB,下载后逐个核对 SHA-256:

# Thor 端,国内网络走镜像
export HF_ENDPOINT=https://hf-mirror.com
huggingface-cli download nvidia/Cosmos3-Edge 
  --revision 344d602b128d1bbdacb43b08d0a3626f46343e29 
  --include "*.json" "*.jinja" "*.md" "transformer/*.safetensors" "vision_encoder/model.safetensors" 
  --local-dir ~/cosmos_validation/models/Cosmos3-Edge-344d602b128d

3.2 启动 vLLM 服务

推理服务用 vLLM:它自带 OpenAI 兼容接口,节点里一行 requests.post 就能调用;原生支持多模态,base64 图片可以直接走 chat completions。下面是实测用的启动命令,Reasoner 以 BF16 起在 8000 端口:

# Thor 端
cd ~/cosmos_validation && source venv-cosmos3/bin/activate

vllm serve ~/cosmos_validation/models/Cosmos3-Edge-344d602b128d 
  --host 127.0.0.1 --port 8000 
  --served-model-name cosmos3-edge 
  --dtype bfloat16 --max-model-len 4096 --max-num-seqs 1 
  --gpu-memory-utilization 0.18 --enforce-eager 
  --kv-cache-memory-bytes 1073741824 
  --limit-mm-per-prompt '{"image":1,"video":0}' 
  --mm-processor-kwargs '{"size":{"longest_edge":307200,"shortest_edge":65536}}' 
  --generation-config vllm --no-enable-prefix-caching 
  --structured-outputs-config '{"backend":"xgrammar","disable_any_whitespace":true}' 
  --attention-backend TRITON_ATTN

几个关键参数:

    • •

--dtype bfloat16:Edge 官方只测过 BF16。•

--max-model-len 4096:一张 640×480 的图输入约 614 个 token,输出两三百个,够用。•

--max-num-seqs 1 和 --kv-cache-memory-bytes:并发固定为 1,KV cache 固定 1 GiB,占用好预估。•

--mm-processor-kwargs:像素上限设成 640×480,和相机输出一致。•

--structured-outputs-config:用 xgrammar 约束 JSON,disable_any_whitespace 必须在服务端设(见第 8 章第四个坑)。•

--enforce-eager:关掉 CUDA Graph,启动快一点,但 3.3 节的结果表明正式使用应该去掉。

启动时先加载权重、预分配 KV cache,开 CUDA Graph 还要多一轮编译和图捕获。实测 eager 约 65 秒就绪,CUDA Graph 约 90 秒。/health 返回 200 就能用了,再用下面两条命令确认推理通路:

# Thor 端,另开终端
curl -fs http://localhost:8000/v1/models | jq
# 期望返回 {"data":[{"id":"cosmos3-edge","object":"model",...}]}

curl -s -X POST http://localhost:8000/v1/chat/completions 
  -H "Content-Type: application/json" 
  -d '{
    "model": "cosmos3-edge",
    "messages": [{"role":"user","content":"用一句话介绍你自己。"}],
    "max_tokens": 80,
    "temperature": 0.2
  }' | jq -r '.choices[0].message.content'

能正常返回一句话就说明服务通了;换成图片加完整 JSON,一次要 4 秒左右。如果返回 502 或超时,多半是 KV cache 或 --gpu-memory-utilization 给大了导致 OOM,看 tegrastats 的内存读数就能确认;也可能是服务还没起完。

3.3 性能基准与精度档位选型

单次推理要多久,决定了节点该多久触发一次。我们把第 5 章的 60 张实拍图逐张重放给 vLLM,用流式接口分别记录首 token 时间和生成速度。Edge 只支持 BF16,所以比的是几项能调的配置:CUDA Graph、精简输出、输入分辨率,另外拿 Thor 上现成的 Cosmos-Reason2-2B FP8 做对照:

# Thor 端,同一批 60 张图,每种配置跑一遍
cd ~/cosmos_validation
venv-cosmos3/bin/python scripts/replay_tier1.py --condition edge-generic --out data/replay
venv-cosmos3/bin/python scripts/replay_tier1.py --condition edge-compact --system compact --out data/replay
venv-cosmos3/bin/python scripts/replay_tier1.py --condition edge-halfres --resize 320x240 --out data/replay
# 去掉 --enforce-eager 重启 vLLM 后重跑
venv-cosmos3/bin/python scripts/replay_tier1.py --condition edge-graph-generic --out data/replay
# Reason2-2B FP8 起在 8001 端口
venv-cosmos3/bin/python scripts/replay_tier1.py --condition r2-generic --model reason2-2b 
  --endpoint http://127.0.0.1:8001/v1/chat/completions --out data/replay

实测结果如下(每种配置 60 条,单并发,P95 按 nearest-rank 计算):

配置 模型 精度 输出 token 中位数 生成速度(token/s) P50 延迟 P95 延迟
A Cosmos3-Edge,eager BF16 266 40~57 5.00 s 6.75 s
B Cosmos3-Edge,CUDA Graph BF16 264 68.5 3.95 s 5.09 s
C Cosmos3-Edge,精简输出 BF16 113 52.8 2.23 s 3.32 s
D Cosmos3-Edge,输入降到 320×240 BF16 268 58.5 4.65 s 6.22 s
E Cosmos-Reason2-2B,CUDA Graph FP8 189 108 1.87 s 2.55 s

几点结论:

    • 首 token 只要 0.1 秒,时间几乎都花在逐个生成 JSON 上,一条两百多个 token。
    • eager 模式下一个 CPU 核满载,速度在 40 到 57 token/s 之间跳;开 CUDA Graph 后稳定在 68.5,延迟降约 20%,启动只多 25 秒。
    • 精简输出(description 限 40 字,objects 只留名称、颜色、位置)把延迟降到一半以下,识别没有整体变差。
    • 降分辨率几乎不省时间,还有 17/60 条描述变成了英文,不划算。

默认配置因此选 Cosmos3-Edge BF16 加 CUDA Graph,单次 3~5 秒,适合按需触发,不适合 1 Hz 周期推理。Reason2-2B FP8 快一倍,红色目标也认得更准,但更容易顺着问题说,详见 6.2 节。

图 3 五种配置在同一批 60 张实拍图上的单条耗时,色条是中位数,竖线是 P95。

4. cosmos_reason_node:把模型变成 ROS2 公民

4.1 节点位置与设计取舍

vLLM 提供的是 HTTP 服务,代理通过 RosClaw 拿到的却是话题、服务、动作,所以需要一个 ROS2 节点把两边接起来。节点放在 Thor 上:OriginMan 的 RDK X5 没有 GPU 跑 vLLM,推理节点和 vLLM 放在同一台机器,调用、推理、发布都在本机完成,延迟最低,也最好调试。

节点只用话题,不定义服务:三个订阅、三个发布。调试用 ros2 topic echo 就够了,RosClaw discovery 也会自动把这些话题写进 /rosclaw/capabilities,代理不用额外配置。

方向 话题 类型 说明
订阅 /originman/camera/image_raw sensor_msgs/Image OriginMan 适配器桥接过来的相机帧,是 VLM 的视觉输入
订阅 /originman/state std_msgs/String OriginMan 当前模式和状态 JSON,给 VLM 提供文本上下文
订阅 /originman/cosmos/trigger std_msgs/String 代理主动触发推理用的"敲门"话题,消息体是 focus 提示
发布 /originman/scene/description std_msgs/String 一句话场景描述,自然语言
发布 /originman/scene/objects std_msgs/String (JSON) 结构化物体列表,含名称、颜色、位置
发布 /originman/scene/advice std_msgs/String 下一步动作建议,自然语言

话题前缀统一用 /originman/,代理看命名空间就知道这些能力属于这台机器人,以后接第二台机器人也方便复用。

4.2 Prompt 工程:让 VLM 输出严格 JSON

Prompt 定了几条规矩:系统消息用英文,模型对英文指令更服从;用户消息用中文,让输出锚定在中文;输出必须是严格 JSON,字段名在 system prompt 里逐个列出。完整 system prompt 如下:

You are Cosmos 3 Reasoner, a physical-AI vision-language model deployed on a
Jetson AGX Thor, controlling a desktop humanoid robot named OriginMan.
Observe the scene with physical common sense. You MUST output a strict JSON
object matching this schema, with no surrounding text, no markdown fence:

{
  "description": "<one-sentence Chinese summary of the scene, <= 60 chars>",
  "objects": [
    {
      "name": "<Chinese object name>",
      "color": "<dominant color in Chinese>",
      "rough_position": "<e.g. 桌面左前方 / 正中央 / 右后角>",
      "approx_distance_cm": null
    }
  ],
  "advice": "<one-sentence Chinese suggestion for OriginMan's next action>"
}

Reasoning rules:
- Consider physical constraints (reachability, occlusion, balance).
- If unsure about an attribute, give your best guess based on context.
- Keep description grounded in what is actually visible.

user prompt 拼上当前模式和 focus,告诉模型机器人在做什么、代理这次关心什么:

OriginMan 当前模式: {active_mode}
本次关注重点: {focus}
请观察图像并按 JSON schema 输出。

还有两个细节。第一,我们打开了 vLLM 的结构化输出:请求里带上 JSON Schema(SCENE_SCHEMA 对应上面的字段),由 xgrammar 在采样时约束,只多一行:

payload['structured_outputs'] = {'json': SCENE_SCHEMA, 'disable_any_whitespace': True}

开了之后,长跑 117 条和实景 60 条全部是合法 JSON,没有 markdown 围栏,也没有多余的解释文字;60 条里有 3 条 description 写成英文,被中文检查拒绝。第二,temperature 用 0,加上 seed=0 和 repetition_penalty=1.1,重放 60 张图能逐字复现原始输出。另外 approx_distance_cm 固定为 null,相机没有标定,厘米数没有依据。

4.3 节点完整代码

下面是 cosmos_reason_node 的完整实现,放在 ~/cosmos_ws/src/originman_cosmos_reason/originman_cosmos_reason/cosmos_reason_node.py,setup.py 和 package.xml 按 ament_python 的标准结构写:

#!/usr/bin/env python3
"""
cosmos_reason_node — 把 OriginMan 的相机帧送进 Cosmos 3 Reasoner(Cosmos3-Edge),
                    再把 VLM 的结构化输出发回 ROS2 话题。

订阅:
  /originman/camera/image_raw   sensor_msgs/Image  — 相机帧
  /originman/state              std_msgs/String    — 当前模式 JSON
  /originman/cosmos/trigger     std_msgs/String    — 按需触发,消息体是 focus

发布:
  /originman/scene/description  std_msgs/String    — 一句话场景描述
  /originman/scene/objects      std_msgs/String    — 结构化物体列表 JSON
  /originman/scene/advice       std_msgs/String    — 下一步动作建议
"""

import base64
import json
import threading
import time

import cv2
import rclpy
import requests
from cv_bridge import CvBridge
from rclpy.node import Node
from rclpy.qos import QoSProfile, ReliabilityPolicy
from sensor_msgs.msg import Image
from std_msgs.msg import String


SYSTEM_PROMPT = """You are Cosmos 3 Reasoner, a physical-AI vision-language model
deployed on a Jetson AGX Thor, controlling a desktop humanoid robot named OriginMan.
Observe the scene with physical common sense. You MUST output a strict JSON object
matching this schema, with no surrounding text, no markdown fence:

{
  "description": "<one-sentence Chinese summary of the scene, <= 60 chars>",
  "objects": [
    {
      "name": "<Chinese object name>",
      "color": "<dominant color in Chinese>",
      "rough_position": "<e.g. 桌面左前方 / 正中央 / 右后角>",
      "approx_distance_cm": null
    }
  ],
  "advice": "<one-sentence Chinese suggestion for OriginMan's next action>"
}

Reasoning rules:
- Consider physical constraints (reachability, occlusion, balance).
- If unsure about an attribute, give your best guess based on context.
- Keep description grounded in what is actually visible.
"""


class CosmosReasonNode(Node):
    VLLM_URL = 'http://localhost:8000/v1/chat/completions'
    MODEL = 'cosmos3-edge'

    def __init__(self) -> None:
        super().__init__('cosmos_reason_node')
        self.bridge = CvBridge()
        self.latest_image = None
        self.active_mode = 'idle'
        self.busy = False
        self.lock = threading.Lock()

        # 图像走 BEST_EFFORT:丢几帧无所谓
        qos_img = QoSProfile(depth=1, reliability=ReliabilityPolicy.BEST_EFFORT)
        # 控制和结果走 RELIABLE:丢了就影响代理
        qos_msg = QoSProfile(depth=10, reliability=ReliabilityPolicy.RELIABLE)

        self.create_subscription(Image, '/originman/camera/image_raw',
                                 self._on_image, qos_img)
        self.create_subscription(String, '/originman/state',
                                 self._on_state, qos_msg)
        self.create_subscription(String, '/originman/cosmos/trigger',
                                 self._on_trigger, qos_msg)

        self.desc_pub = self.create_publisher(String, '/originman/scene/description', qos_msg)
        self.obj_pub = self.create_publisher(String, '/originman/scene/objects', qos_msg)
        self.advice_pub = self.create_publisher(String, '/originman/scene/advice', qos_msg)

        # 1Hz 周期推理作为兜底;生产环境可由 ROS 参数关闭
        self.timer = self.create_timer(1.0, self._tick)
        self.get_logger().info(f'cosmos_reason_node ready @ {self.VLLM_URL}')

    def _on_image(self, msg: Image) -> None:
        try:
            self.latest_image = self.bridge.imgmsg_to_cv2(msg, 'bgr8')
        except Exception as exc:
            self.get_logger().warn(f'image decode error: {exc}')

    def _on_state(self, msg: String) -> None:
        try:
            self.active_mode = json.loads(msg.data).get('active_mode', 'idle')
        except json.JSONDecodeError:
            pass

    def _on_trigger(self, msg: String) -> None:
        focus = msg.data.strip() or 'general'
        threading.Thread(target=self._infer, args=(focus,), daemon=True).start()

    def _tick(self) -> None:
        if not self.busy:
            threading.Thread(target=self._infer, args=('periodic',), daemon=True).start()

    def _infer(self, focus: str) -> None:
        # 占位预留,CHUNK 续接 _infer 实现
        with self.lock:
            if self.busy or self.latest_image is None:
                return
            self.busy = True
            img = self.latest_image.copy()
        try:
            self._do_infer(img, focus)
        finally:
            self.busy = False

上半段是骨架,有三点要注意:busy 标志位加锁做反压,单次推理要 3~5 秒,1 Hz 的 timer 不加反压很快会堆满请求队列;推理放在独立线程里,避免 HTTP 调用卡住 rclpy 的单线程 spin;图像用 BEST_EFFORT,状态和结果用 RELIABLE。下半段 _do_infer 负责 HTTP 调用和 JSON 解析:

    def _do_infer(self, img, focus: str) -> None:
        ok, buf = cv2.imencode('.jpg', img, [cv2.IMWRITE_JPEG_QUALITY, 85])
        if not ok:
            self.get_logger().warn('jpeg encode failed')
            return
        b64 = base64.b64encode(buf.tobytes()).decode().replace('n', '')

        user_text = (f'OriginMan 当前模式: {self.active_mode}n'
                     f'本次关注重点: {focus}n'
                     f'请观察图像并按 JSON schema 输出。')

        payload = {
            'model': self.MODEL,
            'messages': [
                {'role': 'system', 'content': SYSTEM_PROMPT},
                {'role': 'user', 'content': [
                    {'type': 'text', 'text': user_text},
                    {'type': 'image_url',
                     'image_url': {'url': f'data:image/jpeg;base64,{b64}'}},
                ]},
            ],
            'max_tokens': 512,
            'temperature': 0.0,
        }

        t0 = time.time()
        try:
            resp = requests.post(self.VLLM_URL, json=payload, timeout=90)
            resp.raise_for_status()
        except requests.RequestException as exc:
            self.get_logger().error(f'vLLM call failed: {exc}')
            return
        dt_ms = (time.time() - t0) * 1000

        content = resp.json()['choices'][0]['message']['content']
        parsed = self._parse_json(content)
        if parsed is None:
            self.get_logger().warn(f'non-JSON output ({dt_ms:.0f}ms): {content[:160]}')
            return

        self.desc_pub.publish(String(data=parsed.get('description', '')))
        self.obj_pub.publish(String(data=json.dumps(
            parsed.get('objects', []), ensure_ascii=False)))
        self.advice_pub.publish(String(data=parsed.get('advice', '')))
        self.get_logger().info(
            f'reasoned in {dt_ms:.0f}ms | mode={self.active_mode} | focus={focus}')

    @staticmethod
    def _parse_json(content: str):
        """容错地从模型输出里抠出第一个 JSON 对象。"""
        import re
        # 剥掉可能的 markdown 围栏
        content = re.sub(r'^```(?:json)?s*|s*```$', '', content.strip(),
                         flags=re.MULTILINE)
        # 直接尝试
        try:
            return json.loads(content)
        except json.JSONDecodeError:
            pass
        # 退化方案:用贪婪正则抠第一个 {...}
        match = re.search(r'{.*}', content, re.DOTALL)
        if match:
            try:
                return json.loads(match.group(0))
            except json.JSONDecodeError:
                return None
        return None


def main(args=None) -> None:
    rclpy.init(args=args)
    node = CosmosReasonNode()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.shutdown()


if __name__ == '__main__':
    main()

_parse_json 先剥掉 markdown 围栏,再用正则贪婪匹配取出 JSON,解析失败只记 warn,不让节点崩。开了结构化输出以后,177 条结果里一次围栏都没出现,这一层只是兜底。

4.4 构建与启动

按 ament_python 的标准流程用 colcon 构建:

# Thor 端
cd ~/cosmos_ws
source /opt/ros/humble/setup.bash
colcon build --symlink-install --packages-select originman_cosmos_reason
source install/setup.bash

# 启动(前提是 vLLM 已在 :8000)
ros2 run originman_cosmos_reason cosmos_reason_node

正常启动后日志大致是这样:

[INFO] [cosmos_reason_node]: cosmos_reason_node ready @ http://localhost:8000/v1/chat/completions
[INFO] [cosmos_reason_node]: reasoned in 5548ms | mode=idle | focus=periodic
[INFO] [cosmos_reason_node]: reasoned in 5500ms | mode=idle | focus=periodic
[INFO] [cosmos_reason_node]: reasoned in 5672ms | mode=idle | focus=periodic

服务刚启动的第一条请求偏慢(约 6.6 秒),之后稳定在 4~6 秒,开 CUDA Graph 后 4 秒左右。如果一直没有 reasoned in 日志,按顺序查三处:vLLM 是否起来(curl http://localhost:8000/v1/models),相机话题是否桥过来(ros2 topic hz /originman/camera/image_raw),适配器是否在发 state(ros2 topic echo /originman/state --once)。

5. 三个视觉模式的语义增益对比

5.1 对比方法

这一章回答一个问题:装上 Cosmos 3 Reasoner 后,代理拿到的信息到底多了多少。对比时我们在 OriginMan 上另起一个静态采集节点,每 0.5 秒取一帧,同一张 JPEG 同时跑 AprilTag、颜色识别、巡线三种原有算法,再原样送给 Cosmos3-Edge。每条请求带帧编号和 SHA-256,检测结果、模型输出和状态回写按同一帧对齐。现场摆了 12 个场景,每个拍 5 张,共 60 组;提示统一用"描述当前可见物体、颜色与画面中的位置,指出无法确认的信息",不告诉模型摆了什么。

# Thor 端,每个场景摆好后采 5 条,间隔 15 秒
cd ~/cosmos_validation
venv-cosmos3/bin/python scripts/run_live_validation.py 
  --out data/scene-red-block-white-paper --count 5 --interval 15 
  --label red_block_white_paper

每条请求存一个目录:input.jpg、observation.json(原始检测)和 result.json(模型输出)。采集时机器人保持不动。12 个场景是:桌面白纸上的红方块;地面白纸上的左绿右红、左红右绿两个方块,红块被纸挡住一部分,再调暗灯光;两张不同 ID 的 AprilTag,再拿走标签;黑胶带直线、远处的 Y 形分叉、移近的 Y 形分叉;左红方块加右红鼠标。

图 4 12 个场景中的 6 个,都是 OriginMan 相机拍到的原图。

5.2 对比表

下表每种模式挑一帧,中间是原版检测输出,右边是 Cosmos3-Edge 对同一帧的输出:

模式 原版输出(raw_detection) Cosmos3-Edge 输出
apriltag ID: 2, 家族: tag36h11 一张简易桌面照片显示一个白张工作表上的二维码模型,位于表上中央。
对象:二维码模型,黑色,在白张工作表上中心
建议:注意二维码模型的尺寸和位置,检查桌面是否有其他隐藏的标记。
color_detect Color: blue, X: 320, Y: 53, Radius: 110 一张桌子上有多个物品,包括一个红立方形、一个白纸张、一个数码打印机和一个电脑显示器。桌子边缘有一些杂物,如皮革片和其他材料。
对象:红立方形,红色,放在白纸张上中心
建议:注意检查所有物体是否正确排列,尤其是红立方形和数码打印机的位置.
line_follow Line Center X: 313 一张浅白色桌子上方放置了一块同样浅白色的纸张,该纸张有两道从顶部到底部的黑色线条形成一个Y形结构,像是两个连接起来的带子或扣子。
对象:浅白色纸张 (2) / 黑色线条,浅白色 / 黑色,左侧
建议:注意这是两片相同尺寸的浅白色纸张,上面分别有两道垂直且在顶端相连的黑色线条,形成类似两根带子的Y结构.

AprilTag:原版解码器很稳,两张标签各 5 次都解对了 ID,拿走标签后立刻返回空。Cosmos3-Edge 平时不提 AprilTag,10 张标签图里只有 2 次说是"二维码",有一次还说成"数字 4 的缩写",但从没猜过编号;专门问标签时能认出 9/10,另外 50 张图没有误报(6.2 节)。所以 ID 和位置以解码器为准,模型补充的是标签贴在哪、周围有什么。

颜色识别:原版的 LAB 阈值在有红色物体的各组几乎都输出 blue,调暗灯光后又变成 green。Cosmos3-Edge 认出了白纸上的红方块(5/5),左绿右红两个方块的类别、颜色、左右全对(5/5);但交换位置后全对的只剩 2/5,出现了"粉红色""立方形垫子",红方块加红鼠标那组也只有 2/5。模型补充的是"方块还是鼠标、在左还是在右",原版阈值则需要重新标定。

巡线:原版只看画面下半部三个水平条带(y=240~279、340~379、440~479),把每条里最大黑色轮廓的中心加权成一个 x 坐标。直线输出 313;Y 形分叉在条带上方时只看到主干,同样是 313;分叉移进条带后,它选了面积稍大的左支,输出 309。它没有"分叉"这个概念。Cosmos3-Edge 看的是整张图:通用提示下,远处的 Y 形 5 次里只有 1 次说对,移近后只说成"V 形";专门问线形时提高到 5/5 和 4/5,没有误报。

图 5 三种模式的真实对照。左边是相机原图,右边上一行是原版输出,下一行是 Cosmos3-Edge 的输出。

5.3 失败案例与边界条件

成功对照只是一半,另一半是 VLM 什么时候不可信。遮挡、光照变化、同色物体、AprilTag 和输出语言这几类场景里模型都出过错,7.3 节逐个说明。

应对思路是"结构化字段做硬约束,自由文本做软提示":objects 和检测器结果交叉验证,前提是检测器阈值先标定好;AprilTag 的 ID 和位置以解码器为准;自由文本里的尺寸用正则拦掉;description 和 advice 只给代理参考,不直接驱动控制。

6. 让代理读到语义层:RosClaw 适配器补丁

6.1 通过 state 把语义交给代理

RosClaw discovery 会周期性地把 ROS2 图(包括 /originman/state)发给代理,读 state 是代理获取机器人信息最稳的路径。所以我们的做法是改 OriginMan 端的适配器:额外订阅 cosmos_reason_node 发布的三个 scene 话题,缓存下来,再塞进每 2 秒推送一次的 /originman/state。代理只要能读 state,就能读到 scene。

代理也可以按需取用:只关心当前模式就读 detection_info,需要场景再多读 scene 字段。适配器要新增的代码如下(B.1 补丁):

# adapter_node.py 的 __init__ 末尾追加 ────────────────────────────
self.scene_cache = {
    'description': '',
    'objects': [],
    'advice': '',
    'updated_at': 0.0,
}

# 三条 scene 话题订阅
self.create_subscription(
    String, '/originman/scene/description',
    lambda m: self._on_scene('description', m.data), 10)
self.create_subscription(
    String, '/originman/scene/objects',
    lambda m: self._on_scene('objects', m.data, parse_json=True), 10)
self.create_subscription(
    String, '/originman/scene/advice',
    lambda m: self._on_scene('advice', m.data), 10)

# 让代理可以触发推理
self.scene_trigger_pub = self.create_publisher(
    String, '/originman/cosmos/trigger', 10)
self.create_subscription(
    String, '/originman/cmd/reason',
    self._on_reason_cmd, 10)


# 新增方法 ───────────────────────────────────────────────────────
def _on_scene(self, key, data, parse_json=False):
    import time
    try:
        self.scene_cache[key] = json.loads(data) if parse_json else data
        self.scene_cache['updated_at'] = time.time()
    except Exception as exc:
        self.get_logger().warn(f'scene parse {key}: {exc}')


def _on_reason_cmd(self, msg):
    """代理通过 /originman/cmd/reason 触发一次按需推理。"""
    focus = msg.data.strip() or 'general'
    self.scene_trigger_pub.publish(String(data=focus))
    self.get_logger().info(f'cosmos reason triggered: focus={focus}')


# 修改 _publish_state,在 state dict 里加 scene 字段 ─────────────
def _publish_state(self):
    state = {
        'active_mode': self.active_mode,
        'available_modes': list(self.AVAILABLE_MODES.keys()),
        'mode_details': {k: v['desc'] for k, v in self.AVAILABLE_MODES.items()},
        'camera': 'active' if self._image_sub else 'inactive',
        'tts': 'available' if self.tts_ok else 'unavailable',
        'robot': self.active_mode if self.active_mode != 'idle' else 'idle',
        'scene': self.scene_cache,             # 新增字段
    }
    self.state_pub.publish(
        String(data=json.dumps(state, ensure_ascii=False)))

补丁打完,重新构建并启动适配器,state 就变成下面这样。scene 是嵌套对象,代理在 prompt 里说一句"读取 robot.state 字段",整段语义就能带进上下文。下面的 scene 是白纸红方块那一帧的真实输出,"数码打印机"是模型多看出来的:

{
  "active_mode": "color_detect",
  "available_modes": ["color_detect", "apriltag", "line_follow",
                      "color_track", "audio_control"],
  "camera": "active",
  "tts": "available",
  "robot": "color_detect",
  "scene": {
    "description": "一张桌子上有多个物品,包括一个红立方形、一个白纸张、一个数码打印机和一个电脑显示器。桌子边缘有一些杂物,如皮革片和其他材料。",
    "objects": [
      {"name": "红立方形", "color": "红色",
       "rough_position": "放在白纸张上中心",
       "approx_distance_cm": null},
      {"name": "白纸张", "color": "白色",
       "rough_position": "放在桌子中央",
       "approx_distance_cm": null},
      {"name": "数码打印机", "color": "黑色",
       "rough_position": "放在桌子左侧",
       "approx_distance_cm": null},
      {"name": "电脑显示器", "color": "黑色",
       "rough_position": "放在桌子左侧,靠近背景",
       "approx_distance_cm": null}
    ],
    "advice": "注意检查所有物体是否正确排列,尤其是红立方形和数码打印机的位置.",
    "updated_at": 1793101847.10
  }
}

6.2 让代理主动触发推理

固定 1 Hz 推理只适合早期联调。正式使用时应该让代理自己决定什么时候再看一次,大部分时间机器人在执行确定的动作,没必要每秒都问。补丁里的 /originman/cmd/reason 就是为此准备的:代理往这个话题发一个字符串,适配器转发到 cosmos_reason_node 的 /originman/cosmos/trigger,触发一次按需推理,大约 4~6 秒后新的 scene 会出现在 state 里。

# 模拟代理的工具调用
ros2 topic pub --once /originman/cmd/reason std_msgs/msg/String 
  "data: 'locate red object'"

cosmos_reason_node 会把收到的字符串作为 focus 拼进 user prompt。这有两个用处:一是省算力,推理频率只跟着任务需要走;二是引导模型的注意力。第二点我们用 60 张实拍图专门测了:写了三句针对性的 focus,每句都发给全部 60 张图,既看有目标时答没答对,也看没有目标时会不会乱说:

标签:画面中是否有 AprilTag 或类似的黑白方形编码标记?如有,说明其位置;如没有,明确说没有。不要猜测编号
黑线:画面中是否有铺在地面或纸上的黑色线条?如有,判断它是直线、Y形分叉还是弯道,并说明走向;如没有,明确说没有
红色:列出画面中所有红色物体,分别给出类别和在画面中的左右位置;如没有红色物体,明确说没有

问标签和线形时效果很明显:标签从 2/10 提高到 9/10,两组 Y 形从 1/5、0/5 提高到 5/5、4/5,没有目标的图都没有误报。问红色物体反而变差,从 24/30 降到 19/30,出现了左右说反、把绿方块说成红的;换 CUDA Graph 重跑,结果差不多。看起来,模型平时不主动说的东西(标签、线形),问一句就能问出来;它本来就会说的(红方块在左边),专门去问反而容易答错。所以默认用通用 focus,找标签、看线形时再换成针对性的。

同样的提问也发给了 Cosmos-Reason2-2B FP8。它在通用提示下红色目标 30/30 全对,直线 5 次都说是竖直的,还能认出木地板;但更容易顺着问题说:问线形时,40 张没有黑线的图里有 3 次说有黑线,另有 8 次描述里说没有、物体列表里却列了一条。

图 6 通用 focus 和针对性 focus 的对比(同一批 60 张实拍图)。

6.3 一段完整的代理对话

下面这段示意展示代理如何把检测层和语义层串成一个任务:先问 scene 拿语义,再切模式拿检测,最后触发动作并复核,全程都是话题驱动:

User: 看看桌上现在有什么,然后把红色方块朝桌子中间挪。

Agent: 我先让 OriginMan 看一下场景。
       [tool: ros_topic_pub /originman/cmd/reason "general"]
       [wait 1.5s]
       [tool: ros_topic_echo /originman/state --once]

       看到桌面左前方有一个红色方块(约 5cm,距相机约 30cm),
       桌面中央目前是空的。我先切到颜色追踪模式让 OriginMan
       的头部锁定目标。
       [tool: ros_topic_pub /originman/cmd/mode "color_track"]
       [wait 2s]
       [tool: ros_topic_echo /originman/detection_info --once]

       颜色追踪已锁定,X 偏差 12px 在死区内。现在触发推力
       动作组让 OriginMan 朝目标方向轻推。
       [tool: ros_topic_pub /originman/cmd/action "push_forward"]
       [wait 1s]
       [tool: ros_topic_pub /originman/cmd/reason "did the cube move toward center?"]
       [wait 1.5s]

       Reason 报告"红色方块已移动至桌面中央偏左,距中心约 8cm"。
       距离已大幅缩短,需要再推一次还是停在此处?

代理用到的工具收敛成五个:get_robot_state 一次拿到模式、scene 和硬件状态;switch_mode 切换模式;reason_about_scene 触发推理;read_detection 读最新检测结果;trigger_action 执行动作组。最常用的是第一个,一次就能拿全上下文,系统提示里也只需说明这五个工具,不用列出底层话题。

7. 实测:延迟、稳定性与失败模式

7.1 端到端延迟分解

端到端延迟是推理节点最先要看的指标。我们把从"相机拍到画面"到"代理读到语义"的链路拆开计时,数字来自 30 分钟长跑的 117 条请求,首 token 和生成速度来自 60 张图的流式重放:

阶段 实测耗时 备注
图像采集、JPEG 编码与三种传统检测(OriginMan 端) P50 67 ms / P95 79 ms 三种检测器合计
rosbridge 传输、ROS 调度与回调 P50 61 ms / P95 82 ms 端到端耗时减去 Thor 处理耗时
首 token:图像编码与 prefill(vLLM) 约 100 ms 流式重放测得
逐 token 生成 JSON(vLLM) 约 4.2 s 平均 231 个输出 token,eager 模式
JSON 校验与状态回写 P50 9 ms 桥接端严格校验
端到端 P50 4.39 s Thor 处理 P50 为 4.33 s
端到端 P95 5.61 s 最大 6.02 s

模型推理占了 98% 以上,其中又几乎都是逐个生成 token,首 token 只要 0.1 秒。要压延迟,就让模型少输出、输出快:开 CUDA Graph 从 5.00 秒降到 3.95 秒,精简输出降到 2.23 秒,换 Reason2-2B FP8 是 1.87 秒,降分辨率几乎没用。图像传的是 640×480 的 JPEG,模型之外的等待只有几十毫秒。

功耗方面,每 15 秒推理一次时,板级功耗在请求之间回落到 21~22 W,推理时冲到 60~69 W,平均 33.3 W,最高 79.7 W;结温 47~53 °C。单路推理远喂不饱 Thor。下一篇会专门测多模型并发:同时跑 Cosmos 3 Reasoner、RT-DETR 和 OpenClaw 网关,看延迟和功耗怎么变。

7.2 30 分钟长跑的稳定性

短时间跑通不代表长时间稳,端侧 VLM 常见的隐患是 KV cache 累积和 JSON 输出退化。长跑脚本每 15 秒触发一次推理,每 2 秒采一次 tegrastats(共 898 个样本),每 5 秒探一次 vLLM 健康接口(共 359 次),并逐条核对原图哈希、模型结果和机器人状态:

# Thor 端
cd ~/cosmos_validation
venv-cosmos3/bin/python scripts/run_live_validation.py 
  --out data/final-longrun --duration 1800 --interval 15 --hardware-monitor
指标 起始值 30 分钟末值 峰值 备注
系统内存(MB) 15930 16284 16284 tegrastats 报告值,含系统和其他进程
板级功耗 VIN(W) 55.98 21.71 79.65 30 分钟平均 33.27 W
结温 Tj(°C) 49.41 49.06 52.97
累计输入 tokens 0 71838 n/a vLLM 返回的 usage
累计输出 tokens 0 27076 n/a
JSON 解析失败次数 0 0 n/a 117/117 通过
健康检查失败次数 0 0 n/a 共探测 359 次
节点崩溃次数 0 0 n/a 模型与桥接进程 PID 前后一致

内存 30 分钟涨了 354 MB,主要是匿名页(AnonPages 增加约 343 MB)。服务已经限定单并发、关闭前缀缓存、KV cache 固定 1 GiB,空闲时 KV 使用率为 0,后续长时间运行会继续盯这条曲线。延迟前两分钟在 5.5 秒左右,之后稳定在 4~4.5 秒,偶有几条到 6 秒;JSON 解析零失败,117 条结果全部按同一帧写回状态,没有一条过期(图 7)。

图 7 30 分钟长跑曲线:单条耗时、系统内存、板级功耗和结温。功耗的每个尖峰对应一次推理。

7.3 五个典型失败案例

本地 VLM 也有失败的时候。下面是实测里最典型的五种,原图和原始输出见图 8:

    • 遮挡:红方块被纸挡住一部分,5 次都认出了它,但有 4 次说一共三个方块,还多列出一个蓝色方块。
    • 光照:调暗灯光后,模型的左右判断不受影响;原版颜色识别从 blue 变成了 green。
    • 认错物体:左红方块加右红鼠标,有一次输出成"红方程式键盘"和"黑色显示器背板"。地面场景里,模型也一直把木地板说成桌面。
    • AprilTag:从没说出 AprilTag,有一次把图案说成"数字 4 的缩写",但也从没猜过 ID。
    • 语言和尺寸:60 条里有 3 条描述整句写成英文;还有几条在文字里写了"约10厘米"这样的尺寸。

所以 VLM 的输出只能当作给代理的提示,落到舵机的动作都要有检测器或传感器的二次确认。

图 8 五种失败情况的实测样本,左边是相机原图,右边是 Cosmos3-Edge 的原始输出摘录。

8. 实测中遇到的八个坑

把坑写出来,比只讲顺利的部分更有用。下面八个问题按出现的先后排列,每个都说明现象、原因和解决办法。

第一个坑:升级 vLLM 后导入报 FlashInfer 版本不匹配。旧的 flashinfer-cubin 0.6.12 还留在虚拟环境里,而 vLLM 0.26 用的是 FlashInfer 0.6.14。直接卸掉 flashinfer-cubin 即可,0.26 已经不需要它;不要去装 flashinfer-cubin==0.6.14,这个版本不存在。

第二个坑:加载 Cosmos3-Edge 权重时报 k_norm_und_for_gen 参数错误。这是生成分支的参数,Reasoner 用不到,vLLM 0.31 已经忽略它,0.26 还没有。在 vllm/model_executor/models/cosmos3_edge.py 的忽略列表里,"_moe_gen": None, 下面补一行 "k_norm_und_for_gen": None, 就行。

第三个坑:GPU 预热时 Quack 报类型接口错误。Quack 0.5.0 和 CUTLASS DSL 4.6 不兼容,降 CUTLASS 又会和 vLLM 0.26 的依赖冲突。换成 Quack 0.6.1 配 CUTLASS DSL 4.6.0 即可。pip check 报的 cuSPARSELt 平台标签警告不影响运行。

第四个坑:模型在 null 字段前不停输出空白,直到撞上 token 上限。xgrammar 默认允许 JSON 里有任意空白,要在服务端的 --structured-outputs-config 里加 "disable_any_whitespace":true,只在请求里要求没用。

第五个坑:eager 模式下 CPU 成了瓶颈。生成时一个 CPU 核满载,Edge 的速度在 40 到 57 token/s 之间跳;Reason2-2B 更明显,每条要 19 秒,GPU 功耗只有 4 W 左右。去掉 --enforce-eager 打开 CUDA Graph,Edge 稳定在 68.5 token/s,Reason2 到 108 token/s,启动只多 25 秒。

第六个坑:原有视觉程序不能直接拿来做对照。color_detect.py 会初始化头部舵机,认出颜色后点头摇头;line_follower.py 会启动行走线程;AprilTag 节点还会把标签文字画在输出图上,模型看了等于直接读答案。我们把视觉计算函数单独抽出来,做成只拍照、只计算的静态节点。

第七个坑:出错后原样重试没用。temperature 为 0 时,同样的输入每次得到同样的输出。重试要带上上一次的输出和一句纠正,比如"所有字符串都用中文,不要写长度、尺寸或距离"。这样英文能改回中文(偶尔会变成繁体),但尺寸改不掉,模型照样写"十厘米",只能在后处理里用正则拦。

第八个坑:原版颜色识别在现场几乎选不中红色。有红色物体的各组都输出 blue,调暗灯光后又变成 green。离线重放发现,有的图里红色区域经过阈值和形态学处理只剩几十个像素,有的虽然形成了候选,面积却比蓝色小。要针对自己的相机和光照重新标定阈值,才能拿它给 VLM 做交叉验证。

序号 现象 首选排查命令 常见根因
1 导入报 FlashInfer 版本不匹配 pip list | grep -i flashinfer 遗留的 flashinfer-cubin 0.6.12
2 加载权重报 k_norm_und_for_gen 看 vLLM 启动日志里的权重加载报错 0.26 加载器没忽略生成分支参数
3 预热时 Quack 类型错误 pip show quack-kernels nvidia-cutlass-dsl Quack 0.5.0 与 CUTLASS 4.6 不兼容
4 输出空白直到截断 看响应的 finish_reason 是否为 length 服务端没设 disable_any_whitespace
5 生成速度低且跳变 top 看是否单核满载 eager 模式 CPU 受限
6 对照时机器人点头或走动 读 color_detect.py、line_follower.py 源码 原程序带舵机和行走线程
7 重试后输出不变 对比两次输出是否逐字一致 temperature 0 下原样重试
8 红色物体被识别成 blue 离线重放看各颜色掩膜的面积 LAB 阈值和现场光照不匹配

遇到问题时可以倒着查:先按现象找到对应的行,再用排查命令定位,最后看根因决定怎么修。

9. 结论

整套链路跑下来,结果可以分三条线看:

    • 延迟:端到端中位数 4.39 秒,开 CUDA Graph 降到 3.95 秒,精简输出能到 2.23 秒,足够代理按需调用。
    • 稳定性:30 分钟 117 条请求全部是合法 JSON,全部按同一帧写回状态,没有过期,也没有崩溃;平均功耗 33 W,结温不超过 53 °C,Thor 还有大量余量。
    • 语义增益:模型补上了检测器给不了的类别和空间关系,白纸上的红方块、左右两个方块都是 5/5;换成针对性的 focus,标签能认出 9/10,两组 Y 形分叉分别说对 5/5 和 4/5。

参考资料

1. NVIDIA Cosmos Reason 官方文档:https://docs.nvidia.com/cosmos/latest/reason2/index.html

2. nvidia-cosmos/cosmos-reason GitHub 仓库:https://github.com/nvidia-cosmos/cosmos-reason2

3. Cosmos-Reason1 论文(From Physical Common Sense to Embodied Reasoning):https://arxiv.org/abs/2503.15558

4. Cosmos Predict 3 论文(World Simulation with Video Foundation Models for Physical AI):https://arxiv.org/abs/2511.00062

5. Cosmos World Foundation Model Platform 论文:https://arxiv.org/html/2501.03575v3

6. NVIDIA 开发者博客 — Scale Synthetic Data and Physical AI Reasoning:https://developer.nvidia.com/blog/scale-synthetic-data-and-physical-ai-reasoning-with-nvidia-cosmos-world-foundation-models/

7. Jetson AI Lab — Cosmos Reason on Jetson 教程:https://www.jetson-ai-lab.com/tutorials/cosmos-reason2-vlm

8. Hugging Face Blog — Deploying Open Source VLMs on Jetson:https://huggingface.co/blog/nvidia/cosmos-on-jetson

9. NVIDIA 开发者博客 — Introducing NVIDIA Jetson Thor:https://developer.nvidia.com/blog/introducing-nvidia-jetson-thor-the-ultimate-platform-for-physical-ai

10. NVIDIA 博客 — Jetson Thor Unlocks Real-Time Reasoning for Robotics:https://blogs.nvidia.com/blog/jetson-thor-physical-ai-edge/

11. NVIDIA 投资者公告 — Blackwell-Powered Jetson Thor Now Available:https://investor.nvidia.com/news/press-release-details/2025/NVIDIA-Blackwell-Powered-Jetson-Thor-Now-Available-Accelerating-the-Age-of-General-Robotics/default.aspx

12. ADI Adopts NVIDIA Jetson Thor 公告:https://www.analog.com/en/newsroom/press-releases/2025/8-25-2025-adi-adopts-jetson-thor.html

13. ROS2 Humble 官方文档:https://docs.ros.org/en/humble/

14. vLLM 官方文档:https://docs.vllm.ai/en/latest/

15. RosClaw 仓库:https://github.com/PlaiPin/rosclaw

16. OpenClaw 文档:https://docs.openclaw.ai/

17. c-sharpcorner 对比文章 — What NVIDIA Cosmos Transfer, Predict, Reason and Isaac GR00T Are:https://www.c-sharpcorner.com/article/what-nvidia-cosmos-transfer-predict-reason-and-isaac-gr00t-are-and-how-to-use/

18. NVIDIA Cosmos 3 模型矩阵:https://docs.nvidia.com/cosmos/latest/cosmos3/model_matrix.html

19. Cosmos3-Edge 模型卡:https://huggingface.co/nvidia/Cosmos3-Edge

附录 A:bench_vllm.py 性能压测脚本

这个脚本压测 vLLM OpenAI 端点的真实性能。每次启动一种 (模型, 精度) 组合后跑一遍,得到 short / medium / long 三种 prompt 的 P50、P99 和 TPS。脚本默认输出到 ~/cosmos_ws/data/bench.csv,多次运行追加记录,便于横向对比。

#!/usr/bin/env python3
"""bench_vllm.py — 压测 Cosmos 3 Reasoner 在 Thor 上的 vLLM 推理性能。

使用:
  python3 bench_vllm.py --label 2b-fp16 --runs 20
"""
import argparse
import base64
import csv
import os
import statistics
import time

import cv2
import numpy as np
import requests

URL = os.environ.get('VLLM_URL', 'http://localhost:8000/v1/chat/completions')
MODEL = os.environ.get('VLLM_MODEL', 'cosmos3-edge')
SAMPLE = os.environ.get('SAMPLE_IMG', '/userdata/sample_table.jpg')

PROMPTS = [
    ('short', 'What is on the table? One sentence.'),
    ('medium', 'List all objects with rough position and approx distance in cm. JSON only.'),
    ('long', 'Describe the scene in detail, infer affordances, and propose three '
             'possible next actions for a desktop humanoid robot. Use chain-of-thought.'),
]


def encode_image(path: str) -> str:
    img = cv2.imread(path)
    if img is None:
        img = (np.random.rand(480, 640, 3) * 255).astype('uint8')
    ok, buf = cv2.imencode('.jpg', img, [cv2.IMWRITE_JPEG_QUALITY, 85])
    return base64.b64encode(buf.tobytes()).decode().replace('n', '')


def call_once(b64: str, prompt: str, max_tokens: int):
    payload = {
        'model': MODEL,
        'messages': [{'role': 'user', 'content': [
            {'type': 'text', 'text': prompt},
            {'type': 'image_url', 'image_url': {
                'url': f'data:image/jpeg;base64,{b64}'}},
        ]}],
        'max_tokens': max_tokens,
        'temperature': 0.2,
    }
    t0 = time.time()
    resp = requests.post(URL, json=payload, timeout=60)
    dt = (time.time() - t0) * 1000
    resp.raise_for_status()
    usage = resp.json().get('usage', {})
    return dt, usage.get('completion_tokens', 0), usage.get('total_tokens', 0)


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument('--label', required=True, help='例如 2b-fp16, 8b-fp8')
    ap.add_argument('--runs', type=int, default=20)
    ap.add_argument('--out', default='~/cosmos_ws/data/bench.csv')
    args = ap.parse_args()
    out = os.path.expanduser(args.out)
    os.makedirs(os.path.dirname(out), exist_ok=True)

    b64 = encode_image(SAMPLE)
    rows = []
    for tag, prompt in PROMPTS:
        max_tok = {'short': 64, 'medium': 256, 'long': 512}[tag]
        for _ in range(2):
            call_once(b64, prompt, max_tok)  # 预热两次
        lats, comp, total = [], [], []
        for _ in range(args.runs):
            dt, c, t = call_once(b64, prompt, max_tok)
            lats.append(dt); comp.append(c); total.append(t)
        p50 = statistics.median(lats)
        p99 = sorted(lats)[max(0, int(0.99 * len(lats)) - 1)]
        tps = (sum(comp) / (sum(lats) / 1000.0)) if sum(lats) else 0.0
        row = {
            'label': args.label, 'prompt': tag, 'runs': args.runs,
            'p50_ms': round(p50, 1), 'p99_ms': round(p99, 1),
            'tps_completion': round(tps, 1),
            'avg_completion_tokens': round(sum(comp) / len(comp), 1),
            'avg_total_tokens': round(sum(total) / len(total), 1),
        }
        rows.append(row)
        print(row)

    is_new = not os.path.exists(out)
    with open(out, 'a', newline='') as f:
        w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
        if is_new:
            w.writeheader()
        w.writerows(rows)
    print(f'wrote {len(rows)} rows -> {out}')


if __name__ == '__main__':
    main()

脚本里有几处设计细节值得展开。预热两次是因为 vLLM 首次请求会触发 KV cache 初始化和 PyTorch lazy compile,这两次耗时会比稳态高 2-3 倍,必须排除在统计之外。P99 用 sorted(lats)[int(0.99 * len(lats)) - 1] 而不是 numpy.percentile,是为了不引入额外依赖——20 次采样取 P99 在统计意义上不严谨,但在工程意义上够用。CSV 用 append 模式写入,多次运行会自动叠加,方便后续 summarize.py 一次性读所有历史记录出表。

附录 B:capture_pairs.py 对照采样脚本

这个脚本采集第 5 章对照表用的样本。它启动一个临时 ROS2 节点,依次切到指定的几个模式,每个模式触发 N 次按需推理,把当前帧、原始检测、Cosmos 输出三元组落到磁盘。

#!/usr/bin/env python3
"""capture_pairs.py — 采集第 5 章对照表所需的 (图, 原版检测, Cosmos 输出) 样本。

使用:
  python3 capture_pairs.py --modes apriltag color_detect line_follow --per-mode 5
"""
import argparse
import json
import os
import time

import cv2
import rclpy
from cv_bridge import CvBridge
from rclpy.node import Node
from sensor_msgs.msg import Image
from std_msgs.msg import String


class Capturer(Node):
    def __init__(self, modes, per_mode, outdir) -> None:
        super().__init__('cosmos_capturer')
        self.modes = modes
        self.per_mode = per_mode
        self.outdir = outdir
        os.makedirs(outdir, exist_ok=True)
        self.bridge = CvBridge()

        self.latest = {'img': None, 'det': '', 'desc': '', 'objs': '', 'adv': ''}
        self.create_subscription(Image, '/originman/camera/image_raw',
            lambda m: self._set('img', self.bridge.imgmsg_to_cv2(m, 'bgr8')), 1)
        self.create_subscription(String, '/originman/detection_info',
            lambda m: self._set('det', m.data), 10)
        self.create_subscription(String, '/originman/scene/description',
            lambda m: self._set('desc', m.data), 10)
        self.create_subscription(String, '/originman/scene/objects',
            lambda m: self._set('objs', m.data), 10)
        self.create_subscription(String, '/originman/scene/advice',
            lambda m: self._set('adv', m.data), 10)

        self.mode_pub = self.create_publisher(String, '/originman/cmd/mode', 10)
        self.reason_pub = self.create_publisher(String, '/originman/cmd/reason', 10)
        self.records = []

    def _set(self, key, value) -> None:
        self.latest[key] = value

    def _wait(self, sec: float) -> None:
        end = time.time() + sec
        while time.time() < end and rclpy.ok():
            rclpy.spin_once(self, timeout_sec=0.05)

    def _snapshot(self, mode: str, idx: int) -> None:
        img = self.latest['img']
        if img is None:
            self.get_logger().warn('no image yet')
            return
        ts = int(time.time() * 1000)
        img_path = os.path.join(self.outdir, f'{mode}_{idx:02d}_{ts}.jpg')
        cv2.imwrite(img_path, img)
        self.records.append({
            'mode': mode, 'idx': idx, 'ts_ms': ts, 'image': img_path,
            'raw_detection': self.latest['det'],
            'cosmos_description': self.latest['desc'],
            'cosmos_objects': self.latest['objs'],
            'cosmos_advice': self.latest['adv'],
        })
        self.get_logger().info(f'captured {mode}#{idx}')

    def run(self) -> None:
        for mode in self.modes:
            self.get_logger().info(f'switching to {mode}')
            self.mode_pub.publish(String(data=mode))
            self._wait(4.0)
            for i in range(self.per_mode):
                self.reason_pub.publish(String(data=mode))
                self._wait(2.5)
                self._snapshot(mode, i)
        self.mode_pub.publish(String(data='stop'))
        self._wait(2.0)
        out = os.path.join(self.outdir, f'pairs_{int(time.time())}.json')
        with open(out, 'w') as f:
            json.dump(self.records, f, ensure_ascii=False, indent=2)
        self.get_logger().info(f'wrote {len(self.records)} records -> {out}')


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument('--modes', nargs='+',
                    default=['apriltag', 'color_detect', 'line_follow'])
    ap.add_argument('--per-mode', type=int, default=5)
    ap.add_argument('--outdir', default='~/cosmos_ws/data/pairs')
    args = ap.parse_args()
    rclpy.init()
    cap = Capturer(args.modes, args.per_mode, os.path.expanduser(args.outdir))
    try:
        cap.run()
    finally:
        cap.destroy_node()
        rclpy.shutdown()


if __name__ == '__main__':
    main()

脚本里几个关键决策。模式切换之后等 4 秒再采,这是给 OriginMan 视觉模块足够的稳定时间——第一帧检测可能还没出来,舵机也可能还在动。每次按需推理之后等 2.5 秒,是给 cosmos_reason_node 完成推理并 publish 结果的时间窗口。整套脚本不依赖任何 ROS2 service,全部走话题——和正文一贯的话题驱动哲学一致,也让脚本可以被任何工作站远程触发,不需要本地 ROS2 上下文。

附录 C:longrun_monitor.sh 长跑监控脚本

这个 shell 脚本同时采两路数据:tegrastats 系统指标和 vLLM Prometheus 端点的内部状态,每 5 秒一行连续采集。脚本默认跑 1800 秒(30 分钟),可以通过参数调整。所有产物落到带时间戳的目录里,便于多次实验留档。

#!/usr/bin/env bash
# longrun_monitor.sh — Thor + vLLM 双路长跑采样器
# 使用:./longrun_monitor.sh 1800   # 跑 1800 秒
set -u
DUR=${1:-1800}
OUT=~/cosmos_ws/data/longrun_$(date +%Y%m%d_%H%M%S)
mkdir -p "$OUT"

# 后台跑 tegrastats,输出系统层指标
tegrastats --interval 5000 --logfile "$OUT/tegrastats.log" &
TPID=$!
trap 'kill $TPID 2>/dev/null || true' EXIT

# 主循环采 vLLM /metrics(若启用了 Prometheus 端点)
# 没启用时退化为 /v1/models 探活
echo "ts,vllm_alive,prompt_tokens_total,gen_tokens_total,running,waiting" 
  > "$OUT/vllm.csv"

end=$(( $(date +%s) + DUR ))
while [ "$(date +%s)" -lt "$end" ]; do
  ts=$(date +%s)
  alive=0; pt=0; gt=0; run=0; wait_=0
  if metrics=$(curl -fs --max-time 2 http://localhost:8000/metrics 2>/dev/null); then
    alive=1
    pt=$(echo  "$metrics" | awk '/^vllm:prompt_tokens_total/     {print $2; exit}')
    gt=$(echo  "$metrics" | awk '/^vllm:generation_tokens_total/ {print $2; exit}')
    run=$(echo "$metrics" | awk '/^vllm:num_requests_running/    {print $2; exit}')
    wait_=$(echo "$metrics" | awk '/^vllm:num_requests_waiting/  {print $2; exit}')
  elif curl -fs --max-time 2 http://localhost:8000/v1/models >/dev/null 2>&1; then
    alive=1
  fi
  echo "$ts,$alive,${pt:-0},${gt:-0},${run:-0},${wait_:-0}" >> "$OUT/vllm.csv"
  sleep 5
done

echo "[done] artifacts in $OUT"
ls -lh "$OUT"

跑完之后 tegrastats.log 是文本,每行长得像 RAM 5234/65536MB ... GR3D_FREQ 32% ... VDD_IN 38W 这样的拼接格式,可以用 awk 拆出具体字段画曲线。vllm.csv 是规范 CSV,可以直接喂给 pandas 或者 Excel 看 token 累计、并发数和存活率。把这两路数据合在一起,能完整重现 30 分钟里发生的事情:什么时候 GPU 利用率上升、什么时候显存出现跳变、有没有出现 vLLM 短暂不响应。这是排查"为什么长跑某次会异常"的核心证据来源。

附录 D:summarize.py 一键出表脚本

最后一个脚本把前三个附录采集的数据汇总成第 3 章和第 5 章里要替换的 Markdown 表格,省去手工搬运。这个脚本运行一次就能把 bench.csv 和 pairs_*.json 都解析完,输出可以直接粘贴到正文里替换 <!-- TODO --> 占位的 Markdown 片段。

#!/usr/bin/env python3
"""summarize.py — 把 bench.csv 和 pairs_*.json 转成可粘贴的 Markdown 表。

使用:
  python3 summarize.py > tables.md
"""
import argparse
import csv
import glob
import json
import os
from collections import defaultdict


def md_bench(path: str) -> None:
    rows = list(csv.DictReader(open(os.path.expanduser(path))))
    if not rows:
        print('# (no bench data)')
        return
    cols = ['label', 'prompt', 'runs', 'p50_ms', 'p99_ms',
            'tps_completion', 'avg_completion_tokens']
    print('### 第 3 章 vLLM 基准结果(自动生成)n')
    print('| ' + ' | '.join(cols) + ' |')
    print('|' + '|'.join(['---'] * len(cols)) + '|')
    for r in rows:
        print('| ' + ' | '.join(str(r.get(k, '')) for k in cols) + ' |')


def md_pairs(globpat: str) -> None:
    files = sorted(glob.glob(os.path.expanduser(globpat)))
    if not files:
        print('n# (no pairs)')
        return
    records = []
    for fp in files:
        records.extend(json.load(open(fp)))
    by_mode = defaultdict(list)
    for r in records:
        by_mode[r['mode']].append(r)

    print('n### 第 5 章 语义增益对比(每模式挑一组)n')
    print('| 模式 | 原版输出(raw_detection) | Cosmos Reason 增益输出 |')
    print('| --- | --- | --- |')
    for mode, rs in by_mode.items():
        r = rs[0]
        raw = (r.get('raw_detection') or '').replace('n', ' ')[:80]
        desc = (r.get('cosmos_description') or '')[:120]
        adv = (r.get('cosmos_advice') or '')[:80]
        print(f'| `{mode}` | `{raw}` | {desc}<br>**建议**:{adv} |')


if __name__ == '__main__':
    ap = argparse.ArgumentParser()
    ap.add_argument('--bench', default='~/cosmos_ws/data/bench.csv')
    ap.add_argument('--pairs', default='~/cosmos_ws/data/pairs/pairs_*.json')
    a = ap.parse_args()
    md_bench(a.bench)
    md_pairs(a.pairs)

跑法是 python3 summarize.py > tables.md,然后打开 tables.md 把里头的两段 Markdown 复制到正文里替换 <!-- TODO --> 占位即可。这套"采集脚本加 summarize 脚本"的工具链让正文不需要等所有真机数据就能定稿,数据回来后 5 分钟内能把所有占位填满。


本文是"OriginMan + Thor + Cosmos"系列文档的第二篇,配套代码与脚本可在 ~/cosmos_ws/ 工作区一键复现。下一篇将展开 Thor 多模型并发实测。

英伟达

英伟达

NVIDIA(中国大陆译名:英伟达,港台译名:辉达),成立于1993年,是一家美国跨国科技公司,英伟达主要提供GPU、SoC、边缘AI计算平台,核心产品/技术包括Jetson Orin;在低空经济产业链中覆盖计算与控制芯片。

NVIDIA(中国大陆译名:英伟达,港台译名:辉达),成立于1993年,是一家美国跨国科技公司,英伟达主要提供GPU、SoC、边缘AI计算平台,核心产品/技术包括Jetson Orin;在低空经济产业链中覆盖计算与控制芯片。收起

查看更多

相关推荐