跳转至

TAMP_T5 行为树与执行监控 (Behavior Trees and Execution Monitoring)

本文档属于 Robotics Tutorial 项目移动机器人规控方向 60_任务运动规划/ 子线,对应总论 (T0) §3.5 板块④「执行式」、§4.3 根问题三「怎么稳地做下去」。 难度: ⭐⭐ ~ ⭐⭐⭐(执行层,重工程机制与设计权衡,轻数学推导) 定位: 这是 TAMP 线第五章 (T5)。前四章 (T1 基础 / T2 规划与分配 / T3 PDDLStream / T4 LGP) 都在回答「做什么、怎么把符号和几何缝起来」——它们生成计划。本章换一个根问题:计划有了,怎么把它稳健地执行下去——监控执行、捕获异常、失败恢复、响应新目标。这是从「实验室 demo」到「能用的系统」之间那道最关键的缝。 前置知识: TAMP_T0 总论 (尤其 §3.5/§4.3/§6.1);TAMP_T1 §11 的 Mini-TAMP 累积项目 (本章要把它的协调器换成行为树);C++ 基础 (虚函数、智能指针、模板);A* / RRT 概念 (知道导航栈里有规划器即可)。无需任何行为树基础——本章从 tick 机制从零讲起。 核心参考: Colledanchise & Ögren (2018, CRC Press, 行为树标准教材); Colledanchise & Ögren (2017, IEEE T-RO 33(2), BT 推广三大经典架构的形式化); Marzinotto et al. (2014, ICRA, 首个机器人 BT 统一框架); Iovino et al. (2022, RAS 154, 行为树综述); Colledanchise et al. (2019, ICRA, plan-to-BT 回链展开); Macenski et al. (2020, IROS, Nav2 导航系统)


0. 前置自测

开始前先做这 5 道题。能答出大半,说明前置就绪;卡壳的题指向对应前置章节,建议先回去补。它们不是考试,而是帮你校准「这一章我能不能直接读」。

# 自测题 能答出什么算过关 不会的话回看
Q1 TAMP 线的三个根问题是哪三个?本章 (T5) 负责其中哪一个? 说出「做什么 (规划) / 谁来做 (分配) / 怎么稳地做下去 (执行)」,本章是第三个 TAMP_T0 §4
Q2 「先把任务计划排好,再让运动层逐条执行」这个朴素方案为什么会出问题? 说出符号层看不到几何、执行中世界会变 (障碍突现、抓取滑脱),open-loop 计划必然脱节 TAMP_T0 §1.4
Q3 TAMP_T1 的 Mini-TAMP 累积项目里,那个「先规划、再逐步检查运动」的协调器叫什么?它的执行逻辑有什么缺陷? 说出 Plan-then-Check / TAMPCoordinator,缺陷是「事后盲目检查、出错只能从头重排、不监控执行」 TAMP_T1 §11、T3 §8
Q4 一个 C++ 虚函数 virtual Status tick() 被基类指针调用时发生什么?什么是纯虚函数? 说出动态分派 (运行期按真实类型选实现)、纯虚函数 =0 使类不可实例化、强制派生类重写 任意 C++ 教材;本项目 C++ 进阶线
Q5 有限状态机 (FSM) 是什么?\(N\) 个状态之间最多有多少条转移边?这会带来什么维护问题? 说出状态+转移的模型,最坏 \(O(N^2)\) 条边,加一个状态要改很多既有转移、不可复用 任意控制/软件工程教材;§3 会从这里出发

怎么用这张表:Q1–Q3 是「为什么需要本章」的地基,答不出建议先回 T0/T1;Q4 是读懂 §5 代码的门槛;Q5 是理解 §3 历史动机与 §8 对比的引子,不熟也没关系——§3 会从 FSM 的痛点从头讲起。这五题没有一道需要你已经懂行为树——本章假设你对 BT 一无所知。


1. 本章目标

学完本章后,你应当能够:

  1. 解释行为树 (Behavior Tree, BT) 的执行原语——tick 信号、\(\{\text{Success}, \text{Failure}, \text{Running}\}\) 三种返回状态——并说清为什么 Running 状态是 BT 区别于一切「一次性求值」结构的灵魂。
  2. 辨析四类控制节点 (Sequence / Fallback / Parallel / Decorator) 的返回语义,把 Sequence 读成「与门」、Fallback 读成「或门」,并用它们组合出任意命题逻辑的守卫与恢复结构。
  3. 区分 BT 最容易出错的两组语义——Reactive (每拍从头重检) vs Memory (记住进度不回头)——并判断守卫条件该用哪个、顺序步骤该用哪个,避免把有状态动作错放进 Reactive 分支。
  4. 精读 Nav2 (ROS 2 导航栈) 的默认 navigate_to_pose 行为树:看懂 PipelineSequenceRecoveryNodeRoundRobin 如何把「规划—跟踪—恢复」编排成一棵可逐级升级的树,并定位 FollowPath 叶子与运动控制器 (如 MPPI_08) 的接口。
  5. 澄清三个常被混淆的概念:BT 不是决策树 (Decision Tree) 这种机器学习分类器,BT 相对有限状态机 (Finite State Machine, FSM) 的核心优势是「两块组合性」(two-block modularity) 而非转移爆炸,以及决策树/包容架构/序贯行为组合都是 BT 的特例 (Colledanchise & Ögren 2017)。
  6. 实现 plan-to-BT 的在线生成思路:理解回链展开 (back-chaining) 如何从目标条件反推出一棵自带反应性的 BT (PA-BT, Colledanchise et al. 2019),并说清为什么 BT 是「融合规划与执行 (blended planning and acting)」的理想载体。
  7. 设计一套执行监控与异常恢复闭环:把前置条件失效、进度停滞、外部中断这些「世界的不配合」用守卫条件与 Parallel 监视支接住,按「重试 → 替代 → 降级 → 升级求助 → 重规划」分类施加恢复,并掌握重规划的节流与抢占 (preemption) 的 halt 语义。
  8. 重写 TAMP_T1 的 Mini-TAMP 累积项目协调器:用 BehaviorTree.CPP 把 PDDLStream/规划器封装成叶子节点,把原来「Plan-then-Check 一锤子买卖」的协调器升级为「规划 → 执行 → 监控 → 失败重规划」的闭环 BT。

这 8 条里,1–4 是必须拿下的主干(BT 的机制与一份真实工业树的精读),5 是概念澄清(防止把 BT 和决策树/FSM 混为一谈),6–8 是把 BT 接回 TAMP 全线的落地(在线生成、监控恢复、累积项目)。

本章知识导航

本章换一个根问题。前四章 (T1–T4) 都在回答「做什么、怎么把符号和几何缝起来」——它们生成计划。本章问的是:计划有了,怎么把它稳健地执行下去? 这是 TAMP_T0 §4.3「根问题三」的领地,也是工业界工程量最大、学术界却最容易忽略的一环。整章围绕一个核心数据结构——行为树——展开,从它最小的执行原语 (tick) 一路讲到用它重写累积项目:

               根问题:计划有了,怎么稳健地执行下去?
         ┌───────────────────────┼───────────────────────┐
         │                       │                       │
    【为什么要它】           【它是什么、怎么转】        【怎么用它】
         │                       │                       │
  §2 计划不是终点          §4 tick 机制与三态语义      §9 精读 Nav2 默认树
  ├─ 会犯错的世界          ├─ tick 是自根而下的心跳    ├─ PipelineSequence
  ├─ open-loop 的灾难      ├─ Running 是 BT 的灵魂     ├─ RecoveryNode 逐级升级
  └─ 监控/反应/恢复三件事  └─ BT 作为函数 Tick:S→{S,F,R} └─ FollowPath ↔ MPPI 接口
         │                       │                            │
  §3 从 FSM 到 BT 的历史   §5 叶子:Action/Condition    §10 plan-to-BT 在线生成
  ├─ FSM 转移爆炸 O(N²)    ├─ 动作(副作用) vs 条件(查询) ├─ 回链展开 back-chaining
  ├─ 包容架构 Brooks 1986 ├─ 长动作必须返回 Running    ├─ PA-BT 扩展循环
  └─ BT 统一三者          └─ 叶子背后是 A*/RRT         └─ 接 T1–T4 的规划输出
         │                       │                            │
         │                §6 控制节点:Seq/Fb/Par/Dec   §11 执行监控与异常恢复
         │                ├─ Sequence=与门 Fallback=或门 ├─ 监控什么/放哪/恢复分类学
         │                ├─ Parallel:并发监控 M-of-N  ├─ 重规划触发与节流
         │                └─ Decorator:Retry/Timeout   └─ 抢占 preemption 与 halt
         │                       │                            │
         └───────────────────────┴── §7 Reactive vs Memory   §12 工程实践:
                §8 BT vs 决策树 vs FSM 三澄清          重写 Mini-TAMP 协调器

怎么读这张图:左列建立「为什么需要执行层」(§2–§3),中列是 BT 的机制内核——tick 与三态 (§4)、叶子 (§5)、控制节点 (§6)、最易错的 Reactive/Memory 语义 (§7)、以及与决策树/FSM 的澄清 (§8)。右列是落地——精读一份真实工业树 (§9 Nav2)、让规划器在线生成树 (§10)、执行监控闭环 (§11)、重写累积项目 (§12)。

主干与分支:第一遍务必拿下 §4 (tick 与 Running)、§6 (四类控制节点)、§7 (Reactive vs Memory)、§9 (Nav2 精读)——这四节是「会用 BT」的最小集。⭐⭐⭐⭐ 的 §10 (plan-to-BT) 是把 BT 接回规划层的研究级内容,可第二遍深入。§3 的历史和 §8 的对比是「理解 BT 为何如此设计」的引子,速读时可略过细节、只看演进表与对比大表。

前置知识桥接

本章站在三块基石上,各用几行重新激活——这是 R14 跨章桥接的要求:引用前置知识不能只写「见 ChXX」,要让你不翻回去也能跟上。

回顾 TAMP_T0 §4.3:根问题三「怎么稳地做下去」。 总论把任务层拆成三个根问题——做什么 (规划)、谁来做 (分配)、怎么稳地做下去 (执行)。前两个是 T1–T4 的领地 (符号规划、PDDLStream、LGP 都在生成计划)。第三个——执行——总论只画了地图,说它「在工业界占工程量最大头」,并预告由「板块④行为树」承担、留给 T5 精读 Nav2。本章就是把这块地图填成实地:执行层到底监控什么、怎么反应、如何恢复。

回顾 TAMP_T1 §11:Mini-TAMP 累积项目的协调器。 T1 搭过一个累积项目,它的协调器叫 TAMPCoordinator,执行逻辑是「Plan-then-Check」——先让符号规划器排出完整计划,再逐条检查运动可行性,出错就从头重排。T3 §8 用 PDDLStream 升级了它的「规划+几何」部分,但执行部分一直是朴素的「排好就盲目顺序跑、不监控、出错从头来」。本章的 §12 会把这个协调器彻底换成一棵行为树,让它具备监控与失败重规划的能力——这是累积项目在执行维度的最后一块拼图。

回顾 TAMP_T3/T4:计划是怎么生成的。 T3 (PDDLStream) 和 T4 (LGP) 是两大主流的「计划生成器」:PDDLStream 用流式采样把几何可行性喂进符号搜索,LGP 把符号与几何揉进一个优化问题。它们的输出都是一个动作序列(可能带几何参数,如抓取位姿、轨迹)。本章不关心计划怎么生成,只关心生成之后——把这个序列稳健地执行下去。所以本章与 T3/T4 是接力关系:T3/T4 产出计划,T5 执行计划,执行中发现计划失效就回头触发 T3/T4 重新生成。§10 的 plan-to-BT 和 §11 的重规划,正是这条接力链的两个接口。

本质洞察:T1–T4 和 T5 的分工,对应机器人系统里一条根本的界线——「深思 (deliberation) 与反应 (reaction) 的分离」。T1–T4 是深思层:慢、全局、在符号/几何空间里搜索一个好计划,一次规划可能要几百毫秒到几秒。T5 是反应层:快、局部、以几十赫兹的 tick 频率盯着世界、一旦不对就立刻退到恢复分支。把这两层分开,是因为它们的时间尺度差了两三个数量级——你不可能每 20 毫秒重新跑一遍 PDDLStream。BT 的价值,正在于它给反应层提供了一个既能「快速反应」又能「在线接住深思层产出的计划」的结构。读完本章你会看到,这条「深思—反应」界线贯穿 §10 (plan-to-BT) 和 §11 (重规划节流) 的每一个设计。

如果跳过本章会怎样

  • 场景一(只学了 T1–T4,把计划当终点):你在仿真里用 PDDLStream 解出一个漂亮的 pick-and-place 计划,照着 [pick(cup), place(cup, shelf)] 逐条发给运动层执行。仿真里一切完美。搬到真机:抓取时杯子滑了一下没夹稳,但你的执行器不知道——它只是「按计划」继续往 shelf 移动,结果把空夹爪精准地放到了 shelf 上,杯子还躺在原地。根因:open-loop 执行不监控前置条件,计划与真实世界一旦脱节就再也回不来。这正是 §2.2 的灾难,也是为什么执行层不是「可选加项」而是「真实系统的默认刚需」(总论 §6 须知二)。
  • 场景二(用错了结构,拿 FSM 硬扛长任务):你不知道有 BT,于是用有限状态机写一个「导航 + 抓取 + 放置 + 失败恢复」的执行器。一开始 5 个状态还能管。后来要加「电量低就回充电桩」「有人挡路就等待」「抓取失败就重试 3 次」——每加一个行为,你都要在已有的十几个状态之间手工补转移边,改一处牵动一片,最后 30 个状态、近百条转移边,没人敢动。根因:FSM 的转移是 \(O(N^2)\) 量级且不可组合 (§3.1),而 BT 的「两块组合性」让你能像搭积木一样插拔行为而不碰旧逻辑 (§8.3)。缺了本章,你会在一个本可优雅解决的问题上陷进维护泥潭。
  • 场景三(看不懂工业代码,改 Nav2 树全靠猜):你要给一个移动机器人定制导航行为——比如「恢复时先转一圈扫描再后退」。Nav2 用一棵 XML 描述的行为树编排导航,你打开 navigate_to_pose_w_replanning_and_recovery.xml,满屏 RecoveryNodePipelineSequenceRoundRobin,不知道哪个控制哪个、改了会怎样,只能复制粘贴试错,一跑就死循环或者根本不恢复。根因:不懂控制节点的返回语义 (§6) 和 Nav2 的恢复编排约定 (§9),改树就是盲改。缺了本章,你面对工业级 BT 代码寸步难行。

预计阅读时间

模式 覆盖范围 预计时间
精读 全部小节 + §5/§6/§9 代码复现 + §12 累积项目动手 + 全部练习 14–18 小时
速读 §2 动机 + §4 tick/三态 + §6 控制节点 + §7 Reactive/Memory + §9 Nav2 主干 (跳过 §3 历史细节、§8 对比细节、§10) 5–6 小时
实战 §4 三态 + §5 叶子接口 + §6 控制节点 + §9 Nav2 精读 + §11 监控恢复 + §12 接入 + §12.6 调试清单 7–9 小时
速查 知识导航 + §6.6 控制节点语义总表 + §7.6 Reactive/Memory 选择 + §9.5 Nav2 恢复语义 + API 速查表 2 小时

针对不同目标:想理解 BT 原理走精读/速读;想动手改 Nav2 或写执行器走实战路径 (重点 §6/§9/§11/§12.6);想快速查语义走速查 (控制节点返回规则是最高频查询)。


2. 为什么需要执行层:计划不是终点 ⭐⭐

本节解决一个问题:前四章辛辛苦苦生成的计划,为什么不能直接拿去执行? 答案会引出执行层要做的三件事,进而引出「为什么是一棵树」。

2.1 动机:一个会犯错的世界

回到 TAMP_T0 §2 那个贯穿全线的场景:机械臂要把桌上的杯子 cup 抓起来放到架子 shelf 上。T1–T4 已经能为它生成一个符号上合法、几何上可行的计划:

计划 π = [ pick(cup, grasp=g, q1),     # 用抓取位姿 g、构型 q1 抓杯子
          move(q1 → q2, traj=τ),       # 沿轨迹 τ 从 q1 移到 q2
          place(cup, shelf, p, q2) ]   # 在放置位姿 p、构型 q2 把杯子放上架子

在仿真里,这个计划跑得天衣无缝:抓取位姿 \(g\) 是 IK 算准的,轨迹 \(\tau\) 是碰撞检测验证过的,放置位姿 \(p\) 是稳定性判据通过的。每一步的前置条件,规划时都成立。

但真实世界不是仿真。在真实世界里,计划成立的前提,会在执行的任意一拍突然失效

失效类型 一个具体例子 计划的哪个前提被打破
抓取不牢 pick 时夹爪闭合,但杯子表面有水,滑了 2 cm 才夹稳——或者根本没夹住 place 隐含的前提「夹爪里有 cup」可能不成立
障碍突现 move 执行到一半,有人把椅子推进了机械臂的轨迹 \(\tau\) \(\tau\)「全程无碰撞」这个规划时验证过的前提,现在不成立了
目标变更 杯子刚抓起来,操作员改主意了:「别放 shelf,放回原处」 整个计划 \(\pi\) 的目标 On(cup, shelf) 被新目标取代
执行偏差 place 把杯子放下,但放偏了几毫米,杯子在 shelf 边缘晃了一下 place 后置条件「cup 稳定在 shelf 上」处于临界
资源耗尽 任务做到一半,电量低于阈值,再不回充电桩就要趴窝 一个计划里根本没建模的全局约束被触发

这张表不是穷举(§11.1 会做更系统的分类),但它点出一个朴素却致命的事实:计划是在「规划那一刻的世界」里生成的,而执行发生在「不断变化的世界」里。规划时世界是 \(W_0\),执行到第 \(k\) 步时世界已经是 \(W_k \neq W_0\)。计划 \(\pi\)\(W_0\) 最优,但对 \(W_k\) 可能根本不可行。

本质洞察:这正是 TAMP_T0 §1.4 批判 open-loop 方案时说的「执行中世界会变」,但在 T1–T4 我们一直把它搁置——因为那四章的任务是「在给定世界里把计划算对」,世界变不变不是它们的职责。到了执行层,这个被搁置的问题成了主角。换句话说:T1–T4 假设世界是静止的(规划完世界不变),T5 直面世界是动态的。这不是 T1–T4 偷懒,而是合理的分工——你必须先有「在静止世界里算对计划」的能力,才谈得上「在动态世界里维护计划」。本章就是补上后半句。

2.2 如果不做执行监控会怎样:open-loop 的灾难

假设我们不做任何执行监控,就是最朴素的「按计划逐条发指令」——这叫 open-loop(开环)执行:把 \(\pi\) 的每个动作依次发给运动层,发完就认为做完,发下一个。

open-loop 执行器(反面教材):
  for action in π:
      send_to_motion_layer(action)   # 发出去
      wait_until_motion_done()       # 等运动层报告"动作结束"
  # 循环结束 = 任务完成(真的吗?)

这段代码的致命缺陷,藏在 wait_until_motion_done() 里:它只问「这个动作执行完了吗」,从不问「这个动作执行成功了吗」、「下一个动作的前置条件还成立吗」。回到 §2.1 的抓取滑脱:

open-loop 灾难时间线:
  t=0   send pick(cup)        → 运动层闭合夹爪
  t=1   wait → "夹爪闭合完成"  → 但杯子滑落了,夹爪是空的!open-loop 不检查
  t=2   send move(q1→q2)      → 机械臂带着空夹爪移动
  t=3   wait → "移动完成"
  t=4   send place(cup,shelf) → 机械臂在 shelf 上张开空夹爪
  t=5   wait → "放置完成"
  循环结束 → 报告"任务成功"   → 而杯子还躺在桌上

机器人自信地报告成功,而任务彻底失败。这就是 open-loop 的灾难:它没有任何机制把「世界的真实状态」和「计划假设的状态」对齐。一旦某一步的现实偏离了计划的假设,偏差会沿着后续动作放大,且永远不会被发现。

不是 X 而是 Y:很多初学者以为「执行就是把计划翻译成电机指令」——不是。执行不是把计划单向地「播放」出去,而是持续地把「计划期望的世界」和「传感器看到的世界」做比对,并在二者偏离时介入。前者是录音机(open-loop),后者是带反馈的控制器(closed-loop,闭环)。本章讲的一切,本质上都是在给执行装上这个反馈回路。

那么,能不能简单粗暴地「每步执行后检查一下,错了就从头重新规划」?这就是 T1 的 Plan-then-Check(§2.3 会回顾)。它比 open-loop 强,但有两个新问题:检查放在哪、错了恢复到哪。从头重规划太贵(一次 PDDLStream 可能几秒),而且很多错误根本不需要重规划——抓滑了重抓一次就行。这就引出执行层真正要做的三件事。

2.3 执行层的三件事:监控 / 反应 / 恢复

总论 §4.3 把执行根问题的核心机制列为三件事。这里把它们讲透——它们是本章所有机制的「需求规格」:

要做的事 它回答什么 具体内容 本章在哪实现
监控 (Monitoring) 「现在还对吗?」 持续检查:当前动作的前置条件是否仍成立、动作是否在推进、有没有外部中断/资源告警 守卫条件 (Reactive Condition) §7 + Parallel 监视支 §6.4 + §11.1–11.2
反应 (Reaction) 「不对了立刻怎么办?」 一旦监控发现偏差,立即(同一拍内)中止当前动作、切换到合适的应对分支,而不是等当前动作慢慢跑完 Reactive 节点的「每拍重检」§7.3 + 抢占 halt §11.5
恢复 (Recovery) 「怎么把局面救回来?」 按偏差的性质选择应对:重试 / 换个办法 / 降级目标 / 求助 / 回到 T1–T4 重规划 Fallback 恢复链 §6.2 + 恢复分类学 §11.3 + 重规划触发 §11.4

这三件事是递进的:监控是眼睛(发现问题),反应是反射弧(快速中止、切换),恢复是决策(选择如何补救)。缺任何一个都不行——只监控不反应,等于知道出事却干等着;只反应不恢复,等于慌乱中止却不知道下一步做什么。

一个关键的设计张力贯穿这三件事:反应要快、恢复要对。反应必须在毫秒级完成(椅子推进来了,不能等机械臂把当前这一大步走完才停),所以它必须是「每个 tick 都重新检查、随时能打断」的——这是 §7 Reactive 语义存在的根本理由。恢复则允许慢一点、但要选对策略(抓滑了别傻乎乎重规划,重抓就行),所以它需要一个「从轻到重」的恢复阶梯——这是 §6.2 Fallback 和 §11.3 恢复分类学的主题。

本质洞察:监控/反应/恢复这三件事,恰好对应行为树三类机制的存在理由——条件节点 (Condition) 做监控(把世界状态接入树)、Reactive 控制节点做反应(每拍重检、随时中止)、Fallback 控制节点做恢复(一条路不行换下一条)。这不是巧合:BT 这个结构之所以适合做执行层,正是因为它的原语和「执行层要做的三件事」一一对应。理解了这个对应,你就理解了为什么本章选 BT 而不是别的结构——下一小节展开。

2.4 执行层在软件栈里的位置

把执行层放回整个系统,它处在一个明确的位置——规划层和运动层之间的「调度器」

┌─────────────────────────────────────────────────────────┐
│  规划层 (T1–T4):符号规划 / PDDLStream / LGP             │
│  产出:动作序列 π(可能带几何参数)          慢、全局、深思 │
└────────────────────────┬────────────────────────────────┘
                         │ 计划 π            ↑ 重规划请求 (§11.4)
                         ▼                  │
┌─────────────────────────────────────────────────────────┐
│  执行层 (T5):行为树                                       │
│  • 监控:守卫条件持续检查前置条件                          │
│  • 反应:偏差出现立即中止、切换                  快、局部、反应 │
│  • 恢复:重试/替代/降级/升级/重规划                        │
│  按 tick 频率 (如 10–100 Hz) 自根而下驱动                  │
└────────────┬──────────────────────────────┬──────────────┘
             │ 原子指令 (导航到X/抓Y/...)     ↑ 成功/失败/进度
             ▼                              │
┌─────────────────────────────────────────────────────────┐
│  运动层 (T线/U线/G线/MPPI):A*/RRT/MPC/控制器             │
│  执行单个原子动作:算路径、跟轨迹、闭合夹爪      最快、底层、执行 │
└─────────────────────────────────────────────────────────┘

执行层向接收计划、在计划失效时回送重规划请求;向把计划里的每个动作翻译成对运动层的调用、并收集运动层回报的成功/失败/进度。它本身不算路径、不解 IK——那是运动层的事;它也不搜索计划——那是规划层的事。它只管「按什么顺序调用、谁失败了退到哪、什么时候该回头找规划层」。

本质洞察:这个「三明治」结构揭示了一个常被误解的事实——BT 是编排器 (orchestrator),不是执行器 (executor),更不是规划器 (planner)。当你在 Nav2 的树里看到一个叫 ComputePathToPose 的叶子节点,它本身不是 A*——它只是「调用 A* 那个运动层服务、然后根据返回的成功/失败决定下一拍 tick 谁」的一个接口包装。真正算路径的 A* 在运动层。这呼应了总论 §3.5 的核心洞察:「ComputePathToPose 这个 BT 叶子节点背后是 A* 在规划,BT 只负责按什么顺序 tick、失败退到哪」。把 BT 的职责钉死在「编排」上,是读懂后面一切的前提——否则你会困惑「为什么 BT 章不讲 A* 怎么实现」(因为那根本不是它的事)。

2.5 为什么是「树」而不是别的:剧透 BT 解决了什么

执行层要做监控/反应/恢复,候选的结构其实有好几种:一段命令式代码(if-else 嵌套)、一个有限状态机 (FSM)、一棵决策树、或者一棵行为树。为什么本章(和整个机器人界)选了行为树?这里先剧透结论,§3 讲历史动机、§8 做严格对比。

行为树用一个统一的、可递归组合的树结构,同时满足执行层的全部需求:

执行层的需求 BT 怎么满足 别的结构的困境
顺序执行(先抓再放) Sequence 节点(§6.1) 命令式代码也能,但和下面的需求难统一
失败恢复(这条不行换那条) Fallback 节点(§6.2) FSM 要为每个失败手工连转移边
快速反应(随时中止切换) Reactive 节点每拍重检(§7.3) 命令式代码一旦进入某函数就难打断
并发监控(一边走一边盯电量) Parallel 节点(§6.4) FSM 表达并发要做状态笛卡尔积,爆炸
可组合、可插拔(加个新行为不动旧的) 子树即节点,递归嵌套(§8.3) FSM 加状态要改一片转移,不可组合
接住在线生成的计划 计划可编译成 BT(§10) 命令式代码难以程序化生成与热替换

一句话:行为树是「能把监控/反应/恢复/并发/可组合统一在一个结构里」的最简洁答案。它不是唯一的答案(强时序、状态极少的场景 FSM 仍更合适,§8.6),但它是覆盖面最广、最容易扩展的那一个——这正是它从游戏 AI 一路普及到机器人界的原因。

本质洞察:BT 流行的真正原因,不是它「能做某件别的结构做不了的事」——理论上 FSM 和 BT 表达能力等价(都能描述有限的控制逻辑)。BT 的优势是软件工程层面的:它把「控制流」做成了可组合的、声明式的、可递归嵌套的数据结构,于是「加一个行为」从「改一片转移逻辑」(FSM)变成了「插一个子树」(BT)。这就是 Iovino et al. (2022) 综述开篇点出的历史动机——游戏程序员发现 FSM「难扩展、难适配、难复用」,才转向 BT。BT 解决的不是「能不能做」,而是「好不好维护」。记住这一点,你才能在 §8 不被「BT 和 FSM 谁更强」这种伪命题带偏。

⚠️ 常见陷阱

陷阱 2-A:把规划当终点,认为「计划生成出来任务就算解决了」。 - 现象/后果:仿真里跑通的系统一上真机就频繁「假成功」——机器人报告任务完成,实际杯子没抓起来、路没走通。Demo 惊艳,落地崩盘。 - 根本原因:混淆了「在静止世界算对计划」(T1–T4 的职责)和「在动态世界维护计划」(T5 的职责)。计划只对「规划那一刻的世界」最优,执行中世界一变,open-loop 执行的偏差会沿后续动作放大且永不被发现 (§2.2)。 - 正确做法:把执行层当作系统的默认必选组件而非可选加项(总论 §6 须知二)。任何要在真实世界跑的系统,都必须有监控/反应/恢复的闭环——这正是本章 §11 要装上的反馈回路。

陷阱 2-B:以为执行层要自己实现 A*/RRT/IK,于是在 BT 叶子里塞进重型算法。 - 现象/后果:BT 叶子节点变得巨大、tick 一次要几百毫秒,整棵树的反应频率被拖垮,「快速反应」彻底失效;同样的运动算法在多个叶子里重复实现,难以维护。 - 根本原因:没分清执行层(编排)和运动层(执行)的职责边界 (§2.4)。BT 是 orchestrator 不是 executor,叶子应该是「调用运动层服务的轻量接口」,而不是算法本身。 - 正确做法:把 A*/RRT/IK/MPC 留在运动层(T线/MPPI 线),BT 叶子只做「发起调用 + 根据返回值决定三态」。叶子 tick 必须是轻量、可快速返回的(长动作返回 Running,§5.2),绝不在 tick 里阻塞做重计算。

陷阱 2-C:把「动作执行完毕」等同于「动作执行成功」。 - 现象/后果:就是 §2.2 的 open-loop 灾难——wait_until_done() 返回了就发下一个动作,从不验证后置条件,导致空夹爪也能「成功」走完全程。 - 根本原因:监控的缺失。「完成」是时间维度的(动作的执行过程结束了),「成功」是状态维度的(世界达到了期望状态),二者不能划等号。 - 正确做法:每个动作执行后(以及后续动作执行前)用条件节点检查关键的后置/前置条件 (§5.3、§7)。在 BT 里,这通常表现为「在 Sequence 里给动作前面放一个守卫 Condition」或「用 Reactive 结构让条件每拍被重检」。

练习

  1. (概念) §2.1 的失效表里,「目标变更」和其余几种失效有本质不同:其余几种是「计划的某个前提被打破」,而目标变更是「整个计划的目标被替换」。请说明:为什么前者通常用「恢复(重试/替代)」就能应对,而后者往往必须回到规划层重新生成计划?这个区别将如何影响你在 §11 设计监控逻辑时「把什么放进守卫条件、什么触发重规划」?
  2. (反事实) 把 §2.2 的 open-loop 执行器改造成「每步执行后检查后置条件,不满足就从计划第一步重新执行整个计划」。这比纯 open-loop 强,但请举一个具体场景,说明这种「从头重试」策略会陷入无限循环或做无用功——并说明 BT 的 Fallback 恢复链(§6.2)和 Retry 装饰器(§6.5)如何避免它。
  3. (设计) 仅用你现在的直觉(还没学控制节点),为「机械臂抓杯子放架子,抓取可能失败」这个任务画一个执行流程草图,要求它能「抓失败时重抓最多 3 次,3 次都失败则求助操作员」。把你画的草图保留下来,学完 §6 后回来对照:你的草图和用 Sequence/Fallback/Retry 写出的 BT 有多接近?差异在哪?

3. 历史脉络:从有限状态机到行为树 ⭐⭐

本节解决一个问题:行为树这个结构是怎么来的、它取代了什么、为什么能取代? 不了解历史,你会觉得 BT 的某些设计(比如为什么用 Fallback 而不是状态转移)是任意的;了解了,你会发现每个设计都是在修补前一代的痛点。

R5 要求「先动机后理论」,对一个结构的理解尤其要从「它的前身有什么毛病」讲起。BT 不是凭空发明的,它是「控制结构」这条演化线上的最新一环。我们按时间顺序走一遍。

3.1 第一代:有限状态机 (FSM) 与它的痛点

最古老、也最直观的行为编排工具是有限状态机 (Finite State Machine, FSM):把机器人的行为切成若干状态(如「导航中」「抓取中」「放置中」「恢复中」),状态之间用转移 (transition) 连接,每条转移挂一个触发条件。

一个朴素的取放 FSM:
  [导航到桌前] --到达--> [抓取] --抓到--> [导航到架前] --到达--> [放置] --放好--> [完成]
       │                   │                                        │
     超时               抓取失败                                   放置失败
       ↓                   ↓                                        ↓
   [恢复:重新导航]      [恢复:重试抓取]                          [恢复:重新放置]

状态少的时候,FSM 清晰好用——上图一眼就能看懂。问题出在规模增长时,FSM 有两个致命痛点:

痛点一:转移数量爆炸,\(O(N^2)\) 量级。 设有 \(N\) 个状态。最坏情况下,任意两个状态之间都可能需要一条转移,转移总数是

\[ |\text{transitions}| \le N(N-1) = O(N^2). \]

这个平方关系是 FSM 可维护性崩溃的数学根源。直观地说:每当你加入第 \(N{+}1\) 个状态(比如新增一个「电量低回充电」状态),你要考虑「从已有的哪些状态能进入它」和「从它能回到哪些状态」——最坏要新增 \(2N\) 条转移。状态线性增长,转移平方增长,到几十个状态时,转移网络就成了没人能看懂、没人敢改的「面条」。

直觉补充:为什么是 \(N(N-1)\)?因为从每个状态出发,最多可以转移到其余 \(N-1\) 个状态,共 \(N\) 个起点,故 \(N(N-1)\) 条有向边的上界。实践中不会真的全连接,但「加一个状态、潜在牵动 \(O(N)\) 条转移」这个增量行为,已经足以让大型 FSM 难以维护。这与 §8.3 BT 的「加一个行为只插一个子树、\(O(1)\) 改动」形成尖锐对比。

痛点二:不可组合、不可复用。 FSM 的状态和转移是「焊死」在一起的——「抓取」状态的「抓取失败 → 恢复」这条转移,直接指向了某个具体的恢复状态。如果你想把「抓取」这段逻辑原封不动搬到另一个任务里复用,你会发现它的转移指向了旧任务的状态,搬不走。FSM 没有「把一段行为打包成黑盒、在别处整体复用」的机制。

本质洞察:FSM 的根本局限,在于它的核心抽象是「状态 + 状态间的跳转」,而跳转是全局的、点对点的——A 状态知道自己失败后要跳到 B 状态。这种「每个状态都得知道别的状态」的耦合,正是不可组合的根源:一段逻辑要复用,就得把它依赖的所有跳转目标一起搬走,而那些目标又依赖别的状态……牵一发动全身。后面你会看到,BT 用「返回状态向父节点上报、由父节点决定下一步」替换了「状态点对点跳转」——子节点不知道自己失败后会发生什么,只管上报 Failure,是父节点(Fallback)决定退到哪。这个「子节点对全局无知」的特性,正是可组合性的来源。这是理解 BT 为何优于 FSM 的钥匙,§8.3 会把它讲到底。

3.2 包容架构 (Subsumption Architecture, Brooks 1986):分层反应的雏形

FSM 之外,机器人界早期还有另一条思路。1986 年,Rodney Brooks 提出包容架构 (Subsumption Architecture),针对的是当时主流的「感知—规划—执行」串行范式反应太慢的问题。它的核心思想是:把行为分层,高层行为可以「包容(抑制/覆盖)」低层行为的输出。

包容架构(示意,越上层优先级越高):
  第2层: [探索]      ─┐
  第1层: [漫游]      ─┼─→ 高层抑制低层 → 最终输出
  第0层: [避障]      ─┘   (避障是兜底,任何时候障碍近了就压制上层)

每一层是一个独立的、持续运行的行为模块,直接从传感器读、往执行器写。层与层之间靠「抑制 (suppression)」和「压制 (inhibition)」信号竞争对执行器的控制权。比如「避障」是最底层但优先级机制上能抑制上层——一旦障碍逼近,不管上层「探索」想干什么,避障都能夺取控制权。

包容架构的贡献,是确立了反应式 (reactive) 控制的价值:行为应该持续地、并行地对世界做出反应,而不是「先想清楚再行动」。但它也有明显短板:层间的抑制关系是硬编码、扁平的,行为一多,抑制关系同样难以管理;而且它几乎没有「任务级时序」的表达力——它擅长「持续反应」,不擅长「先做 A 再做 B」这种有序任务。

本质洞察:包容架构和 FSM 恰好是两个极端——FSM 强于「时序」(先做什么后做什么一清二楚)但弱于「反应」(要反应得手工连转移);包容架构强于「反应」(天生并行、随时抢占)但弱于「时序」(不会表达有序任务)。机器人执行层真正需要的,是两者兼得:既能表达「先抓再放」的时序,又能表达「障碍来了立刻避」的反应。BT 的历史意义,正在于它把这两条线统一了——Sequence/Memory 给时序,Fallback/Reactive 给反应。Colledanchise & Ögren (2017) 证明的「BT 推广包容架构」(§8.4),形式化地坐实了这一点:包容架构是 BT 的一个特例。

3.3 决策树 / 行为脚本:游戏 AI 的探索

与机器人界平行,电子游戏工业也在为「怎么让 NPC(非玩家角色)表现得聪明」这个问题挣扎。早期游戏 AI 用过两种工具:

  • 决策树 (Decision Tree):一棵每个内部节点问一个条件、叶子是一个动作的树。「敌人近吗?近 → 攻击;远 → 敌人在视野里吗?在 → 追击;不在 → 巡逻」。它擅长「根据当下状态选一个动作」,但没有时序记忆——每次都从根重新问起,无法表达「正在执行一个需要好几拍才能完成的动作」。
  • 行为脚本 (scripted behavior):直接用脚本语言写死「先走到 A,再播放动画 B,再……」。灵活但完全不可复用、不可组合,且和 FSM 一样难维护。

游戏程序员在这两者之间反复横跳,始终没找到「既能表达复杂行为、又模块化好维护」的结构——直到行为树出现。

关键澄清(贯穿全章):这里的决策树 (Decision Tree)——游戏 AI 用的那种「问条件选动作」的树——和后来机器学习里「从数据学出来的分类器决策树」是两回事但同名,也和行为树 (Behavior Tree) 不是一回事。三者都是树、都叫某种「树」,极易混淆。本章 §8.1–8.2 会专门澄清,并给出 Colledanchise & Ögren (2017) 的结论:决策树是行为树的一个特例(BT 比决策树更一般)。现在只需记住:本章从头到尾讲的是行为树

3.4 行为树的诞生:从 Halo 到机器人

行为树 (Behavior Tree, BT) 诞生于 2000 年代的电子游戏工业。它最广为人知的早期应用是 Bungie 公司的游戏《光环 2》(Halo 2, 2004) ——其 NPC 的 AI 用 BT 编排,使得敌人能表现出有层次、可预测又灵活的战术行为。随后《光环 3》及大量 3A 游戏(如《孤岛危机》系列)都采用了 BT。游戏程序员发现:BT 恰好补上了决策树(无时序)和 FSM(不可组合)的短板——它既能像决策树一样「根据条件选行为」,又能像 FSM 一样表达时序,还天生模块化(子树即节点,随便嵌套复用)。

大约十年后,BT 从游戏「跨界」到机器人。Marzinotto, Colledanchise, Smith & Ögren (2014, ICRA) 发表了机器人领域第一个统一的、数学严格的 BT 框架——在此之前,BT 在机器人界零散出现过,但缺乏一致的定义和形式化。这篇论文把 BT 和受控混合动力系统 (Controlled Hybrid Dynamical System, CHDS) 建立等价,给了 BT 在机器人控制中的理论地位。从此 BT 在机器人界进入快车道:BehaviorTree.CPP 库(§5 起贯穿全章)、Nav2 用 BT 做导航编排(§9 精读),都是这股浪潮的产物。

3.5 形式化里程碑:Colledanchise & Ögren 2017 与 2018 教材

如果说 2014 年的工作给了 BT 在机器人界的「出生证」,那么真正把 BT 的理论体系建立起来的,是 Colledanchise & Ögren (2017, IEEE Transactions on Robotics 33(2):372–389)。这篇论文的核心贡献,是证明了一个统一性定理:

行为树推广了三个经典工具:序贯行为组合 (Sequential Behavior Composition, SBC)、包容架构 (Subsumption Architecture)、以及决策树 (Decision Tree)。这三个工具各自只擅长一隅,BT 把它们都作为特例包含进来。

这个定理(§8.4 会展开)是 BT「为什么值得用」的最强理论背书——它说明 BT 不是又一个并列的工具,而是一个更一般的框架,把前人的几条路都收编了。同一批作者随后出版了领域标准教材 Colledanchise & Ögren (2018), 《Behavior Trees in Robotics and AI: An Introduction》(CRC Press),系统整理了 BT 的定义、控制节点语义、与经典架构的关系、以及形式化分析(收敛性、鲁棒性、安全性)。这本书是本章理论部分的主要依据。

到 2022 年,Iovino et al. (2022, Robotics and Autonomous Systems 154:104096) 发表了一篇覆盖面极广的 BT 综述,梳理了 BT 在机器人与 AI 中的应用版图——从经典控制编排,到与规划结合(plan-to-BT,§10),到学习式生成(§10.7)。这篇综述是本章「近五年进展」的主要索引。

3.6 演进表:每代解决上代什么局限

把上面的历史压缩成一张表——这是 R6E「系统性分类」的要求,让你获得「演化框架」而非「记忆清单」:

代际 代表工具 核心抽象 解决了上一代什么 留下什么局限 → 由谁解决
第 0 代 命令式脚本 顺序语句 (起点) 不可组合、不可复用、难维护 → FSM 试图结构化
第 1 代 有限状态机 FSM 状态 + 点对点转移 给了行为以「结构」 转移 \(O(N^2)\) 爆炸、不可组合 → BT 的返回状态机制
第 1' 代 包容架构 (1986) 分层 + 抑制 确立了反应式控制 抑制硬编码、无时序表达 → BT 的 Reactive + Sequence
第 1'' 代 决策树 / 行为脚本 条件选动作 / 脚本 游戏 AI 的条件分支 无时序记忆、脚本不可复用 → BT 的 Running 态 + 子树
第 2 代 行为树 BT (游戏 2000s → 机器人 2014) 子树 + 返回状态上报 统一时序与反应、可组合可复用 手写树不可扩展 → plan-to-BT 在线生成 (§10)
第 3 代 plan-to-BT / 学习式 BT (2019–) 规划器/学习器生成 BT 自动生成树、融合规划与执行 仍在前沿(LLM 生成 BT 等,§10.7)

读这张表的方式:每一行的「留下什么局限」就是下一行存在的理由。这条「问题驱动演化」的链,正是 R5「先动机后理论」在历史维度的体现——BT 不是天才的灵光一闪,而是几十年「修补前代痛点」累积的结果。

本质洞察:纵观这条演化线,有一个不变的主题——控制结构的演化,方向始终是「提高模块化、降低耦合」。从脚本到 FSM 是「给行为加结构」,从 FSM 到 BT 是「让结构可组合」,从手写 BT 到 plan-to-BT 是「让结构自动生成」。每一步都在回答同一个工程问题:「怎么让『加一个新行为』的代价更小?」理解了这条主线,你就不会把 BT 当成孤立的知识点,而能看到它在「机器人行为编排」这棵大树上的位置——它是当前的主干,但不是终点(§10 的在线生成是正在生长的新枝)。这也呼应了本项目「构建知识体系而非堆砌知识点」的要求。

⚠️ 常见陷阱

陷阱 3-A:认为「BT 能做 FSM 做不到的事,所以 BT 更强」。 - 现象/后果:在该用 FSM 的场景(强时序、状态极少、转移逻辑天然清晰)硬上 BT,反而把简单问题复杂化;或在争论「BT vs FSM 谁强」时陷入伪命题。 - 根本原因:误解了 BT 的优势性质。BT 和 FSM 表达能力本质等价(都描述有限控制逻辑),BT 的优势是软件工程层面的可组合性,不是表达能力层面的「能做更多」(§2.5、§8.6)。 - 正确做法:把「BT vs FSM」理解为「可维护性之争」而非「能力之争」。状态多、行为常变、需要复用的复杂系统选 BT;状态极少、时序固定的简单逻辑,FSM 甚至更直观(§8.6 给判据)。 - 多视角理解(类比,标边界):BT 取代 FSM,高级语言取代汇编——二者图灵等价(能算的东西一样多),但高级语言的可读性、可组合性、可维护性让大型程序成为可能。不像之处在于:高级语言要编译成汇编才能跑,而 BT 不是「编译成 FSM 再跑」——它是一个独立的执行模型(tick 驱动)。这个类比只在「等价但更好维护」这一点上成立,别延伸到「BT 是 FSM 的语法糖」(它不是)。

陷阱 3-B:把游戏 AI 的「决策树」、机器学习的「决策树分类器」、和「行为树」混为一谈。 - 现象/后果:看到论文里说「决策树是行为树的特例」时一头雾水(「分类器怎么会是控制流的特例?」),或者把机器学习训练出的分类树直接当成机器人执行结构。 - 根本原因:三个同名/近名的「树」分属不同领域:游戏 AI 决策树(条件选动作的控制流)、ML 决策树(从数据学的分类器)、行为树(带 Running 态与子树的执行控制流)。§3.3 的「决策树」指第一种。 - 正确做法:明确语境。本章说「决策树是 BT 的特例」(§8.2) 时,指的是游戏 AI 那种「问条件选动作」的控制流决策树——它确实是「没有 Running 态、没有 Memory」的退化 BT。这和 ML 分类器无关。术语首次出现都标了英文,遇到「决策树」先看它是 Decision Tree 作控制流还是作分类器。

陷阱 3-C:以为「BT 来自游戏,所以在机器人上只是玩具」。 - 现象/后果:轻视 BT,认为严肃的机器人系统应该用「更学术」的方法,错过 BT 在工业界(Nav2、众多移动操作系统)已是事实标准的现实。 - 根本原因:不了解 BT 自 2014 年 Marzinotto 等人的形式化、2017 年 Colledanchise-Ögren 的统一性定理以来,已建立了严格的理论基础(与 CHDS 等价、收敛性/安全性分析),并非「只是游戏脚本」。 - 正确做法:认识到 BT 已是机器人执行层的主流工程标准——ROS 2 的 Nav2 导航栈(§9)、大量移动操作框架都以 BT 为编排核心。它「出身游戏」恰恰证明了它的工程实用性经过了海量产品的检验。

练习

  1. (推导) 证明:一个有 \(N\) 个状态、允许任意两状态间转移的 FSM,其有向转移边数的上界是 \(N(N-1)\)。然后说明:当你向这个 FSM 新增第 \(N{+}1\) 个状态时,最坏情况下需要新增多少条转移边?把这个增量与 BT「新增一个行为只需插入一个子树」的 \(O(1)\) 结构改动对比,解释为什么大型系统倾向于 BT。
  2. (历史分析) §3.2 说包容架构「强于反应、弱于时序」,FSM「强于时序、弱于反应」。请各举一个具体机器人任务:(a) 一个用包容架构很自然、用 FSM 很别扭的任务;(b) 一个用 FSM 很自然、用包容架构几乎无法表达的任务。然后说明 BT 如何用 Fallback/Reactive(管反应)+ Sequence/Memory(管时序)同时优雅地表达这两个任务。
  3. (跨章·开放) 结合总论 §3.5 的「BT 推广序贯行为组合、包容架构、决策树」这一论断,预测:既然 BT 是这三者的「最一般形式」,那么在表达能力上是否存在「比 BT 更一般」的执行控制结构?查阅 Iovino et al. (2022) 综述的相关讨论,谈谈 BT 当前被指出的局限(提示:可读性随规模下降、缺乏对「记忆/数据流」的原生支持等)以及学界的改进方向。

4. BT 的本质:tick 机制与三种返回状态 ⭐⭐⭐

本节解决一个问题:行为树到底是怎么「跑」的? 答案只需两个概念——tick(驱动信号)和三种返回状态(Success/Failure/Running)。这两个概念是全章的地基,尤其 Running 状态,是 BT 区别于一切「一次性求值结构」的灵魂。

4.1 动机:执行层到底需要什么原语

先想清楚:一个执行层最小需要哪些「原语 (primitive)」?回到 §2.3 的三件事——监控、反应、恢复——它们对底层机制提出了三个要求:

  1. 要能反复检查。反应要快(§2.3),意味着不能「发起一个动作就撒手不管直到它结束」,而要高频地、反复地去看「现在还对吗」。所以执行层需要一个周期性的驱动信号
  2. 要能表达「还没做完」。一个动作(如导航到 3 米外)需要好几秒、跨越成百上千次检查才能完成。在它没完成的那些检查里,执行层需要一种方式表达「这个动作正在进行中,既没成功也没失败」。
  3. 要能让结果向上汇报、由上层决策。子行为的成功/失败,不应该由子行为自己决定「接下来跳到哪」(那是 FSM 点对点跳转的毛病,§3.1),而应该汇报给父节点,由父节点根据汇报决定下一步——这是可组合性的来源(§3.1 本质洞察)。

行为树用两个极简的设计同时满足这三点:tick 满足要求 1(周期性驱动),三种返回状态满足要求 2 和 3(其中 Running 表达「进行中」,三态向父节点汇报)。下面逐一讲透。

4.2 tick 是什么:自根而下的「心跳」信号

tick 是行为树的驱动信号——你可以把它想成树的「心跳」。系统以某个固定频率(如每秒 10 次、100 次)向树的根节点发出一个 tick 信号。这个信号像水一样自根而下流淌:根节点收到 tick 后,按自己的类型规则把 tick 传给(部分)子节点,子节点再传给孙节点,直到到达叶子节点。叶子节点真正「干活」(检查一个条件、或推进一个动作),产生一个返回状态,这个状态再自下而上回流,每一层的控制节点根据子节点返回的状态,决定自己返回什么、以及下一拍 tick 谁。

一次 tick 的流动(↓ tick 下行,↑ 状态上行):

              [根]  ←──────────────┐
               │ ↓ tick            │ ↑ 汇总后的状态
            [Sequence] ←───────┐
            ↙   ↓ tick   ↘     │ ↑ 子节点状态
      [条件A] [动作B]  [动作C]   │
        │ ↓     │ ↓      │ ↓     │
       检查    推进     推进  →──┘
        ↑       ↑        ↑  返回 Success/Failure/Running

关键点:tick 是「无状态的脉冲」,不携带数据。它只是「该你动一下了」的信号。一次 tick 从根流到叶、再从叶流回根,构成一个完整的「遍历 (traversal)」。系统下一拍再发一个 tick,又是一次完整遍历。整棵树就是靠这一拍接一拍的 tick 反复遍历来运转的——这是理解 BT 的第一个关键转变:BT 不是「执行一次就完了」,而是「被反复 tick、每拍重新遍历」。

本质洞察:tick 这个设计的精髓,在于它把「持续监控」和「推进动作」统一成了同一个机制。在 open-loop 执行器里(§2.2),「检查条件」和「执行动作」是两类不同的代码,时机也不同。在 BT 里,它们都是「被 tick 时做点事、返回一个状态」——条件节点被 tick 时检查、动作节点被 tick 时推进一小步。于是「每拍都重新检查所有该检查的条件」变成了 tick 遍历的自然副产品,而不需要额外的监控线程。这就是为什么 BT 天生适合做监控——监控不是附加功能,而是 tick 机制的内在行为。§7 的 Reactive 语义把这一点用到极致。

4.3 三种返回状态:Success / Failure / Running

每次一个节点被 tick,它必须返回三种状态之一。这三种状态构成了 BT 的全部「输出词汇表」:

返回状态 含义 叶子节点何时返回它 控制节点何时返回它
Success(成功) 「我(这个节点代表的目标/动作)已经达成」 条件成立 / 动作完成 取决于节点类型(Sequence 全子成功才成功,Fallback 一子成功就成功)
Failure(失败) 「我无法达成」 条件不成立 / 动作失败 取决于节点类型(Sequence 一子失败就失败,Fallback 全子失败才失败)
Running(运行中) 「我正在做,还没有结果,请下一拍再来」 动作需要多拍、尚未完成 有子节点处于 Running 时通常上传 Running

前两种(Success/Failure)很直观,几乎所有「会返回结果」的结构都有类似概念(函数返回成功/失败、布尔判定真/假)。真正让 BT 与众不同的是第三种——Running

为什么 Running 是 BT 的灵魂? 考虑一个动作「导航到 3 米外的目标点」。这个动作不可能在一次 tick(几毫秒)内完成——它需要几秒、跨越几百次 tick。那么在这几百次 tick 里,这个动作节点该返回什么?

  • 不能返回 Success——它还没到目标,谎报成功会让父节点以为做完了,往下走(这就是 §2.2 的灾难)。
  • 不能返回 Failure——它没失败,只是没做完,谎报失败会触发不必要的恢复。
  • 它需要第三种状态:Running——「我在路上,还没到,下一拍请继续 tick 我」

有了 Running,BT 才能表达跨越多拍的、有时间延续的动作,而不是「一锤子买卖」。这正是 BT 区别于决策树(§3.3)的关键:决策树每次从根问到叶、立刻得到一个动作,没有「一个动作正在进行、占据好几拍」的概念。Running 让 BT 有了时间维度

不是 X 而是 Y:初学者常把 BT 的 tick 理解成「调用一次、得到最终结果」——不是。一次 tick 不是「执行整个动作直到它结束」,而是「把整个动作往前推进一小步(一个时间片),然后立即返回当前状态(多半是 Running)」。把 tick 想成「问一句『进展如何?』」,而不是「下令『把这件事做完!』」。这个区别是后面理解一切控制节点语义的前提——如果你以为 tick 会阻塞到动作结束,你会彻底误解 Sequence、Fallback、Parallel 的行为。

本质洞察Running 状态的引入,把行为树从「决策结构」升级成了「执行结构」。没有 Running,BT 退化成决策树——每拍从根选出一个动作就完事,无法跟踪「某个动作正在跨多拍执行」。有了 Running,BT 能在「正在执行一个长动作」和「同时每拍检查守卫条件」之间取得平衡:长动作返回 Running 占住自己的位置,而树的其余部分(守卫条件)依然每拍被重新遍历检查。这个「一边持续执行、一边持续监控」的能力,正是执行层的命脉,也是 §7 Reactive 语义、§9 Nav2 恢复机制能够工作的底层基础。把 Running 理解透,你就理解了 BT 的一半。

4.4 形式化:BT 作为一个函数

把上面的直觉精确化。借用 Colledanchise & Ögren (2017, 2018) 的形式化,一棵行为树可以定义为一个函数,它把当前世界状态映射到一个返回状态和一个动作。

设系统的状态空间为 \(\mathcal{X}\)(机器人构型、环境状态等的全体),\(x \in \mathcal{X}\) 是当前状态。一棵行为树 \(\mathcal{T}\) 在被 tick 时,本质上计算一个映射:

\[ \mathcal{T} : \mathcal{X} \longrightarrow \{\,\mathtt{Success},\ \mathtt{Failure},\ \mathtt{Running}\,\} \times \mathcal{U}, \]

其中 \(\mathcal{U}\) 是动作(控制输入)空间。也就是说,一次 tick 接收当前状态 \(x\),返回一个二元组 \((\,r,\ u\,)\)\(r\) 是返回状态(三选一),\(u\) 是这一拍要施加给系统的动作。

更细致地,一个叶子动作节点对应一个三元组 \((\,f,\ r,\ R\,)\)

  • \(f : \mathcal{X} \to \mathcal{U}\) 是它的控制律(被 tick 时根据状态算出这一拍的动作 \(u = f(x)\));
  • \(R \subseteq \mathcal{X}\) 是它的返回状态判据——当 \(x\) 落在某些子集时返回 Success,落在另一些时返回 Failure,否则 Running

形式化地,定义两个区域:成功区 \(S \subseteq \mathcal{X}\) 和失败区 \(F \subseteq \mathcal{X}\)(二者不交),则叶子的返回状态为

\[ r(x) = \begin{cases} \mathtt{Success}, & x \in S,\\[2pt] \mathtt{Failure}, & x \in F,\\[2pt] \mathtt{Running}, & x \in \mathcal{X} \setminus (S \cup F). \end{cases} \]

直觉解读:一个动作节点把状态空间切成三块——「已经到了目标区 \(S\)」(返回 Success)、「掉进了死路区 \(F\)」(返回 Failure)、「还在路上」(返回 Running,并通过 \(u=f(x)\) 继续往 \(S\) 推)。比如「导航到目标点」:\(S\) 是「机器人在目标点附近的小球」,\(F\) 是「规划器报告无路可走」,其余广大区域都是 Running,控制律 \(f\) 持续把机器人往目标推。

控制节点则是把子树的返回函数组合起来的算子。例如一个有两个子节点 \(\mathcal{T}_1, \mathcal{T}_2\) 的 Sequence,其返回状态可以写成(先 tick \(\mathcal{T}_1\)):

\[ r_{\text{Seq}}(x) = \begin{cases} r_1(x), & r_1(x) \in \{\mathtt{Failure}, \mathtt{Running}\},\\[2pt] r_2(x), & r_1(x) = \mathtt{Success}. \end{cases} \]

读法:Sequence 先看第一个子节点 \(\mathcal{T}_1\)——如果它 FailureRunning,Sequence 直接返回这个状态(不再看 \(\mathcal{T}_2\));只有当 \(\mathcal{T}_1\) Success 了,才把控制权交给 \(\mathcal{T}_2\),返回 \(\mathcal{T}_2\) 的状态。这正是 §6.1 要讲的「与门」语义的数学表达。Fallback 是对偶的(把 Success 和 Failure 的角色对调),§6.2 给出。

本质洞察:这个形式化揭示了 BT 的数学身份——它是一个把「状态空间到 \(\{\text{三态}\} \times \text{动作}\)」的映射,通过控制节点算子递归组合而成的复合函数。这个视角有两个深远后果:其一,因为 BT 是函数的递归组合,任何子树都是一个同类型的函数——这就是「子树即节点」可组合性的数学根源(§8.3)。其二,因为每个叶子的控制律 \(f\) 把状态往成功区 \(S\) 推,整棵树可以被分析其「收敛性」(系统状态是否最终进入根的成功区)——Colledanchise & Ögren (2017, 2018) 据此证明了一类 BT 的有限时间收敛与安全性保证。这就是为什么 BT 不只是工程技巧,而有坚实理论基础:它本质上是一个可分析的动力系统控制器(与 §3.4 提到的 CHDS 等价正源于此)。

4.5 tick 频率与时间语义:为什么 BT 能「反应」

tick 以固定频率发出,这个频率(tick rate)决定了 BT 的「反应速度」。设 tick 频率为 \(f_{\text{tick}}\)(单位 Hz),则相邻两次 tick 的间隔为 \(\Delta t = 1 / f_{\text{tick}}\)。这个 \(\Delta t\) 就是 BT 能「察觉世界变化」的时间分辨率——任何变化最多在 \(\Delta t\) 之后被下一次 tick 发现。

tick 频率 \(f_{\text{tick}}\) 间隔 \(\Delta t\) 典型用途 权衡
1 Hz 1 s 高层任务编排(任务级,状态变化慢) 反应慢,但 CPU 开销低
10 Hz 100 ms 移动机器人任务编排(Nav2 默认量级) 平衡:足够反应导航事件,开销可控
100 Hz 10 ms 需要快速中止的反应行为 反应快,但每拍遍历整树的开销上升

为什么 BT 能「反应」,核心就在这里:因为每个 tick 都重新从根遍历(至少遍历那些 Reactive 的部分,§7),守卫条件每隔 \(\Delta t\) 就被重新检查一次。一旦某个守卫条件由真变假(比如「前方无障碍」变成「前方有障碍」),下一次 tick(最多 \(\Delta t\) 之后)就会发现,并立即切换分支。这个「周期性重检 + 立即切换」就是反应性的来源。

对比 open-loop 执行器(§2.2):它「发出动作 → 阻塞等待动作完成」,在等待期间根本不检查任何条件,反应延迟等于「当前动作的整个执行时长」(可能几秒)。BT 把这个延迟压缩到一个 tick 间隔 \(\Delta t\)(如 100 ms)。这就是为什么同样面对「椅子突然推进轨迹」,open-loop 要等机械臂把当前整步走完才可能停,而 reactive BT 在 \(\Delta t\) 内就能中止。

理论-工程桥接:tick 频率的选择是一个实打实的工程权衡,不是越高越好。频率太低,反应迟钝(障碍出现到察觉延迟大);频率太高,每拍都要遍历整棵树,大树的遍历开销会吃掉 CPU,甚至让 tick 都来不及在 \(\Delta t\) 内完成(tick 超时,反而失去实时性)。Nav2 默认在 10 Hz 量级(§9),因为导航事件(路被挡、到达航点)的时间尺度是百毫秒级,10 Hz 足够;而它把更高频的闭环(如轨迹跟踪的 MPC,可能上百 Hz)留在运动层FollowPath 控制器里(MPPI_08)——BT 只在 10 Hz 编排,不在 BT 里做高频控制。这再次印证 §2.4:BT 是编排器,高频控制律在运动层。把 tick 频率和控制频率分开,是分层架构的关键设计。

4.6 与函数调用栈的类比

为了让 tick 机制更亲切,用一个程序员熟悉的概念来类比——但严格标注边界(R7 要求)。

像的地方:一次 tick 的遍历,一次递归的函数调用——根节点「调用」子节点(把 tick 传下去),子节点「返回」一个值(返回状态)给父节点,父节点根据返回值决定要不要「调用」下一个子节点。Sequence 的「前一个 Success 才 tick 后一个」,「前一个函数返回成功才调用后一个」。这个类比能帮你理解 tick 的「下行调用、上行返回」结构。

不像的地方(关键,别把类比延伸过头):

  1. 普通函数调用「调用一次、跑到结束、返回最终结果」;BT 的 tick「调用一次、只推进一个时间片、可能返回 Running(未完成)」。 没有任何普通函数会返回「我还没算完,请你下一拍再调我一次」——但 BT 的动作节点天天这么干。Running 在普通调用栈里没有对应物。
  2. 普通函数调用是「一次性」的,调完栈就弹空了;BT 是「周期性反复调用」的,下一拍又从根重新「调用」一遍整棵树。 普通程序不会每 100 ms 把 main() 重新跑一遍,但 BT 就是每拍重新遍历一遍。
  3. 普通函数的控制流由调用者写死;BT 的控制流由树的结构和返回状态共同决定,且可在运行时换树(§10 在线生成)。

本质洞察:这个类比的「不像之处」恰恰是 BT 的全部价值所在。如果 BT 真的「像普通函数那样调用一次跑到底」,它就退化成了 open-loop 执行器,失去了反应能力。BT 的两个反常之处——返回 Running(未完成)周期性重新遍历——正是它能做监控/反应的根本。所以记住:tick 不是函数调用,tick 是「心跳式的、推进一步就返回的、周期性重复的」遍历。任何时候你发现自己在用「函数调用一次跑到底」的直觉理解 BT,就要警惕——你多半正在踩 §4.3 那个「把 tick 当一次性调用」的陷阱。

⚠️ 常见陷阱

陷阱 4-A:忽略 Running 状态,把动作节点当成「要么立刻成功要么立刻失败」。 - 现象/后果:写一个「导航到目标」的动作节点,在 tick 里同步地阻塞执行整个导航(走几秒),导致 tick 无法在 \(\Delta t\) 内返回。整棵树被这一个节点卡住,其余守卫条件在这几秒内完全不被检查,反应性彻底丧失——退化成 open-loop。 - 根本原因:没理解 tick 是「推进一步就返回」而非「执行到结束」(§4.3、§4.6)。把长动作写成同步阻塞,等于在 BT 里塞进了一个 open-loop 段。 - 正确做法:长动作(long-running action)必须实现为异步——每次 tick 只推进一小步(或只检查异步任务的进度),未完成就立即返回 Running,把控制权交还给树,让其余部分继续被遍历。BehaviorTree.CPP 用 StatefulActionNode(§5.5)专门支持这种异步模式。

陷阱 4-B:以为 tick 一次就「执行完整棵树」,于是只 tick 一次就期待任务完成。 - 现象/后果:调用一次 tree.tickRoot() 然后检查返回值,发现是 Running,就以为「树没工作 / 卡住了」,困惑为什么任务没做完。 - 根本原因:误以为一次 tick = 执行整个任务。实际上一次 tick 只是「从根到叶遍历一遍、每个被 tick 的节点推进一个时间片」,整个任务需要成百上千次 tick 才完成 (§4.2)。 - 正确做法:在一个循环里持续 tick,直到根返回 SuccessFailure

// 正确:在循环里反复 tick,直到根给出终态
BT::NodeStatus status = BT::NodeStatus::RUNNING;
while (status == BT::NodeStatus::RUNNING) {
    status = tree.tickOnce();           // 一次遍历,推进一个时间片
    std::this_thread::sleep_for(10ms);  // 控制 tick 频率,这里约 100 Hz
}
// 退出循环时 status 是 SUCCESS 或 FAILURE,任务才真正结束

陷阱 4-C:在 tick 中做重计算或长时间 I/O,阻塞整棵树的遍历。 - 现象/后果:某个叶子在 tick 里同步调用一个耗时 200 ms 的服务(如重型路径规划、阻塞式网络请求),导致单次 tick 远超 \(\Delta t\),tick 频率名义 100 Hz 实际跌到 5 Hz,反应性崩塌。 - 根本原因:tick 的契约是「快进快出」——每个节点应在远小于 \(\Delta t\) 的时间内返回。在 tick 里做阻塞重计算违反这个契约。 - 正确做法:重计算/长 I/O 放到后台线程或异步服务里发起,叶子节点在 tick 里只「查询后台任务的状态」:未完成返回 Running,完成了返回 Success/Failure。这正是 §5.2 long-running action 的异步范式,也是 §9 Nav2 把规划/控制做成 action server、BT 叶子只做异步客户端的原因。

练习

  1. (概念·核心) 用你自己的话解释:为什么 BT 必须有 Running 这第三种状态,只有 Success/Failure 两种为什么不够?请构造一个具体的两节点 Sequence(如「先导航到桌前,再抓杯子」),分别说明:如果导航动作在未到达时被迫返回 Success 会发生什么、被迫返回 Failure 会发生什么——从而论证 Running 不可或缺。
  2. (形式化) 按 §4.4 的定义,为一个「等待 5 秒」的动作节点写出它的成功区 \(S\)、失败区 \(F\) 和控制律 \(f\)(提示:可以用一个内部计时器变量作为状态的一部分)。然后写出它在第 0、1、2、3、4、5 秒被 tick 时各返回什么状态。再为一个 Fallback 节点(两个子节点 \(\mathcal{T}_1, \mathcal{T}_2\))写出类似 §4.4 Sequence 的返回状态分段函数 \(r_{\text{Fb}}(x)\),并说明它和 Sequence 公式的对偶关系。
  3. (工程·反事实) 假设你把 §4.2 的 tick 频率从 10 Hz 提到 1000 Hz,期望「反应更快」。请分析两个后果:(a) 对一棵有 500 个节点的大树,每秒要做多少次节点遍历?这对 CPU 意味着什么?(b) 如果单次完整 tick 遍历需要 2 ms,1000 Hz(\(\Delta t = 1\) ms)会发生什么灾难?由此说明 §4.5「tick 频率不是越高越好」的工程权衡,并给出你会如何为「百毫秒级导航事件 + 个别需要 10 ms 内中止的安全行为」这种混合需求选择 tick 策略(提示:是否所有节点都需要同一个 tick 频率?)。

5. 叶子节点:Action 与 Condition ⭐⭐⭐

本节解决一个问题:树的「叶子」——真正干活的节点——长什么样? 叶子分两类:动作 (Action) 和条件 (Condition)。它们是 BT 与外部世界(运动层、传感器)的唯一接口;控制节点(§6)只负责调度,从不直接碰世界。

5.1 两类叶子:动作节点 vs 条件节点

行为树的所有「实际工作」都发生在叶子节点。叶子分两类,区别在于它对世界有没有副作用

叶子类型 做什么 有副作用吗 典型例子 返回状态的含义
动作节点 (Action) 改变世界 / 命令机器人做事 (驱动电机、闭合夹爪、调用规划服务) NavigateTo(goal)PickObject(cup)ClearCostmap Success=做成了;Failure=做不成;Running=正在做
条件节点 (Condition) 查询世界 / 判断一个谓词 (只读,不改变任何东西) IsBatteryOKIsPathClearObjectInGripper Success=条件成立(真);Failure=条件不成立(假);几乎不返回 Running

这个「有无副作用」的区分是纪律性的,不是装饰性的——它直接决定了节点能放在树的什么位置、被 tick 时安不安全。两条核心纪律:

  1. 动作节点有副作用,因此「被 tick」意味着「世界可能被改变」。这要求动作节点能正确处理「被反复 tick」和「被中途打断(halt)」——因为 Reactive 结构(§7)会反复 tick 它、或在它没做完时打断它。
  2. 条件节点无副作用,因此「被 tick」是绝对安全的——可以任意频繁地、在任意位置 tick 它,不会留下任何痕迹。这正是条件节点能充当「守卫 (guard)」、被每拍重检的前提(§7.3)。

本质洞察:动作/条件的二分,对应 §2.3 三件事里的「反应/恢复」(动作)和「监控」(条件)。条件节点是 BT 的「眼睛」——它把世界状态接入树动作节点是 BT 的「手脚」——它通过运动层改变世界。一棵设计良好的 BT,其结构往往是「条件守着动作」:用条件节点检查前提,前提成立才让动作节点动手。这种「守卫 + 动作」的配对,是后面所有模式(Sequence 里的前置检查、Reactive 守卫、Nav2 的恢复触发)的原子单元。把「条件只读、动作有副作用」这条纪律刻进肌肉记忆,你写的树才不会出现「条件节点偷偷改世界」这种最隐蔽的 bug(§5.3 陷阱)。

5.2 动作节点的三态语义与异步执行

动作节点是叶子里更复杂的一类,因为它要处理「动作需要时间」这件事。回到 §4.3:一个长动作(导航、抓取)跨越成百上千拍,在没完成的那些拍里必须返回 Running。这就要求动作节点区分两种实现风格:

同步动作 (Synchronous Action):动作能在一次 tick 内瞬间完成——比如「设置一个标志位」「读一个参数」「发布一条消息」。它被 tick 时直接干完、立即返回 SuccessFailure永远不返回 Running。这类动作简单,但只适用于「真的能瞬间完成」的操作。

异步 / 有状态动作 (Asynchronous / Stateful Action):动作需要多拍才能完成——这是机器人里的绝大多数动作(导航、抓取、跟轨迹)。它的执行被切成三个阶段:

异步动作的生命周期(一次完整执行跨多拍):

  第一次被 tick:   onStart()  —— 发起动作(如向运动层发"开始导航"请求)
                              └→ 返回 RUNNING(已发起,但远没完成)
  后续每次被 tick: onRunning() —— 查询进度(如问运动层"到了吗?")
                              ├→ 没到 → 返回 RUNNING
                              ├→ 到了 → 返回 SUCCESS
                              └→ 出错 → 返回 FAILURE
  若中途被打断:    onHalted() —— 清理(如向运动层发"取消导航",释放资源)

这三个回调(onStart / onRunning / onHalted)是异步动作的骨架。关键设计:

  • onStart 只「发起」不「等待」:它向运动层(或后台线程)发起任务请求后立即返回 Running绝不阻塞等任务完成。这是 §4.3 陷阱 4-A 的正解——长动作不能在 tick 里同步阻塞。
  • onRunning 是「非阻塞轮询」:每拍被调用一次,快速查一下后台任务的状态就返回。它绝不自己「干活」,只「查进度」。
  • onHalted 是「打断时的清理」:当 Reactive 结构(§7)因为更高优先级的分支被激活而要打断这个还在 Running 的动作时,BT 会调用 onHalted,让动作有机会取消底层任务、释放资源(如停止电机、取消导航请求)。这是抢占语义(§11.5)能干净工作的关键——没有 onHalted,被打断的动作会留下「电机还在转、导航请求还挂着」的脏状态。

理论-工程桥接:异步动作的「发起—轮询—清理」三段式,不是 BT 独有的发明,而是和现代机器人中间件(ROS 2 的 Action 机制)天然对齐的。ROS 2 的 Action 正是「客户端发起目标 (goal) → 服务端异步执行并反馈 (feedback) → 客户端可取消 (cancel)」。所以 Nav2 的 BT 动作叶子(如 ComputePathToPoseFollowPath)几乎都是「BT 异步动作节点 = ROS 2 Action 客户端」的包装:onStart 发 goal、onRunning 查 feedback/result、onHalted 发 cancel。理解了这个对齐,你就理解了为什么 §2.4 说「BT 叶子是调用运动层服务的接口包装」——它字面上就是个异步 RPC 客户端。

5.3 条件节点:把「世界状态」接入树

条件节点是 BT 与「世界状态」之间的只读窗口。它被 tick 时,查询某个谓词(predicate)的真假——「电量够吗」「路通吗」「夹爪里有东西吗」——成立返回 Success,不成立返回 Failure

条件节点的三条铁律:

  1. 绝对只读,零副作用。条件节点不许改变任何世界状态、不许发命令、不许写黑板(§5.5 的数据共享机制)里会影响别处的量。原因见下面陷阱 5-C:条件会被 Reactive 结构每拍 tick 很多次,如果它有副作用,副作用会被反复触发,行为完全不可预测。
  2. 几乎从不返回 Running。条件是「当下的一个判断」,要么成立要么不成立,没有「正在判断中」这回事。返回 Running 的条件节点是一个强烈的设计气味(多半是把动作误写成了条件)。
  3. 要快。条件节点会被高频 tick(每拍、且 Reactive 下可能一拍多次),所以判断必须轻量——读一个缓存的传感器值即可,不要在条件里做重计算或阻塞查询。

条件节点的威力,在于它让「世界状态」能驱动树的控制流。把条件放在动作前面(Sequence 里)当前置守卫,把条件放在 Reactive 结构里当持续监视——同一个条件节点,放在不同位置就实现「检查一次」或「每拍重检」,这是 §7 的核心。

多视角理解(双重解读):条件节点可以从两个互补的角度理解。角度一(逻辑视角):条件节点是命题逻辑里的一个原子命题,返回 Success/Failure 就是真/假。配合 Sequence(与)、Fallback(或)、Inverter(非),BT 能表达任意布尔公式(§6.3)——从这个角度,一棵由条件和逻辑控制节点构成的子树,就是一个「对世界状态的布尔查询」。角度二(控制视角):条件节点是控制流的开关/守卫——它的真假决定 tick 能不能「流过」它继续往下。前置守卫就是「门」:条件为真,门开,动作得以执行;条件为假,门关,Sequence 当即失败。两个角度描述的是同一个机制:逻辑视角看「它表达什么命题」,控制视角看「它如何影响 tick 流动」。

5.4 叶子与运动层的接口:ComputePathToPose 背后是 A*

现在把 §2.4「BT 是编排器」这个抽象论断,落到一个具体叶子上。Nav2 的导航树(§9 精读)里有个动作叶子叫 ComputePathToPose。它的名字听起来像「计算路径」,但它本身不实现任何路径搜索算法——它是一个异步动作节点,做的事是:

ComputePathToPose 这个 BT 叶子做的事:
  onStart():   向 Nav2 的 planner_server 发一个"算从当前位姿到 goal 的路径"的请求
               (planner_server 内部跑的是 A* / Dijkstra / Theta* 等真正的搜索算法)
               → 返回 RUNNING
  onRunning(): 查询 planner_server:"算好了吗?"
               ├ 算好了,拿到路径 → 把路径写进黑板,返回 SUCCESS
               ├ 还在算         → 返回 RUNNING
               └ 算失败(无路)   → 返回 FAILURE
  onHalted():  向 planner_server 发"取消"

真正的 A*(或别的搜索算法)在 planner_server 里,属于运动层ComputePathToPose 这个 BT 叶子只是它的异步客户端包装——发请求、查结果、按结果定三态。这就是 §2.4 本质洞察的字面落实,也是总论 §3.5 那句话的实证:「ComputePathToPose 这个 BT 叶子节点背后是 A* 在规划,BT 只负责按什么顺序 tick、失败退到哪」。

同理,FollowPath 叶子背后是路径跟踪控制器(可以是 DWB、TEB,或本项目 MPPI_08 讲的 MPPI 控制器)在以上百 Hz 跟踪轨迹;BT 只在 10 Hz 编排「让它跟」并监视「跟成功了吗」。叶子是接口,算法在运动层——这条边界,是读懂 §9 Nav2 树的前提。

本质洞察:BT 叶子与运动层的这种「薄接口 + 厚后端」分工,是一种深刻的关注点分离 (separation of concerns)。BT 关心「什么时候调用规划、规划失败了退到哪」(时序与恢复逻辑),运动层关心「怎么算出一条好路径」(几何与最优化)。两者通过「异步 action 接口 + 黑板数据」解耦——换掉运动层的规划算法(A* 换成 Theta*),BT 一行不用改;换掉 BT 的恢复策略(加一级恢复),运动层一行不用改。这种解耦正是分层架构的红利,也解释了为什么同一棵 Nav2 树能配不同的 planner/controller 插件。当你回头看 MPPI_08,会更懂 FollowPath 背后那个 MPPI 控制器在干什么——这正是总论 §7 强调的「带着大脑如何指挥小脑的视角在 TAMP 线和运动线之间往返」。

5.5 代码:BehaviorTree.CPP 实现 SyncAction / StatefulAction / Condition

理论讲完,上代码。本项目用 BehaviorTree.CPP(C++ 的主流 BT 库,Nav2 也用它)。按 R8 算法工程「为什么 → 正确写法 → 错误写法 → 对比」四步展开。

为什么需要这些基类。 BehaviorTree.CPP 提供了几个叶子基类,对应 §5.1–5.2 的分类:SyncActionNode(同步动作,瞬间完成)、StatefulActionNode(异步有状态动作,§5.2 的三回调)、ConditionNode(条件,只读)。你的任务是继承对应基类、重写关键方法。

正确写法一:同步动作(瞬间完成的操作)。

#include "behaviortree_cpp/behavior_tree.h"
using namespace BT;

// 同步动作:清空代价地图。这是个瞬间能完成的操作,故继承 SyncActionNode。
class ClearCostmap : public SyncActionNode {
public:
    ClearCostmap(const std::string& name, const NodeConfig& config)
        : SyncActionNode(name, config) {}

    // 声明这个节点需要/产出的黑板端口(数据接口,见下文)
    static PortsList providedPorts() { return {}; }  // 本例无端口

    // tick() 同步执行:干完立即返回,绝不返回 RUNNING
    NodeStatus tick() override {
        bool ok = costmap_client_->clear();   // 调用运动层服务,瞬间返回
        return ok ? NodeStatus::SUCCESS : NodeStatus::FAILURE;
    }
private:
    CostmapClient* costmap_client_;  // 实际工程中通过构造或黑板注入
};

正确写法二:异步有状态动作(§5.2 的核心,最常用)。 这是机器人动作的标准范式——导航、抓取都该这么写。

// 异步动作:导航到目标点。需要多拍完成,故继承 StatefulActionNode,实现三回调。
class NavigateTo : public StatefulActionNode {
public:
    NavigateTo(const std::string& name, const NodeConfig& config)
        : StatefulActionNode(name, config) {}

    // 声明输入端口:从黑板读取目标位姿 goal
    static PortsList providedPorts() {
        return { InputPort<Pose2D>("goal") };
    }

    // ① 第一次 tick:发起动作,立即返回 RUNNING(不阻塞等待!)
    NodeStatus onStart() override {
        Pose2D goal;
        if (!getInput<Pose2D>("goal", goal))      // 从黑板读目标
            return NodeStatus::FAILURE;            // 没给目标 → 直接失败
        nav_client_->sendGoal(goal);              // 向运动层异步发起导航
        return NodeStatus::RUNNING;               // 已发起,远未完成
    }

    // ② 后续每拍:非阻塞查询进度
    NodeStatus onRunning() override {
        auto state = nav_client_->checkState();   // 快速查一下,立即返回
        switch (state) {
            case NavState::SUCCEEDED: return NodeStatus::SUCCESS;
            case NavState::FAILED:    return NodeStatus::FAILURE;
            default:                  return NodeStatus::RUNNING;  // 还在路上
        }
    }

    // ③ 被打断时(如更高优先级分支抢占):清理资源
    void onHalted() override {
        nav_client_->cancelGoal();   // 取消导航,停下机器人——关键!否则机器人会"失控"继续走
    }
private:
    NavClient* nav_client_;
};

正确写法三:条件节点(只读守卫)。

// 条件:电量是否充足。只读、瞬间、绝无副作用。
class IsBatteryOK : public ConditionNode {
public:
    IsBatteryOK(const std::string& name, const NodeConfig& config)
        : ConditionNode(name, config) {}

    static PortsList providedPorts() {
        return { InputPort<double>("threshold") };   // 阈值作为输入端口
    }

    NodeStatus tick() override {
        double threshold = 0.2;
        getInput<double>("threshold", threshold);
        double level = battery_->readLevel();        // 只读传感器,不改任何东西
        return (level >= threshold) ? NodeStatus::SUCCESS : NodeStatus::FAILURE;
        // 注意:永远不返回 RUNNING——条件是"当下的判断"
    }
private:
    BatterySensor* battery_;
};

关于黑板 (Blackboard)。 上面 getInput/InputPort 用到的是 BehaviorTree.CPP 的黑板机制——一个所有节点共享的键值存储,用于节点间传数据(如 ComputePathToPose 把算出的路径写进黑板,FollowPath 从黑板读出来跟踪)。黑板是 BT 的「数据流」通道,与 tick 的「控制流」正交:tick 决定谁执行,黑板决定数据怎么传。端口 (port) 是节点对黑板的声明式接口——InputPort 声明「我要读这个键」,OutputPort 声明「我会写这个键」。

错误写法(反面教材):把长动作写成同步阻塞。

// ❌ 错误:异步动作被写成同步阻塞,灾难!
class NavigateTo_BAD : public SyncActionNode {   // ← 错误地继承了 SyncActionNode
    NodeStatus tick() override {
        nav_client_->sendGoal(goal);
        while (!nav_client_->isDone()) {          // ← 致命:在 tick 里阻塞等待!
            std::this_thread::sleep_for(100ms);   //    导航走几秒,tick 就卡几秒
        }
        return nav_client_->succeeded() ? NodeStatus::SUCCESS : NodeStatus::FAILURE;
    }
};

为什么这是灾难(对比正确写法):这个 tick() 一旦被调用,就会阻塞几秒(导航全程)才返回。在这几秒里,整棵树的根没法发出下一个 tick——所有守卫条件都不被检查(电量低了不知道、有人挡路了不知道),反应性彻底丧失。它把一个本该「每拍推进一步」的异步动作,退化成了 §2.2 的 open-loop 段。正确写法(StatefulActionNode)的 onRunning 每拍快速返回,树得以继续遍历,这才是异步的精髓。对比的本质:错误写法让 tick「执行到结束」,正确写法让 tick「推进一步就返回」——这正是 §4.3 那个区别在代码层面的体现。

⚠️ 常见陷阱

陷阱 5-A:动作节点在 tick 里阻塞,拖垮整棵树(§5.5 错误写法)。 - 现象/后果:单次 tick 耗时数秒,tick 频率从名义 10 Hz 跌到不足 1 Hz,守卫条件长时间不被检查,机器人对障碍/电量/中断全部失聪——退化成 open-loop。 - 根本原因:把异步长动作误用 SyncActionNode 实现,在 tick()while(!done) sleep,违反了 tick「快进快出」的契约 (§4.6 陷阱 4-C)。 - 正确做法:长动作一律用 StatefulActionNodeonStart 只发起、onRunning 非阻塞轮询、未完成返回 Running。把「等待」交给 tick 循环,而不是在单次 tick 里 sleep。

陷阱 5-B:动作节点不实现 onHalted(或实现为空),被抢占后留下脏状态。 - 现象/后果:一个还在 Running 的导航动作被更高优先级分支(如「避障」)打断后,底层导航请求没被取消,机器人「失控」地继续按旧目标走,或者电机还在转——抢占语义彻底失效(§11.5)。 - 根本原因:忽略了异步动作的第三个回调 onHalted。BT 在打断节点时会调用它,但若它是空的,底层资源(电机、导航 goal、抓取力)就不会被释放。 - 正确做法:每个有副作用的异步动作都必须实现 onHalted,在其中取消底层任务、停止执行器、释放占用的资源。把「被打断时如何干净退出」当作动作设计的必答题,而非可选项。

陷阱 5-C:条件节点有副作用(偷偷改世界),导致行为诡异不可复现。 - 现象/后果:把一个「检查并递增计数器」「检查时顺便发一条命令」的操作写成条件节点,结果在 Reactive 结构下条件每拍被 tick(甚至一拍多次),副作用被反复触发——计数器疯涨、命令被狂发,行为完全不可预测,且换个树结构就变。 - 根本原因:违反了「条件节点必须只读」的铁律 (§5.3)。条件节点会被高频、反复 tick,任何副作用都会被放大成不可控的反复执行。 - 正确做法:严格区分——「查询世界」用条件节点(只读),「改变世界」用动作节点(有副作用)。如果一个操作既要判断又要改变,把它拆成「一个条件节点(判断) + 一个动作节点(改变)」,用 Sequence 串起来,而不是塞进一个条件里。

练习

  1. (实现) 用 BehaviorTree.CPP 实现一个异步动作节点 PickObject,它向运动层发起「抓取指定物体」的请求。要求:用 StatefulActionNode,正确实现 onStart/onRunning/onHalted 三个回调;从黑板读取输入端口 object_id;抓取成功后通过输出端口 grasp_pose 把实际抓取位姿写回黑板(供后续 place 使用)。特别说明你的 onHalted 里要做什么清理(提示:抓取中途被打断,夹爪和机械臂处于什么状态?)。
  2. (辨析) 下面四个操作,各应该实现为同步动作、异步动作、还是条件节点?说明理由:(a)「机械臂是否处于初始位姿」;(b)「打开夹爪」(假设夹爪开合需要 0.5 秒);(c)「把当前任务 ID 写入日志」;(d)「沿规划好的路径行驶到终点」。对其中你判断为「异步动作」的,指出它的成功区 \(S\)、失败区 \(F\) 大致是什么(用 §4.4 的语言)。
  3. (综合·接 §2.4) 仿照 §5.4 ComputePathToPose 的剖析,描述 FollowPath 这个 BT 叶子在 onStart/onRunning/onHalted 里分别做什么、它背后的运动层是什么(提示:MPPI_08)。然后回答:如果把运动层的路径跟踪控制器从 DWB 换成 MPPI,FollowPath 这个 BT 叶子的代码需要改吗?为什么?这说明了 §5.4 本质洞察里「薄接口 + 厚后端」的什么好处?

6. 控制节点:Sequence / Fallback / Parallel / Decorator ⭐⭐⭐

本节解决一个问题:叶子有了,怎么把它们「组织」起来? 这是控制节点的职责。控制节点不碰世界(不像叶子),它只做一件事:根据子节点返回的状态,决定 tick 谁、自己返回什么。四类控制节点——Sequence、Fallback、Parallel、Decorator——就是 BT 的全部「组合语法」。学完这一节,你就能读写任意一棵树。

控制节点是 BT 的「连接词」。一棵树的「行为」完全由「叶子干什么」加「控制节点怎么组织叶子」决定。我们先把两个最基础、最重要的控制节点(Sequence 和 Fallback)讲到底,它们俩配合就能表达任意布尔逻辑(§6.3);再讲并发用的 Parallel(§6.4)和修饰用的 Decorator(§6.5)。

6.1 Sequence:与门 (AND),全成功才成功

Sequence(顺序节点) 的语义最直观:从左到右依次 tick 子节点,全部成功它才成功;任何一个失败它立即失败。 它表达「先做 A,A 成了再做 B,B 成了再做 C……」这种有序步骤。

精确的 tick 规则:

Sequence 被 tick 时(子节点从左到右记为 c1, c2, ..., cn):
  从左到右依次 tick:
    tick c_i:
      返回 FAILURE → Sequence 立即返回 FAILURE(后面的不再 tick)
      返回 RUNNING → Sequence 立即返回 RUNNING(停在这个子节点,下拍从它继续)
      返回 SUCCESS → 继续 tick 下一个 c_{i+1}
  所有子节点都 SUCCESS → Sequence 返回 SUCCESS

把它读成与门 (AND):所有子节点的「成功」逻辑与起来,才是 Sequence 的成功。任一子节点失败,整体就失败——就像与门任一输入为 0,输出为 0。回顾 §4.4 的形式化,两子节点 Sequence 的返回函数 \(r_{\text{Seq}}\) 正是这个规则的数学表达。

一个典型用法——「取放」任务的主干:

Sequence "取放":
  ├─ NavigateTo(桌前)     # 先导航到桌前
  ├─ PickObject(cup)      # 到了再抓杯子
  ├─ NavigateTo(架前)     # 抓到了再导航到架前
  └─ PlaceObject(shelf)   # 到了再放下

这棵 Sequence 表达「四步按顺序做,每步成功才进行下一步,任一步失败则整个取放失败」。注意 Running 的处理:当 NavigateTo(桌前) 还在路上返回 Running 时,Sequence 也返回 Running停在这个子节点——下一拍 tick 时,Sequence 会(默认)从头开始 tick,但前面已 Success 的会被跳过还是重新检查,取决于这是普通 Sequence 还是带记忆的 Sequence——这正是 §7 Reactive/Memory 的核心议题,这里先记住「Sequence 遇到 Running 会停在那个子节点」。

6.2 Fallback:或门 (OR),一个成功就成功——恢复策略的载体

Fallback(回退节点,也叫 Selector 选择节点) 是 Sequence 的对偶:从左到右依次 tick 子节点,任何一个成功它就成功;只有全部失败它才失败。 它表达「试 A,A 不行试 B,B 不行试 C……」这种「依次尝试备选方案」的逻辑——这正是恢复策略的天然载体。

精确的 tick 规则(注意和 Sequence 的对偶:把 SUCCESS 和 FAILURE 的角色对调):

Fallback 被 tick 时(子节点从左到右记为 c1, c2, ..., cn):
  从左到右依次 tick:
    tick c_i:
      返回 SUCCESS → Fallback 立即返回 SUCCESS(后面的不再 tick)
      返回 RUNNING → Fallback 立即返回 RUNNING(停在这个子节点)
      返回 FAILURE → 继续 tick 下一个 c_{i+1}(这条路不行,试下一条)
  所有子节点都 FAILURE → Fallback 返回 FAILURE(所有办法都试过了,都不行)

把它读成或门 (OR):任一子节点成功,Fallback 就成功——就像或门任一输入为 1,输出为 1。它的对偶返回函数(§4.4 练习 2 要求推导)是把 \(r_{\text{Seq}}\) 里的 Success/Failure 对调。

Fallback 之所以是恢复策略的核心,看这个例子——「导航,失败就恢复」:

Fallback "稳健导航":
  ├─ NavigateTo(goal)     # 先试正常导航
  └─ Sequence "恢复":      # 正常导航失败了,才执行恢复
       ├─ ClearCostmap     #   清空代价地图(可能是旧障碍残留)
       ├─ Spin(360°)       #   原地转一圈重新感知
       └─ NavigateTo(goal) #   再试一次导航

读这棵树:先 tick NavigateTo(goal),如果它成功,Fallback 成功,恢复分支根本不执行(Running 时也只走第一支);只有当 NavigateTo(goal) 返回 Failure(导航失败),Fallback 才转向第二个子节点——恢复 Sequence。这就是「正常路径优先,失败才恢复」的标准结构。Nav2 的整棵树(§9)本质上就是这种「主干 Fallback 恢复」的层层嵌套。

本质洞察:Fallback 是 BT 能优雅做「恢复」的根本原因,也是它碾压 FSM 的关键所在(§3.1)。在 FSM 里,「导航失败 → 恢复」需要你手工画一条从「导航」状态到「恢复」状态的转移边,而且「恢复完了回哪」又要画一堆边。在 BT 里,「失败就退到下一个备选」是 Fallback 内建的语义——你只要把备选方案从左到右排进 Fallback,「失败回退」自动发生,无需任何显式转移。更妙的是:因为 Fallback 的子节点本身可以是任意子树(甚至又是一个 Fallback),你能构造多级恢复阶梯——轻量恢复在前,重型恢复(如重规划)在后,一级不行自动升级到下一级(§9.4 Nav2 的恢复升级正是如此,§11.3 的恢复分类学也建立在此之上)。「失败沿 Fallback 自动向右回退升级」——把这句话刻住,你就抓住了 BT 做恢复的全部精髓。

6.3 Sequence + Fallback = 命题逻辑:BT 表达任意布尔条件

Sequence 是与门、Fallback 是或门,再加一个 Inverter 装饰器(§6.5,把 Success↔Failure 互换,即非门),三者齐备,意味着 BT 拥有了 \(\{\wedge, \vee, \neg\}\) 这套功能完备的布尔算子。由命题逻辑的基本定理,任何布尔函数都能用与、或、非表达,所以——

由条件节点(原子命题)+ Sequence(与)+ Fallback(或)+ Inverter(非)构成的 BT 子树,可以表达任意命题逻辑公式

这有什么用?它意味着 BT 的「守卫条件」可以是任意复杂的逻辑组合。比如「(电量充足 路径畅通) 已在充电桩」这个守卫,可以直接搭出来:

Fallback (∨):
  ├─ Sequence (∧):
  │    ├─ IsBatteryOK        # 电量充足
  │    └─ IsPathClear        # 且 路径畅通
  └─ IsAtChargingStation     # 或 已在充电桩

这棵子树作为一个整体被 tick 时,返回的 Success/Failure 恰好等于布尔公式 \((\text{BatteryOK} \wedge \text{PathClear}) \vee \text{AtCharger}\) 的真假。于是「世界状态的复杂判断」和「控制流」用同一套结构统一了——这是 §5.3「条件节点的逻辑视角」的兑现。

多视角理解(双重解读):同一棵「与或」子树有两种读法。逻辑读法:它是一个布尔公式,输出真假——用于「判断世界是否满足某个复合条件」。控制读法:它是一段控制流,Sequence 让 tick「全通过才继续」、Fallback 让 tick「一条不行换下条」——用于「按条件选择执行哪个分支」。这两种读法不是两个东西,而是同一结构的两面:当子节点全是只读条件时,偏向逻辑读法(在算一个谓词);当子节点含动作时,偏向控制读法(在调度执行)。BT 的威力,正来自它把「逻辑判断」和「行为调度」用同一套与/或/非语法统一——你不需要在「写条件表达式」和「写控制流」之间切换语言。

6.4 Parallel:并发监控的关键

前三个节点(Sequence/Fallback)都是「一次只激活一个子节点」的串行逻辑。但执行层有一类需求是天然并发的——「一边走路,一边盯着电量」「一边执行任务,一边监视有没有人闯入」。这需要 Parallel(并行节点)

Parallel 被 tick 时,同一拍内 tick 它的所有子节点(不是只 tick 一个),然后根据「有多少子节点成功/失败」按一个阈值决定自己返回什么。最常见的是 M-of-N 阈值:设 Parallel 有 \(N\) 个子节点,设定一个成功阈值 \(M\)\(1 \le M \le N\)),则:

Parallel(成功阈值 M) 被 tick 时:
  同一拍 tick 所有 N 个子节点
  统计:成功数 S,失败数 F
    若 S ≥ M           → Parallel 返回 SUCCESS(够 M 个成功了)
    若 F > N - M       → Parallel 返回 FAILURE(失败太多,凑不齐 M 个成功了)
    否则               → Parallel 返回 RUNNING(还在等结果)

阈值 \(M\) 的两个极端很有用:

  • \(M = N\)(全成功才成功):像「并行的与门」——所有并发分支都得成功。
  • \(M = 1\)(一个成功就成功):像「并行的或门」——任一分支成功即可。

但 Parallel 在执行监控里最重要的用法不是上面这种「并行任务」,而是「任务 + 监视」的组合:把「主任务」和「监视条件」放进同一个 Parallel,让监视和任务同时被 tick

Parallel(成功阈值=1, 失败阈值=1):
  ├─ ExecuteMainTask       # 主任务(如导航 + 抓取的长流程)
  └─ Sequence "安全监视":   # 与主任务并发运行的监视
       └─ IsNoIntrusion    #   持续检查"无人闯入"

设置成「任一失败则 Parallel 失败」:只要 IsNoIntrusion 在任何一拍返回 Failure(有人闯入了),整个 Parallel 立即失败,从而中止主任务——实现了「主任务执行期间持续监视,一旦监视条件破坏立即中止」。这是 §11.2「Parallel 监视支」的基础。

本质洞察:Parallel 把 BT 从「单线程的决策遍历」扩展到「并发的监控 + 执行」,这正是执行层「一边做一边盯」(§2.3 监控与反应并行)的结构化实现。但要警惕:BT 的 Parallel 不是操作系统的多线程——它仍在同一个 tick 线程里顺序地 tick 每个子节点,只是「在概念上把它们视为同拍并发、按阈值汇总」。所以 Parallel 的子节点之间不能有数据竞争(它们是顺序执行的),它解决的是「逻辑上的并发监控」而非「物理上的并行计算」。把 Parallel 误当成「能并行加速」会犯错(§6.5 后的陷阱)——它的价值在「同拍内既推进任务又检查监视」,不在「跑得更快」。

6.5 Decorator:Inverter / Retry / Timeout / RateController

Decorator(装饰节点) 是只有一个子节点的控制节点,它「装饰」(修改)子节点的返回状态或 tick 行为。装饰器是 BT 的「修饰语」——它不增加新分支,只调整单个子树的语义。常用的几个:

装饰器 作用 返回规则 典型用途
Inverter(反相器) 把成功↔失败互换(非门) 子 Success→返回 Failure;子 Failure→返回 Success;Running 原样透传 「条件不成立时才执行」;构造 §6.3 的非门
Retry(重试,N 次) 子失败时重试,最多 N 次 子 Failure→重新 tick(计数+1),N 次内成功则 Success,超 N 次仍失败则 Failure 抓取失败重试 3 次(§2.3 恢复中的「重试」)
Timeout(超时) 限制子节点的执行时长 子在时限内给出终态则透传;超时则强制返回 Failure(并 halt 子节点) 防止某动作无限期 Running 卡死(如导航卡住)
RateController(限频) 限制子节点被 tick 的频率 在设定周期内只让子 tick 一次,其余拍透传上次状态/Running 控制重规划频率(§9.6、§11.4 重规划节流)
ForceSuccess / ForceFailure 强制子节点的返回状态 无论子返回什么,强制返回 Success(或 Failure) 「这步可选,做不成也不影响后续」

装饰器的价值在于「正交地调整语义而不改变树的结构」。比如你有一个会失败的 PickObject,想让它失败时自动重试——你不用改 PickObject、不用加新分支,只要在它外面包一个 Retry(3)

RetryUntilSuccessful(num_attempts=3):
  └─ PickObject(cup)      # 失败自动重试,最多 3 次

RateControllerTimeout 在 Nav2(§9)和监控(§11)里尤其关键:RateController 让「重规划」这种昂贵操作不会每拍都触发(§11.4 节流),Timeout 给长动作兜底防止永久卡死。

理论-工程桥接:装饰器体现了软件工程里的装饰器模式 (Decorator Pattern)——在不修改原对象的前提下,动态地给它附加职责。BT 的装饰器把这个模式用在「行为」上:Retry 给行为附加「失败重试」职责、Timeout 附加「限时」职责,而被装饰的子树本身(如 PickObject)完全不知情、无需改动。这种正交性是 BT 可维护性的又一来源——「重试逻辑」「超时逻辑」「限频逻辑」都从业务动作里剥离出来,成了可插拔的装饰。这呼应了 §3.6 那条主线:BT 的演化方向始终是「提高模块化、降低耦合」,装饰器就是这条主线在「单节点修饰」层面的体现。

6.6 控制节点语义总表

把四类控制节点的返回规则汇成一张表——这是全章查询频率最高的内容,建议加书签(R3 要求对比类内容用表格):

节点类型 别名 tick 子节点的方式 返回 SUCCESS 返回 FAILURE 返回 RUNNING 类比
Sequence 从左到右,逐个 全部子成功 任一子失败 某子 Running(停在该子) 与门 \(\land\)
Fallback Selector 从左到右,逐个 任一子成功 全部子失败 某子 Running(停在该子) 或门 \(\lor\)
Parallel 同拍 tick 全部 成功数 \(\ge M\) 失败数 \(> N-M\) 未达任一阈值 M-of-N 阈值
Inverter Not tick 唯一子 子返回 Failure 子返回 Success 子 Running 非门 \(\lnot\)
Retry(N) RetryUntilSuccessful tick 唯一子(可重复) N 次内子成功 超 N 次子仍失败 子 Running / 重试间隙 重试循环
Timeout(t) tick 唯一子,计时 时限内子成功 子失败 超时 时限内子 Running 限时器
RateController(f) 限频 tick 子 透传子的 Success 透传子的 Failure 周期未到 / 子 Running 节流阀

关键提醒:表中 Sequence 和 Fallback 的「返回 RUNNING」一栏写的是「停在该子」——但下一拍从哪重新开始(从头重检 vs 从停的地方继续),表里没说,因为这取决于它是「Reactive 版」还是「Memory 版」。这是 BT 最容易出错的语义,是 §7 的专门主题。这张表给的是「单拍内」的返回规则;「跨拍」的记忆行为见 §7。 不要在没读 §7 之前就以为自己完全掌握了 Sequence——你掌握的只是它单拍的一半。

6.7 代码:组合出「导航 + 恢复」的最小树

把 §6 的节点用 BehaviorTree.CPP 的 XML 描述语言组装成一棵真正能跑的「导航 + 三级恢复」树(Nav2 的极简版,§9 是完整版)。BehaviorTree.CPP 支持用 XML 声明树结构,把「树长什么样」和「叶子怎么实现」分离——这是工业实践的标准做法。

正确写法:XML 描述的导航 + 恢复树。

<root BTCPP_format="4">
  <BehaviorTree ID="NavigateWithRecovery">
    <!-- 顶层 Fallback:正常导航优先,失败才恢复 -->
    <Fallback name="root_fallback">

      <!-- 主干:先检查路通,再导航(Sequence = 与门) -->
      <Sequence name="normal_navigation">
        <Condition ID="IsPathClear"/>          <!-- 守卫:路通吗 -->
        <Action ID="NavigateTo" goal="{target}"/> <!-- 异步导航动作 -->
      </Sequence>

      <!-- 恢复分支:主干失败才走这里,三级恢复逐级升级 -->
      <Sequence name="recovery">
        <Action ID="ClearCostmap"/>            <!-- 一级:清代价地图 -->
        <RetryUntilSuccessful num_attempts="2"> <!-- 二级:转一圈重试,最多2次 -->
          <Sequence>
            <Action ID="Spin" angle="3.14"/>
            <Action ID="NavigateTo" goal="{target}"/>
          </Sequence>
        </RetryUntilSuccessful>
      </Sequence>

    </Fallback>
  </BehaviorTree>
</root>

C++ 侧:注册叶子、加载并 tick 这棵树。

#include "behaviortree_cpp/bt_factory.h"
int main() {
    BT::BehaviorTreeFactory factory;
    // 注册我们在 §5.5 实现的叶子节点
    factory.registerNodeType<NavigateTo>("NavigateTo");
    factory.registerNodeType<IsPathClear>("IsPathClear");
    factory.registerNodeType<ClearCostmap>("ClearCostmap");
    factory.registerNodeType<Spin>("Spin");

    // 从 XML 文件创建树
    auto tree = factory.createTreeFromFile("./navigate_with_recovery.xml");
    tree.rootBlackboard()->set("target", Pose2D{3.0, 2.0, 0.0});  // 设目标

    // §4.6 陷阱 4-B 的正确 tick 循环
    BT::NodeStatus status = BT::NodeStatus::RUNNING;
    while (status == BT::NodeStatus::RUNNING) {
        status = tree.tickOnce();
        std::this_thread::sleep_for(100ms);   // 10 Hz
    }
    std::cout << "结果: " << BT::toStr(status) << std::endl;
    return 0;
}

这棵树的行为:正常情况下 IsPathClear 真、NavigateTo 成功 → 顶层 Fallback 成功,恢复分支根本不碰。一旦 NavigateTo 失败 → Fallback 转向恢复 Sequence:先清代价地图,再「转一圈+重试导航」最多 2 次。用四类节点(Fallback/Sequence/Retry/Condition+Action)就拼出了一个有三级恢复的稳健导航器——这正是 §6.2 本质洞察说的「失败沿 Fallback 自动向右回退升级」。

⚠️ 常见陷阱

陷阱 6-A:把 Parallel 当成「多线程并行加速」,期望它让任务跑得更快。 - 现象/后果:把几个本应串行的重计算动作塞进 Parallel,期望并行提速,结果发现毫无加速(甚至更慢),还出现子节点间的数据不一致。 - 根本原因:BT 的 Parallel 不是物理多线程,它仍在同一 tick 线程里顺序 tick 每个子节点,只是按阈值汇总返回 (§6.4)。它提供的是「逻辑并发监控」而非「并行计算」。 - 正确做法:Parallel 用于「任务 + 监视」「任务 + 任务的逻辑并发」(如同时导航和播报),不用于加速。真要并行计算,让叶子在运动层/后台线程异步执行(§5.2),叶子本身仍快速返回——并行发生在运动层,不在 BT。

陷阱 6-B:混淆 Sequence/Fallback 遇到 Running 时的行为,以为「失败了会自动跳过继续后面」。 - 现象/后果:以为 Sequence 里某步失败后会「跳过这步做下一步」,结果整个 Sequence 直接失败、后续步骤根本不执行,行为与预期完全相反。 - 根本原因:没记牢返回规则——Sequence 任一失败就整体失败(不跳过),Fallback 才是「失败就试下一个」。把 Sequence(与门,全成功)和 Fallback(或门,一个成功即可)的语义记反了。 - 正确做法:查 §6.6 总表。需要「失败就换下一个」用 Fallback;需要「全成功才算成功,一个失败就整体失败」用 Sequence。需要「某步可选、失败也继续」用 ForceSuccess 装饰那一步。

陷阱 6-C:Decorator 语义混淆,尤其把 Inverter 用在带 Running 的动作上。 - 现象/后果:用 Inverter 包一个长动作(如 Inverter → NavigateTo),期望「导航失败时返回成功」,但忽略了 Inverter 对 Running原样透传的——动作在 Running 期间 Inverter 也返回 Running,导致逻辑在「动作进行中」这段时间表现得和预期不符。 - 根本原因:Inverter 只翻转 Success↔Failure,不翻转 Running(§6.6 表)。在有 Running 的动作上用 Inverter,要想清楚 Running 期间的语义。 - 正确做法:Inverter 主要用在条件节点(条件几乎不返回 Running,翻转干净)。用在动作上要格外小心 Running 的透传语义。需要「失败也算成功」用 ForceSuccess 而非 Inverter——二者对 Running 的处理和意图都不同。

练习

  1. (综合·设计) 用 §6 的四类控制节点,为「机械臂取放,含两级恢复」设计一棵 BT(画出树结构):正常流程是「导航到桌前 → 抓杯子 → 导航到架前 → 放杯子」;任何一步失败时,先尝试「轻量恢复」(清理状态后重试该步 2 次),仍失败则「升级恢复」(回到初始位姿并求助操作员)。把你的树和 §2 练习 3 当初凭直觉画的草图对比,差异在哪?
  2. (语义·辨析) 给定子树 Sequence[ A, Fallback[ B, C ], D ],其中各叶子被 tick 时返回:A=Success, B=Failure, C=Success, D=Running。请逐步推演这一拍 tick 的过程:哪些节点被 tick 了、各返回什么、整个子树最终返回什么?然后把 C 改成 Failure,重新推演一遍,整个子树返回什么变了吗?
  3. (布尔逻辑·接 §6.3) 用条件节点 + Sequence + Fallback + Inverter,搭出表达布尔公式 \(\neg(\text{IsBatteryLow}) \wedge (\text{IsPathClear} \vee \text{IsAtCharger})\) 的 BT 子树(画结构)。验证:当 IsBatteryLow=真 时,无论其他条件如何,子树都返回 Failure——逐步 tick 推演证明这一点。这道题让你确信「BT 子树 = 布尔公式」(§6.3)不是修辞,而是字面成立。

7. Reactive 与 Memory:BT 最容易出错的语义 ⭐⭐⭐

本节解决一个 §6 故意留下的问题:当 Sequence/Fallback 遇到子节点返回 Running,下一拍 tick 时,是从头重新检查、还是从停的地方继续? 这个「跨拍记忆」的差异,分出了 Reactive(每拍重检)和 Memory(记住进度)两种节点。它是 BT 最容易出错、也最能体现 BT 反应性精髓的语义——同一棵结构图,选错版本,行为南辕北辙。

7.1 动机:tick 到 Running 之后,下一拍从哪重新开始?

§6.6 总表里,Sequence 遇到子节点 Running 会「停在该子」并返回 Running。但「停在该子」只描述了这一拍。问题在下一拍

Sequence[ A, B, C ],假设上一拍 A=Success、B=Running(停在 B)。
下一拍 tick 时,有两种可能的行为:

  行为 X(Memory,记住进度):
    跳过已成功的 A,直接从 B 继续 tick
    → "A 我已经做完了,不用再看,继续推进 B"

  行为 Y(Reactive,每拍重检):
    从头重新 tick A,A 还成功才轮到 B
    → "每拍都重新确认 A 仍然成立,再继续 B"

这两种行为截然不同,而且都有用:

  • 行为 X(Memory)适合「已经做完的步骤不必重做」——比如「导航到桌前」成功后,没必要每拍都重新导航一次,记住「已到」继续抓取就好。
  • 行为 Y(Reactive)适合「前面的条件必须时刻保持」——比如「电量充足」这个前置条件,不能只在开始时检查一次,必须每拍重新确认;电量一旦掉下去,立刻就要反应。

BehaviorTree.CPP 把这两种行为做成了不同的节点类型Sequence(默认,带某种记忆行为)、SequenceWithMemory(明确的 Memory 版)、ReactiveSequence(Reactive 版)。Fallback 同理有 ReactiveFallback选哪个,取决于你要「推进」还是「监控」。下面把两类讲透。

本质洞察:「下一拍从哪开始」这个看似细节的问题,其实触及了 BT 的核心张力——执行层要同时做两件矛盾的事:推进(已做完的别重做,往前走)和监控(前提变了立刻停,往回看)。Memory 偏向推进(记住进度不回头),Reactive 偏向监控(每拍回头重检)。一棵真实的树几乎总是两者混用:顺序步骤的主干用 Memory(别重复执行已完成的动作),守卫条件用 Reactive(持续监视前提)。理解 Reactive/Memory,本质上是理解「在同一棵树里,哪些部分要往前冲、哪些部分要时刻回头看」——这正是反应式执行的灵魂。 这个区分是 BT 区别于「静态决策结构」的关键,也是初学者栽跟头最多的地方。

7.2 Memory 节点(SequenceWithMemory):记住进度,不回头

SequenceWithMemory 实现 §7.1 的行为 X:它记住哪些子节点已经成功,下一拍从上次停下的(返回 Running 的)子节点继续,不重新 tick 前面已成功的

SequenceWithMemory[ A, B, C ] 的跨拍行为:
  拍1: tick A → Success;tick B → Running(记住"到 B 了"),返回 Running
  拍2: 跳过 A,直接 tick B → 还是 Running,返回 Running
  拍3: 跳过 A,tick B → Success;tick C → Running,返回 Running
  拍4: 跳过 A、B,直接 tick C ...
  (只有当整个 Sequence 返回 Success 或 Failure,记忆才重置)

它的语义就像「带进度条的任务清单」——做完一项打个勾,下次从没打勾的继续,已打勾的不再碰。这对「顺序执行的动作步骤」是正确的:你不希望「导航到桌前」成功后,每拍都重新发起一次导航。

什么时候用 Memory:当子节点是有副作用的、按顺序执行一次就够的动作时。「先导航、再抓取、再放置」这种主干步骤,每步做完就该往前走,不该回头重做——用 SequenceWithMemory(或具备记忆行为的默认 Sequence)。

关键代价:Memory 的代价是「前面的步骤不再被监控」。一旦 A 成功被记住,后续拍就不再检查 A——如果 A 代表的条件后来失效了(比如「夹爪里有杯子」这个 A 成功后,杯子中途滑落了),带记忆的 Sequence 察觉不到,会继续推进后面的步骤。这正是 §7.4 要用反事实讲清的核心权衡,也是 Memory 不能用于「需要持续保持的守卫条件」的原因。

7.3 Reactive 节点(ReactiveSequence):每拍从头重检——反应性的来源

ReactiveSequence 实现 §7.1 的行为 Y:每一拍都从第一个子节点重新 tick,前面的子节点必须每拍都成功,才轮得到后面的。它不记忆进度。

ReactiveSequence[ Guard, Action ] 的跨拍行为(Guard 是条件,Action 是长动作):
  拍1: tick Guard → Success;tick Action → Running,返回 Running
  拍2: 重新 tick Guard → Success(再次确认!);tick Action → Running,返回 Running
  拍3: 重新 tick Guard → Failure(条件变了!)→ ReactiveSequence 立即返回 Failure,
       并 halt(中止)正在 Running 的 Action!

看拍 3:因为每拍都重检 Guard,一旦 Guard 由真变假,ReactiveSequence 立即失败并打断正在运行的 Action。这就是反应性的来源——「守卫条件 + 受守卫的动作」放进 ReactiveSequence,守卫一破,动作立刻被中止。

这正是 §2.3「反应」那件事的结构化实现,也是 §11.2 监控的核心模式:

ReactiveSequence "受监控的导航":
  ├─ IsPathClear        # 守卫条件:每拍重检"路是否通"
  └─ NavigateTo(goal)   # 受守卫的长动作:导航
  # 语义:导航期间持续监视路况,路一旦被挡(IsPathClear 失败),
  #       立即中止导航(NavigateTo 被 halt),ReactiveSequence 返回 Failure

对比 Memory 版:如果这里用 SequenceWithMemoryIsPathClear 只在第一拍检查一次,之后导航期间就不再看路况了——路中途被挡也察觉不到,机器人会径直撞上去。正是 Reactive 的「每拍重检」赋予了 BT 监控与快速反应的能力。

Reactive 的代价:每拍都从头重 tick,意味着前面的节点会被反复执行。如果前面放的是条件节点(只读、无副作用),没问题——重检多少次都安全。但如果前面放的是有副作用的动作,灾难就来了:这个动作会每拍被重新发起!这是 §7.5 和陷阱 7-A 的主题,也是「Reactive 分支里只能放只读守卫、不能放有状态动作」这条铁律的由来。

7.4 二者的本质区别:监控 vs 推进(用反事实讲清)

把 Memory 和 Reactive 并排,用反事实推理(R6B)把区别钉死:

维度 SequenceWithMemory(推进) ReactiveSequence(监控)
下一拍从哪开始 从上次 Running 的子节点继续 从第一个子节点重新开始
前面已成功的节点 不再 tick(记住了) 每拍重新 tick(重检)
适合放什么 顺序执行的有状态动作 需持续保持的只读守卫条件
核心能力 不重复执行已完成的步骤 前提一变立即反应、中止
核心代价 前面的条件失效察觉不到 前面若放动作会被反复发起
一句话 「做完的别重做」 「时刻确认前提还在」

反事实一:如果守卫用 Memory 会怎样? 「电量充足才执行任务」用 SequenceWithMemory[ IsBatteryOK, DoTask ]IsBatteryOK 只在第一拍检查,通过后被记住,任务执行期间电量掉到 5% 也不会被发现——任务继续跑到机器人趴窝。后果:守卫形同虚设。这就是守卫必须用 Reactive 的原因。

反事实二:如果顺序动作用 Reactive 会怎样? 「先导航再抓取」用 ReactiveSequence[ NavigateTo, PickObject ]:每拍都重新 tick NavigateTo。当 NavigateTo 成功、轮到 PickObject 返回 Running 时,下一拍又从 NavigateTo 重来——而 NavigateTo 是有副作用的,它可能重新发起一次导航(机器人又往桌前挪,尽管已经在那了)!行为彻底错乱。这就是顺序有状态动作必须用 Memory 的原因。

本质洞察:Reactive 和 Memory 的区别,可以一句话概括为——Reactive 把「过去的成功」当作「需要持续维持的前提」,Memory 把「过去的成功」当作「已经翻篇的历史」。守卫条件属于前者(「电量充足」是个要一直维持的前提,不是做完就翻篇的事),顺序动作属于后者(「已经导航到位」是翻篇的历史,不需要反复重做)。一棵正确的树,是把这两种「对过去成功的态度」安放在正确的位置:守卫放 Reactive(前提要维持),动作主干放 Memory(历史要翻篇)。这个洞察是 §7.6 选择准则的根,也是读懂 §9 Nav2 树为什么这里用 Reactive、那里用 Pipeline 的钥匙。

7.5 BehaviorTree.CPP v4 的 ReactiveSequence 异步子节点限制

§7.3 末尾的代价,在 BehaviorTree.CPP v4 里被升级成了一条硬性约束,初学者极易撞上:

BehaviorTree.CPP v4 规定:ReactiveSequence(及 ReactiveFallback)最多只能有一个会返回 Running 的(异步)子节点。 若有两个或更多子节点同时处于 Running,运行时会抛出 LogicError

为什么有这条限制? 因为 Reactive 节点每拍从头重 tick 所有子节点。如果有两个异步动作子节点都返回 Running,那么每拍重启时,第一个 Running 的动作和第二个 Running 的动作之间的「谁先谁后、谁该被打断」语义就变得矛盾且不确定——Reactive 的「每拍从头」与「多个动作同时进行中」在语义上冲突。v4 干脆禁止这种情况,强制你把树写清楚。

正确的模式:ReactiveSequence 的典型正确用法是「若干个只读守卫条件 + 最多一个长动作(放在最后)」:

ReactiveSequence:        # ✓ 正确:多个只读守卫 + 末尾一个长动作
  ├─ IsBatteryOK         #   守卫1(条件,瞬间返回,从不 Running)
  ├─ IsPathClear         #   守卫2(条件,瞬间返回,从不 Running)
  └─ NavigateTo(goal)    #   唯一的长动作(可返回 Running)—— 放最后

这里前两个是条件节点(瞬间返回 Success/Failure,从不 Running),只有最后的 NavigateTo 会 Running——满足「至多一个异步子」的约束,且语义清晰:每拍先重检两个守卫,都通过才推进导航。

触发限制的错误模式

ReactiveSequence:        # ✗ 错误:两个长动作都会 Running,抛 LogicError
  ├─ NavigateTo(goal)    #   长动作1(会 Running)
  └─ PickObject(cup)     #   长动作2(也会 Running)→ 二者同时 Running 时崩溃

需要「两个动作有序执行」时,用 Sequence/SequenceWithMemory(它们一次只激活一个子节点,不存在两个同时 Running 的问题),而不是 ReactiveSequence。

理论-工程桥接:这条 v4 限制不是库的「缺陷」,而是把 §7.4 反事实二的语义错误前置到运行时报错——与其让你写出「两个 Reactive 动作互相重启」的诡异行为再去 debug,不如直接抛错逼你写对。这体现了一个好框架的设计哲学:让错误的用法无法编译/无法运行,而不是让它静默地产生错误行为。BehaviorTree.CPP v3 没有这条强约束,v4 加上它,正是社区从大量 bug 中总结出的纪律。记住这条限制,你能省下无数小时的诡异 debug。

7.6 何时用哪个:守卫条件用 Reactive,顺序步骤用 Memory

把选择准则提炼成一条可操作的规则——这是本节最该带走的实战结论:

选择准则(贴在显示器上):
  这个分支的子节点,是"需要持续保持的守卫条件"吗?
    是 → 用 Reactive(ReactiveSequence/ReactiveFallback)
         典型:前置条件检查、安全监视、抢占触发
    否 → 它是"按顺序执行一次的有状态动作"吗?
            是 → 用 Memory(SequenceWithMemory)
                 典型:导航→抓取→放置的主干步骤
            混合 → 用"Reactive 包 Memory":
                 外层 ReactiveSequence 放守卫,
                 内层 SequenceWithMemory 放动作主干

最常见的工业模式是「Reactive 守卫 + Memory 主干」的嵌套——外层 Reactive 持续监视守卫,内层 Memory 顺序推进动作:

ReactiveSequence "受守卫的任务":        # 外层 Reactive:每拍重检守卫
  ├─ IsBatteryOK                       #   守卫:电量(每拍监控)
  ├─ IsNoIntrusion                     #   守卫:无人闯入(每拍监控)
  └─ SequenceWithMemory "任务主干":     #   内层 Memory:动作不回头
       ├─ NavigateTo(目标)             #     步骤1(做完翻篇)
       ├─ PickObject(cup)              #     步骤2
       └─ PlaceObject(shelf)           #     步骤3

这棵树的行为:每拍先重检电量和闯入(守卫,Reactive),都 OK 才进入任务主干;主干内部按 Memory 推进(已完成的步骤不重做)。一旦任一守卫在任何时刻失败,外层 ReactiveSequence 立即失败、halt 掉正在执行的主干动作——「持续监控 + 顺序推进」二者兼得。这个「Reactive 守卫包 Memory 主干」的模式,是 §11 执行监控的骨架,也是 §9 Nav2 树和 §12 累积项目的基本套路。

多视角理解(类比,标边界):「Reactive 守卫 + Memory 主干」的嵌套,操作系统里「中断 + 主程序」的关系——主程序(Memory 主干)顺序往下执行、保存进度,而中断(Reactive 守卫)随时可以打断主程序、抢夺控制权去处理紧急情况。的地方:都是「正常流程顺序推进 + 紧急情况随时打断」。不像的地方:操作系统中断是硬件异步触发的(中断信号一来 CPU 立即跳转),而 BT 的 Reactive 守卫是每拍轮询检查的(最多延迟一个 tick 间隔 \(\Delta t\) 才发现)——它不是真异步中断,是「高频轮询模拟的准中断」。别把这个类比延伸到「BT 守卫能像硬件中断那样零延迟响应」(它有最多 \(\Delta t\) 的延迟,§4.5)。

⚠️ 常见陷阱

陷阱 7-A:在 Reactive 分支里放置有副作用的动作节点,导致动作被反复发起。 - 现象/后果:把一个有副作用的动作(如 SendCommandPlaySound、甚至 NavigateTo)放进 ReactiveSequence 的非末尾位置,每拍都从头重 tick 它,副作用每拍触发一次——命令被狂发、声音被反复播放、导航被反复重新发起,行为完全失控。 - 根本原因:Reactive 节点每拍重 tick 前面所有子节点 (§7.3)。只读条件节点重检无害,但有副作用的动作被反复 tick 就是反复执行其副作用。 - 正确做法:Reactive 分支里,非末尾位置只放只读条件节点(守卫);有副作用的长动作只能放在末尾且至多一个(§7.5)。需要在守卫满足后执行有状态的动作主干,用「Reactive 外层(守卫)包 Memory 内层(动作)」的嵌套(§7.6)。

陷阱 7-B:在 Memory 分支里放置需要持续保持的守卫条件,导致前提失效后察觉不到。 - 现象/后果:用 SequenceWithMemory[ IsBatteryOK, LongTask ]IsBatteryOK 只在第一拍检查、通过后被记住,长任务执行期间电量耗尽也不会触发任何反应,任务一路跑到机器人趴窝。 - 根本原因:Memory 节点记住已成功的子节点、后续不再 tick (§7.2)。把「需要每拍维持的守卫」放进 Memory,等于守卫只生效一次,之后失效。 - 正确做法:守卫条件必须放在 Reactive 分支(每拍重检)。规则见 §7.6:需持续保持的前提用 Reactive,做完就翻篇的步骤才用 Memory。

陷阱 7-C:在 ReactiveSequence 里放两个以上会返回 Running 的异步动作,触发 v4 的 LogicError - 现象/后果:程序运行到两个异步子节点同时 Running 时抛 LogicError 崩溃,或(在不报错的旧版本里)出现两个动作互相重启的诡异行为。 - 根本原因:BehaviorTree.CPP v4 规定 ReactiveSequence/ReactiveFallback 至多一个异步(Running)子节点 (§7.5),因为「每拍从头重启」与「多个动作同时进行中」语义冲突。 - 正确做法:多个动作要有序执行,用 Sequence/SequenceWithMemory(一次只激活一个子,无并发 Running 问题)。ReactiveSequence 仅用于「多个只读守卫 + 末尾至多一个长动作」的模式。

练习

  1. (反事实·核心) 对「机械臂夹着杯子导航到架前」这个步骤,分别用 ReactiveSequenceSequenceWithMemory 实现「守卫=ObjectInGripper(夹爪里有杯子),动作=NavigateTo(导航到架前)」。逐拍推演:如果导航途中杯子滑落(ObjectInGripper 由真变假),两个版本各会发生什么?哪个能及时发现杯子掉了并中止导航?由此说明为什么这个守卫必须用 Reactive。
  2. (设计·嵌套) 用「Reactive 守卫 + Memory 主干」的嵌套模式(§7.6),为「自主巡检」任务设计一棵 BT:主干是「依次走到 3 个巡检点并各拍一张照片」(顺序动作),守卫是「电量充足」和「未收到急停信号」(需持续保持)。画出树结构,标明哪层用 Reactive、哪层用 Memory,并说明当巡检到第 2 点时收到急停信号,这棵树会如何反应(哪个节点被 halt?整树返回什么?)。
  3. (辨析·v4 限制) 下面三棵 ReactiveSequence,哪些合法、哪些会触发 v4 的 LogicError?说明理由并给出修正方案:(a) ReactiveSequence[ IsBatteryOK, IsPathClear, NavigateTo ];(b) ReactiveSequence[ NavigateTo, FollowPath ];(c) ReactiveSequence[ IsSafe, SequenceWithMemory[ NavigateTo, PickObject ] ]。(提示:数一数每棵里「会返回 Running 的子节点」有几个,注意 SequenceWithMemory 作为一个整体被外层看待时返回什么。)

8. BT vs 决策树 vs 有限状态机:三个澄清 ⭐⭐⭐

本节解决三个反复困扰初学者的混淆:BT 和决策树 (Decision Tree) 是不是一回事?BT 凭什么比 FSM 好?什么时候 FSM 反而更合适?把这三个澄清做透,你对 BT 的定位就稳了。

8.1 BT \(\neq\) 决策树:执行控制流 vs 机器学习分类器

第一个、也是最常见的混淆:行为树 (Behavior Tree) 不是决策树 (Decision Tree)。它们都叫「树」、都做某种「决策」,但分属完全不同的世界:

维度 行为树 (Behavior Tree) 决策树 (Decision Tree, ML 分类器)
领域 机器人/游戏的执行控制流 机器学习的分类/回归模型
怎么来的 工程师设计出来(或规划器生成,§10) 数据训练出来(ID3/C4.5/CART 算法)
节点含义 内部=控制节点(Seq/Fb),叶子=动作/条件 内部=特征判断(「年龄>30?」),叶子=预测类别/值
输出 三态 + 动作;驱动机器人行为 一个预测标签(如「垃圾邮件/正常」)
有时间维度吗 (Running 态,跨多拍执行长动作) (输入特征→立刻输出标签,一次性)
典型用途 编排「先导航再抓取,失败就恢复」 从特征预测「这封邮件是不是垃圾」

一句话:ML 决策树是「从特征到标签的预测函数」,行为树是「驱动机器人持续行动的控制结构」。把训练出来的分类树直接当机器人执行结构,或者把行为树当成能「从数据学习」的模型,都是范畴错误。

本质洞察:这两个「树」唯一的共同点是「树形结构 + 沿树走」,但它们「沿树走」的目的完全相反。ML 决策树沿树走一次得到一个预测(分类),走完即止,没有「正在执行某动作」的状态。行为树反复沿树走、每拍推进行为(执行),核心是 Running 带来的时间延续。混淆的根源往往是名字——中文「决策树」和「行为树」、英文 Decision Tree 和 Behavior Tree,都只差一个词。记住区分的钥匙:有没有 Running 态、是不是反复 tick。 有 Running、反复 tick 的是行为树;一次性输出标签的是决策树。

8.2 形式化:决策树是 BT 的特例(Colledanchise & Ögren 2017)

澄清了「不是一回事」之后,有一个更深的结论会让初学者困惑:Colledanchise & Ögren (2017) 证明了决策树是行为树的一个特例。这里的「决策树」指的是 §3.3 游戏 AI 那种「问条件选动作」的控制流决策树(不是 §8.1 的 ML 分类器——注意区分语境)。

为什么是特例?一棵「问条件选动作」的决策树,可以机械地翻译成一棵只用 Fallback 和 Sequence、且叶子动作都瞬间完成(无 Running 态) 的行为树。比如决策树「敌人近?近→攻击;远→巡逻」翻译成:

Fallback:
  ├─ Sequence:
  │    ├─ IsEnemyClose    # 条件:敌人近吗
  │    └─ Attack          # 近 → 攻击
  └─ Patrol               # 否则 → 巡逻

这棵 BT 在「所有动作瞬间完成、不用 Running」的退化情形下,行为和原决策树完全一致。换句话说:决策树 = 「砍掉 Running 态、砍掉跨拍记忆」的行为树。BT 比决策树多了什么?多了 Running(时间维度)、多了 Reactive/Memory(跨拍记忆)、多了 Parallel(并发)——这些正是执行长时程、可中断、可监控任务所必需的,而决策树通通没有。

本质洞察:「决策树是 BT 的特例」这个定理,回答了一个工程问题——「我已经会用决策树编排游戏 AI,为什么要学 BT?」答案是:BT 完全包含决策树的表达能力(你会的决策树写法在 BT 里照样能写),同时额外提供了决策树没有的 Running/Reactive/Parallel。所以从决策树迁移到 BT 是「只赚不亏」——不会失去任何已有能力,只会获得处理长动作、中断、并发的新能力。这也是总论 §3.5 那个「决策树是行为树的特例」论断的技术内核:BT 是更一般的框架,把决策树收编为退化情形。

8.3 BT vs FSM:可组合性 (two-block) vs 转移爆炸——核心优势

第二个混淆:BT 相对 FSM 到底强在哪?§3.1 已经埋了伏笔(FSM 转移 \(O(N^2)\)、不可组合),这里把 BT 的核心优势——两块组合性 (two-block modularity)——讲到底。

回顾 §3.1:FSM 的状态间是点对点跳转——「抓取」状态直接知道「失败了跳到恢复状态」。这种「每个状态都得知道别的状态」的耦合,导致两个后果:转移数 \(O(N^2)\)、且一段逻辑不可整体复用(搬走它要连带搬走它指向的所有状态)。

BT 怎么解决?靠「返回状态向父节点汇报、由父节点决策」 替换「状态点对点跳转」。关键差异:

FSM BT
子单元如何「退出」 跳转到一个具名的目标状态(必须知道跳哪) 向父节点返回 Success/Failure(不知道接下来谁执行)
加一个行为的代价 最坏新增 \(O(N)\) 条转移(连到/连出已有状态) 插入一个子树,\(O(1)\) 结构改动(父节点多一个孩子)
一段逻辑能否整体复用 难(它指向旧状态,搬不走) 易(子树是自包含的,返回三态即可,到处可插)
组合的「接口」 状态名(全局,耦合) 三态返回值(局部,解耦)

「两块组合性」(two-block modularity) 是 Colledanchise & Ögren 对这个优势的命名:在 BT 里,任意一个子树都是一个「黑盒块」,它对外只暴露「被 tick 时返回三态之一」这个统一接口。你可以把任意两个块用一个控制节点组合成一个更大的块,组合出的块还是同样的接口——于是「组合」可以无限递归,且每次组合不需要了解块的内部。这就像电路里的「黑盒模块只通过引脚连接」,而 FSM 像「每个元件的内部导线都焊到别的元件内部」。

BT 的两块组合性(子树即块,统一接口):

   任意子树 T_A ──┐
                  ├─→ [控制节点] ─→ 组合成更大的子树(还是同样的"三态"接口)
   任意子树 T_B ──┘                    └→ 可继续和 T_C 组合 ... 无限递归

   FSM 的耦合(状态内部互相焊死):
   [状态A]──跳转──>[状态B]   要复用 A,必须把 B(以及 B 跳向的...)一起搬走

本质洞察:BT 优于 FSM 的根,不是「BT 能做 FSM 做不到的事」(二者表达能力等价,§2.5、§8.6),而是「BT 的组合接口是局部的(三态返回),FSM 的组合接口是全局的(状态名)」。局部接口意味着「加一个、改一个、复用一个」都不牵动全局;全局接口意味着牵一发动全身。这就是为什么大型、频繁演化的系统几乎都选 BT——它把「修改成本」从 \(O(N)\) 降到了 \(O(1)\)。这个洞察也解释了 §3.6 那条贯穿历史的主线(控制结构演化方向 = 提高模块化):BT 是这条线上「把组合接口彻底局部化」的里程碑。理解了「局部接口 vs 全局接口」,你就理解了 BT 工程优势的全部。

8.4 BT 推广包容架构与序贯行为组合 (SBC)

§3.5 提到 Colledanchise & Ögren (2017) 的统一性定理:BT 推广了序贯行为组合 (SBC)、包容架构、决策树三者。§8.2 已说明决策树是特例,这里补完另外两个:

  • 包容架构 (Subsumption Architecture) 是 BT 的特例:包容架构「高层抑制低层」(§3.2),可翻译成一个 Fallback——把高优先级行为放左边、低优先级放右边,Fallback「左边能成功就不轮到右边」恰好实现「高层抑制低层」。所以一个分层抑制的包容架构,等价于一棵特定的 Fallback 树。
  • 序贯行为组合 (Sequential Behavior Composition, SBC) 是 BT 的特例:SBC 是控制理论里「用一串局部控制器,每个把状态推进到下一个的吸引域,串成一条到目标的链」的方法。这串行的「一个成功推进到下一个」恰好是 Sequence 的语义——SBC 等价于一棵 Sequence 树(每个子节点是一个局部控制器)。

把三者收编的意义:BT 不是「又一个并列的编排工具」,而是一个把前人三条路都作为特例包含的更一般框架。你想要决策树的「条件选动作」?用 Fallback+Sequence。想要包容架构的「分层抑制反应」?用 Fallback。想要 SBC 的「控制器串接收敛」?用 Sequence。BT 用统一的 tick + 三态 + 控制节点,把这三种历史上各自独立的范式统一在一套语法下——这是它理论价值的核心。

多视角理解(双重解读):「BT 推广三者」可以从两个角度理解其价值。角度一(理论统一视角):它说明 BT 在表达能力上是这三者的「上确界」,给了 BT 在控制架构谱系里一个明确的、更一般的位置——这是学术价值。角度二(工程迁移视角):它意味着任何已经用决策树/包容架构/SBC 搭的系统,都能无损迁移到 BT,并在迁移后获得 BT 额外的可组合性和 Running/Parallel 能力——这是实践价值。两个角度共同支撑了一个结论:选 BT 作为执行层的统一框架,在理论上有依据、在实践上无损失。这正是 BT 能成为机器人执行层事实标准(Nav2 等)的根本原因。

8.5 三方对比大表

把 BT、决策树(控制流版)、FSM 三者放在一张大表里系统对比(R6E 系统性分类):

维度 行为树 BT 决策树 (控制流版) 有限状态机 FSM
核心抽象 子树 + 三态返回 条件分支 + 动作叶子 状态 + 点对点转移
时间维度 (长动作) ✓ Running 态 ✗ 一次性选动作 ✓ 状态可持续
跨拍记忆 ✓ Memory 节点 ✗ 每次从根 ✓ 当前状态即记忆
反应/中断 ✓ Reactive 每拍重检 △ 每次重选但无长动作中断 △ 需手工连中断转移
并发监控 ✓ Parallel △ 状态笛卡尔积,爆炸
可组合性 ✓✓ 两块组合,子树即节点 △ 树可嵌套但无 Running ✗ 转移焊死,不可整体复用
加一个行为的代价 \(O(1)\) 插子树 \(O(1)\) 加分支(但能力弱) \(O(N)\) 连转移
转移/连接数量 边数 = 节点数−1(树) 树结构 最坏 \(O(N^2)\) 转移
失败恢复 ✓✓ Fallback 内建 △ 可表达但无中断 △ 手工连恢复转移
表达能力 三者最广(推广另两者) BT 的特例(去掉 Running) 与 BT 等价但难组合
何时它最合适 复杂、常变、需复用、长任务 简单的「条件→瞬时动作」选择 状态极少、强时序、转移天然清晰

读表的关键:BT 在「时间维度/反应/并发/可组合」四项上全面占优,这正是它适合做执行层的原因。决策树胜在简单(但能力弱),FSM 胜在「状态极少时直观」(§8.6)。

8.6 何时 FSM 仍然更合适

最后一个澄清,也是对 BT 的「祛魅」:BT 不是万能的,有些场景 FSM 反而更合适。 这呼应总论 §6 的判断思想——选型要看场景,别迷信单一工具。

FSM 更合适的场景,有三个共同特征:

  1. 状态数极少(如 \(\le 5\) 个)\(O(N^2)\) 的转移爆炸在 \(N\) 很小时根本不是问题(\(N=4\) 时最多 12 条转移,一目了然)。BT 的可组合优势在小规模时体现不出来,反而 BT 的 tick/三态机制是额外的认知负担。
  2. 强时序、状态语义清晰:有些系统天然是「一个明确的状态机」——比如一个交通灯「红→绿→黄→红」,或一个充电协议「握手→认证→充电→结束」。这种「就是一串明确状态循环」的逻辑,FSM 的状态图比 BT 的树更直观、更贴合心智模型。
  3. 每个状态有明确的「持续行为」且转移条件简单:FSM 的「在某状态持续做某事,满足条件才转移」对这类问题表达得很自然。
场景特征 选 FSM 选 BT
状态/行为数量 极少(\(\le 5\) 多、且会增长
时序性质 强时序、线性/循环、状态清晰 复杂分支、需失败恢复
反应/中断需求 少或无 频繁、需快速中止
复用/扩展需求 一次性、稳定 需复用子逻辑、频繁演化
并发监控 需要(一边做一边盯)

本质洞察:BT 和 FSM 的选择,本质是「复杂度的盈亏平衡点」问题。BT 的机制(tick/三态/Reactive/Memory)有固定的「认知开销」,但它的可组合性让「每增加一个行为的边际成本」极低(\(O(1)\));FSM 几乎零认知开销,但「每增加一个状态的边际成本」随规模上升(\(O(N)\))。所以存在一个交叉点:规模小于它,FSM 的低开销胜出;规模大于它,BT 的低边际成本胜出。工程上的判断是:如果你预见系统会长大、行为会反复增删、需要复用和恢复,从一开始就用 BT(别等 FSM 长成面条再重构);如果是一个确定不会怎么变的小逻辑,FSM 甚至更省心。 不要因为「BT 更先进」就无脑选 BT——这是陷阱 3-A 的回声。看场景,看盈亏平衡点。

⚠️ 常见陷阱

陷阱 8-A:把机器学习的决策树分类器直接当作机器人执行结构。 - 现象/后果:训练一个决策树分类器去「预测下一个动作」,然后直接拿它当执行器,结果发现它无法表达「正在执行一个跨多拍的长动作」「执行中监控并中止」,机器人行为僵硬、无法处理长时程任务。 - 根本原因:混淆了 ML 决策树(一次性「特征→标签」预测,无时间维度)和行为树(反复 tick、有 Running 态的执行控制流)(§8.1)。分类器没有执行层需要的 Running/Reactive/Parallel。 - 正确做法:执行控制用行为树。如果想用学习的方法,学的应该是「生成 BT 结构」或「BT 叶子节点内部的策略」(§10.7),而不是用一个分类器替代整个执行结构。

陷阱 8-B:用 BT 硬套一个本该用 FSM 的强时序小逻辑,把简单问题复杂化。 - 现象/后果:给一个只有 3-4 个明确状态、强时序循环的协议(如充电握手)套上 BT,结果为了用 Fallback/Sequence/Reactive 表达「状态循环」绕了一大圈,代码比直接写 FSM 还难懂。 - 根本原因:忽略了 §8.6 的盈亏平衡点——状态极少、时序清晰时 FSM 更直观,BT 的机制成了额外负担。「BT 更先进」不等于「BT 处处更合适」。 - 正确做法:按 §8.6 的判据选型。状态少、强时序、不怎么变 → FSM;复杂分支、需恢复、会演化 → BT。也可以混用:用 FSM 管顶层的几个大阶段,每个阶段内部用 BT 管复杂行为。

陷阱 8-C:误以为「BT 比 FSM 表达能力更强(能算更多东西)」。 - 现象/后果:在技术讨论中坚持「BT 能做 FSM 做不到的计算」,或据此论证某些不成立的结论。 - 根本原因:混淆了「表达能力 (expressive power)」和「可组合性 (modularity)」。BT 和 FSM 表达能力等价(都描述有限控制逻辑,可互相转换),BT 的优势是工程层面的可组合性,不是计算层面的「能做更多」(§2.5、§8.3、§8.5)。 - 正确做法:准确表述为「BT 和 FSM 等价,但 BT 可组合性更好、维护成本更低」。任何一棵 BT 都能转成等价 FSM(反之亦然),区别只在「哪个好写好维护」。

练习

  1. (辨析·核心) 你的同事说:「我用 sklearn 训练了一棵决策树,准确率 95%,可以直接拿来当机器人的行为树用。」请指出这句话里的三处概念混淆(提示:ML 决策树 vs 行为树的领域、训练 vs 设计、有无 Running 态),并解释为什么一个分类器无法承担执行层的职责。然后说明:如果确实想用学习的方法改进执行,正确的切入点是什么(§10.7)?
  2. (论证·两块组合性) 用一个具体例子论证 BT 的「\(O(1)\) 加行为」对比 FSM 的「\(O(N)\) 加转移」:给一个已有 6 个状态的导航 FSM(导航/抓取/放置/避障/充电/待机),现在要新增一个「听到呼救就过去查看」的行为。分别估算:在 FSM 里实现它最坏要新增多少条转移边(考虑从哪些状态能进入、从它能回到哪些状态)?在 BT 里实现它要做多少处结构改动?由此具体说明「两块组合性」的工程价值。
  3. (选型·跨章·开放) 结合总论 §6 的选型思想和本节 §8.6 的判据,为下面三个系统各判断该用 BT 还是 FSM(或混用),说明理由:(a) 一个自动门控制器(关→开→保持→关,4 个状态,时序固定);(b) 一个仓库 AMR 的完整任务执行器(导航+取货+放货+充电+避障+异常恢复+响应调度指令,会不断加新任务类型);(c) 一个工业机械臂的安全联锁逻辑(急停/复位/运行/暂停,状态少但安全关键)。对判断为「混用」的,说明哪部分用 FSM、哪部分用 BT。

9. 精读 Nav2 默认导航行为树 ⭐⭐⭐

本节解决一个问题:前面学的 BT 机制,在一个真实工业系统里长什么样? 我们精读 ROS 2 导航栈 Nav2 的默认导航树——这是 BT 在机器人界最有影响力的落地,也是检验你是否真懂 §4–§8 的试金石。读懂这棵树,你就能改它、定制它。

9.1 Nav2 是什么:ROS 2 导航栈,BT 做任务编排

Nav2 (Navigation 2) 是 ROS 2 的官方导航框架,由 Macenski 等人开发并发表("The Marathon 2: A Navigation System", IROS 2020)。它把移动机器人「从 A 点自主导航到 B 点」所需的一切——全局路径规划、局部轨迹控制、代价地图、恢复行为——整合成一个可配置的系统。Nav2 的任务编排核心,就是一棵行为树:当你调用 Nav2 的「导航到目标点」服务时,它内部 tick 一棵 BT,由这棵树决定「先规划、再跟踪、失败了怎么恢复」的完整流程。

为什么 Nav2 选 BT 做编排?正是 §2.5、§8.3 的理由:导航是个「需要失败恢复、需要并发监控、行为会不断定制」的复杂任务,BT 的可组合性让用户能通过改 XML 定制导航行为而不碰 C++ 代码——换一种恢复策略、加一级重试、调整重规划频率,都只是改树结构。这正是 BT 工程价值的最佳示范。

本质洞察:Nav2 用 BT 做编排,把「导航策略」从「导航算法」里彻底剥离了出来。算法(A* 怎么搜路、MPPI 怎么跟轨迹)在 C++ 插件(planner/controller server)里;策略(什么时候重规划、失败退到哪、恢复怎么升级)在 XML 描述的 BT 里。于是同一套算法插件,配不同的 BT,就能得到不同行为的导航器——「保守重规划版」「激进恢复版」「简单无恢复版」各是一个 XML 文件。这种「算法/策略分离」是 §5.4「薄接口 + 厚后端」在系统层面的放大,也是 Nav2 能服务从仓库 AMR 到清洁机器人千差万别需求的根本。读这棵树时,始终带着「我看的是策略层,算法在叶子背后的插件里」这个视角(§2.4)。

9.2 默认树全景:navigate_to_pose_w_replanning_and_recovery

Nav2 的默认导航树叫 NavigateToPoseWReplanningAndRecovery(带重规划与恢复的导航到位姿)。我们直接读它的真实 XML(BehaviorTree.CPP v4 格式),先看顶层骨架,再逐层下钻。顶层是一个两段式结构

顶层骨架(简化标注):
RecoveryNode(number_of_retries=6) "NavigateRecovery"   ← 顶层:主干失败就触发恢复,最多 6 轮
├─ [主干] PipelineSequence "NavigateWithReplanning"     ← 正常导航:周期性重规划 + 跟踪
└─ [恢复] Sequence                                       ← 主干失败才走这里:系统级恢复
     └─ ReactiveFallback → RoundRobin[ 清地图, Spin, Wait, BackUp ]

这个顶层 RecoveryNode 是 Nav2 自定义的控制节点,语义是:先 tick 第一个子节点(主干);如果主干失败,tick 第二个子节点(恢复);恢复成功后,再回头重试主干——如此最多循环 number_of_retries=6 次,6 次后仍失败才整体失败。 你可以把它理解成一个「带重试上限的 Fallback + 自动回退主干」——这是 §6.2 Fallback「失败回退」思想的工业变体(§9.5 详解它和普通 Fallback 的区别)。

一眼看懂这棵树的行为:正常情况下,主干 PipelineSequence 不断「规划路径 → 跟踪路径」,机器人顺利到达目标,恢复分支根本不碰。一旦主干失败(规划不出路、跟踪卡住),顶层 RecoveryNode 转去执行恢复子树(清代价地图、原地转、等一会、后退),恢复完再重试主干,最多折腾 6 轮。这就是 §2.3「监控 → 反应 → 恢复」三件事在一棵真实树里的样子。

9.3 主干:PipelineSequence + RateController 控制重规划

下钻到主干 PipelineSequence "NavigateWithReplanning"。它的真实结构(略去一串 Selector 配置节点,它们只是选用哪个 planner/controller 插件)核心是两部分:

<PipelineSequence name="NavigateWithReplanning">
  <!-- ...一串 Selector 节点:选 planner/controller/goal_checker 插件... -->
  <RateController hz="1.0">                          <!-- 限频:每秒最多重规划一次 -->
    <RecoveryNode number_of_retries="1" name="ComputePathToPose">
      <Fallback name="FallbackComputePathToPose">
        <ReactiveSequence name="CheckIfNewPathNeeded"> <!-- 先判断"需要新路径吗" -->
          <Inverter><GlobalUpdatedGoal/></Inverter>    <!--   目标没变才... -->
          <IsGoalNearby path="{path}" .../>            <!--   且目标在附近... -->
          <ValidatePath path="{remaining_path}"/>      <!--   且现有路径仍有效 → 不必重规划 -->
        </ReactiveSequence>
        <ComputePathToPose goal="{goal}" path="{path}" .../> <!-- 否则:真正调用规划器 -->
      </Fallback>
      <!-- ComputePathToPose 失败时的上下文恢复:清全局代价地图 -->
      <Sequence>
        <WouldAPlannerRecoveryHelp .../>
        <ClearEntireCostmap name="ClearGlobalCostmap-Context" .../>
      </Sequence>
    </RecoveryNode>
  </RateController>
  <RecoveryNode number_of_retries="1" name="FollowPath">
    <FollowPath path="{path}" controller_id="{selected_controller}" .../>  <!-- 跟踪路径 -->
    <Sequence>  <!-- FollowPath 失败的上下文恢复:清局部代价地图 -->
      <WouldAControllerRecoveryHelp .../>
      <ClearEntireCostmap name="ClearLocalCostmap-Context" .../>
    </Sequence>
  </RecoveryNode>
</PipelineSequence>

PipelineSequence 是什么? 它是 Nav2 自定义的一种 Sequence 变体,语义是「像 Sequence 一样从左到右推进,但已经成功并返回过的前面的节点,在后续拍会被重新 tick」——这让前面的节点(如规划)能在后面的节点(如跟踪)执行期间被持续重新触发。这正是「边走边重规划」的关键:FollowPath 在跟踪路径(返回 Running)的同时,前面的 ComputePathToPose 被周期性重新 tick,从而周期性地重新规划全局路径。

RateController hz="1.0" 的作用:如果不限频,ComputePathToPose 会被每拍(如 10 Hz)重新 tick,意味着每秒重规划 10 次——太浪费(全局规划是昂贵操作)。RateController 把它节流到 1 Hz:每秒最多让 ComputePathToPose 真正执行一次。这就是 §6.5 RateController 和 §11.4「重规划节流」在 Nav2 里的实证——重规划是昂贵的,必须限频

ComputePathToPose 外面那个 Fallback + ReactiveSequence "CheckIfNewPathNeeded" 是什么? 这是一个聪明的优化:在真正调用规划器之前,先用 ReactiveSequence 判断「到底需不需要重新规划」——如果「目标没变(Inverter[GlobalUpdatedGoal])」且「目标就在附近(IsGoalNearby)」且「现有路径仍然有效(ValidatePath)」,那就不必重规划,直接复用现有路径。只有当这个判断失败(确实需要新路径)时,Fallback 才转向真正的 ComputePathToPose 去调规划器。这避免了「目标没变、路也没问题时还白白重规划」的浪费——§6.3 布尔逻辑(Fallback+ReactiveSequence+Inverter)在这里直接落地为一个「要不要重算」的决策。

9.4 恢复子树:RoundRobin 循环升级 ClearCostmap / Spin / Wait / BackUp

回到顶层 RecoveryNode 的第二个子节点——系统级恢复子树。当主干 PipelineSequence 失败,这里被激活:

<Sequence>
  <Fallback>  <!-- 先判断:planner 或 controller 恢复是否可能有帮助 -->
    <WouldAControllerRecoveryHelp error_code="{follow_path_error_code}"/>
    <WouldAPlannerRecoveryHelp error_code="{compute_path_error_code}"/>
  </Fallback>
  <ReactiveFallback name="RecoveryFallback">
    <GoalUpdated/>                          <!-- 守卫:恢复期间若来了新目标,立即放弃恢复 -->
    <RoundRobin name="RecoveryActions">     <!-- 轮流尝试四种恢复,每次失败换下一个 -->
      <Sequence name="ClearingActions">     <!--   ① 清局部+全局代价地图(最轻量) -->
        <ClearEntireCostmap name="ClearLocalCostmap-Subtree" .../>
        <ClearEntireCostmap name="ClearGlobalCostmap-Subtree" .../>
      </Sequence>
      <Spin spin_dist="1.57" .../>          <!--   ② 原地转 90°(重新感知周围) -->
      <Wait wait_duration="5.0" .../>       <!--   ③ 等 5 秒(也许动态障碍会走开) -->
      <BackUp backup_dist="0.30" backup_speed="0.15" .../> <!-- ④ 后退 0.3 米(脱困) -->
    </RoundRobin>
  </ReactiveFallback>
</Sequence>

恢复策略的升级阶梯体现在 RoundRobin 上。RoundRobin 的语义:轮流 tick 子节点——这一轮 tick 第一个(清地图),如果它失败,下一轮恢复被触发时 tick 第二个(Spin),再失败下一轮 tick 第三个(Wait)……循环推进。配合顶层 RecoveryNode 的「恢复完重试主干」,整体行为是:

恢复升级的完整循环(顶层 RecoveryNode 最多 6 轮 + RoundRobin 轮换恢复手段):
  主干失败 → 恢复①清代价地图 → 重试主干 → 还失败
          → 恢复②Spin 转一圈 → 重试主干 → 还失败
          → 恢复③Wait 等5秒   → 重试主干 → 还失败
          → 恢复④BackUp 后退   → 重试主干 → 还失败
          → (RoundRobin 回到①,但顶层 retries 计数已用掉若干)
          → 累计 6 轮仍失败 → 整棵树返回 FAILURE(导航彻底失败)

这个「从轻到重、逐级升级」的恢复设计是教科书级的:先试最便宜的(清地图,也许只是旧障碍残留),不行再试稍贵的(转一圈重新感知),再不行等一等(动态障碍自己走开),最后物理脱困(后退)。每一级都比上一级「动作更大、代价更高」——这正是 §11.3 恢复分类学「重试 → 替代 → 升级」的思想,Nav2 把它具体化成了四级。

那个 ReactiveFallback[GoalUpdated, RoundRobin] 的守卫GoalUpdated 是个条件,检查「恢复期间是否来了新导航目标」。因为是 ReactiveFallback(每拍重检),一旦恢复执行到一半收到新目标,GoalUpdated 返回 Success → ReactiveFallback 立即成功 → 放弃当前恢复,回去处理新目标。这是 §7.3 Reactive 守卫 + §11.5 抢占语义在 Nav2 里的实证——新目标能抢占正在进行的恢复。

9.5 RecoveryNodeRoundRobin:Nav2 的恢复语义

Nav2 用了两个自定义控制节点(RecoveryNodeRoundRobin)来表达恢复,它们和标准的 Sequence/Fallback 有微妙但重要的区别,单独讲清:

节点 子节点数 语义 和标准节点的区别
RecoveryNode 2(主 + 恢复) tick 主;主失败则 tick 恢复;恢复成功则回头重试主;如此最多 N 轮 普通 Fallback 不会「恢复成功后回头重试主」,RecoveryNode 会——它专为「失败→恢复→再试」循环设计
RoundRobin 多个 轮流 tick:本轮 tick 当前子,失败则记住并在下次推进到下一个子,循环 普通 Fallback 每次都从第一个开始;RoundRobin 记住上次轮到谁,下次接着轮换——实现「依次尝试不同恢复手段」
PipelineSequence 多个 像 Sequence 推进,但已成功的前序节点在后续拍仍被重 tick 普通 Sequence(Memory 版)不会重 tick 已成功的;PipelineSequence 会——实现「跟踪期间持续重规划」

这三个节点是 Nav2 在标准 BT 节点之上的「领域定制」——它们都能用标准节点组合近似,但 Nav2 把高频用到的恢复/重规划模式封装成专用节点,让 XML 更简洁。理解它们的关键,还是回到三态语义和「下一拍从哪开始」(§7):RecoveryNode 的「回头重试主」、RoundRobin 的「记住轮到谁」、PipelineSequence 的「重 tick 前序」,本质都是对「跨拍行为」的不同定制。

本质洞察:Nav2 的三个自定义节点,揭示了一个工程现实——标准 BT 节点(Sequence/Fallback/Parallel/Decorator)是「原语」,真实系统往往在原语之上封装「领域惯用节点」。这不违背 BT 精神,反而是 BT 可扩展性的体现:因为控制节点本身就是「根据子节点三态决定 tick 谁、返回什么」的小算子,你完全可以定义新算子(RecoveryNode = 一个会「失败后回头重试」的特化 Fallback)。读懂任何工业 BT,第一步都是「查清它用了哪些自定义控制节点、各自的三态规则」——然后整棵树就还原成了 §6 的标准语义。Nav2 的文档为每个自定义节点都写了精确的返回状态规则,正是因为「控制节点的语义 = 它的三态规则」(§6.6)。

9.6 为什么这样设计:把「反应」和「恢复」显式化

退一步看整棵树的设计哲学。Nav2 本可以把「重规划、恢复」逻辑写死在 C++ 里(像很多老导航栈那样),为什么偏要用一棵 XML 的 BT?因为 BT 把两件本来「隐藏在代码里」的事显式化、可配置化了:

  1. 「反应」显式化PipelineSequence + RateController 让「边走边以 1 Hz 重规划」成为树结构里看得见、可调的一环——想改重规划频率?改 hz 值。想让路径失效时立即重规划?树里 ValidatePath 失败就触发。反应逻辑不再埋在代码深处。
  2. 「恢复」显式化RecoveryNode + RoundRobin 把「失败后怎么逐级恢复」摊开成一个看得见、可增删的列表——想加一级恢复(比如「呼叫人工」)?在 RoundRobin 里加一个子节点。想改恢复顺序?调整子节点次序。恢复策略不再是硬编码。

这种「显式化」的收益,是把「定制导航行为」从「改 C++ 重新编译」降级成「改 XML 重启」——运维和研究人员都能调,无需懂 Nav2 内部 C++。这就是 §2.5、§8.3 反复强调的 BT 工程价值,在 Nav2 里的最终兑现。

多视角理解(类比,标边界):Nav2 的 BT-XML 之于导航策略,配置文件之于程序行为——都是「把易变的策略从稳定的代码里抽出来,做成外部可改的声明」。的地方:改 XML/配置都不用重新编译核心代码、都让非开发者也能调整行为。不像的地方:普通配置文件是「静态参数」(一组键值),而 BT-XML 是「控制流结构」(带 tick 语义的树)——它不只配置「参数是多少」,而是配置「逻辑怎么走、失败退到哪」。所以别把 BT-XML 当普通配置:改它要懂控制节点的三态语义(否则就是陷阱 9-A 的「盲改」),它比配置文件强大得多也危险得多。

9.7 与 MPPI_08 / FollowPath 的接口

最后落到本章反复强调的接口(§2.4、§5.4):FollowPath 这个叶子。在 Nav2 树里,FollowPath 是一个异步动作节点,它做的是「把规划好的路径 {path} 交给控制器服务去跟踪」:

FollowPath 叶子的运作(回到 §5.2 异步三回调):
  onStart:   把黑板里的 {path} 发给 controller_server,让它开始跟踪
  onRunning: 查询跟踪进度——没到终点返回 Running,到了返回 Success,跟丢/卡住返回 Failure
  onHalted:  通知 controller_server 停止跟踪(如被恢复抢占时)

真正干「跟踪」活的是 controller_server 里的控制器插件——可以是 DWB、TEB、Regulated Pure Pursuit,或者本项目 MPPI_08 讲的 MPPI 控制器。这个控制器以远高于 BT tick 频率的频率(如 20–50 Hz)求解「下一步该发什么速度指令」。BT 在 1–10 Hz 编排「让它跟、监视它跟得怎么样」,MPPI 在运动层高频算控制律——两个频率、两个层次,通过 FollowPath 这个异步接口解耦。

这正是总论 §7 那张交叉表的含义:「T5 行为树 ↔ MPPI_08:Nav2 行为树里的 FollowPath 叶子,可由 MPPI 控制器实现」。当你学完本章回头看 MPPI_08,会更懂 FollowPath 背后那个控制器在做什么;反过来,理解了 FollowPath 是「BT 对控制器的异步包装」,你也更懂 MPPI 在整个导航系统里的位置——它是「小脑」,BT 是「指挥小脑的大脑」(总论 §7 本质洞察)。

本质洞察FollowPath 这一个叶子,浓缩了本章的全部核心思想。它是异步动作(§5.2,三回调)、是薄接口厚后端(§5.4,叶子在 BT、控制器在运动层)、是频率分层(§4.5,BT 低频编排、控制器高频执行)、是可插拔(§9.1,换控制器插件 BT 不变)的集大成者。如果你能完整说清「FollowPath 被 tick 时发生了什么、它和 MPPI 控制器怎么协作、换控制器为什么 BT 不用改」,你就真正读懂了 BT 作为执行层编排器的本质——它指挥而不亲为,监控而不计算。这是从「会画 BT」到「懂 BT 在系统里的角色」的关键一跃。

⚠️ 常见陷阱

陷阱 9-A:改 Nav2 树的 XML 时只改结构、不理解控制节点的三态语义,导致行为诡异。 - 现象/后果:把 RecoveryNode 换成普通 Fallback、或把 PipelineSequence 换成普通 Sequence,结果「恢复成功后不再重试主干」「跟踪期间不再重规划」,导航行为大变却找不到原因。 - 根本原因:把 BT-XML 当普通配置文件改,忽略了自定义控制节点(RecoveryNode/RoundRobin/PipelineSequence)有特殊的三态/跨拍语义(§9.5),换个节点就是换了控制逻辑。 - 正确做法:改 Nav2 树前,先查清每个控制节点的精确返回状态规则(Nav2 文档为每个节点都列了)。明确「这个节点失败/成功/Running 时会怎样、下一拍从哪开始」,再动手。把 §9.5 的对比表当改树前的检查清单。

陷阱 9-B:恢复无上限或重规划无节流,导致死循环或 CPU 爆炸。 - 现象/后果:去掉顶层 RecoveryNodenumber_of_retries 限制(或设得过大),机器人在一个根本无解的死局里无限「失败→恢复→失败」循环,永不放弃;或去掉 RateController,全局规划每拍触发,CPU 跑满、tick 超时。 - 根本原因:忽略了「恢复要有上限」「重规划要节流」这两条铁律(§11.4)。恢复和重规划都是昂贵的,无限制会拖垮系统或陷入死循环。 - 正确做法:恢复循环必须有重试上限(Nav2 默认 6 次),到顶就果断返回 Failure 上报(让更高层决策,如换目标或求助)。重规划必须用 RateController 节流(Nav2 默认 1 Hz)。这两个数值要按任务和算力调,但「必须有限制」不可妥协。

陷阱 9-C:误以为 ComputePathToPose/FollowPath 叶子里实现了规划/控制算法,去叶子里找 A*/MPPI 代码。 - 现象/后果:想改规划算法,去翻 BT 叶子节点的 C++ 实现,发现里面只有「发请求、查结果」的接口代码,找不到任何搜索/优化算法,一头雾水。 - 根本原因:忘了 §2.4「BT 是编排器」——叶子是运动层服务的异步客户端包装,真正的算法在 planner_server/controller_server 的插件里,不在 BT 叶子里。 - 正确做法:改算法去对应的 server 插件(planner/controller plugin),不是改 BT 叶子。改「什么时候规划、失败退哪」才去改 BT。牢记「策略在树、算法在插件」(§9.1 本质洞察)的分层。

练习

  1. (精读·核心) 对照 §9.2–9.4 的真实 XML,逐层画出 NavigateToPoseWReplanningAndRecovery 整棵树的结构图(顶层 RecoveryNode → 主干 PipelineSequence 与恢复 Sequence → 各自的子结构)。然后用一句话标注每个控制节点的作用。最后回答:当机器人正常导航到一半,前方突然出现一个动态障碍挡住了规划好的路径,这棵树会依次发生什么(哪个节点先察觉、怎么重规划、若重规划也失败怎么进入恢复)?
  2. (定制·实战) 你要给这棵树新增一级恢复行为:「前四级恢复都失败后,呼叫人工协助(一个 CallOperator 动作节点)」。说明你会把 CallOperator 加在树的什么位置(提示:RoundRobin 里?还是顶层 RecoveryNode 之外再包一层?两种放法语义有何不同?),并写出修改后的 XML 片段。再说明:加这一级会不会影响顶层 number_of_retries=6 的计数行为?
  3. (接口·跨章·接 MPPI_08) 仿照 §9.7,详细描述:如果把 Nav2 的控制器插件配置成 MPPI 控制器,那么一次完整的「BT tick FollowPath → MPPI 算控制 → 机器人动 → 反馈回 BT」的数据流是怎样的?标明哪部分在 BT(什么频率)、哪部分在运动层(什么频率)、二者通过什么接口交换什么数据。最后回答:如果 MPPI 算出当前路径无法跟踪(如被逼到死角),它怎么让 BT 知道、BT 又会怎么反应(提示:FollowPath 返回什么、触发哪个恢复)?

10. Plan-to-BT:让规划器在线生成行为树 ⭐⭐⭐⭐

本节解决一个问题:手写行为树不可扩展,长任务和变化的目标怎么办? 答案是让规划器自动生成 BT。这一节是本章把 BT 接回 TAMP 全线(T1–T4 的规划层)的关键,也是研究级(⭐⭐⭐⭐)内容——它揭示「规划」和「执行」如何在 BT 上融为一体。

10.1 动机:手写树不可扩展,长任务怎么办

前面的树都是手写的(§6 的取放树、§9 的 Nav2 树)。手写对「导航 + 恢复」这种固定结构没问题,但对「长时程、目标多变」的任务就崩了:

  • 任务一长,树就爆:一个「整理整个房间」的任务,涉及几十个物体、上百个子目标,手写一棵覆盖所有情况和失败恢复的树,工作量和出错率都不可接受。
  • 目标一变,树就废:手写的树是为某个固定目标定制的。目标换了(「不整理桌子了,改整理书架」),整棵树要重写。
  • 手写难以保证「反应 + 恢复」完备:人写树容易漏掉某个失败分支,导致某些情况下机器人无所适从。

这正是 §1 目标 6、总论 §3.5「前沿做法是让规划器在线生成 BT (plan-to-BT)」要解决的。核心想法:不手写树,而是给定目标,让一个算法自动生成一棵 BT——既省去手写,又能保证生成的树自带反应性和恢复结构。最经典的算法是 PA-BT (Planning and Acting using Behavior Trees),由 Colledanchise, Almeida & Ögren (2019, ICRA) 提出,核心是回链展开 (back-chaining)

10.2 回链展开 (back-chaining):从目标条件反推

回链展开的思想,和符号规划里的「目标回归 (goal regression)」一脉相承(回顾 TAMP_T1 的规划基础):从目标出发,反向推理「要达成它,需要先满足什么」,再为那些前提找动作,递归下去。 但 PA-BT 的巧妙在于:它把这个反向推理的产物直接组织成一棵 BT,而不是一个动作序列。

回链展开的一拍步骤:

回链展开(back-chaining)一步:
  1. 从目标条件出发,构造一棵最简 BT:就是"检查目标条件"的 Sequence
       BT₀ = Sequence[ 目标条件c ]
  2. tick 这棵树。如果某个条件节点 c 返回 Failure(说明这个目标还没达成):
  3. 找出所有"能使 c 变为真"的动作 a(a 的效果里包含 c)
  4. 把失败的条件节点 c 替换成一个 Fallback:
       Fallback[
         c,                         # 先检查 c 是否已经满足(满足就不用动作了)
         Sequence[ a的前置条件..., a ] # 否则:先满足 a 的前置条件,再执行 a
       ]
  5. a 的前置条件本身又是条件节点——如果它们也失败,对它们重复第 2–4 步(递归展开)
  6. 重复,直到树能 tick 到根 Success(目标达成)

关键是第 4 步那个替换模式——把「失败的条件」换成「Fallback[ 条件本身, Sequence[ 前置条件, 达成它的动作 ] ]」。读这个模式:

  • Fallback 的第一支是条件 c 本身:如果 c 碰巧已经满足(比如别的动作顺带达成了,或外部 agent 帮忙了),就直接成功,不必执行动作——这赋予了树「跳过不必要动作」的反应性。
  • Fallback 的第二支是「前置 + 动作」的 Sequencec 不满足时,先(递归地)满足动作 a 的前置条件,再执行 a 来达成 c

一个具体例子——目标是「杯子在架子上 On(cup, shelf)」:

回链展开 On(cup, shelf):
  BT₀ = Sequence[ On(cup,shelf) ]   # tick → Failure(杯子还在桌上)
  展开:On(cup,shelf) 由动作 place(cup,shelf) 达成,place 的前置是 Holding(cup)
  BT₁ = Fallback[
          On(cup,shelf),                              # 已在架上?没有
          Sequence[ Holding(cup), place(cup,shelf) ]  # 否则:先握着杯子,再放
        ]
  tick BT₁ → Holding(cup) 失败(手里没杯子),递归展开 Holding(cup):
  Holding(cup) 由 pick(cup) 达成,pick 的前置是 HandEmpty ∧ Reachable(cup)
  BT₂ = Fallback[
          On(cup,shelf),
          Sequence[
            Fallback[ Holding(cup),                    # 递归展开的子树
                      Sequence[ HandEmpty, Reachable(cup), pick(cup) ] ],
            place(cup,shelf)
          ]
        ]
  继续递归直到所有前置条件要么已满足、要么有动作能达成 → 得到完整可执行 BT

10.3 算法:PA-BT 的扩展循环

把 §10.2 的展开放进一个「执行—失败—展开」循环,就是 PA-BT 的完整算法。它的精髓是边执行边扩展 (planning and acting interleaved)——不是先把整棵树规划完再执行,而是执行中遇到失败才展开那一处:

PA-BT 主循环(Planning and Acting using Behavior Trees):
  T ← Sequence[ 所有目标条件 ]        # 初始最简树
  loop:
      tick T,得到返回状态和"导致失败的条件节点 c_f"(若失败)
      if T 返回 Success:  return 成功   # 目标全达成
      if T 返回 Running:  continue       # 还在执行,下一拍继续
      if T 返回 Failure:                 # 某条件 c_f 失败,需要扩展
          找出能达成 c_f 的动作集合 A = { a : c_f ∈ effects(a) }
          for a in A(按某种优先级,如代价):
              用 §10.2 的模式把 c_f 替换为 Fallback[ c_f, Sequence[ precond(a), a ] ]
          (可选)冲突解决:若新加的动作会破坏已满足的条件,调整子树顺序
      # 回到 loop 顶部,重新 tick 扩展后的树

这个循环有三个深刻特性:

  1. 惰性展开 (lazy expansion):树只在「实际 tick 到失败」时才展开那一处,不预先规划所有可能。世界顺利时,树保持精简;只有遇到障碍才长出恢复分支。这比「先规划完整计划再执行」省去了大量「为可能不发生的情况预先规划」的浪费。
  2. 天生反应性:因为每个子目标都包成 Fallback[ 条件, 动作 ],每拍 tick 都先检查「条件是否已满足」。如果外部 agent 帮机器人完成了某步(如有人把杯子递到手里),对应条件直接成功,机器人跳过该动作——无需重规划就适应了变化。反过来,若外部 agent 撤销了某步(把杯子拿走),对应条件失败,机器人自动重新执行该步——也无需重规划。这正是论文标题「Blended Reactive Planning and Acting(融合的反应式规划与执行)」的含义。
  3. 冲突解决:新加的动作可能破坏已经满足的条件(如「为了拿 B 而移动,结果撞翻了已放好的 A」)。PA-BT 通过调整子树在 Sequence 里的顺序来处理这种冲突——把「会被破坏的目标」排到「破坏它的动作」之后再重新达成。

本质洞察:PA-BT 模糊了「规划」和「执行」的传统界线。经典范式里,规划(T1–T4)和执行(T5)是两个分离的阶段——先规划出完整计划,再执行。PA-BT 把它们交织成一个循环:执行(tick)暴露出「哪里不对」(失败条件),规划(展开)只针对那一处生成修补,然后继续执行。这是一种「最小承诺 (least commitment)」的智能——不预先规划一切(那既贵又容易因世界变化而作废),而是「走一步看一步、缺什么补什么」。BT 是这种交织的理想载体,正因为它的 Fallback[条件,动作] 结构天生就是「先看条件满足没、没满足才动手」——这恰好是「执行(检查)」和「规划(动手达成)」的统一原语。理解了这一点,你就理解了为什么 §10.4 说「BT 是 plan-to-BT 的理想目标」。

10.4 为什么 BT 是 plan-to-BT 的理想目标

你可能会问:规划器的输出,为什么要编译成 BT,而不是别的(如一个动作序列、一个 FSM)?因为 BT 同时具备「计划生成的目标结构」所需的两个性质,而别的结构至多有其一:

性质 BT 动作序列 FSM
模块化(易增量修改) ✓ 子树可局部替换(§8.3),展开只改一处 ✗ 改一步常需重排整个序列 ✗ 改一处牵动转移网(§3.1)
反应性(执行中适应变化) ✓ 每拍重检条件,自动跳过/重做(§7) ✗ 序列是 open-loop,不反应(§2.2) △ 需手工连反应转移
可组合(递归生成) ✓ 展开产物还是 BT,可继续展开 △ 序列可拼接但无反应 ✗ 难递归组合

模块化让回链展开能「只修补失败处」(§10.3 惰性展开)——因为替换一个子树不影响树的其余部分(§8.3 两块组合性)。反应性让生成的树在执行中能自动适应世界变化(外部帮忙就跳过、外部破坏就重做)——这是动作序列(open-loop)根本没有的。两者结合,使「生成的计划」不是一个静态的动作清单,而是一个活的、会对世界做出反应的执行结构

多视角理解(双重解读):plan-to-BT 可以从两个角度理解其价值。角度一(规划视角):它是一种「把规划结果表示成可执行、可反应结构」的编译——规划器负责「想出怎么做」,BT 负责「稳健地做下去」,编译把两者粘合。角度二(执行视角):它是一种「让执行结构能自我修补」的机制——执行中发现缺口(失败条件),就地长出补丁(展开子树),无需推倒重来。两个角度描述同一件事:规划的产物和执行的载体统一成了同一棵 BT。这种统一消除了「规划完再执行」范式里「计划与执行脱节」的老问题(计划是死的、执行是活的,二者总对不上)——在 plan-to-BT 里,计划本身就是活的执行结构。这正是 BT 相对「先规划后执行」范式的根本进步。

10.5 与 T1–T4 的接口:TAMP 的输出如何编译成 BT

把 plan-to-BT 接回本线前四章。T1–T4 的规划器(符号规划、PDDLStream、LGP)输出的是带几何参数的动作序列。怎么把它变成 BT?有两条路:

路线一:序列直接「串成 Sequence」(简单但 open-loop)。 最朴素的编译——把计划 \(\pi = [a_1, a_2, \dots, a_n]\) 的每个动作做成一个动作叶子,用一个 Sequence 串起来:

朴素编译:π = [pick(cup), move, place(cup,shelf)] →
  Sequence[ pick_action, move_action, place_action ]

这能跑,但它是 open-loop 的(§2.2)——只是把序列换了个表示,没有反应性。改进是给每个动作加前置守卫(在动作前放条件节点检查前置条件),让它至少能监控:

加守卫的编译:
  Sequence[
    Sequence[ HandEmpty, Reachable(cup), pick_action ],   # pick 带前置守卫
    Sequence[ Holding(cup), move_action ],
    Sequence[ AtShelf, Holding(cup), place_action ]
  ]

路线二:用回链展开生成反应式 BT(PA-BT 风格)。 不直接用序列,而是把 T1–T4 的动作模型(每个动作的前置/效果)喂给 PA-BT 的回链展开(§10.2),从目标条件生成一棵自带反应性和恢复的 BT。这棵树执行中能自动适应变化(§10.3),是路线一的「活」版本。代价是需要动作的符号模型(前置/效果),且要处理几何参数(抓取位姿等)——这正是 T1–T4 与 §10 的接口要解决的:符号层用回链展开搭 BT 骨架,几何参数(位姿、轨迹)作为动作叶子被 tick 时再向运动层采样/求解(回到 §5.4 的「叶子调运动层」)。

实践中常是两者结合:用 T1–T4(如 PDDLStream)生成主干计划(路线一搭骨架),再在关键的易失败处用回链展开补上反应式恢复子树(路线二加韧性)。§12 的累积项目正是这种「规划器搭主干 + BT 加监控恢复」的组合。

本质洞察:T1–T4 和 §10 plan-to-BT 的接口,揭示了「规划」和「执行」的分工可以有不同的粒度。一个极端:规划器只管搭主干(路线一),所有反应/恢复靠手写 BT 包在外面(§9 Nav2 风格)。另一个极端:规划器用回链展开生成整棵带反应的树(路线二,PA-BT)。中间地带:规划器搭主干、回链展开补恢复。选哪个,取决于「动作的符号模型有多完整」「任务有多动态」——模型完整、任务动态选路线二,模型粗糙、结构固定选路线一。这个谱系告诉你:plan-to-BT 不是单一算法,而是「规划器输出和 BT 执行结构之间的一族编译策略」,从「纯手写 BT」到「全自动生成 BT」连续过渡。理解这个谱系,你才能为具体系统选对接口粒度。

10.6 伪代码:plan → BT 的骨架编译

把路线一(加守卫)的编译写成伪代码,作为最小可实现的起点(路线二的回链展开见 §10.3):

def compile_plan_to_bt(plan, action_models):
    """把动作序列 plan 编译成带前置守卫的 BT(路线一)。
    plan: [a_1, ..., a_n],每个 a 是带参数的动作
    action_models: 每个动作类型的 {preconditions, effects} 符号模型
    返回: 一棵 BT(这里用嵌套 dict 表示树结构)
    """
    main_steps = []
    for a in plan:
        model = action_models[a.type]
        # 为动作 a 构造"前置守卫 + 动作"的 Sequence
        guards = [ConditionNode(pre) for pre in model.preconditions]
        action_leaf = ActionNode(a)          # 动作叶子,tick 时调运动层(§5.4)
        step = SequenceNode(children=guards + [action_leaf])
        main_steps.append(step)
    # 主干:所有步骤按顺序(用带记忆的 Sequence,已完成的不重做,§7.2)
    main_trunk = SequenceWithMemoryNode(children=main_steps)

    # 在主干外包一层 Reactive 守卫(全局安全监控,§7.6 嵌套模式)+ 顶层恢复
    safety_guards = [ConditionNode("IsBatteryOK"), ConditionNode("IsNoEmergency")]
    guarded = ReactiveSequenceNode(children=safety_guards + [main_trunk])

    # 顶层:主干失败则触发重规划(§11.4),最多 N 次
    root = RecoveryNode(
        number_of_retries=3,
        main=guarded,
        recovery=ActionNode("RequestReplan")   # 失败 → 请求 T1–T4 重规划
    )
    return root

这段伪代码把本章前面的零件拼了起来:动作叶子(§5)、前置守卫(§5.3)、带记忆主干(§7.2)、Reactive 安全守卫(§7.6)、顶层恢复 + 重规划(§9.5 RecoveryNode、§11.4)。它生成的树是「带监控的计划执行器」——比 open-loop 序列强得多,是 §12 累积项目的雏形。

10.7 前沿:学习式 BT 生成(LLM 生成 BT,接 T7)

plan-to-BT 的最新方向,是用学习/大模型生成 BT,这衔接到 T7(大模型任务规划)。几条前沿线索(来自 Iovino et al. 2022 综述及近年工作):

  • 从演示学 BT:通过模仿学习,从人类演示中归纳出 BT 结构,而非手写或符号回链。
  • 进化/遗传生成 BT:用遗传编程搜索 BT 结构空间,以任务成功率为适应度,自动「进化」出有效的树(如 Iovino 等人的工作)。
  • LLM 生成 BT:给大语言模型一个自然语言任务描述(「把厨房收拾干净」),让它直接生成 BT 的 XML 或结构。LLM 的常识和组合能力,能把模糊的自然语言目标翻译成结构化的 BT——这是 T7 的主题之一。挑战在于 LLM 生成的树不保证可执行/不保证逻辑完备,需要验证和修正(如用符号检查器校验生成的树是否满足前置/效果一致性)。
  • BT 扩展的可靠性研究:如 "BT Expansion: a Sound and Complete Algorithm for Behavior Planning"(AAAI 2021)等工作,给回链展开式的 BT 生成提供了可靠性(soundness)和完备性(completeness)的理论保证——回答「生成的树一定能达成目标吗」。

前向预告(接 T7):本节的 plan-to-BT 是「符号规划器生成 BT」,T7 会讲「大模型生成 BT/计划」。两者的关系是:符号回链展开(§10.2)逻辑严谨、可保证完备性,但需要完整的动作符号模型且难处理模糊目标;LLM 生成灵活、能处理自然语言和常识,但不保证逻辑正确。前沿的融合思路是「LLM 提议、符号验证」——让 LLM 生成候选 BT(利用其常识和灵活性),再用符号方法(如本节的前置/效果一致性检查、AAAI 2021 的可靠性算法)验证和修正(保证可执行性)。现在只需记住:BT 作为「执行结构的统一表示」,恰好是符号规划和大模型规划的公共落点——无论用什么方法生成计划,最终都可以编译成 BT 来稳健执行。这个「公共落点」地位,是 BT 在 TAMP 全线里独特价值的又一体现。

⚠️ 常见陷阱

陷阱 10-A:把规划器输出的动作序列直接串成 Sequence 就当 plan-to-BT,以为有了 BT 就有了反应性。 - 现象/后果:把计划 \(\pi\) 简单串成 Sequence[a1, a2, ...],部署后发现它和 open-loop 执行一样脆弱——某步的前提中途失效照样察觉不到、照样「假成功」。 - 根本原因:误以为「用了 BT 这个结构 = 有了反应性」。反应性不来自「是不是 BT」,而来自「有没有 Reactive 守卫、有没有 Fallback 恢复」(§7、§6.2)。光把序列换成 Sequence 表示,没加守卫和恢复,依然是 open-loop(§10.5 路线一的朴素版)。 - 正确做法:编译时必须给动作加前置守卫(§10.6)、给易失败处加 Fallback 恢复、用 Reactive 包安全守卫。或直接用回链展开(§10.2)生成自带反应性的树。「是 BT」是必要条件,「有守卫和恢复结构」才是反应性的充分条件。

陷阱 10-B:回链展开时忽略动作间的冲突,生成的树执行时互相破坏。 - 现象/后果:展开生成的树,执行「为达成目标 B 的动作」时破坏了「已经达成的目标 A」(如移动撞翻已放好的物体),导致树在 A 和 B 之间反复横跳、永不收敛。 - 根本原因:回链展开只考虑「怎么达成每个子目标」,没考虑「达成一个子目标的动作会不会破坏另一个」(§10.3 冲突解决)。 - 正确做法:在展开循环里加入冲突检测——检查新动作的效果是否删除(破坏)了已满足的条件,若是,通过调整子树在 Sequence 里的顺序(把被破坏的目标排到破坏动作之后重新达成)来消解冲突。这是 PA-BT 算法的必要组成,不可省略。

陷阱 10-C:盲目信任 LLM 生成的 BT,不做验证就部署。 - 现象/后果:让 LLM 生成一棵 BT 直接上机器人,结果树里有逻辑漏洞(某失败分支缺失、前置条件不完整、甚至引用了不存在的节点),机器人行为不可预测或直接崩溃。 - 根本原因:LLM 生成的结构不保证逻辑完备/可执行(§10.7)。它擅长把自然语言翻译成貌似合理的结构,但不保证每个分支都正确、每个前置都满足。 - 正确做法:采用「LLM 提议、符号验证」的流程——LLM 生成候选 BT 后,用符号检查器验证前置/效果一致性、检查所有节点是否已注册、用形式化方法(如 AAAI 2021 的可靠性算法)校验可达目标,验证通过才部署。把 LLM 当「灵活的提议者」而非「可信的最终生成者」。

练习

  1. (回链展开·核心) 手动执行回链展开(§10.2),为目标「Clean(table)(桌子干净)」生成 BT。给定动作模型:wipe(table) 效果 Clean(table)、前置 \(\text{Holding(cloth)} \land \text{At(table)}\)pick(cloth) 效果 Holding(cloth)、前置 \(\text{HandEmpty} \land \text{Reachable(cloth)}\)navigate(table) 效果 At(table)、前置(无)。从 Sequence[Clean(table)] 开始,逐步展开每个失败的条件,画出最终的 BT。然后说明:如果执行中有人把抹布从机器人手里拿走(Holding(cloth) 变假),这棵树会如何自动反应?
  2. (对比·接 §2.2) 对同一个计划 \(\pi = [\text{pick(cup)}, \text{move}, \text{place(cup,shelf)}]\),分别用「朴素 Sequence 编译」(§10.5 路线一朴素版)和「加前置守卫 + 顶层恢复编译」(§10.6 伪代码)生成两棵 BT。构造一个具体的执行扰动(如 move 途中杯子滑落),逐拍对比两棵树的行为:哪棵能察觉滑落、怎么反应?由此具体说明「是 BT \(\neq\) 有反应性」(陷阱 10-A)。
  3. (前沿·跨章·开放·接 T7) 阅读 Iovino et al. (2022) 综述中关于「BT 学习与生成」的章节,对比三种 BT 生成方法——符号回链展开(PA-BT)、进化/遗传生成、LLM 生成——在「逻辑完备性保证」「处理自然语言目标的能力」「需要的先验知识(动作模型/演示/无)」三个维度上的优劣。然后设计一个「LLM 提议 + 符号验证」的混合流程草图:LLM 负责什么、符号方法验证什么、验证失败时怎么反馈给 LLM 修正?这道题为你进入 T7 大模型任务规划做铺垫。

11. 执行监控与异常恢复:把世界的不配合接住 ⭐⭐⭐

本节解决一个问题:怎么系统地设计监控和恢复,把 §2.1「会犯错的世界」真正接住? 前面零散讲了守卫(§7)、Fallback 恢复(§6.2)、Nav2 恢复(§9.4);这一节把它们整合成执行监控的完整方法论——监控什么、放哪、怎么恢复、何时重规划、怎么抢占。这是执行层的「闭环」一节。

11.1 监控什么:四类需要盯住的异常

§2.1 给了一张失效表,这里把「执行中需要监控的东西」系统化为四类(R6E 穷举式分类,给思考框架而非清单):

监控类别 监控对象 典型信号 用什么监控
前置条件失效 当前/后续动作的前提是否仍成立 「夹爪里有杯子」变假、「路径仍有效」变假 Reactive 守卫条件(§7.3)
进度停滞 动作是否在推进,还是卡住了 导航位置长时间不变、抓取力矩异常 进度检查器 + Timeout 装饰器(§6.5)
资源耗尽 全局资源是否触及阈值 电量低、内存/计算超限、超时预算用尽 Parallel 监视支里的资源条件(§6.4)
外部中断 是否有更高优先级的事件 急停信号、新目标、人工接管请求 Reactive 守卫 + 抢占(§11.5)

这四类覆盖了执行中「会出错」的主要来源:前置条件失效是「计划假设被打破」(§2.1 的抓滑、障碍突现);进度停滞是「动作没失败但也没进展」(卡死,最隐蔽);资源耗尽是「计划没建模的全局约束」(电量);外部中断是「来自系统外的优先事件」(急停、新目标)。设计监控时,逐类问「这个任务的这一类异常长什么样、怎么检测」,就不会漏。

本质洞察:这四类监控对象,对应四种「不同时间尺度、不同来源」的异常,因而需要不同的监控机制——前置条件失效要每拍重检(Reactive 守卫,变化快),资源耗尽适合并发监视(Parallel 支,独立于主任务),进度停滞要带时间窗判断(Timeout/进度检查器,需要时间积累),外部中断要能抢占(最高优先级)。初学者常犯的错是「用一种机制监控所有东西」(比如全塞进守卫条件),结果要么漏(进度停滞用瞬时守卫测不出)、要么乱(资源监控塞进主干 Sequence 阻碍推进)。监控的艺术,是为每类异常匹配正确的机制和正确的位置——这正是 §11.2 要讲的。

11.2 监控放在树的哪里:守卫条件 + Parallel 监视支

监控机制有了,放在树的什么位置同样关键。两种主要的放置模式:

模式一:守卫条件放在 Reactive 分支(监控「与当前动作绑定」的前提)。 把「当前动作必须维持的前提」作为守卫,放在动作前面的 ReactiveSequence 里(§7.3、§7.6):

ReactiveSequence:
  ├─ IsPathClear           # 守卫:路通(与"导航"这个动作绑定的前提)
  └─ NavigateTo(goal)      # 受守卫的动作
  # 路一旦被挡,守卫失败,立即中止导航

这种监控与具体动作绑定——只在执行这个动作时检查这个前提。适合「前置条件失效」类(§11.1)。

模式二:监视条件放在 Parallel 监视支(监控「全程都要满足」的全局约束)。 把「整个任务期间都不能违反的全局约束」作为一个独立的监视支,和主任务并发放进 Parallel(§6.4):

Parallel(失败阈值=1):
  ├─ MainTaskSubtree       # 主任务(导航+抓取+放置的完整子树)
  └─ ReactiveSequence "全局监视":
       ├─ IsBatteryOK      # 全局约束:电量(整个任务期间都要满足)
       └─ IsNoEmergency    # 全局约束:无急停
  # 任一全局约束破坏 → Parallel 失败 → 中止整个主任务

这种监控独立于具体动作、全程生效——无论主任务在哪一步,电量和急停都被持续监视。适合「资源耗尽」「外部中断」类(§11.1)。

实践中两种模式叠加——全局约束用 Parallel 监视支包在最外层,动作专属前提用 Reactive 守卫贴在各动作旁。这就是 §7.6「Reactive 守卫 + Memory 主干」嵌套模式的完整版:最外层 Parallel/Reactive 放全局监视,主干内部各动作再带自己的局部守卫。

多视角理解(双重解读):监控的两种放置,对应两种「监控的语义范围」。局部监控(守卫贴动作):「这个前提只在做这件事时要紧」——像函数的参数检查,只在调用这个函数时验证。全局监控(Parallel 监视支):「这个约束在整个任务期间都要紧」——像程序的全局不变量(invariant),任何时刻都不能违反。两者不是二选一,而是分层的监控网:全局监视是「天网」(全程罩着),局部守卫是「岗哨」(在特定动作旁站岗)。设计监控时先问「这个东西是全局不变量还是局部前提」——全局的放 Parallel 监视支,局部的放动作旁的 Reactive 守卫。分清这一点,监控网才既不漏(全局的没全程盯)也不冗余(局部的被全程检查浪费)。

11.3 恢复策略分类学:重试 / 替代 / 降级 / 升级求助 / 重规划

监控发现异常后,要恢复。恢复不是单一动作,而是一个从轻到重的策略阶梯(§9.4 Nav2 的四级恢复是其特例)。系统化为五级(R6E 分类):

级别 策略 含义 代价 例子
1 重试 (Retry) 原样再做一次,可能是偶发失败 最低 抓取滑脱 → 重抓;通信超时 → 重发
2 替代 (Substitute) 换一个方法/参数达成同一目标 此抓取位姿不行 → 换一个抓取位姿;此路不通 → 换条路
3 降级 (Degrade) 放宽目标,接受次优结果 放不到指定位置 → 放到附近任意稳定处;精度要求降低
4 升级求助 (Escalate) 自己解决不了,请求外部(人/上层)介入 抓取反复失败 → 呼叫操作员;导航彻底卡死 → 报告调度系统
5 重规划 (Replan) 当前计划作废,回到 T1–T4 重新生成 最高 环境大变,原计划整体不可行 → 重新规划

这个阶梯的设计原则:从最便宜、最局部的开始,逐级升到最贵、最全局的。原因很直观——大部分失败是偶发的、局部的(抓滑了、路被临时挡了),用重试/替代就能解决,根本不需要惊动昂贵的重规划。只有当局部恢复反复失败、说明问题是系统性的,才升级到重规划或求助。

在 BT 里怎么实现这个阶梯?Fallback 的从左到右自然表达「逐级升级」(§6.2 本质洞察)——把恢复策略从轻到重排进 Fallback:

Fallback "恢复阶梯":
  ├─ NormalAction              # 正常执行(成功就到此为止)
  ├─ RetryUntilSuccessful(2):  # 级1:重试 2 次
  │    └─ NormalAction
  ├─ Sequence:                 # 级2:替代(换参数重试)
  │    ├─ AdjustParameters
  │    └─ NormalAction
  ├─ DegradedAction            # 级3:降级(接受次优)
  ├─ CallOperator              # 级4:求助
  └─ RequestReplan             # 级5:重规划(最后手段)
  # Fallback 从左到右:前面的不行才试后面的,自动逐级升级

本质洞察:恢复策略阶梯体现了一个深刻的工程原则——恢复的代价应该和故障的严重性匹配,而不是一上来就用最重的手段。这是 §2.3 末尾「反应要快、恢复要对」里「恢复要对」的精确含义:「对」不仅是「能解决问题」,更是「用最小代价解决问题」。一个常见的反模式是「任何失败都触发重规划」——这既慢(重规划几秒)又往往没必要(抓滑了重抓一次就行,何必重新规划整个任务)。Fallback 的「从左到右逐级升级」恰好把这个原则结构化了:便宜的恢复排左边先试,贵的排右边兜底。把恢复设计成「从轻到重的 Fallback 阶梯」,是执行层韧性的核心套路——它让系统对小故障轻盈应对、对大故障才动用重型手段。

11.4 重规划的触发与节流:什么时候回到 T1–T4

恢复阶梯的最后一级是重规划——回到 T1–T4 重新生成计划。它是最强大但也最昂贵的恢复手段,必须谨慎触发。两个关键问题:何时触发、如何节流

何时触发重规划? 当且仅当「局部恢复(级 1–4)都无效,说明当前计划整体已不可行」时。典型触发条件:

  • 恢复阶梯前几级反复失败(Fallback 走到了最右边的 RequestReplan)。
  • 世界发生了「使原计划的核心假设失效」的大变化(如目标物体不见了、关键通路永久封闭)。
  • 监控检测到「原计划的后续步骤已不可能成功」(如要放置的架子被占满了)。

如何节流重规划? 重规划昂贵(PDDLStream/LGP 可能几秒),绝不能高频触发——否则机器人会陷入「规划→执行片刻失败→再规划」的颠簸,甚至规划占满 CPU 导致执行停滞。节流手段就是 §6.5、§9.3 的 RateController

RateController(hz=0.2):          # 重规划最多每 5 秒一次
  └─ Sequence:
       ├─ NeedReplan             # 条件:确实需要重规划
       └─ RequestReplan          # 动作:触发 T1–T4 重新规划

Nav2 的 1 Hz 全局重规划(§9.3 RateController hz=1.0)就是这个思想的实例——即使「每拍都想重规划」,RateController 也把它压到 1 Hz。重规划频率是一个工程权衡:太高则颠簸、占算力;太低则对大变化反应迟钝。要按「环境变化的时间尺度」和「规划器的耗时」来定。

理论-工程桥接:重规划的「触发 + 节流」,本质是在管理「深思层(T1–T4)和反应层(T5)之间的交互频率」(回到 §1 前置桥接的本质洞察)。深思层慢而全局,反应层快而局部。重规划是「反应层请求深思层介入」——这个请求必须稀疏(节流),否则慢的深思层会拖垮快的反应层。这就是为什么 RateController 在这里不可或缺:它是两个时间尺度之间的「减速齿轮」,把反应层「随时想重规划」的高频冲动,减速成深思层「能从容响应」的低频请求。理解了这个「频率匹配」,你就理解了分层架构里「上下层交互必须节流」的普遍原则——它不只用于重规划,也用于任何「快层请求慢层」的场景。

11.5 抢占 (preemption):新目标如何打断当前任务

执行层还要处理一类特殊事件——抢占 (preemption):一个更高优先级的事件(新目标、急停、人工接管)需要立即打断当前正在执行的任务。抢占和普通恢复的区别:恢复是「当前任务失败了怎么补救」,抢占是「当前任务没失败,但要被更重要的事打断」。

抢占在 BT 里靠 Reactive 守卫 + halt 语义实现(§7.3、§5.2)。把「是否有抢占事件」作为最高优先级的 Reactive 守卫放在树的顶部:

ReactiveFallback "抢占处理":
  ├─ Sequence "急停":           # 最高优先级:急停
  │    ├─ IsEmergencyStop       #   守卫:有急停信号吗(每拍重检)
  │    └─ ExecuteEmergencyStop  #   立即执行急停
  ├─ Sequence "响应新目标":      # 次高优先级:新目标
  │    ├─ HasNewGoal            #   守卫:来新目标了吗
  │    └─ SwitchToNewGoal       #   切换到新目标
  └─ NormalTaskSubtree          # 正常任务(前面都没触发才执行)
  # ReactiveFallback 每拍从左重检:高优先级事件一出现,立即抢占正常任务

抢占发生时的关键机制是 halt:当 IsEmergencyStop 突然为真,ReactiveFallback 转向急停分支,而原来正在 Running 的 NormalTaskSubtree 会被 halt(中止)——这会调用其中正在执行的动作节点的 onHalted 回调(§5.2),让它们干净地停下(取消导航、停电机、释放抓取力)。没有 halt 和 onHalted,抢占就是「假抢占」——表面切换了分支,但底层的电机还在按旧任务转,机器人「失控」。这就是 §5.2 反复强调 onHalted 必须实现的根本原因。

本质洞察:抢占把「Reactive 每拍重检」(§7.3)和「halt 清理」(§5.2)这两个机制合体成了执行层最关键的能力之一——「响应外部世界的优先事件」。一个不能被抢占的执行器是危险的(急停按了停不下来)、也是不灵活的(新指令来了还在埋头做旧任务)。BT 用「高优先级 Reactive 守卫 + halt」优雅地解决了抢占:守卫每拍重检保证「及时发现」(最多延迟一个 tick,§4.5),halt 保证「干净中止」(释放资源)。把抢占放在树的顶部、用 ReactiveFallback 按优先级排列,是执行层「既能专注当前任务、又能随时响应紧急事件」的标准结构。这也是 §9.4 Nav2 用 ReactiveFallback[GoalUpdated, ...] 让新目标抢占恢复的设计依据。

11.6 监控-恢复闭环的完整数据流

把 §11.1–11.5 整合成一张「监控—反应—恢复」的完整闭环数据流(回到总论 §3.8 的执行闭环):

执行层监控-恢复闭环(一个 tick 周期内的数据流):

   传感器/运动层反馈
         │ (世界状态、动作进度、资源水平)
   ┌──────────────────────────────────────────────┐
   │ ① 监控(§11.1-11.2)                            │
   │   Reactive 守卫重检前置条件                       │
   │   Parallel 监视支查资源/中断                      │
   └────────────┬─────────────────────────────────┘
                │ 异常信号 (哪类异常、在哪)
   ┌──────────────────────────────────────────────┐
   │ ② 反应(§7.3, §11.5)                           │
   │   守卫失败 → halt 当前动作(onHalted 清理)        │
   │   抢占事件 → 切换到高优先级分支                    │
   └────────────┬─────────────────────────────────┘
                │ 已中止、待恢复
   ┌──────────────────────────────────────────────┐
   │ ③ 恢复(§11.3-11.4)                            │
   │   沿 Fallback 阶梯逐级升级:                      │
   │   重试→替代→降级→求助→重规划(节流)               │
   └────────────┬─────────────────────────────────┘
                │ 若到"重规划"级
        请求 T1–T4 重新规划(RateController 节流)
                │ 新计划
                └──────→ 编译成 BT(§10)→ 替换/更新当前树 → 继续 tick

这个闭环每个 tick 都在转:监控(看世界)→ 反应(出事就中止)→ 恢复(逐级补救)→ 必要时重规划(回深思层)。它就是 §2.3「监控/反应/恢复三件事」的完整工程实现,也是本章所有零件(§4 tick、§5 叶子、§6 控制节点、§7 Reactive/Memory、§10 plan-to-BT)的总装。 把这张图看懂,你就掌握了执行层的全貌。

11.7 代码:给 Mini-TAMP 加一层执行监控

把上面的闭环落到代码——给一个计划执行器加监控恢复层(§12 累积项目的核心片段)。用 BehaviorTree.CPP 的 XML:

<root BTCPP_format="4">
  <BehaviorTree ID="MonitoredExecution">
    <!-- 最外层:抢占处理(新目标/急停最高优先级,§11.5) -->
    <ReactiveFallback name="preemption">
      <Sequence name="emergency">
        <IsEmergencyStop/>
        <ExecuteEmergencyStop/>
      </Sequence>
      <!-- 全局监视 + 主任务(§11.2 Parallel 监视支) -->
      <Parallel failure_count="1" success_count="1" name="monitored_task">
        <!-- 监视支:全局约束全程监控 -->
        <ReactiveSequence name="global_monitor">
          <IsBatteryOK threshold="0.2"/>
          <IsWithinTimeBudget/>
        </ReactiveSequence>
        <!-- 主任务支:带恢复阶梯的计划执行(§11.3) -->
        <RecoveryNode number_of_retries="3" name="task_with_recovery">
          <ReactiveSequence name="guarded_plan">
            <IsPlanStillValid/>                    <!-- 守卫:计划仍有效(§11.1 前置失效) -->
            <SequenceWithMemory name="plan_steps"> <!-- 主干:计划步骤(§7.2 Memory) -->
              <ExecuteAction action="{step_1}"/>
              <ExecuteAction action="{step_2}"/>
              <ExecuteAction action="{step_3}"/>
            </SequenceWithMemory>
          </ReactiveSequence>
          <!-- 恢复:节流的重规划(§11.4) -->
          <RateController hz="0.2">
            <RequestReplan/>                       <!-- 回到 T1–T4 重新规划 -->
          </RateController>
        </RecoveryNode>
      </Parallel>
    </ReactiveFallback>
  </BehaviorTree>
</root>

这棵树是本章方法论的总装:抢占(最外 ReactiveFallback)+ 全局监视(Parallel 监视支)+ 前置守卫(ReactiveSequence 的 IsPlanStillValid)+ 顺序主干(SequenceWithMemory)+ 恢复重规划(RecoveryNode + RateController 节流)。它把一个朴素的「计划执行」升级成了「带监控、能抢占、会恢复、可重规划」的稳健执行器。§12 会把它接到 Mini-TAMP 累积项目上。

⚠️ 常见陷阱

陷阱 11-A:恢复陷入无限循环——恢复成功后重试主任务,又失败,又恢复……永不收敛。 - 现象/后果:机器人在一个根本无解的局面里反复「执行→失败→恢复→执行→失败」,永远停不下来,既不成功也不放弃报告失败。 - 根本原因:恢复循环没有上限,或恢复策略根本无法解决问题(如目标物体已永久消失,再多重试/恢复也没用)却一直重试(§9.4、§11.3)。 - 正确做法:恢复循环必须有重试上限(RecoveryNode 的 number_of_retries),到顶就果断升级到「求助」或「报告失败」(§11.3 级 4),把决策权交给更高层(人或调度系统)。绝不让恢复无限循环——「优雅地放弃并求助」也是一种正确的恢复结果。

陷阱 11-B:halt 不清理资源,抢占/中止后留下脏状态(机器人「失控」)。 - 现象/后果:急停触发、或新目标抢占后,分支切换了,但原来的导航/抓取动作没被真正取消——电机还在按旧目标转,夹爪还在用力,机器人物理上「失控」。 - 根本原因:被 halt 的动作节点没实现 onHalted(或实现为空),底层资源(电机、导航 goal、抓取力)没被释放(§5.2、§11.5)。 - 正确做法:每个有副作用的动作节点必须实现 onHalted,在其中取消底层任务、停止执行器。抢占/中止的正确性完全依赖 halt 链路的完整——从顶层 ReactiveFallback 的中止,一路 halt 到正在 Running 的叶子,每个叶子都干净退出。把「被打断时如何清理」当作每个动作的必答题。

陷阱 11-C:把所有异常都用同一种机制监控,导致漏检或阻碍执行。 - 现象/后果:把「进度停滞」(需要时间窗判断)也写成瞬时守卫条件,结果测不出卡死;或把「资源监控」塞进主干 Sequence,导致资源检查阻碍了任务推进。 - 根本原因:忽略了四类异常需要不同机制和位置(§11.1、§11.2)——前置失效用 Reactive 守卫、资源用 Parallel 监视支、进度停滞用 Timeout/进度检查器、中断用抢占。 - 正确做法:按 §11.1 的四类逐类匹配机制:每拍变化的前提 → Reactive 守卫;全程的全局约束 → Parallel 监视支;需时间积累判断的停滞 → Timeout 装饰器或专门的进度检查器;优先事件 → 顶层抢占。不要用一种机制硬监控所有东西。

陷阱 11-D:重规划无节流,高频触发导致执行颠簸或 CPU 爆炸。 - 现象/后果:每次小失败都立即触发重规划,机器人陷入「规划→走两步又规划」的颠簸,或重规划(几秒)占满 CPU 导致 tick 都跑不动。 - 根本原因:重规划没用 RateController 节流,且把「该用轻量恢复的小失败」也升级到了重规划(§11.3、§11.4)。 - 正确做法:重规划放在恢复阶梯最后一级(小失败先用重试/替代解决),并用 RateController 节流(如 \(\le 0.2\)–1 Hz)。重规划是「减速齿轮」后的稀疏请求(§11.4 本质洞察),不是每个失败的默认响应。

练习

  1. (设计·闭环·核心) 为「移动机器人送餐」任务设计完整的监控-恢复方案,覆盖 §11.1 四类异常:列出这个任务里每一类异常的具体表现(前置失效、进度停滞、资源耗尽、外部中断各举 1–2 个),为每类匹配监控机制(Reactive 守卫 / Parallel 监视支 / Timeout / 抢占)和恢复策略(§11.3 五级阶梯里的哪几级)。然后画出整棵 BT 的顶层结构(参考 §11.7),标明各监控/恢复零件的位置。
  2. (恢复阶梯·辨析) 对下面三个失败,分别判断该用 §11.3 阶梯的哪一级(或哪几级组合)恢复,说明为什么不该用更轻或更重的级别:(a) 抓取时夹爪打滑,物体没夹住(物体还在原位);(b) 要放置的目标架子被其他物体占满了(原计划的放置位置不可用);(c) 导航的目标房间门被锁死、永久无法进入。对 (c) 特别说明:为什么「无限重试导航」是错的(陷阱 11-A),正确的终态应该是什么?
  3. (抢占·跨章·综合) 综合 §5.2(onHalted)、§7.3(Reactive 守卫)、§11.5(抢占),实现一个完整的抢占场景:机器人正在执行「导航到 A 并抓取」,此时收到「立即去 B 充电」的高优先级新目标。逐拍描述:哪个 Reactive 守卫先检测到新目标?正在 Running 的导航/抓取动作怎么被 halt?它们的 onHalted 各自要做什么清理(导航取消、夹爪如何处理)?整棵树如何切换到充电任务?最后说明:如果导航动作的 onHalted 是空的,会发生什么物理后果?


12. 工程实践:用 BT 重写 Mini-TAMP 协调器 ⭐⭐⭐

本节是累积项目在执行维度的收尾:把 Mini-TAMP 那个朴素的「Plan-then-Check」协调器,彻底换成一棵带监控、能恢复、会重规划的行为树。 这呼应了 §1 的承诺,也是 T3 §8、T4 §6 一路埋下的伏笔的兑现。

12.1 回顾累积项目:从 Plan-then-Check 到 PDDLStream

回顾累积项目的来路(R14 跨章桥接):

  • TAMP_T1 §11 搭了 Mini-TAMP 项目,核心是 TAMPCoordinator 协调器,执行逻辑是 Plan-then-Check:符号规划器排出完整计划,再逐条检查运动可行性,出错就从头重排。它能跑,但执行是朴素的——排好就盲目顺序执行,不监控、出错从头来。
  • TAMP_T3 §8PDDLStream 升级了协调器的「规划 + 几何」部分——流式采样让符号搜索和几何采样交替进行,解决了 Plan-then-Check 的盲目回溯。但 T3 升级的是「怎么生成计划」,执行部分依旧朴素。
  • 本章 §12 升级最后一块——执行。把「排好就盲目顺序跑」的执行逻辑,换成一棵行为树,让 Mini-TAMP 终于具备 §11 的监控/反应/恢复/重规划闭环。

一句话概括三章的累积:T1 给了「能规划能执行的骨架」,T3 把「规划」做强(PDDLStream),T5(本章)把「执行」做稳(行为树)。 三章合起来,Mini-TAMP 才是一个「规划强、执行稳」的完整 TAMP 系统。

12.2 本章升级:用行为树替换协调器

升级的核心,是把协调器的「执行」职责从命令式代码迁移到 BT。对比升级前后:

维度 升级前(T1/T3 的协调器执行部分) 升级后(本章 BT)
执行方式 命令式循环:for a in plan: execute(a) tick 一棵 BT(§4)
监控 无(执行完不验证后置条件) Reactive 守卫 + Parallel 监视支(§11.2)
失败处理 从头重排整个计划 Fallback 恢复阶梯:重试→替代→…→重规划(§11.3)
反应性 无(open-loop) 每拍重检守卫,可中止可抢占(§7、§11.5)
重规划 每次失败都从头规划 仅局部恢复无效时触发,且 RateController 节流(§11.4)
与 PDDLStream 的关系 PDDLStream 在协调器里被直接调用 PDDLStream 被封装成「重规划」动作叶子(§12.3)

升级后的协调器,本质上变成了「一个 tick BT 的循环 + 一组把规划器/运动层包装成叶子的适配器」。协调器不再亲自管「先做什么、失败怎么办」(那是 BT 的事),只管「驱动 BT、把 BT 叶子的请求转发给 PDDLStream 和运动层」。

12.3 BehaviorTree.CPP 接入:把 PDDLStream/规划器封装成叶子

接入的关键,是把 T1–T4 的两类能力——规划器(PDDLStream/符号规划)和运动层(IK/RRT/控制器)——封装成 BT 叶子节点(§5.4「叶子调运动层」的思想):

// 把 PDDLStream 规划器封装成"重规划"动作叶子(§11.4)
class RequestReplan : public StatefulActionNode {
public:
    RequestReplan(const std::string& name, const NodeConfig& cfg)
        : StatefulActionNode(name, cfg) {}
    static PortsList providedPorts() {
        return { InputPort<Goal>("goal"), OutputPort<Plan>("new_plan") };
    }
    NodeStatus onStart() override {
        Goal goal;
        getInput("goal", goal);
        planner_->requestPlan(goal);    // 异步请求 PDDLStream 规划(§5.2 不阻塞)
        return NodeStatus::RUNNING;
    }
    NodeStatus onRunning() override {
        auto result = planner_->poll();  // 非阻塞查询规划是否完成
        if (result.pending) return NodeStatus::RUNNING;
        if (result.failed)  return NodeStatus::FAILURE;  // 规划失败(问题无解)
        setOutput("new_plan", result.plan);  // 把新计划写回黑板
        return NodeStatus::SUCCESS;
    }
    void onHalted() override { planner_->cancel(); }  // 被抢占时取消规划
private:
    PDDLStreamPlanner* planner_;   // 复用 T3 的 PDDLStream 规划器
};

// 把"执行单个计划动作"封装成动作叶子(动作背后调运动层,§5.4)
class ExecutePlanStep : public StatefulActionNode {
public:
    ExecutePlanStep(const std::string& name, const NodeConfig& cfg)
        : StatefulActionNode(name, cfg) {}
    static PortsList providedPorts() { return { InputPort<Action>("step") }; }
    NodeStatus onStart() override {
        Action step;
        getInput("step", step);
        // 动作背后:pick→调 IK+抓取控制器,move→调 RRT+轨迹跟踪(运动层,§5.4)
        motion_->execute(step);
        return NodeStatus::RUNNING;
    }
    NodeStatus onRunning() override {
        switch (motion_->status()) {
            case Done:   return NodeStatus::SUCCESS;
            case Failed: return NodeStatus::FAILURE;
            default:     return NodeStatus::RUNNING;
        }
    }
    void onHalted() override { motion_->abort(); }   // 关键:中止运动,释放(§5.2)
private:
    MotionLayer* motion_;   // 复用 T1 的运动层(IK/RRT/控制器)
};

注意这两个叶子完美体现了「BT 编排、T1–T4 干活」(§2.4):RequestReplan 把 PDDLStream 当后端、ExecutePlanStep 把运动层当后端,BT 叶子本身只是「发起—轮询—清理」的异步接口。PDDLStream 没有被丢弃,而是被「降级」成 BT 恢复阶梯里的一个叶子——平时不用,只在局部恢复无效时才被 tick。

12.4 完整树:规划 → 执行 → 监控 → 失败重规划

把叶子组装成 Mini-TAMP 的完整执行树(§11.7 的具体化):

<root BTCPP_format="4">
  <BehaviorTree ID="MiniTAMPExecutor">
    <ReactiveFallback name="preemption">          <!-- 抢占层(§11.5) -->
      <Sequence>
        <HasNewGoal/>
        <RequestReplan goal="{new_goal}" new_plan="{plan}"/>  <!-- 新目标→重规划 -->
      </Sequence>
      <Parallel failure_count="1" success_count="1">  <!-- 监视+任务(§11.2) -->
        <ReactiveSequence name="global_monitor">       <!-- 全局监视支 -->
          <IsBatteryOK threshold="0.2"/>
          <IsWorkspaceClear/>
        </ReactiveSequence>
        <RecoveryNode number_of_retries="3" name="exec_with_replan">  <!-- 主任务+重规划 -->
          <Fallback name="plan_or_replan">
            <!-- 有有效计划就执行 -->
            <ReactiveSequence>
              <HasValidPlan plan="{plan}"/>             <!-- 守卫:有计划吗 -->
              <SequenceWithMemory name="execute_plan">  <!-- 顺序执行计划各步(§7.2) -->
                <ExecutePlanStep step="{step_0}"/>
                <ExecutePlanStep step="{step_1}"/>
                <ExecutePlanStep step="{step_2}"/>
              </SequenceWithMemory>
            </ReactiveSequence>
            <!-- 没计划/计划失效:重规划(恢复阶梯最后一级,节流,§11.4) -->
            <RateController hz="0.5">
              <RequestReplan goal="{goal}" new_plan="{plan}"/>
            </RateController>
          </Fallback>
        </RecoveryNode>
      </Parallel>
    </ReactiveFallback>
  </BehaviorTree>
</root>

这棵树的完整行为,把全章串成了一个闭环:

  1. 正常HasValidPlan 真 → 执行 SequenceWithMemory 里的计划步骤(Memory 不重做已完成步,§7.2)。
  2. 监控:全程 global_monitor(电量、工作区)和 IsWorkspaceClear Reactive 守卫每拍重检(§11.2)。
  3. 局部失败:某步失败 → RecoveryNode 触发恢复(这里简化为直接重规划,实际可在中间加重试/替代级,§11.3)。
  4. 重规划:恢复转向 RequestReplan → 调 PDDLStream 重新生成计划 → 写回黑板 {plan} → 重试执行(RateController 节流 0.5 Hz,§11.4)。
  5. 抢占:最外 ReactiveFallback 每拍检查 HasNewGoal → 来新目标立即重规划、halt 当前执行(§11.5)。
  6. 彻底失败RecoveryNode 重试 3 次仍失败 → 整树返回 Failure → 上报(§11.3 升级求助)。

这就是 Mini-TAMP 从「Plan-then-Check 一锤子买卖」到「带监控、能抢占、会恢复、可重规划的稳健执行器」的完整升级。 PDDLStream(T3)作为重规划叶子、运动层(T1)作为执行叶子,都被这棵 BT 编排起来——三章的累积在此合龙。

12.5 累积项目里程碑

章节 累积项目状态 本章新增
T1 §11 Mini-TAMP 骨架:Plan-then-Check 协调器
T2 加入任务分配(多机时)
T3 §8 PDDLStream 替换规划核心(流式采样)
T4 §6 (可选)LGP 优化式求解对比
T5 §12(本章) BT 重写执行层 监控守卫 + Parallel 监视支 + 恢复阶梯 + 抢占 + 节流重规划,PDDLStream 降级为重规划叶子
T6 (后续)信念空间:不确定性进入监控

本章里程碑达成标志:你的 Mini-TAMP 现在能——在执行中持续监控前置条件和资源、对偏差快速反应(halt 中止)、按从轻到重的阶梯恢复、局部恢复无效时调用 PDDLStream 重规划、随时被新目标抢占。这是一个能在动态世界里稳健运行的 TAMP 系统,而非只能在静止仿真里跑 demo 的玩具。

12.6 调试 BT:当树行为不符预期时的排查清单

BT 调试有其特殊性——行为是「tick 遍历 + 三态 + 跨拍记忆」涌现的,不像顺序代码那样一眼看出。一份结构化排查清单(接 §15 故障排查的思路):

症状 优先检查 对应节
树「卡住」不动(一直 Running) 哪个叶子一直返回 Running?它的 onRunning 是否永远不返回终态?是否缺 Timeout 兜底? §5.2、§6.5
某分支「该执行却没执行」 它前面的 Sequence 是否某步失败了(与门,一失败全失败)?应该用 Fallback 吗? §6.1、§6.6
守卫「该中止却没中止」 守卫是否错放进了 Memory 节点(只检查一次)?应该放 Reactive 吗? §7.2、§7.6
动作「被反复重新发起」 有副作用的动作是否错放进了 Reactive 分支(每拍重 tick)? §7.3、陷阱 7-A
抢占/急停「切换了但机器人没停」 被 halt 的动作是否实现了 onHalted?资源是否真的释放了? §5.2、§11.5
ReactiveSequence 运行时崩溃 是否有两个以上异步子节点同时 Running?(v4 LogicError) §7.5
重规划「太频繁/颠簸」 是否缺 RateController 节流?是否小失败也触发了重规划? §11.4
改了 XML 后行为大变 是否把自定义控制节点(RecoveryNode 等)换成了标准节点? §9.5、陷阱 9-A

通用调试方法:BehaviorTree.CPP 提供了 Groot2 可视化工具,能实时显示每个节点的当前状态(哪个在 Running、哪个刚 Success/Failure),把「涌现的行为」变成「看得见的状态流」。调试 BT 的第一步永远是「可视化 tick 过程,看每个节点实际返回什么」——而不是盯着 XML 空想。配合日志记录每拍各节点的返回状态,绝大多数「行为不符预期」都能定位到「某个节点的三态返回或跨拍记忆和你以为的不一样」。

⚠️ 常见陷阱

陷阱 12-A:把 PDDLStream 重规划放在主干高频路径上,每拍都想重规划。 - 现象/后果:执行树里把 RequestReplan 放在了每拍都会 tick 的位置(而非恢复阶梯的最后一级),导致 PDDLStream 被高频调用,规划占满 CPU,执行颠簸或停滞。 - 根本原因:没把重规划当作「最后手段」放在恢复阶梯末端,也没用 RateController 节流(§11.4、陷阱 11-D)。 - 正确做法RequestReplan 只放在恢复阶梯最后一级(局部恢复都无效才到它),且外包 RateController 节流。平时 HasValidPlan 为真,根本 tick 不到 RequestReplan——重规划是稀疏事件。

陷阱 12-B:BT 叶子里直接同步调用 PDDLStream(阻塞几秒),拖垮整棵树。 - 现象/后果RequestReplan 的实现里同步等 PDDLStream 出解(几秒),这几秒里整棵树卡死,监控守卫全部失效(§5.5 错误写法的重演)。 - 根本原因:把耗时的重规划写成同步阻塞,违反 tick「快进快出」契约(§4.6 陷阱 4-C)。 - 正确做法RequestReplanStatefulActionNodeonStart 异步发起规划请求、onRunning 非阻塞轮询(§12.3 代码)。规划在后台跑,叶子每拍快速返回 Running,树照常遍历、监控照常工作。

陷阱 12-C:升级后忘了让 BT 叶子复用 T1/T3 已有的规划器和运动层,而是重新实现一套。 - 现象/后果:在 BT 叶子里重新写了一遍规划/运动逻辑,与 T1/T3 的实现重复且可能不一致,维护两套代码。 - 根本原因:没理解「BT 是编排层,T1–T4 是被编排的后端」(§2.4)——叶子应该是 T1/T3 现有能力的薄包装,不是重新实现。 - 正确做法:BT 叶子(RequestReplanExecutePlanStep)内部持有 T3 的 PDDLStream 规划器和 T1 的运动层的引用,只做「转发请求 + 收集结果 + 定三态」(§12.3)。一份规划器/运动层实现,被 BT 叶子调用——不重复造轮子。

练习

  1. (综合·累积项目·核心) 把 §12.4 的执行树扩展成「带三级恢复阶梯」的版本:在 RecoveryNode 的主任务和重规划之间,插入「级 1 重试当前步 2 次」和「级 2 替代(让运动层换一组几何参数重试该步)」,只有这两级都失败才升级到重规划。写出扩展后的 XML,并说明这样做相对「任何失败直接重规划」的好处(提示:§11.3 本质洞察——恢复代价匹配故障严重性)。
  2. (调试·实战) 你的 Mini-TAMP 执行树出现这个症状:「正常时能跑通,但每当重规划后,机器人会把已经完成的前几步又重做一遍」。用 §12.6 的清单定位问题(提示:重规划写回新计划后,SequenceWithMemory 的记忆是否被重置了?计划步骤的黑板键 {step_i} 是否被更新?),给出至少两个可能原因和对应的修复。
  3. (架构·跨章·开放) 对比两种把 PDDLStream 接入执行的架构:(a) 本章的「PDDLStream 作为 BT 恢复阶梯里的重规划叶子」(BT 主导,规划器被调用);(b) 一种假想的「PDDLStream 主导,每步执行后调 BT 检查」(规划器主导,BT 被调用)。从「反应延迟」「重规划频率控制」「抢占能力」「与 T6 不确定性的兼容」四个维度对比两种架构的优劣,论证为什么本章选 (a)。这道题让你理解「为什么执行层该由 BT 主导,而非规划器主导」。

本章常见误解汇总

# 误解 正确理解 出处
1 计划生成出来,任务就算解决了 计划只对「规划那一刻的静止世界」最优;执行在动态世界里,必须监控/反应/恢复,否则偏差沿后续动作放大且永不被发现 §2.1、§2.2
2 执行就是把计划翻译成电机指令(单向播放) 执行是闭环——持续比对「计划期望的世界」和「传感器看到的世界」,偏离就介入。open-loop 是录音机,BT 是带反馈的控制器 §2.2
3 BT 叶子里实现了 A*/RRT/MPPI 等算法 BT 是编排器不是执行器——叶子是运动层服务的异步接口包装,算法在 planner/controller 后端插件里 §2.4、§5.4、§9.7
4 一次 tick 就执行完整棵树/整个任务 一次 tick 只「从根到叶遍历一遍、每个节点推进一个时间片」,任务需成百上千次 tick 才完成 §4.2、§4.6
5 动作节点要么立刻成功要么立刻失败(无中间态) 长动作必须返回 Running(正在做、未完成)——这是 BT 区别于决策树的灵魂,赋予 BT 时间维度 §4.3
6 BT 比 FSM「能做更多」(表达能力更强) 二者表达能力等价,BT 的优势是软件工程层面的可组合性(两块组合性),不是「能做更多」 §2.5、§8.3、§8.5
7 行为树 (Behavior Tree) 就是决策树 (Decision Tree) 三个同名/近名的「树」:行为树(执行控制流,有 Running)、游戏 AI 决策树(控制流,是 BT 特例)、ML 决策树(分类器,无关) §3.3、§8.1、§8.2
8 Sequence 里某步失败会跳过它做下一步 Sequence 是与门——任一失败整体立即失败(不跳过);「失败就试下一个」是 Fallback(或门)的语义 §6.1、§6.6、陷阱 6-B
9 Parallel 节点能并行加速计算 BT 的 Parallel 不是多线程,仍在同一 tick 线程顺序 tick 子节点;它提供「逻辑并发监控」而非「并行计算」 §6.4、陷阱 6-A
10 Reactive 和 Memory 只是实现细节,无所谓 它是 BT 最易错的语义:守卫必须用 Reactive(每拍重检前提),顺序动作必须用 Memory(不重做已完成);放反了行为南辕北辙 §7.4、§7.6
11 把有副作用的动作放进 Reactive 分支没问题 Reactive 每拍重 tick 前面所有子节点——有副作用的动作会每拍被反复发起。非末尾位置只能放只读条件 §7.3、§7.5、陷阱 7-A
12 改 Nav2 树的 XML 像改配置文件一样随便换节点 BT-XML 是控制流结构不是参数配置;自定义节点(RecoveryNode/RoundRobin/PipelineSequence)有特殊三态/跨拍语义,换了就换了逻辑 §9.5、§9.6、陷阱 9-A
13 用了 BT 这个结构就自动有了反应性 反应性来自 Reactive 守卫和 Fallback 恢复,不来自「是不是 BT」。把序列简单串成 Sequence 仍是 open-loop §10.4、陷阱 10-A
14 任何失败都该触发重规划 重规划是恢复阶梯最贵的最后一级;大部分失败用重试/替代就能解决。恢复代价应匹配故障严重性 §11.3、§11.4、陷阱 11-D
15 抢占/急停只要切换分支就行 必须 halt 正在 Running 的动作并调用 onHalted 清理资源,否则电机还按旧任务转,机器人「失控」——是「假抢占」 §5.2、§11.5、陷阱 11-B

本章小结

本章回答了 TAMP 线的第三个根问题——「计划有了,怎么稳健地执行下去」。逐节回顾:

  • §2 为什么需要执行层:计划只对静止世界最优,真实世界会犯错(抓滑、障碍突现、目标变更)。open-loop 执行的偏差会沿动作放大且永不被发现。执行层要做三件事——监控(看世界)、反应(出事中止)、恢复(逐级补救)。BT 是编排器,处在规划层和运动层之间。
  • §3 历史脉络:从命令式脚本 → FSM(转移 \(O(N^2)\) 爆炸、不可组合)→ 包容架构(强反应弱时序)→ 决策树(无时序记忆)→ 行为树(统一时序与反应、可组合)→ plan-to-BT(自动生成)。每代修补上代痛点,主线是「提高模块化、降低耦合」。
  • §4 tick 与三态:tick 是自根而下的心跳,反复遍历驱动整树。三种返回状态 Success/Failure/Running,其中 Running(正在做、未完成)是 BT 的灵魂,赋予它时间维度。BT 形式化为 \(\mathcal{X} \to \{S,F,R\} \times \mathcal{U}\) 的递归复合函数。
  • §5 叶子节点:动作(有副作用,异步三回调 onStart/onRunning/onHalted)和条件(只读守卫)。叶子是 BT 与世界的唯一接口;ComputePathToPose 背后是 A*、FollowPath 背后是 MPPI——薄接口、厚后端。
  • §6 控制节点:Sequence(与门,全成功)、Fallback(或门,一个成功即可,恢复载体)、Parallel(并发监控,M-of-N)、Decorator(Retry/Timeout/RateController 等修饰)。Sequence+Fallback+Inverter 表达任意布尔逻辑。
  • §7 Reactive vs Memory:跨拍记忆的差异。Memory(记住进度不回头,用于顺序动作)vs Reactive(每拍从头重检,用于守卫条件)。「Reactive 守卫包 Memory 主干」是工业标准模式。v4 限制 ReactiveSequence 至多一个异步子。
  • §8 三个澄清:BT \(\neq\) ML 决策树;游戏 AI 决策树是 BT 特例(去掉 Running/Memory/Parallel);BT 优于 FSM 在「两块组合性」(局部三态接口 vs 全局状态名),\(O(1)\) vs \(O(N)\) 加行为;状态极少强时序时 FSM 仍更合适。
  • §9 精读 Nav2:真实工业树。顶层 RecoveryNode(6 次) 包 PipelineSequence(RateController 1 Hz 重规划 + ComputePathToPose + FollowPath)和恢复 Sequence(RoundRobin 循环 ClearCostmap/Spin/Wait/BackUp 逐级升级)。策略在树、算法在插件。
  • §10 plan-to-BT:手写树不可扩展,用回链展开(back-chaining)从目标条件反推生成 BT——失败条件替换为 Fallback[条件, Sequence[前置, 动作]]。PA-BT 边执行边展开,融合规划与执行。BT 是符号规划和 LLM 规划的公共落点。
  • §11 执行监控与恢复:监控四类异常(前置失效/进度停滞/资源耗尽/外部中断),各匹配机制(Reactive 守卫/Timeout/Parallel 监视支/抢占)。恢复五级阶梯(重试→替代→降级→升级求助→重规划),代价匹配故障严重性。重规划节流(RateController)、抢占(Reactive 守卫 + halt)。
  • §12 工程实践:用 BT 重写 Mini-TAMP 协调器。PDDLStream 降级为重规划叶子、运动层为执行叶子,BT 编排监控/恢复/抢占/节流重规划。三章累积合龙:T1 给骨架、T3 把规划做强、T5 把执行做稳。

全章一句话:行为树是执行层的「编排大脑」——它用 tick + 三态 + 可组合控制节点,把「监控、反应、恢复」统一在一棵可读、可改、可自动生成的树里,让规划好的计划能在动态世界里稳健地执行下去。它不生成计划(那是 T1–T4),不亲算路径(那是运动层),它指挥而不亲为,监控而不计算。

术语速查表

中文 英文 含义
行为树 Behavior Tree, BT 用 tick + 三态 + 控制节点组织机器人执行的可组合控制流树
tick tick 自根而下的周期性驱动信号,每拍触发一次树的遍历
返回状态(三态) Success / Failure / Running 节点被 tick 的输出:成功/失败/进行中;Running 是 BT 灵魂
叶子节点 Leaf node 树的末端节点,分动作(Action)和条件(Condition)
动作节点 Action node 有副作用、命令机器人做事的叶子;长动作异步返回 Running
条件节点 Condition node 只读、查询世界谓词的叶子;几乎不返回 Running
控制节点 Control node 根据子节点三态决定 tick 谁、自己返回什么的内部节点
顺序节点 Sequence 与门:全部子成功才成功,任一失败即失败
回退/选择节点 Fallback / Selector 或门:任一子成功即成功,全失败才失败;恢复载体
并行节点 Parallel 同拍 tick 全部子节点,按 M-of-N 阈值返回;用于并发监控
装饰节点 Decorator 单子节点,修饰其返回状态/tick 行为(Inverter/Retry/Timeout/RateController)
反应式(节点) Reactive 每拍从头重检子节点,用于守卫条件,反应性来源
记忆式(节点) Memory / WithMemory 记住已成功的子节点不重做,用于顺序动作
中止/清理 halt / onHalted 节点被打断时的回调,释放底层资源;抢占正确性的关键
黑板 Blackboard 节点间共享的键值数据存储,BT 的数据流通道
抢占 Preemption 高优先级事件立即打断当前任务(用 Reactive 守卫 + halt)
重规划 Replanning 当前计划作废、回到 T1–T4 重新生成;恢复阶梯最后一级
回链展开 Back-chaining 从目标条件反推、生成 BT 的方法(PA-BT 核心)
有限状态机 Finite State Machine, FSM 状态+点对点转移的控制模型;转移 \(O(N^2)\)、不可组合
包容架构 Subsumption Architecture Brooks 1986 的分层抑制反应式架构;BT 特例
序贯行为组合 Sequential Behavior Composition, SBC 局部控制器串接收敛的方法;BT(Sequence)特例
Nav2 Navigation 2 ROS 2 官方导航栈,用 BT 做任务编排

知识点总表

# 知识点 核心要点 对应节 难度
1 执行层三件事 监控/反应/恢复,对应条件/Reactive/Fallback §2.3 ⭐⭐
2 BT 是编排器 不算路径不搜计划,叶子是运动层异步接口 §2.4 ⭐⭐
3 FSM 痛点 转移 \(O(N^2)\) 爆炸、点对点跳转不可组合 §3.1 ⭐⭐
4 BT 演进主线 提高模块化、降低耦合 §3.6 ⭐⭐
5 tick 机制 自根而下心跳,反复遍历驱动整树 §4.2 ⭐⭐⭐
6 Running 是灵魂 表达「正在做未完成」,赋予 BT 时间维度 §4.3 ⭐⭐⭐
7 BT 形式化 \(\mathcal{X} \to \{S,F,R\}\times\mathcal{U}\) 的递归复合函数 §4.4 ⭐⭐⭐
8 tick 频率权衡 太低反应慢、太高遍历开销大;与控制频率分层 §4.5 ⭐⭐⭐
9 异步动作三回调 onStart 发起、onRunning 轮询、onHalted 清理 §5.2 ⭐⭐⭐
10 条件只读铁律 零副作用,可被高频重检;动作才有副作用 §5.3 ⭐⭐⭐
11 薄接口厚后端 叶子在 BT、算法在插件;换算法 BT 不变 §5.4 ⭐⭐⭐
12 Sequence 与门 全成功才成功,任一失败即失败 §6.1 ⭐⭐⭐
13 Fallback 或门 一个成功即成功;恢复逐级升级载体 §6.2 ⭐⭐⭐
14 BT 表达布尔逻辑 Seq(\(\land\))+Fallback(\(\lor\))+Inverter(\(\lnot\)) 功能完备 §6.3 ⭐⭐⭐
15 Parallel 并发监控 同拍 tick 全部,M-of-N 阈值;非多线程加速 §6.4 ⭐⭐⭐
16 装饰器 Retry/Timeout/RateController 正交修饰 §6.5 ⭐⭐⭐
17 Reactive vs Memory 监控(每拍重检)vs 推进(不重做) §7.4 ⭐⭐⭐
18 Reactive 守卫包 Memory 主干 工业标准嵌套模式 §7.6 ⭐⭐⭐
19 v4 异步子限制 ReactiveSequence 至多一个 Running 子 §7.5 ⭐⭐⭐
20 BT \(\neq\) 决策树 行为树/游戏决策树/ML 分类器三者之分 §8.1 ⭐⭐⭐
21 决策树是 BT 特例 去掉 Running/Memory/Parallel 的退化 BT §8.2 ⭐⭐⭐
22 两块组合性 局部三态接口 vs FSM 全局状态名;\(O(1)\) vs \(O(N)\) §8.3 ⭐⭐⭐
23 BT 推广三者 SBC/包容架构/决策树都是 BT 特例 §8.4 ⭐⭐⭐
24 何时用 FSM 状态少、强时序、不常变时 FSM 更直观 §8.6 ⭐⭐⭐
25 Nav2 默认树结构 RecoveryNode + PipelineSequence + RoundRobin 恢复 §9.2-9.4 ⭐⭐⭐
26 RateController 重规划 PipelineSequence 边走边重规划,1 Hz 节流 §9.3 ⭐⭐⭐
27 恢复逐级升级 RoundRobin 轮换:清地图→Spin→Wait→BackUp §9.4 ⭐⭐⭐
28 自定义控制节点 RecoveryNode/RoundRobin/PipelineSequence 的特殊语义 §9.5 ⭐⭐⭐
29 FollowPath↔MPPI BT 低频编排、控制器高频执行,异步接口解耦 §9.7 ⭐⭐⭐
30 回链展开 失败条件→Fallback[条件,Seq[前置,动作]] 递归 §10.2 ⭐⭐⭐⭐
31 PA-BT 边执行边展开 惰性展开、天生反应性、融合规划与执行 §10.3 ⭐⭐⭐⭐
32 BT 是 plan-to-BT 理想目标 模块化 + 反应性,生成的计划是活的执行结构 §10.4 ⭐⭐⭐⭐
33 是 BT \(\neq\) 有反应性 反应性来自守卫和 Fallback,不来自结构本身 §10.4 ⭐⭐⭐
34 LLM 生成 BT LLM 提议、符号验证;BT 是规划方法的公共落点 §10.7 ⭐⭐⭐⭐
35 监控四类异常 前置失效/进度停滞/资源耗尽/外部中断,各匹配机制 §11.1 ⭐⭐⭐
36 监控放哪 局部前提用 Reactive 守卫、全局约束用 Parallel 监视支 §11.2 ⭐⭐⭐
37 恢复五级阶梯 重试→替代→降级→升级求助→重规划,代价匹配 §11.3 ⭐⭐⭐
38 重规划触发与节流 局部恢复无效才触发、RateController 减速齿轮 §11.4 ⭐⭐⭐
39 抢占 高优先级 Reactive 守卫 + halt 清理 §11.5 ⭐⭐⭐
40 监控-恢复闭环 监控→反应→恢复→(节流)重规划→编译成 BT §11.6 ⭐⭐⭐

累积项目:本章新增模块

模块名称:Mini-TAMP 执行层升级——用行为树重写协调器。

目标:把 T1 的 TAMPCoordinator(Plan-then-Check 命令式执行)替换为一棵 BehaviorTree.CPP 行为树,使 Mini-TAMP 具备执行监控、异常恢复、抢占、节流重规划的能力。

交付物: 1. RequestReplan 异步动作叶子——封装 T3 的 PDDLStream 规划器(§12.3)。 2. ExecutePlanStep 异步动作叶子——封装 T1 的运动层(IK/RRT/控制器)。 3. 一组监控条件叶子——IsBatteryOKIsWorkspaceClearHasValidPlanHasNewGoal 等。 4. MiniTAMPExecutor 执行树 XML——抢占层 + 全局监视(Parallel)+ 主任务(RecoveryNode 含恢复阶梯)+ 节流重规划(§12.4)。 5. tick 循环驱动代码 + Groot2 可视化调试配置。

验收标准(里程碑达成标志):在动态仿真里,Mini-TAMP 能——(a) 执行中杯子滑落(前置条件失效)时被守卫察觉并恢复;(b) 路径中途被挡时局部恢复(清地图/换路);(c) 局部恢复无效时调 PDDLStream 重规划(且不颠簸,RateController 生效);(d) 收到新目标时抢占当前任务并干净中止(onHalted 释放资源);(e) 电量低时全局监视支触发应对。

与前序模块的衔接:本模块不重新实现规划器和运动层——它们分别复用 T3 的 PDDLStream 和 T1 的运动层,只是被 BT 叶子薄包装后编排起来(§12.3、陷阱 12-C)。这是累积项目「一份核心实现、多章复用增强」原则的体现。

与后续的衔接:T6(不确定性 TAMP)将让监控条件从「确定的真假」变成「带置信度的信念」,恢复策略也要考虑感知不确定性——本模块的监控-恢复框架是 T6 在执行层的接入点。


延伸阅读

教材(系统学习,⭐⭐ 核心): - Colledanchise, M., & Ögren, P. (2018). Behavior Trees in Robotics and AI: An Introduction. CRC Press. ⭐⭐ —— 领域标准教材,本章理论部分(控制节点语义、形式化、与经典架构关系、收敛性/安全性分析)的主要依据。读 BT 必读。

综述(把握全局,⭐⭐): - Iovino, M., Scukins, E., Styrud, J., Ögren, P., & Smith, C. (2022). "A Survey of Behavior Trees in Robotics and AI." Robotics and Autonomous Systems, 154, 104096. ⭐⭐ —— 覆盖面最广的 BT 综述,从控制编排到 plan-to-BT 到学习式生成。本章「近五年进展」的主要索引。(arXiv:2005.05842)

奠基论文(理论根基,⭐⭐⭐): - Colledanchise, M., & Ögren, P. (2017). "How Behavior Trees Modularize Hybrid Control Systems and Generalize Sequential Behavior Compositions, the Subsumption Architecture, and Decision Trees." IEEE Transactions on Robotics, 33(2), 372–389. ⭐⭐⭐ —— BT 统一性定理(推广 SBC/包容架构/决策树)的原始出处,§8.2/§8.4 的理论来源。(DOI: 10.1109/TRO.2016.2633567) - Marzinotto, A., Colledanchise, M., Smith, C., & Ögren, P. (2014). "Towards a Unified Behavior Trees Framework for Robot Control." ICRA, 5420–5427. ⭐⭐⭐ —— 机器人领域第一个统一、严格的 BT 框架,建立 BT 与 CHDS 的等价。BT 进入机器人界的奠基工作。

plan-to-BT(研究级,⭐⭐⭐⭐): - Colledanchise, M., Almeida, D., & Ögren, P. (2019). "Towards Blended Reactive Planning and Acting using Behavior Trees." ICRA, 8839–8845. ⭐⭐⭐⭐ —— PA-BT 与回链展开的原始论文,§10 的主要依据。(arXiv:1611.00230) - Cai, Z., et al. (2021). "BT Expansion: a Sound and Complete Algorithm for Behavior Planning of Intelligent Robots with Behavior Trees." AAAI. ⭐⭐⭐⭐ —— 给回链展开式 BT 生成提供可靠性与完备性保证,§10.7 的延伸。

工程系统(动手实践,⭐⭐⭐): - Macenski, S., Martín, F., White, R., & Clavero, J. G. (2020). "The Marathon 2: A Navigation System." IROS. ⭐⭐⭐ —— Nav2 系统论文,§9 精读的对象。 - BehaviorTree.CPP 官方文档(https://www.behaviortree.dev)与 Groot2 可视化工具 ⭐⭐⭐ —— 本章所有代码的库,含完整 API、教程、调试工具。 - Nav2 官方文档(https://docs.nav2.org)行为树章节 ⭐⭐⭐ —— 含所有默认树 XML 和每个节点的精确返回状态规则,§9 的一手资料。


本章与后续章节的关系

关系 章节 衔接点
承接 T1–T4(规划层) T1–T4 生成计划,T5 稳健执行;执行失败回触发 T1–T4 重规划(§11.4)
后续 T6 不确定性 TAMP / 信念空间 监控条件从「确定真假」变为「带置信度的信念」,恢复要考虑感知不确定性;§11 监控框架是接入点
后续 T7 大模型任务规划 §10.7 LLM 生成 BT 接 T7;BT 是符号规划与 LLM 规划的公共落点;「LLM 提议、符号验证」
后续 T8/T9(如多机/学习扩展) 多机执行需多棵 BT 协调;学习式 BT 生成(§10.7)的深入
横切 MPPI_08(Nav2 MPPI 控制器) Nav2 的 FollowPath 叶子可由 MPPI 控制器实现(§9.7);BT 低频编排、MPPI 高频控制
横切 06_工程实践(BehaviorTree.CPP) BT 的工程实现框架、Groot2 调试、ROS 2 集成的深入

本质洞察:T5 在 TAMP 线里是「执行闭环的枢纽」——它向上接 T1–T4 的规划产出、向下接运动层(含 MPPI)的执行、横向为 T6 的不确定性和 T7 的大模型留好接口。学完 T5 回头看:T1–T4 的计划不再是「算出来就结束」,而是「交给 BT 去稳健执行、失败再回来重规划」;MPPI_08 的控制器不再是孤立的,而是「BT FollowPath 叶子背后的高频后端」。带着「BT 如何把规划层、运动层、不确定性、大模型串成一个执行闭环」的视角,你就把整条 TAMP 线在执行维度上贯通了——这正是总论 §7「学 TAMP 不能只学 TAMP」的落点。


🔧 故障排查手册

# 症状 可能原因 排查步骤 相关节
1 整棵树「卡住」,根一直返回 Running,任务无进展 (a) 某异步动作的 onRunning 永远不返回终态(底层任务实际已结束但状态没更新);(b) 某叶子在 tick 里同步阻塞(如同步等规划/导航);(c) 缺 Timeout 兜底,动作无限 Running 1. 用 Groot2 看哪个叶子一直 Running;2. 检查该叶子的 onRunning 是否正确查询并返回底层状态;3. 检查是否误用 SyncActionNode 写了阻塞循环(§5.5 错误写法);4. 给可能卡死的长动作加 Timeout 装饰器 §4.6、§5.2、§5.5、§6.5
2 某分支「该执行却从不执行」 (a) 它前面的 Sequence 某步失败了(与门,一失败整体失败,后续不执行);(b) 该用 Fallback 却用了 Sequence;(c) 前面有守卫条件一直为假挡住了 1. 用 Groot2 看该分支前序节点的返回状态;2. 确认前序 Sequence 是否有步骤返回 Failure;3. 判断逻辑应该是「全成功才继续」(Sequence) 还是「失败就试这个」(Fallback);4. 检查挡在前面的守卫条件的真假 §6.1、§6.2、§6.6、陷阱 6-B
3 守卫条件失效却没触发中止(如杯子掉了还继续) (a) 守卫错放进了 Memory 节点(只检查一次就记住,后续不重检);(b) 守卫放在了 Sequence 而非 ReactiveSequence;(c) 守卫和受守卫动作的层级关系错了 1. 确认守卫所在的是 Reactive 节点(每拍重检)还是 Memory 节点(记住不重检);2. 把需持续保持的守卫移到 ReactiveSequence;3. 用 §7.6 的「Reactive 守卫包 Memory 主干」模式重构;4. Groot2 观察守卫是否每拍都被 tick §7.2、§7.3、§7.6、陷阱 7-B
4 有副作用的动作被反复重新发起(命令狂发/导航反复重启) (a) 该动作错放进了 Reactive 分支的非末尾位置,每拍被重 tick;(b) 把本该是条件的判断写成了有副作用的动作 1. 检查该动作是否在 ReactiveSequence/ReactiveFallback 的非末尾位置;2. Reactive 分支非末尾只放只读条件,有状态动作移到末尾或包进 Memory 子树;3. 确认动作和条件的职责分离(§5.3) §7.3、§7.5、陷阱 7-A、陷阱 5-C
5 抢占/急停触发了,分支切换了,但机器人物理上没停下(「失控」) 被 halt 的动作节点没实现 onHalted(或实现为空),底层运动请求/电机/抓取力没被取消释放 1. 检查被中止的动作节点是否实现了 onHalted;2. 确认 onHalted 里调用了底层的 cancel/abort/stop;3. 验证 halt 链路完整——从顶层中止一路 halt 到正在 Running 的叶子;4. 检查运动层是否正确响应了取消请求 §5.2、§11.5、陷阱 11-B、陷阱 5-B
6 ReactiveSequence 运行时抛 LogicError 崩溃 ReactiveSequence/ReactiveFallback 里有两个或更多会返回 Running 的异步子节点同时处于 Running(v4 限制:至多一个异步子) 1. 数清该 Reactive 节点里「会返回 Running 的子节点」有几个;2. 若 >1,把多个动作改用 Sequence/SequenceWithMemory(一次只激活一个子);3. ReactiveSequence 仅用于「多个只读守卫 + 末尾至多一个长动作」 §7.5、陷阱 7-C
7 重规划过于频繁,机器人「规划→走两步→又规划」颠簸,或 CPU 跑满 (a) 重规划没用 RateController 节流;(b) 把该用轻量恢复的小失败也升级到了重规划;(c) RateController 频率设得太高 1. 确认 RequestReplan 外是否包了 RateController;2. 检查重规划是否在恢复阶梯最后一级(小失败先用重试/替代);3. 降低 RateController 的 hz(如 0.2–1 Hz);4. 监控重规划触发频率 §11.3、§11.4、陷阱 11-D、陷阱 12-A
8 恢复陷入无限循环,永不成功也不报告失败 (a) 恢复循环无重试上限;(b) 恢复策略根本无法解决问题(如目标物体永久消失)却一直重试 1. 确认 RecoveryNode 设了合理的 number_of_retries;2. 到上限后是否升级到「求助/报告失败」;3. 判断当前故障是否「可恢复」——不可恢复的应果断放弃求助,而非死循环 §9.4、§11.3、陷阱 11-A
9 改了 Nav2/自定义树的 XML 后行为大变且莫名其妙 把自定义控制节点(RecoveryNode/RoundRobin/PipelineSequence)换成了标准节点,或反之——它们的三态/跨拍语义不同 1. 查清改动涉及的每个控制节点的精确返回规则(§9.5 对比表);2. 确认 RecoveryNode 的「恢复后回头重试主」、PipelineSequence 的「重 tick 前序」等特殊语义没被破坏;3. 用 Groot2 对比改动前后的 tick 流 §9.5、§9.6、陷阱 9-A
10 找不到规划/控制算法代码(在 BT 叶子里翻不到 A*/MPPI) 误以为算法在 BT 叶子里——实际 BT 叶子只是运动层服务的异步接口,算法在 planner/controller server 插件里 1. 明确「策略在树、算法在插件」(§9.1);2. 改算法去对应的 server 插件,不是改 BT 叶子;3. BT 叶子里只应看到「发请求、查结果、定三态」的接口代码 §2.4、§5.4、§9.7、陷阱 9-C

API 速查表

BehaviorTree.CPP(v4,C++)核心 API

// —— 叶子基类(继承并重写)——
class MyAction : public BT::SyncActionNode {           // 同步动作(瞬间完成)
    BT::NodeStatus tick() override;                    //   返回 SUCCESS/FAILURE,不返回 RUNNING
};
class MyAction : public BT::StatefulActionNode {       // 异步动作(多拍,最常用)
    BT::NodeStatus onStart() override;                 //   首拍:发起,返回 RUNNING
    BT::NodeStatus onRunning() override;               //   后续拍:非阻塞轮询
    void onHalted() override;                          //   被打断:清理资源(必须实现!)
};
class MyCondition : public BT::ConditionNode {         // 条件(只读守卫)
    BT::NodeStatus tick() override;                    //   返回 SUCCESS/FAILURE(真/假)
};

// —— 端口(黑板数据接口)——
static BT::PortsList providedPorts() {                 // 声明节点的端口
    return { BT::InputPort<T>("name"),                 //   输入端口(从黑板读)
             BT::OutputPort<T>("name") };              //   输出端口(写回黑板)
}
getInput<T>("name", var);                              // 读输入端口
setOutput<T>("name", value);                           // 写输出端口

// —— 工厂、加载、tick ——
BT::BehaviorTreeFactory factory;
factory.registerNodeType<MyAction>("MyAction");        // 注册自定义节点
auto tree = factory.createTreeFromFile("tree.xml");    // 从 XML 加载
auto tree = factory.createTreeFromText(xml_string);    // 从字符串加载
tree.rootBlackboard()->set("key", value);              // 设黑板值
BT::NodeStatus s = tree.tickOnce();                    // tick 一次(推进一个时间片)
BT::NodeStatus s = tree.tickWhileRunning();            // tick 直到非 Running

BehaviorTree.CPP 标准控制/装饰节点(XML 标签)

标签 类型 语义
<Sequence> 控制 与门:全成功才成功(默认带记忆行为)
<ReactiveSequence> 控制 每拍从头重检;至多一个异步子(v4)
<SequenceWithMemory> 控制 记住进度,不重做已成功的子
<Fallback> 控制 或门:一个成功即成功
<ReactiveFallback> 控制 每拍从头重检的 Fallback
<Parallel success_count="M" failure_count="K"> 控制 同拍 tick 全部,M-of-N 阈值
<Inverter> 装饰 非门:Success↔Failure(Running 透传)
<RetryUntilSuccessful num_attempts="N"> 装饰 失败重试最多 N 次
<Timeout msec="T"> 装饰 子超时则强制 Failure 并 halt
<ForceSuccess> / <ForceFailure> 装饰 强制子返回 Success/Failure

Nav2 自定义控制节点(XML 标签)

标签 语义
<RecoveryNode number_of_retries="N"> 子1(主)失败→tick 子2(恢复)→恢复成功后回头重试主,最多 N 轮
<RoundRobin> 轮流 tick 子节点,记住上次轮到谁,循环推进
<PipelineSequence> 像 Sequence 推进,但已成功的前序节点后续拍仍被重 tick
<RateController hz="f"> 限频:每 1/f 秒最多让子 tick 执行一次

Nav2 常用动作/条件叶子(XML 标签)

标签 类型 作用 / 后端
<ComputePathToPose goal="..." path="..."> 动作 调 planner_server 算全局路径(后端 A*/Dijkstra 等)
<FollowPath path="..." controller_id="..."> 动作 调 controller_server 跟踪路径(后端 DWB/MPPI 等)
<Spin spin_dist="..."> 动作 原地旋转(恢复)
<Wait wait_duration="..."> 动作 等待(恢复)
<BackUp backup_dist="..." backup_speed="..."> 动作 后退(恢复)
<ClearEntireCostmap service_name="..."> 动作 清空代价地图(恢复)
<GoalUpdated> / <GlobalUpdatedGoal> 条件 检查是否来了新目标(抢占触发)
<IsBatteryLow> 条件 检查电量是否低(资源监控)

研究实践建议

入门者(先会用): - 先把 §4–§7 的机制吃透——tick/三态/控制节点/Reactive-Memory 是地基,缺一不可。重点动手:用 BehaviorTree.CPP 把 §6.7 的「导航+恢复」最小树跑起来,配 Groot2 看 tick 流。 - 精读 §9 Nav2 真实树,对照官方 XML 逐节点理解。改一处恢复行为(如加一级),观察行为变化——这是从「会画 BT」到「会改工业 BT」的关键练习。 - 牢记三条铁律:条件只读(§5.3)、守卫用 Reactive(§7.6)、有副作用动作必须实现 onHalted(§5.2)。这三条覆盖了初学者 80% 的 bug。

进阶者(会设计): - 掌握 §11 的监控-恢复方法论:四类异常各匹配机制、恢复五级阶梯、重规划节流、抢占。设计执行器时先过一遍这套清单。 - 动手做 §12 累积项目:用 BT 重写一个完整执行器,接入真实的规划器和运动层(哪怕是简化版)。这是检验「会不会设计执行层」的试金石。 - 研究 §10 plan-to-BT:手动跑通回链展开(§10.2 练习 1),理解「规划与执行如何在 BT 上融合」。这是连接 TAMP 规划层和执行层的关键认知。

研究者(求创新): - plan-to-BT 的可靠性与可扩展性仍是开放问题:回链展开的完备性(AAAI 2021)、大规模任务下 BT 的可读性退化、BT 与数据流/记忆的原生支持缺失(Iovino 2022 综述指出的局限)。 - BT 生成的学习化(§10.7):从演示学 BT、进化生成 BT、LLM 生成 BT 都在快速发展。「LLM 提议、符号验证」的混合范式(接 T7)是当前热点——如何保证 LLM 生成的 BT 可执行、逻辑完备,是值得深入的方向。 - 执行层与不确定性的结合(接 T6):当监控条件带置信度、恢复要权衡感知风险时,确定性的 BT 三态语义如何扩展?信念空间下的执行监控是前沿交叉点。 - 形式化保证:BT 的收敛性、安全性、活性(liveness)分析(Colledanchise-Ögren 2018 起步),在动态环境和在线生成场景下的扩展仍有空间。


版本信息速查

项目 版本/参数 出处
BehaviorTree.CPP v4(本章 XML 用 BTCPP_format="4");v4 引入 ReactiveSequence 至多一个异步子的强约束 §5.5、§7.5
Groot2 BehaviorTree.CPP 的官方可视化/调试工具 §12.6
Nav2 ROS 2 导航栈;默认树 NavigateToPoseWReplanningAndRecovery(BTCPP v4 格式) §9
Nav2 默认参数 顶层 RecoveryNode number_of_retries="6";主干 RateController hz="1.0";恢复 Spin spin_dist="1.57"Wait wait_duration="5.0"BackUp backup_dist="0.30" backup_speed="0.15" §9.2-9.4
核心教材 Colledanchise & Ögren (2018), CRC Press 延伸阅读
统一性定理 Colledanchise & Ögren (2017), IEEE T-RO 33(2):372-389, DOI 10.1109/TRO.2016.2633567 §8.2、§8.4
BT 统一框架 Marzinotto et al. (2014), ICRA, pp.5420-5427 §3.4、§3.5
PA-BT / 回链展开 Colledanchise et al. (2019), ICRA, pp.8839-8845, arXiv:1611.00230 §10.2、§10.3
BT 综述 Iovino et al. (2022), RAS 154:104096, arXiv:2005.05842 §3.5、§10.7
Nav2 系统 Macenski et al. (2020), "The Marathon 2", IROS §9.1

结语:行为树是 TAMP 线从「实验室 demo」走向「能用的系统」之间那道最关键的缝。前四章教你把计划算对,本章教你把计划做稳。当你下次看到一个机器人在杂乱的真实环境里从容地导航、抓取、遇阻绕行、被打断后干净切换——它的「从容」背后,多半就是一棵正在以稳定心跳 tick 着的行为树。它不声张、不亲为,只是默默地监控着世界、编排着行动、在出错时优雅地退到下一个方案。这就是执行层的艺术,也是 BT 的价值所在。