本节摘要:常用套路不该每次从头搜索。本节把领域知识预制成飞行程序库:HTN 用方法库描述"这类活怎么干",复合任务按前置条件选方法、展开成子任务网络、终止于原语;行为树负责程序的反应式执行。程序库与搜索的正确关系是主次分明——程序为主、搜索填缝,从零搜出的计划既慢又脆。
老机长的飞行包里没有演算纸,只有翻旧的标准程序手册。起飞、巡航、进近,每一阶段按检查单走,遇偏离才临场处置——他不是每次都从牛顿力学重新推导怎么飞,而是站在几十年飞行经验编译成的程序上。规划舱需要同样的手册:飞行程序库。上一节搜索的定位随之修正——程序库里命中的套路直接展开,搜索只负责程序没覆盖的缝隙与兜底。从零搜出的计划在教科书里优雅,在生产环境里既慢又脆:环境稍有风吹草动,整份计划作废。
分层任务网络(HTN)的词汇表很小:原语任务是行动舱接口表上的动作;复合任务是"转运托盘"这类需要展开的活;方法是复合任务的展开配方——带前置条件(什么情况下适用)与子任务网络(展开成什么)。规划过程就是从顶层复合任务出发,反复选方法、展开,直到计划里只剩原语。它和 3.1 节的递归分解是同一思想的工业级形态:加上了多方法择优——同一复合任务可有多种配方,按当前信念选适用的那个。
方法库的价值密度在前置条件上。"装载托盘"这个复合任务可以有两条方法:托盘在近处且机械臂可达,走"直接夹取"配方;托盘压在底下,走"先清障再夹取"配方。前置条件写的是什么?读信念、查航图——感知舱的输出在这里直接参与决策。方法库因此是知识的热区:领域经验沉淀于此,审计与改进都围绕它。
from dataclasses import dataclass from typing import Callable @dataclass class Method: name: str precondition: Callable # 读信念,返回是否适用 subtasks: list # 复合或原语任务名序列 METHODS = { "transport_pallet": [ # 顶层复合任务:转运托盘 Method("direct_route", lambda b: b.read("pallet_pos").conf > 0.8, ["navigate", "grasp", "navigate", "release"]), Method("clear_then_route", lambda b: b.read("pallet_blocked").value, ["inspect", "clear_obstacle", "transport_pallet"]), # 可递归引用自身 ], "navigate": [ Method("topo_then_local", lambda b: b.read("map_ready").value, ["plan_topo_route", "follow_local_path"]), ], } def htn_plan(task, board, depth=0): """HTN 展开:选适用方法,递归到原语序列。""" if depth > 10: raise RecursionError("方法库疑似循环展开") if task in PRIMITIVES: return [task] for m in METHODS.get(task, []): if m.precondition(board): plan = [] for sub in m.subtasks: plan.extend(htn_plan(sub, board, depth + 1)) return plan raise ValueError(f"任务 {task} 无适用方法:信念不足或库缺配方")
与 3.1 节骨架对照,多了三样工业件:方法表外置成数据(加配方不改代码)、前置条件读信念(方法选择随感知更新)、递归自引用合法("先清障再转运"里的转运又走一遍全流程)。深度限额照旧是保险丝——真正防循环靠方法库的静态审查。
HTN 产出的是事前计划,执行时的反应式调度交给行为树。行为树把程序组织成树:叶子是动作或条件,内部节点控制流转——顺序节点依次执行、选择节点按优先级试、并行节点同发。它的特长是失效恢复的常规化:"抓取"失败不必推翻整份计划,选择节点回退到上一分支重试即可。工程分工因此清晰:HTN 管展开(这份活分几步),行为树管执行(每步成了没成、败了怎么退),两者衔接点是任务单的原语序列。
行为树的最小执行骨架也一并交付——HTN 展开结果由它落地:
class Node: """行为树节点:状态三分 成功 / 失败 / 运行中。""" SUCCESS, FAILURE, RUNNING = "成功", "失败", "运行中" class Sequence(Node): def __init__(self, children): self.children = children def tick(self, board): for child in self.children: r = child.tick(board) if r is not Node.SUCCESS: return r # 有一步未成即整段未成 return Node.SUCCESS class Fallback(Node): def __init__(self, children): self.children = children def tick(self, board): for child in self.children: r = child.tick(board) if r is not Node.FAILURE: return r # 有一步能走就走它 return Node.FAILURE # 全败上抛复飞 class ActionLeaf(Node): def __init__(self, step, executor): self.step, self.executor = step, executor def tick(self, board): receipt = self.executor.submit(self.step.action, self.step.params) return Node.SUCCESS if receipt.ok else Node.FAILURE

程序库的联调是配方回归:为每个复合任务准备一组信念快照(正常、边缘、异常),断言方法选择与展开结果符合预期;再加覆盖审计——统计生产日志里各方法的命中频次,长期零命中的配方是死代码,高频命中且常失败的配方是改进对象。展开结果还要过 3.1 节的静态校验(原语合法、依赖有序),两道关都不能省。
按症状排查:"无适用方法"报错是前置条件全灭——信念不足(感知该补)或库缺配方(该新增方法),两种病根方向完全不同,先看报错时的信念快照;展开结果僵硬(环境变了程序不转弯)是前置条件写得太宽,配方在不该适用的局面被选中,收紧条件或增加分支;程序与搜索打架(同一任务两次跑结果不同)是程序命中与搜索兜底的边界没划清,命中日志必须显式记录来源。
⚠️ 常见坑:把方法库当成代码的附属注释随手维护。方法库是知识资产,要有版本、要有回归、要有命中统计——没有审计的程序库会在三个月内变成没人敢动的黑匣子。
程序库让航线有了骨架,但再好的程序也写在无风的日子。下一节请出气象:感知有误差、执行有偏差、环境会变脸——不确定性下的规划怎么打。