尧图精选

Code as Policies:用大语言模型生成机器人控制代码的实践与踩坑

🕒 发布时间:2026/9/9 3:57:40 📁 来源:尧图网络
1. 从用语言控制机器人到让模型写控制代码先说一个很多人在接触CaP之前都会有的误区以为Code as Policies就是用自然语言给机器人下指令比如跟机器人说一句帮我把桌上那个红色杯子拿过来然后它就能动起来。差的有点远。CaPCode 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_distance0.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 libraryprompting。简单来说就是在调用LLM生成代码之前把当前机器人所有可控的底层控制原语primitives整理成一个函数库然后把函数签名和docstring拼到prompt里喂给模型。模型看到了这个函数库才知道自己能调用哪些积木。我这里给一个典型的函数库示例UR5e机械臂场景def move_to_pose(x, y, z, roll0, pitch0, yaw0, speed0.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里。实操上我试过两种做法效果都不错直接把任务描述和完整函数列表发给LLM让它输出选中的函数名列表JSON格式即可。稍微进阶一点先用embedding把函数名和任务描述向量化做一次粗筛把候选集缩到15个以内再让LLM精确选择。第二种做法在函数库超过50个的时候能显著减少LLM看漏常用函数的概率。如果你的场景函数很多建议优先考虑。4. 多模态观测接入把视觉和力觉翻译成模型能用的数字4.1 从像素到位置的数据翻译层很多做机器人控制的朋友第一次接触CaP都会有同一个疑问LLM又不长眼睛它怎么知道桌面上有什么怎么知道杯子在哪答案其实不玄妙——CaP的系统里视觉感知不是LLM直接做的事而是通过感知函数封装好把结果以数值或字符串的形式喂给模型。比如上文函数库里的get_object_position(color_name)它在代码生成计划里被LLM当成一个黑盒来调用。实际问题发生在调用之后返回什么。如果底层是用OpenCVYOLO做物体检测再接一个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, step0.01)你会发现这其实就是在用Python写一个简单的力控循环。传统上这种东西你可能需要写专门的状态机或者用ROS的actionlib来做但CaP让LLM直接生成逻辑透明、可读性好、还容易调试。对我来说这是这套方案特别有吸引力的地方。5. 从仿真到真机我在UR5e和移动机器人上的落地经验5.1 环境准备与实际平台拓扑先说我的实验环境照着CaP论文的模式搭的机械臂UR5e6自由度末端配RobotiQ 2F-85二指夹爪视觉一个RGB-D深度相机RealSense D435i固定安装俯视视角覆盖工作台面移动机器人四轮差速平台的仿真环境基于Gazebo算法平台Python 3.9 PyTorchLLM推理基于云端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流程里我最建议的落地顺序是在仿真环境里跑通整体链路验证策略逻辑用仿真里的单元测试来验证生成的控制程序不崩溃、能完成主要动作序列再切换到真机但真机上第一次运行前把核心运动指令逐条单步执行确认每个位姿都安全论文里的UR5e实验也是先在模拟器的导航任务上做验证再上真机做抓取、推搡、重排这类任务。真机环境中有一个案例印象很深刻——机械臂把笔拨转90度再推动这个任务看似简单但代码里涉及到位姿估算和坐标变换一旦某个环节出错机械臂就可能碰到旁边的东西。仿真里跑了上百次没问题真机上由于相机标定的残余误差第一次执行还是差点蹭到旁边的杯子所以真实环境里的安全复测绝对不能省。6. 模型选择与自验证轻量模型能不能跑CaP6.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, fSlider 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_margin0.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。这个趋势后面还会继续但至少现在它已经是一套值得你花时间亲手试一遍的技术了。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →