☰
Code as Policies:用大语言模型生成机器人控制代码的实践与踩坑
2026/10/29 23:33:03 网站建设 项目流程

1. 从"用语言控制机器人"到"让模型写控制代码"

先说一个很多人在接触CaP之前都会有的误区:以为Code as Policies就是"用自然语言给机器人下指令",比如跟机器人说一句"帮我把桌上那个红色杯子拿过来",然后它就能动起来。

差的有点远。

CaP(Code as Policies)的核心不是"模型直接听懂人话",而是让大语言模型以代码的形式输出机器人策略。输出不是一段文本、不是一串坐标、不是SLAM导航点,而是一段可以直接运行的Python控制程序。这句话才是整个方法的灵魂。

为什么这件事值得做?因为在LLM出现之后,不少研究机构和工程师都尝试让语言模型直接输出机器人控制信号,比如输出关节角、末端位姿、速度指令这类数值。但你真做了就会发现问题:机器人控制本质上是组合性的,一个稍微复杂的任务——比如"把笔转到朝右,推给对面的人"——根本不是一句两句话能描述清楚的,也不是输出一个动作能搞定的。它牵扯到多个子动作的排序、条件判断、异常分支、坐标变换,这些东西用自然语言写出来,模型根本答不准。

但如果让模型把这些逻辑写成Python函数,情况立刻变了。代码本身就是一种天然的"组合指令集",天然支持条件分支、循环、函数调用、数学运算,更重要的是,代码可以直接在仿真器和真实机器人上跑起来验证对错。这就像让一个人给你指路,和让这个人直接在你手机上把导航路线设置好,效率和准确性完全不是一个量级。

这篇文章,我想把我的实操经验和理解拆开揉碎讲清楚:CaP的模型原理、双阶段生成逻辑、代码库prompting的设计方式、视觉和力觉等多模态接入方法,以及在真实机械臂和移动机器人上落地时的踩坑经验。前前后后折腾了大概三个月,踩过的坑不少,写出来给你省点时间。

2. 双阶段代码生成:为什么"直接写完整程序"会翻车

2.1 一次性生成的痛点

如果你用过ChatGPT或者Claude,大概体验过它"想当然"的毛病。让它写一段机器人控制的完整代码,如果任务本身比较简单——比如"机械臂沿X轴移动10厘米"——它通常能写对。但任务一旦叠加了空间推理就麻烦了,比如"推一个物体到某个位置,同时绕开旁边的障碍物",模型生成的代码往往看似合理,运行起来却漏洞百出。

我在测试中就遇到过一次非常典型的翻车:让模型生成代码去控制UR5e把桌面上一个圆柱体从A点推到B点,同时绕过中间的杯子。模型"一口气"写出了一整段三十行的函数,里面又是算直线路径又是判断相交,逻辑看起来头头是道。结果放到仿真器里跑,第一步就报错了——因为模型自己定义了一个calculate_avoidance_waypoint()函数,但忘了实现它,直接调用了。

这就是一次性生成的通病:复杂的控制逻辑一旦全部塞进一个生成过程中,模型既要管整体策略、又要管底层几何计算,输出的程序在"策略层"和"实现层"之间很容易脱节。

2.2 第一次生成:只写高层规划,不涉及具体实现

CaP论文里给了一个非常优雅的解法——把生成过程拆成两层。

第一层,模型只负责生成高层策略函数。这个阶段不写任何具体的运动学计算、不调用底层硬件接口,只描述"要做什么动作序列",并且把这些动作封装成一个个没有实现的函数调用。

还是用上面推圆柱体的例子。第一层生成出来可能是这样的:

def push_cylinder_around_obstacle(obj_pos, obs_pos, target_pos): pre_push_pose = compute_pre_push_pose(obj_pos, obs_pos) push_direction = compute_push_direction(obj_pos, target_pos) move_to_pose(pre_push_pose) push_object(push_direction, push_distance=0.15) return True

注意,这里的compute_pre_push_pose、compute_push_direction、move_to_pose、push_object全都没有具体实现,只是一堆"待填空"的函数名。但从整个策略上看,逻辑是对的:先算一个预推位姿、再算推动方向、最后执行推的动作。

这一步的价值在于:把任务分解成清晰的子步骤。模型不需要在这个阶段操心坐标变换怎么写、路径规划怎么算,只需要把"顺序"和"依赖"搞对。

2.3 第二次生成:把高层函数逐个补全

第二层,模型拿到第一层的高层代码,为其中每一个未实现的"待定函数"单独生成具体实现。这个过程还是用LLM来做,而且是一次一个函数逐个补齐。

比如对这个函数:

def compute_push_direction(obj_pos, target_pos): ...

第二次生成会输出类似:

def compute_push_direction(obj_pos, target_pos): direction_vector = np.array(target_pos) - np.array(obj_pos) return direction_vector / np.linalg.norm(direction_vector)

底层实现就具体到这种程度,有数学计算、有向量归一化,是能直接跑的东西。

你可能觉得这没啥稀奇的,不就是把大任务拆成小任务吗?但这里有个微妙且重要的点:第一层决定了"任务结构对不对",第二层决定了"底层计算对不对",两层分别校验、互不干扰。如果一次性生成,策略结构错了,你根本没法判断是任务分解错了还是运动学算错了;分层之后,每层都能单独检查,调试成本大幅下降。

我在实际使用中的体会是:分层生成带来的提升在复杂任务上尤其明显。论文里PaLM这类540B参数级别的模型,配合双阶段生成,在模拟器任务上成功率能比直接一次生成高出不少;而像Llama-2这种相对轻量的模型,双阶段更是"能不能跑通"和"完全跑不通"的区别。

3. 让模型"会调用API":代码库Prompting是CaP的隐形功臣

3.1 机器人不能只靠"全能"模型,还要给模型一根拐杖

如果只是做双阶段生成,这个方法的想象力还是有限的。真正的难点在于:LLM再怎么强,它也不可能凭空知道你的机器人有哪些可用的API、机械臂的运动范围是多少、夹具的默认开合状态是什么。

这个问题的解法,就是CaP里面非常关键的一块——代码库(code library)prompting。

简单来说就是:在调用LLM生成代码之前,把当前机器人所有可控的底层控制原语(primitives)整理成一个函数库,然后把函数签名和docstring拼到prompt里喂给模型。模型看到了这个函数库,才知道自己能调用哪些"积木"。

我这里给一个典型的函数库示例,UR5e机械臂场景:

def move_to_pose(x, y, z, roll=0, pitch=0, yaw=0, speed=0.2): """机械臂末端以指定速度和姿态移动到世界坐标系下的目标位姿。 Args: x, y, z: 目标位置(米),世界坐标系 roll, pitch, yaw: 末端姿态(弧度),默认水平向下 speed: 插值移动速度,单位 m/s """ ...
def get_object_position(color_name): """通过RGB相机寻找指定颜色的物体,返回其在世界坐标系下的位置 [x, y, z]。 如果没有检测到该物体,返回 None。 """ ...
def is_gripper_closed(): """判断机械臂夹爪当前是否完全闭合。夹爪正在运动中时返回 False。""" ...

你会发现每一个函数都遵循一个原则:接口命名非常直白,docstring把输入、输出和行为约束写清楚。LLM就能像人一样"读"函数库,然后在创作策略代码时自然地调用它们。

3.2 为什么这个函数库不能省、不能乱

我做过的几个项目里,有一个特别明显的对比:第一次我自己图省事,没整理函数库,直接让模型"帮我写个策略,移动机械臂去抓桌上的红色方块"。模型确实生成了代码,但它调用的很多函数都是自己编的(比如control_arm_joints()),根端口名称对不上,根本没法复用。后来花了半小时把函数库梳理清楚再试,模型生成的代码就“靠谱”了很多,因为它不需要猜了,函数名都是现成的。

所以在用CaP做实际任务的时候,花半小时把底层控制API封装整理好,比什么都重要。函数库的设计有四个关键点:

  • 函数粒度要适中。太细(比如控制每个关节转动0.01弧度)会让LLM的注意力耗费在低级运动上;太粗(比如一个函数就把"抓取+放置+返回"全干了)又会把逻辑写死,模型没有灵活组合的空间。我一般以"一个语义完整的动作"为单位,比如move_to_pose、grasp、release、get_object_position。
  • docstring越详细,模型表现越好。这一点怎么强调都不为过,尤其是在涉及坐标系、单位、返回值格式这些容易产生歧义的地方。你不写清楚"位置是世界坐标系还是基座坐标系",模型就可能拿不准。
  • 函数返回值类型要明确。如果get_object_position有时返回list、有时返回tuple,有时还返回字符串格式的坐标,那LLM在后续做数学运算时一定会蒙圈。
  • 函数名称尽量带明显语义。不需要追求极其简短的命名,更像人在代码里读到就能理解的动作短语,比如turn_off_learning_from_demonstrations()这种带上下文的命名,模型在策略编排时正确调用的概率明显更高。

3.3 代码库prompting的进阶用法:自动选择相关函数

随着函数库越来越大——我做过的一个导航项目里有差不多40个可用函数——如果把整个函数库全部塞进prompt,既浪费token,还可能让模型在做任务时受到无关函数的干扰。

CaP论文里提到的办法是做一个轻量的"函数选择"步骤:先根据任务描述(比如"用机械臂把笔拨转向右"),让LLM从大函数库里挑选出最相关的那些函数,只把这部分函数签名和docstring拼进后续生成代码的prompt里。

实操上我试过两种做法,效果都不错:

  1. 直接把"任务描述"和完整函数列表发给LLM,让它输出"选中的函数名列表"(JSON格式即可)。
  2. 稍微进阶一点,先用embedding把函数名和任务描述向量化,做一次粗筛,把候选集缩到15个以内,再让LLM精确选择。

第二种做法在函数库超过50个的时候,能显著减少LLM"看漏"常用函数的概率。如果你的场景函数很多,建议优先考虑。

4. 多模态观测接入:把视觉和力觉翻译成模型能用的"数字"

4.1 从像素到位置的"数据翻译层"

很多做机器人控制的朋友第一次接触CaP都会有同一个疑问:LLM又不长眼睛,它怎么知道桌面上有什么?怎么知道杯子在哪?

答案其实不玄妙——CaP的系统里,视觉感知不是LLM直接做的事,而是通过"感知函数"封装好,把结果以数值或字符串的形式喂给模型。

比如上文函数库里的get_object_position(color_name),它在代码生成计划里被LLM当成一个"黑盒"来调用。实际问题发生在调用之后返回什么。如果底层是用OpenCV+YOLO做物体检测,再接一个RGB-D相机深度图把像素坐标转成世界坐标,最后返回一个[x, y, z]的数值数组,那模型拿到这个数值,后续做向量计算、路径规划就没有任何障碍。

这里有一个小细节很容易被忽略:CaP生成的程序在运行时,感知函数的返回值是真实数据;但在LLM生成代码的那一刻,它对这个函数的认知完全来自docstring。所以你对感知函数的docstring描述必须精准。我之前吃过一个亏:我的get_object_position函数底层在检测不到物体时返回的是None,但docstring里没有写清楚,结果模型生成的后续代码直接拿返回值做减法,运行时报了TypeError,整个动作就断在那里。

4.2 视觉也可以反向处理:LLM在代码里"等一下"字符串

除了把视觉输出数值化,还有一种用法是让视觉模型的输出以自然语言字符串的形式进入策略代码。比如给定一个场景,模型可能写出下面这样的代码:

def pick_green_object(): red_pose = get_object_pose_by_color("green") ...

或者更复杂的场景,比如判断"哪个杯子离海绵最近":

def pick_cup_nearest_to_sponge(): cup_poses = get_all_cup_poses() sponge_pose = get_object_pose("sponge") nearest_cup = None min_dist = float("inf") for cup in cup_poses: dist = compute_distance(cup, sponge_pose) if dist < min_dist: min_dist = dist nearest_cup = cup move_to_pose(nearest_cup)

这类"属性比较""空间距离计算"的任务,恰好是LLM用代码写得最顺手的场景。因为代码里明确的循环、比较逻辑完全不会让模型在"语言序列"层面出岔子。

4.3 力觉、深度信息的扩展

CaP这套范式不仅仅能用于视觉。UR5e实验里就有用力传感器做反馈的场景。思路是一样的:把力传感器读取封装成一个函数get_contact_force(),返回三维力向量;把深度相机数据封装成get_depth_at_point(x, y)。然后模型生成的策略代码里完全可以出现这样的逻辑:

def push_until_contact(direction): while norm(get_contact_force()) < 2.0: # 单位牛顿 move_in_direction(direction, step=0.01)

你会发现这其实就是在用Python写一个简单的力控循环。传统上这种东西你可能需要写专门的状态机或者用ROS的actionlib来做,但CaP让LLM直接生成,逻辑透明、可读性好、还容易调试。对我来说,这是这套方案特别有吸引力的地方。

5. 从仿真到真机:我在UR5e和移动机器人上的落地经验

5.1 环境准备与实际平台拓扑

先说我的实验环境,照着CaP论文的模式搭的:

  • 机械臂:UR5e,6自由度,末端配RobotiQ 2F-85二指夹爪
  • 视觉:一个RGB-D深度相机(RealSense D435i),固定安装,俯视视角覆盖工作台面
  • 移动机器人:四轮差速平台的仿真环境(基于Gazebo)
  • 算法平台:Python 3.9 + PyTorch,LLM推理基于云端API和本地部署模型两种方式

这套环境最核心的一件事,是把机器人的原生控制封装成"LLM友好的Python函数"。比如UR5e原本的控制接口是基于ROS话题和服务调用的,raw形式非常底层,直接丢给LLM会让它一脸懵。所以封装是必经之路,而且封装过程里有一些细节值得拿出来单独说。

5.2 坐标系的坑:世界坐标系和相机坐标系必须统一

这是我踩过最痛的一个坑。

第一次跑通端到端demo,机械臂抓取动作死活差5到8厘米。排查了很久才发现:视觉模块(get_object_position)返回的是相机坐标系下的坐标,而move_to_pose用的move_group期望的是机器人基座坐标系。感知函数和运动函数坐标系都不一致,代码无论怎么生成,结果肯定是歪的。

解决方法是加一个坐标系变换层,用相机标定得到的变换矩阵,把像素-相机坐标先转换到机器人基座坐标,再喂给上层。这一步在你自己的项目里,建议在函数库里就设计好,让所有感知函数统一返回"目标坐标系就是执行坐标系",省得后面反复调试。

5.3 Python环境与运行时的"沙箱"问题

大模型生成的Python代码,直接拿去控制真机,这是一个很危险的举动——不是危言耸听。我的做法是套了一层"安全执行壳":

  • 所有生成的代码在一个独立命名空间里执行,不暴露系统级模块
  • 通过白名单控制代码可以调用的函数,仅限机器人API和标准库(如math、numpy的部分函数)
  • 执行前用AST静态扫描一遍代码,检查有没有import os、import socket这一类越权操作
  • 对move_to_pose这类运动指令,在执行前加一层笛卡尔空间的运动范围检查和奇异位形检查,越界直接拒绝执行

这个"沙箱"层在CaP正常工作的时候几乎不会暴露存在感,但一旦LLM生成了"离谱"代码,它能替你挡住大问题。

我见过有人图省事,直接把LLM输出的代码拼到主进程里exec()执行,然后机械臂猛冲了一下。这种场景真的不是技术问题,是安全问题。机器人是物理设备,代码审查这关决不能省。

5.4 仿真先行,真机第二

整个CaP流程里,我最建议的落地顺序是:

  1. 在仿真环境里跑通整体链路,验证策略逻辑
  2. 用仿真里的单元测试来验证"生成的控制程序不崩溃、能完成主要动作序列"
  3. 再切换到真机,但真机上第一次运行前,把核心运动指令逐条单步执行,确认每个位姿都安全

论文里的UR5e实验也是先在模拟器的导航任务上做验证,再上真机做抓取、推搡、重排这类任务。真机环境中有一个案例印象很深刻——机械臂把笔拨转90度再推动,这个任务看似简单,但代码里涉及到位姿估算和坐标变换,一旦某个环节出错,机械臂就可能碰到旁边的东西。仿真里跑了上百次没问题,真机上由于相机标定的残余误差,第一次执行还是差点蹭到旁边的杯子,所以真实环境里的安全复测绝对不能省。

6. 模型选择与自验证:轻量模型能不能跑CaP

6.1 大模型不是唯一答案,但模型能力确实影响上限

CaP原论文对比了不少模型的表现,比如PaLM 540B、Codex、以及Llama系列。结论基本符合直觉——模型在代码生成上的能力越强,CaP的成功率越高。Codex这类专精代码生成的模型在CaP场景下表现非常好;等尺寸的Llama模型在代码生成方面则明显弱一些,但也不是完全不能用。

我个人的实测感受是:如果你做的是比较简单的策略(比如单步移动、夹爪开闭),一个70亿参数左右的模型经过适当prompt也能跑通;但任务一旦落到"多物体属相比较、位姿估算、复杂推动序列"这个级别,就建议直接用Codex或Claude这类代码能力强的模型,或者用专门微调过的代码大模型。否则你会花大量时间在"纠正模型生成的代码Bug"上,而不是在"执行任务"上。

6.2 自验证与单元测试:让程序自己先过一遍"体检"

CaP还有一个很关键的设计——在生成的策略代码中插入单元测试。这不是额外的工作,而是让LLM在生成代码时就同时给出验证方式。

比如模型要生成"移动滑块向左3个单位"的策略,它可以自测:

def move_slider(direction, units): # 真实控制代码... return final_position slider_pos = move_slider("left", 3) assert slider_pos == expected_pos, f"Slider moved to {slider_pos}, expected {expected_pos}"

通过assert或者OpenCV对图像位置做检查,让程序在正式控制真实机器人之前,先在仿真或者脚本层面跑一遍自我验证。CaP论文把这个过程称为self-verification,它最直接的作用是:生成的控制程序如果逻辑有问题,在这个阶段就能暴露出来,而不是等到机器人真动了才发现。

我自己在实际项目里,会把自验证放在沙箱执行之后的第二步。生成的代码先进沙箱跑仿真版,同时跑自身携带的assert验证,两项都过了,才允许切换到真机设备。

7. 关于防碰撞和失败恢复:CaP怎么应对"意外"

7.1 与其事后防碰撞,不如让模型一开始就想好"膨胀层"

CaP最有意思的一个应用场景是防碰撞策略。论文中介绍了一个很实用的技巧:给障碍物定义一个"可变形膨胀层"。什么意思?就是机械臂的路径不是直接贴着障碍物的边界走,而是把障碍物在编程层面往外"膨胀"一圈,让机械臂从膨胀后的边界之外走。

在代码里这个逻辑并不复杂:

def is_pose_safe(pose, obstacles, safety_margin=0.05): for obs in obstacles: if distance(pose, obs['center']) < obs['radius'] + safety_margin: return False return True

你会发现这段代码完全是LLM的舒适区:语义清晰、数学简单、规则明确。让LLM自然地把这种安全逻辑嵌入到策略代码里,比人在状态机里手工写一个防碰撞分支要灵活得多。

7.2 失败恢复:让程序"看到"问题后自己调整

还有一个让我印象深刻的例子来自所谓的"失败恢复"任务。比如机械臂试图抓取螺丝刀,第一次抓取失败——夹爪合拢但没能夹住。传统的机器人流程是写死一个"重试动作";而CaP生成的代码里可能出现类似这样的逻辑:

def grasp_screwdriver_with_retry(): for attempt in range(3): grasp_object("screwdriver") if not is_gripper_closed(): # 夹爪未闭合,说明没抓住,稍微偏移一点重新抓 move_relative([0.01 * attempt, 0, 0]) else: break assert is_gripper_closed(), "Failed to grasp screwdriver after 3 attempts"

夹爪没合住就自动微调位置再试,这就是失败恢复。关键是这个"失败恢复"不是预先写死的某个固定动作,而是LLM根据"夹爪是否闭合"这一反馈信号当场生成的逻辑。这种写策略的灵活度,传统预编程手段很难做到。

从我的经验看,CaP在"需要临时调整动作序列"的任务上是最能拉开差距的。因为代码本身天然具备条件分支和循环,模型不需要费力去"想象"意外情况,直接把判断和循环写进去就行了。

8. 给你的上手路线图:三个星期从零跑通CaP

最后整理一个我自己的上手路线,给想动手试CaP的朋友做个参考。

第一周,先把Python环境和底层API封装搞定。装好Python、配置好ROS2环境、把机械臂或移动机器人的基础控制封装成第一批函数(move_to_pose、grasp、release、get_object_position)。这一周结束的标准是:你能对着Python的REPL环境,手动调用这些函数控制机器人完成一个简单动作序列。

第二周,接入大模型,跑通"自然语言到代码"的最小闭环。选一个熟悉的任务(比如"把红色方块从A移到B"),用双阶段生成+函数库prompting让模型写出Python代码,先在仿真里跑,验证逻辑。这一周你会踩大量坐标、文档字符串、返回值坑,别灰心,都在预期内。

第三周,上真机。第一天上真机只跑"检查函数库与坐标系"这一件事;第二天才尝试让模型生成策略代码,并加上AST扫描、运动范围检查、单元测试三重验证。顺利的话,到第三周末你可以稳定复现两三个场景任务。

有一个建议很重要:如果条件有限,不一定非得用UR5e这种工业机械臂。先在一个2D仿真环境或者你手头任意支持Python控制的机器人平台上把CaP的链路打通,原理是完全一样的。这就像学Python不需要先买一台超算一样,重点是先把"模型生成代码-代码控制设备"这条链路走通。

关于模型部署,如果你想用本地大模型控制机器人,用Ollama这类工具把一个大模型拉下来做推理是完全可行的,而且对CaP这种场景有个额外的好处——代码生成任务的实时性要求并不极端,本地部署的延迟完全可接受,还不用担心数据出内网。轮到代码能力这块就要权衡了,若你的任务复杂,本地小模型生成质量不够,依然建议混合使用云端代码模型做第二层补全。

就我自己这三个月跑下来的感觉,CaP真正改变的不只是"机器人控制"这件事,而是让“让机器人干活”这件事的门槛,第一次从"懂机器人学+懂控制+懂编程"收敛到了"懂编程+会给机器封装API"。这个趋势后面还会继续,但至少现在,它已经是一套值得你花时间亲手试一遍的技术了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询