1. 先搞清楚规划器在系统里的位置,再谈设计
1.1 为什么"硬编码动作"在真实环境里撑不住
很多项目一开始根本没有规划器(Planner)。早期功能简单,团队往往直接写死动作序列:检测到某个条件,就执行对应的脚本。我在某个自主任务执行系统里接手过一个真实场景:机器人要在不同会议室之间移动,把桌面上的空瓶收进指定回收箱。第一次写硬编码看起来没问题,因为会议室布局固定,瓶子位置固定。可一旦换一层楼、调整了桌椅摆放、临时多出来几个目标物,脚本就开始到处打补丁。
问题出在组合爆炸。真实环境下,目标数量、资源占用、路径选择、执行顺序之间是交叉影响的。用 if-else 表达这些组合,代码量和可维护性会迅速失控。规划器的本质作用,是把"目标"转换成"可执行的动作序列",同时处理约束条件。它让系统从"为每个场景写逻辑"变成"为场景描述写通用求解逻辑",这是完全不同的复杂度量级。
在感知-规划-执行的控制闭环里,规划器处在决策层。感知负责回答"现在在哪里、有什么",执行负责回答"怎么把命令变成物理动作",规划器回答的是中间那个问题:"结合当前状态和目标,接下来应该做哪几步,以什么顺序做"。缺少这一层,系统就只能做反应式决策,没法对未来几步做全局推演。
1.2 输入、输出与边界:三个必须画清楚的线
设计规划器最容易犯的错,是职责边界模糊。我先给自己定了三条线:
输入端,规划器接收的是目标描述、当前世界状态、可用的动作模板和领域规则。目标描述不只是"去倒垃圾"这种自然语言,而是结构化表达,比如清理会议室(room=104, target_type=空瓶)。世界状态是当前所有对象、属性、位置关系的快照。动作模板描述了系统"能做哪些事",领域规则描述了"什么情况下允许做"。
输出端,规划器返回的是一份计划(Plan),也就是有序的动作序列;如果规划失败,返回结构化的失败原因。很多同学会不自觉地让规划器去做"调度"和"执行监控"的事,比如让它直接决定哪个机器人去执行、或者执行到一半再让它调整动作参数。我的建议是:这些都不要做。规划器给出"预期状态下有效的计划",真正执行时出现偏差,应该由独立的执行监控模块去判断是否需要重新调用规划器。
下面这张表是我在设计阶段反复和团队对齐的边界定义:
| 内容 | 归属 | 说明 |
|---|---|---|
| 目标解析与形式化 | 规划器内部 | 把高层意图转成可验证目标 |
| 动作序列生成 | 规划器内部 | 核心规划逻辑 |
| 冲突检测与消解 | 规划器内部 | 资源、顺序、互斥约束 |
| 感知数据融合 | 外部感知模块 | 规划器只消费状态快照 |
| 底层运动/控制 | 外部执行模块 | 规划器不产生控制指令 |
| 执行偏差修正 | 外部监控模块 | 偏差超过阈值时触发重规划 |
边界画清楚之后,规划器的接口才能稳定下来,测试也才能独立做。一旦边界模糊,你会发现所有模块都在依赖规划器,改动成本指数上升。
2. 从目标到动作序列:核心流程的四个环节
2.1 状态建模:对象、属性、关系
规划器眼里没有"图像""点云",只有结构化的状态描述。我习惯把状态建模成三部分:对象集合、属性集合、关系集合。还是拿会议室场景举例:
{ "objects": ["bottle_01", "bottle_02", "table_104", "bin_104", "robot_a"], "properties": { "bottle_01": {"type": "empty_bottle", "location": "table_104"}, "bottle_02": {"type": "empty_bottle", "location": "table_104"}, "bin_104": {"type": "recycle_bin", "location": "corner_104"}, "robot_a": {"type": "mobilerobot", "location": "door_104"} }, "relations": { "on": [["bottle_01", "table_104"], ["bottle_02", "table_104"]], "inside": [] } }这样做有三个好处。第一,规划器可以用统一的方式查询任意对象的状态,不用关心底层传感器数据长什么样。第二,动作的"前置条件"和"效果"可以直接映射到状态的增删改。第三,状态可以序列化成快照,方便日志记录和复现调试。如果你做的是纯软件系统,比如任务调度或 AI Agent,状态建模思路完全一样,只是对象从"瓶子"换成"文件""订单""请求"而已。
2.2 目标分解:从顶层意图到可验证的子目标
目标分解是规划器里最容易忽视却最关键的环节。顶层目标往往是模糊的,比如"把会议室清理干净"。规划器需要把它分解成一组可验证的子目标,每个子目标对应一个可执行动作。
我采用的是一种类似分层任务网络(HTN)的思路:维护一棵目标树,树的根节点是顶层目标,叶子节点是原子动作。每一层分解都需要检查子目标是否前置条件已满足,如果满足则直接执行,否则继续分解或选择能产生该状态的另一个动作。
例如:
清理会议室(room=104)-> 分解为清理桌面(table_104),清理地面(floor_104)清理桌面(table_104)-> 前置条件为exists(bottle, on(bottle, table_104)), 分解为抓取瓶子(bottle_01),放入回收箱(bottle_01, bin_104),重复直到桌面无空瓶- 叶子动作
放入回收箱(bottle_01, bin_104)要求机器人已经抓取bottle_01且当前位置能触达bin_104
分解过程的输出是一棵目标树,但对外返回给执行器的还是线性化之后的计划步骤列表。线性化时要保证每个动作的前置条件在前置动作完成之后全部成立。
2.3 动作模板与效果推演
每个动作模板需要定义为四元组:名称、参数、前置条件、效果。下面是我在实际项目里使用的简化定义格式:
| 字段 | 说明 | 示例 |
|---|---|---|
name | 动作类型 | move_to |
parameters | 动作参数 | {target: "bin_104"} |
preconditions | 执行前必须成立的条件 | {"robot_at": "door_104"} |
effects | 执行后状态的变化 | {"robot_at": "corner_104"} |
cost | 执行该动作的代价 | 2.5 |
代价是规划算法搜索时需要的重要输入。移动类的代价通常是路径长度或预估时间,操作类的代价可以是动作耗时加上失败概率带来的期望损耗。设计动作模板时要克制,不要追求"一个动作模板涵盖所有情况"。模板是给规划器做逻辑推演用的,不是给执行器做控制的,太复杂的模板会让规划器无法做有效的效果推演。
动作模板之间可能有互斥关系。比如抓取(bottle_01)和放入回收箱(bottle_01, bin_104)同时持有一个机械臂资源,就不能并行。这类约束也要写进模板定义里,由规划器在生成计划时做冲突检查。
2.4 主循环:搜索、冲突检查、计划输出
规划器的主循环用伪代码描述大概是这样的:
def plan(goal, state, templates, config): # 1. 目标可到达性预检 if not reachable(goal, state): return PlanResult(status="UNREACHABLE", reason="目标状态不可达") # 2. 目标分解 goal_tree = decompose(goal, templates) # 3. 叶子动作线性化与排序 candidate_steps = linearize(goal_tree) # 4. 冲突检查 conflicts = detect_conflicts(candidate_steps, state, templates) if conflicts: resolved = resolve_conflicts(candidate_steps, conflicts) if not resolved: return PlanResult(status="CONFLICT", conflicts=conflicts) candidate_steps = resolved # 5. 代价与可行性验证 if not validate(candidate_steps, state): return PlanResult(status="INVALID", reason="状态推演无法闭环") return PlanResult(status="OK", plan=candidate_steps)这段主循环看起来简单,真正的复杂度藏在decompose和resolve_conflicts里。分解粒度控制不好,叶子动作会爆炸;冲突检测如果只做顺序约束,不做资源互斥,执行阶段就会频繁出问题。我建议先做"无冲突计划生成",再逐步加入资源约束,不要一上来就做完整的约束求解,否则调试成本极高。
3. 规划算法选型:不同需求匹配不同策略
3.1 常见算法对照:状态空间搜索、HTN、行为树与大模型
规划算法没有银弹,每一类都有强烈的适用条件。我整理了一张选型对照表,方便按项目场景决策:
| 算法类型 | 可解释性 | 实时性 | 复杂任务支持 | 典型代价 |
|---|---|---|---|---|
| 状态空间搜索(BFS/Dijkstra/A*) | 高 | 中等 | 弱,状态爆炸 | 需要设计启发式 |
| PDDL 经典规划器 | 高 | 低 | 中 | 建模成本高,求解慢 |
| HTN(分层任务网络) | 高 | 高 | 强 | 需要编写领域分解方法 |
| 行为树/状态机 | 高 | 高 | 弱,偏反应式 | 状态转移逻辑繁琐 |
| 大模型直接生成计划 | 低 | 低 | 中 | 不确定性高,需要校验层 |
对于大多数工程系统,我的经验是优先考虑 HTN 或"HTN + 局部搜索"的混合方案。原因很简单:可解释性高,出问题时能快速定位到是目标分解错了、状态建模漏了还是动作模板冲突了。行为树适合执行层的快速响应,不适合做全局规划。大模型直接生成计划很诱人,但会引入不可控的格式与语义错误,至少我现在不敢在一个需要稳定输出的系统里直接依赖它。
3.2 我选择的混合方案:分层搜索 + 模板化扩展
我在设计这个规划器时,最终采用的是三层结构:
第一层是目标分解层,用 HTN 风格的方法库把高层目标逐层分解。所谓方法库,就是手写或半自动生成的分解规则,比如"清理会议室可以分解为清理桌子和清理地面"。这一层优先保证逻辑正确性和可读性。
第二层是路径/排列层,处理资源空间上的安排问题。比如机器人要去多个位置取瓶子再放入回收箱,先取哪个再取哪个就是排列问题。这一层用 A* 搜索或者简单的贪心都能做,关键是代价函数要合理。需要触达多个目标点时,我习惯先构建可达关系图,再在图上搜索访问顺序。
第三层是参数填充与实例化层。前两层输出的是抽象计划,比如"移动到 bin_104 附近、抓取 bottle_01"。这一层负责把具体坐标、具体对象名、具体资源句柄填进去,生成执行器可以直接消费的计划步骤。
这样做最大的好处是每层都可以单独测试。目标分解错了,和路径搜索无关;路径搜索慢了,不会拖累分解层。相比用一个通用的经典规划器做全局搜索,混合方案的工程可控性要好很多。
3.3 代价函数与启发式:怎么把"看起来合理"变成"可计算"
搜索结果好不好,很大程度上取决于代价函数和启发式设计。我之前吃过亏:代价函数只考虑距离,结果机器人频繁在两个点之间来回跑,计划总代价反而高。后来我把代价拆成三部分:
- 执行代价:移动距离、操作耗时、能耗。
- 冲突代价:候选动作跟已有计划中资源占用冲突带来的惩罚项。
- 风险代价:动作历史失败率或状态不确定性的预估。
启发式函数h(n)要满足可采纳性,也就是估计值不能大于真实代价。设计时我会先写一个"乐观模型":假设所有资源空闲、所有动作一次成功,估计完成目标所需的最小代价。这样能保证 A* 找出的解不差。
举个例子,假设目标是从当前房间移动到走廊另一头的回收箱,乐观启发式就是欧氏距离除以机器人平均速度。实际规划的代价函数再加入开门时间、避让时间、排队等待等修正项。启发式不需要特别精确,稍微低估没关系,但千万不要高估,否则搜索会丢掉最优解。
4. 接口与数据结构设计:让规划器容易被替换和测试
4.1 对外API:一个接口三个方法
规划器在一个系统里通常不会一直不变。我刚接手项目时,内部用的还是旧的状态机拼接逻辑,后来换成了新的规划器。如果一开始就把接口定义好,替换成本会低很多。我建议对外暴露一个抽象接口,核心只留三个方法:
from dataclasses import dataclass, field from typing import Any, Dict, List, Optional class Planner: def plan(self, goal: GoalSpec, state: StateSnapshot, config: PlannerConfig) -> PlanResult: """ 输入目标、当前状态、配置,输出计划或失败信息。 """ raise NotImplementedError def validate(self, plan: Plan, state: StateSnapshot) -> bool: """ 验证已有计划在当前状态下是否仍然有效,用于执行监控模块。 """ raise NotImplementedError def get_diagnostics(self) -> Dict[str, Any]: """ 返回最近一次规划过程的诊断信息,用于调试和可视化。 """ raise NotImplementedErrorplan是核心,validate是给外部模块做快速验证用的,get_diagnostics则是我强烈建议加入的。没有诊断接口,规划器出问题基本只能靠猜。
4.2 核心数据结构与消息格式
数据结构直接影响模块间协作效率。我定义了几种核心类型:
GoalSpec:目标描述,包含目标类型、参数列表、优先级、约束条件。StateSnapshot:世界状态快照,包含对象、属性、关系和时间戳。PlanStep:一个可执行动作步骤,包含动作名、参数、前置条件、预期效果、资源占用。PlanResult:规划结果,包含状态码、计划列表、失败原因或冲突信息。
一份计划的消息格式不需要很复杂,关键在于稳定。我用的简化 JSON 长这样:
{ "status": "OK", "plan_id": "plan_8f3a2b", "steps": [ { "step_id": 1, "action": "move_to", "params": {"target": "corner_104"}, "resources": ["robot_a"], "preconditions": {"robot_at": "door_104"}, "effects": {"robot_at": "corner_104"} }, { "step_id": 2, "action": "pick_up", "params": {"object": "bottle_01"}, "resources": ["robot_a", "arm_a"], "preconditions": {"robot_at": "corner_104", "object_at": "bottle_01_on_table"}, "effects": {"holding": "bottle_01"} } ] }这个格式看起来简单,但有几个隐含约定要提前说清楚:resources字段是给冲突检测用的,preconditions和effects是给校验器用的,执行器本身只关心action和params。各取所需,各不干扰。
4.3 配置与扩展点
不要把所有决策都写死在代码里。我用一个配置文件管理规划器的核心参数,让同一个二进制能适配不同场景:
{ "algorithm": "hierarchical_search", "max_time_ms": 500, "max_depth": 6, "max_steps": 50, "enable_fallback_plan": true, "resource_rules": { "arm_a": {"exclusive": true}, "robot_a": {"exclusive": true} }, "cost_weights": { "execution": 1.0, "conflict": 3.0, "risk": 1.5 } }配置化的意义不只是方便调参,更是让规划器具备"场景适配"能力。室内小场景下max_steps=20够用,楼宇级场景可能需要max_steps=200。如果没有配置化,这些改动都要重新编译,现场调试非常被动。此外,把算法名称做进配置,意味着未来可以无缝切换到一个新的规划算法实现,只要它遵循同一个接口即可。
5. 健壮性与工程落地:规划失败比规划成功更需要设计
5.1 失败模式与降级策略
规划器返回失败是常态,不是异常。我在设计时把失败模式分了四类,每一类都有明确的返回码和降级策略:
| 失败类型 | 返回码 | 典型原因 | 降级策略 |
|---|---|---|---|
| 目标不可达 | UNREACHABLE | 前置条件无法满足、对象不存在 | 尝试替代目标或忽略该目标 |
| 动作冲突无法消解 | CONFLICT | 资源独占、顺序矛盾 | 放宽约束,或切换到预设应急计划 |
| 搜索超时 | TIMEOUT | 状态空间过大、分解过深 | 返回部分计划,执行再规划 |
| 状态校验失败 | INVALID | 效果推演与实际不符 | 重新获取状态快照再规划一次 |
降级策略一定要由外部模块决定,规划器本身不要悄悄改目标。规划器只需要诚实地说"我找不到解"或者"我没能在时限内找到解"。外部调度模块根据业务优先级决定放弃、换方案还是强制执行。这个原则帮我避免了很多隐藏 bug。
在实际编码中,PlanResult里必须带上reason和conflicts字段,方便执行端决定下一步动作。比如CONFLICT返回的冲突集合可以明确指出是哪两个动作抢了同一个资源,外部模块可以直接看出来,而不是两眼一抹黑。
5.2 执行偏差与重规划触发
计划是"预期状态下的动作序列",现实世界总有误差。机器人移动到目标点,可能偏移了5厘米;操作动作可能失败重试了几次。这些偏差什么时候触发重规划?
我的做法是定义三个重规划触发条件:
- 状态偏差超过阈值。比如机器人实际位置与计划中预期的位置距离相差超过30厘米,或者被占用资源出现意外变化。
- 动作执行失败且重试次数耗尽。
- 目标本身发生变化,比如临时加入新任务或取消旧任务。
触发重规划之前,需要先调用validate快速检查剩余计划是否仍然成立。如果只是微小偏差,后序步骤仍然有效,就不要重规划,避免"抖动"和资源浪费。我在某个版本里踩过坑:因为很小的位置误差就频繁重规划,结果系统始终在规划,几乎没执行几步。后来加了滞回比较,偏差超过前一次触发值的 1.5 倍时才重规划,问题立刻缓解。
5.3 可观测性与测试策略
规划器是逻辑密集型模块,黑盒测试很难定位问题。我强烈建议在开发阶段就搭建可视化调试工具。至少要有三个层面的可观测性:
第一,日志。记录每次规划的目标、输入状态摘要、分解树节点数、搜索耗时、返回状态。日志字段要结构化,方便后续做离线分析。
第二,轨迹记录。把每次规划产生的步骤列表、预期状态变化、实际执行结果都记录下来,形成一条完整的"计划-执行轨迹"。我见过很多项目只记录成功计划,失败计划几乎没有日志,这是最糟糕的做法。
第三,可视化界面。把目标树和计划步骤以流程图或列表形式展示出来,用来快速定位"哪一步的分解逻辑不合理"。不需要做得很复杂,能展示节点关系和前后置条件就够了。
测试策略上,我常用的三类用例:
- 单元测试:用很小的状态空间(比如只放一个瓶子和一个回收箱)验证分解和线性化的正确性。
- 回归测试:记录一批典型场景的规划结果到 JSON 文件,每次修改算法后跑一遍对比,防止"修好一个 bug 弄挂三个功能"。
- 仿真测试:给状态注入噪声,比如随机偏移、动作失败,验证重规划逻辑是否按预期触发。
回归测试我建议做成计划结构的序列化对比,而不是单纯比对字符串。因为计划的步骤顺序、资源占用、前置条件可能微调,直接 diff 文本容易误报。
6. 设计踩坑与个人经验
6.1 最容易犯的错:把规划器写成万能决策器
我见过很多规划器项目最后失控,根本原因是开发者忍不住往里面塞越来越多的职责。一开始只是做动作序列规划,后来发现执行顺序有问题,就在规划器里改策略;后来发现资源冲突频繁,又在规划器里做了调度;再后来发现目标经常变化,又在规划器里加状态机。最后这个模块变成了一团乱麻,任何改动都要回归测试大半天。
规划器应该保持"功能纯粹":只负责在给定状态下,尝试生成满足目标的动作序列。资源冲突可以检测,但冲突消解的策略最好由外部传入;执行顺序可以在计划生成时优化,但不要干预正在执行中的动态调度。这个边界守住,模块才能持续演进。
6.2 重规划频率与实时性的矛盾
规划器搜索是消耗计算资源的,尤其在状态空间较大的场景里。一次规划 500 毫秒可能还行,但如果执行环境变化很快,500 毫秒的性能就不够用了。我用了两个手段:一是把规划超时设成配置项,并实现"有任何可行部分计划就先返回";二是加可到达性预检。
可到达性预检是在目标分解之前,先检查所有必要的对象和状态是否在可达范围内。比如目标要求机器人把瓶子放进bin_104,但当前状态里根本没有bin_104这个对象,那就直接返回UNREACHABLE,完全不需要进入搜索流程。这个小优化让无效规划的耗时从几百毫秒降到几毫秒,实际效果立竿见影。
还有一个经验:设计规划器的动作模板和代价函数时,一定要先做几个真实的失败案例再回头调整。模板定义得完美与否,不是靠理论推导出来的,而是靠跑不通的场景逼出来的。我第一次设计时总想着要"通用",结果动作模板互相嵌套,冲突检测复杂得没法跑。后来改成"先把一个场景彻底跑通,再抽象第二个场景的公共部分",效率反而高很多。
最后分享一个个人的工作习惯:每次修改规划器核心逻辑后,我都会用同一批历史轨迹重放一遍,对比关键指标(规划耗时、步骤数、冲突次数)。没有这套回归数据的规划器改动,我是不敢合入主线的。规划器这种模块,表面上看是算法问题,长期看其实是工程问题,可维护性和可观测性比一次两次的搜索效率重要得多。