第 12.4.2.2 节

控制与装饰规则

作者:董佳辉

控制与装饰规则

对应 BehaviorTree.CPP 3.8 官方文档:Learn the Basic Concepts → Introduction to BTs → Examples,并参考 Nodes Library 中的 Sequences、Fallbacks 与 Decorators。

上一篇已经知道:tick 从父节点向下传播,状态由子节点向上返回。现在进一步学习,父节点拿到 SUCCESS、FAILURE、RUNNING 后,具体怎样决定下一步。

需要表达的任务关系常用节点
前一步成功后,继续下一步Sequence
当前方案失败后,尝试备用方案Fallback
修改一个节点的结果、次数或时限装饰节点

本篇先讲普通 Sequence、普通 Fallback 和常用装饰节点,使用移动机器人示例。 C++ 是解释逻辑的伪代码; XML 只展示树的片段,完整工程、节点注册和 XML 文件结构后面统一学习。


Sequence:依次完成一组步骤

Sequence 按子节点的排列顺序执行,只有前面的步骤成功,才继续后面的步骤:

Sequence
├── CheckBattery          检查电量是否足够
├── NavigateToGoal        导航到目标
└── ReportArrival         报告到达
当前子节点返回Sequence 接下来做什么Sequence 的结果
SUCCESS继续 tick 下一个子节点所有子节点成功后,返回 SUCCESS
FAILURE停止本次序列,不再 tick 后面的节点FAILURE
RUNNING停在当前子节点,等待后续继续执行RUNNING

例如 CheckBattery 返回 FAILURE,本轮不会发送导航请求,也不会报告到达;NavigateToGoal 返回 FAILURE,则跳过 ReportArrival。

一次 tick 可以继续执行多个步骤

假设电量足够,导航要经过几次检查才完成:

外部第几次 tick本次实际执行的子节点Sequence 返回
第 1 次CheckBattery → SUCCESS,接着 NavigateToGoal → RUNNINGRUNNING
第 2 次NavigateToGoal → RUNNINGRUNNING
第 3 次NavigateToGoal → SUCCESS,接着 ReportArrival → SUCCESSSUCCESS

前一步在当前调用中成功后,Sequence 可以立刻继续 tick 下一步,不必等到下一轮。

普通 Sequence 在子节点 RUNNING 时,会记住当前位置。 因此第 2 次 tick 会继续导航,不会重新检查前面已经成功的 CheckBattery。

如果导航期间电量变低,这棵普通 Sequence 不会因此自动重新检查电量并中止导航。需要持续检查条件时,要使用合适的响应式结构,后面结合任务中止来学习。

用子节点下标理解执行位置

下面的 current_index 是节点保存的成员状态,初始为 0:

BT::NodeStatus Sequence::tick()
{
    while (current_index < children.size())
    {
        BT::NodeStatus status = children[current_index].executeTick();

        if (status == BT::NodeStatus::RUNNING)
        {
            // 保留位置,下次继续 tick 同一个子节点
            return BT::NodeStatus::RUNNING;
        }

        if (status == BT::NodeStatus::FAILURE)
        {
            ResetChildren();
            current_index = 0;
            return BT::NodeStatus::FAILURE;
        }

        // 此处假设子节点只返回三个合法执行结果,剩下的是 SUCCESS
        ++current_index;
    }

    ResetChildren();
    current_index = 0;
    return BT::NodeStatus::SUCCESS;
}

这段伪代码省略框架状态维护和非法状态检查。 ResetChildren() 表示结束本次执行并重置子节点,不能理解为撤销已经产生的业务效果。

这一行为可对照官方 3.8.7 sequence_node.cpp。 普通 Sequence 成功或失败结束后,位置都会回到开头;如果上层以后重新执行它,会从第一个子节点开始。 返回失败不会在当前 tick 中自动从头重试。

后面的节点不保证一定执行

下面这棵树不能保证导航失败后执行停止节点:

Sequence
├── NavigateToGoal
└── StopRobot

导航返回 FAILURE 时,Sequence 立即返回失败,StopRobot 会被跳过。

动作失败或被中止时需要的取消、停止和清理,应在动作及相关模块中正确实现。 仅把清理动作放在 Sequence 最后,不能保证它在所有退出路径上执行。


Fallback:按顺序尝试方案

Fallback 在其他框架中也常叫 Selector。 它按顺序尝试子节点,只要找到成功的方案,就结束本次选择:

Fallback
├── NavigateToPrimaryGoal     尝试主要目标
└── NavigateToBackupGoal      主要目标失败后,尝试备用目标
当前子节点返回Fallback 接下来做什么Fallback 的结果
FAILURE继续 tick 下一个子节点全部失败后,返回 FAILURE
SUCCESS不再尝试后面的子节点SUCCESS
RUNNING继续等待当前方案,不启动后面的方案RUNNING

RUNNING 不算失败。 主要目标还在导航时,不会因为“尚未到达”就立即启动备用目标。

例如:

外部第几次 tick本次实际执行的子节点Fallback 返回
第 1 次NavigateToPrimaryGoal → RUNNINGRUNNING
第 2 次NavigateToPrimaryGoal → FAILURE,接着 NavigateToBackupGoal → RUNNINGRUNNING
第 3 次NavigateToBackupGoal → SUCCESSSUCCESS

普通 Fallback 在 RUNNING 时同样保留当前位置。 第 3 次继续备用导航,不会重新尝试前面已经失败的主要目标。

本次选择成功或全部失败结束后,它会重置位置;上层以后重新执行时,再从第一个子节点开始。具体可对照官方 3.8.7 fallback_node.cpp。

条件节点也能作为优先方案

例如“如果已经到达,就不用重新导航”:

Fallback
├── IsGoalReached
└── NavigateToGoal

IsGoalReached 返回 SUCCESS,这部分树就成功结束;返回 FAILURE,才启动导航。

这里的成功表示“到达目标这项需求已经满足”,所以一个条件判断也可以让整段任务成功,不一定非要执行动作。


对照两种控制规则

对比项普通 Sequence普通 Fallback
表达的关系一组都需要成功的步骤一组可以依次尝试的方案
哪种结果让它继续下一个子节点SUCCESSFAILURE
哪种结果让它提前结束FAILURESUCCESS
当前子节点 RUNNING 时返回 RUNNING,保留当前位置返回 RUNNING,保留当前位置
所有子节点都执行完全部成功,返回 SUCCESS全部失败,返回 FAILURE

树结构中的前后顺序会影响结果。把子节点重新排序,可能改变先检查什么、先执行哪个方案,以及哪些节点会被跳过。

3.8 官方文档还介绍了 ReactiveSequence、ReactiveFallback 和带记忆的序列变体。本篇不展开它们;这里的“保留 RUNNING 位置”只是在说明普通节点的行为,不能套用到所有变体。


装饰节点:处理一个子节点的结果或执行方式

装饰节点始终只有一个直接子节点,子节点也可以是整棵子树的入口。

Inverter、ForceSuccess 与 ForceFailure

这三种节点主要处理子节点的返回结果:

子节点返回InverterForceSuccessForceFailure
SUCCESSFAILURESUCCESSFAILURE
FAILURESUCCESSSUCCESSFAILURE
RUNNINGRUNNINGRUNNINGRUNNING

Inverter 已在上一篇用路径检查解释过。这里补充两种强制结果的用途:

Sequence
├── NavigateToGoal
└── ForceSuccess
    └── UploadArrivalLog

如果“导航到达”是主要任务,而“上传到达日志”允许失败,可以只给日志动作加 ForceSuccess。日志最终失败时,这个装饰节点仍返回成功;日志还在上传时,仍返回 RUNNING,不会立刻跳过它。

ForceFailure 则让已结束的子节点统一报告失败。例如,在 Fallback 中,执行完一段诊断记录后仍希望继续下一个方案,可以用它包装这段记录。

强制改变的是向父节点报告的状态,不会让失败的操作实际成功,也不会撤销已经完成的动作。使用前要确定,这个结果是否符合父节点所代表的任务含义。

RetryUntilSuccessful:失败后重新尝试

3.8 文档中的 RetryNode,在 XML 中使用的注册名称是 RetryUntilSuccessful:

<RetryUntilSuccessful num_attempts="3">
    <NavigateToGoal/>
</RetryUntilSuccessful>

这里表示总共最多尝试 3 次,包含第一次:

子节点返回RetryUntilSuccessful 的处理
SUCCESS立即返回 SUCCESS,不再重试
FAILURE记一次失败;还有次数就重置子节点再试,用完则返回 FAILURE
RUNNING返回 RUNNING,当前尝试继续,不消耗一次失败次数

例如导航结果依次为:

第 1 次尝试:RUNNING → FAILURE
第 2 次尝试:RUNNING → RUNNING → SUCCESS
整体结果:SUCCESS,第 3 次尝试不会发生

每次完整尝试可以跨越多个 tick。 num_attempts="3" 限制的是尝试次数,不是整个装饰节点只能被 tick 三次。

根据官方 3.8.7 retry_node.cpp, 如果子节点立即失败,下一次尝试可能在同一次父节点 tick 中继续执行。 这个节点没有自动增加重试间隔;对子节点反复检查,不等于等待环境或传感器数据变化。

Repeat:成功后继续重复

Repeat 用于重复完成一个操作:

<Repeat num_cycles="3">
    <CaptureObservation/>
</Repeat>

这里假设 CaptureObservation 每次执行会采集并保存一份观测数据。任务是成功完成 3 次采集:

子节点返回Repeat 的处理
SUCCESS记一次成功;未到次数则重置子节点继续,达到次数后返回 SUCCESS
FAILURE立即停止重复,返回 FAILURE
RUNNING返回 RUNNING,继续当前这次操作,不增加已完成次数

它与 Retry 的区别是:Retry 失败后继续,Repeat 成功后继续。 num_cycles="3" 同样包含第一次执行,不是先做一次再额外做三次。

官方 3.8.7 repeat_node.cpp 也使用循环处理立即结束的子节点。 因此,3 次快速采集可能在一次 tick 内完成; Repeat 不会自动让它们分布在三个外部更新周期,也不会自动插入时间间隔。

Timeout:给本次执行设置时限

Timeout 使用 msec 指定毫秒数,例如:

<Timeout msec="5000">
    <NavigateToGoal/>
</Timeout>

这给本次导航设置 5 秒时限:

子节点情况Timeout 的处理
未超时就返回 SUCCESS返回 SUCCESS
未超时就返回 FAILURE返回 FAILURE
还在运行且未超时返回 RUNNING
时限到达且子节点仍在运行中止子节点,随后返回 FAILURE

在 3.8.7 timeout_node.h 中,计时从装饰节点本次执行开始,后续 tick 不会反复把这 5 秒重新计时。 到期处理会调用子节点的中止逻辑;超时失败结果在后续执行入口中返回给父节点。

导航动作需要在中止处理中取消实际导航请求。 Timeout 不能替代动作的取消实现,也不能把一个长时间堵在 tick() 里的同步函数自动变成可及时中止的异步任务。


组合示例:检查电量,再有限次尝试导航

把规则组合成一棵任务树:

Sequence
├── CheckBattery
├── RetryUntilSuccessful [num_attempts=3]
│   └── Timeout [msec=5000]
│       └── NavigateToGoal
└── ReportArrival

对应的 XML 片段:

<Sequence>
    <CheckBattery/>
    <RetryUntilSuccessful num_attempts="3">
        <Timeout msec="5000">
            <NavigateToGoal/>
        </Timeout>
    </RetryUntilSuccessful>
    <ReportArrival/>
</Sequence>

阅读时,从外向内看任务约束:

所在层职责
外层 Sequence电量检查通过后才执行导航;导航最终成功后才报告到达
RetryUntilSuccessful导航尝试失败时重新尝试,最多 3 次
Timeout限制每次尝试的运行时间
NavigateToGoal启动、检查并在必要时取消实际导航任务

一种可能的执行过程是:

CheckBattery → SUCCESS
    ↓
第 1 次导航尝试 → RUNNING → 超时并取消 → FAILURE
    ↓ Retry 重置并重新尝试
第 2 次导航尝试 → RUNNING → SUCCESS
    ↓ Retry 返回 SUCCESS
ReportArrival → SUCCESS
    ↓
Sequence → SUCCESS

如果 3 次都失败,Retry 返回 FAILURE,外层序列也失败,ReportArrival 不会执行。 若报告动作本身失败,外层序列同样会报告失败,因此报告动作应按需求定义好“成功”的含义。

这里的普通 Sequence 只在开始时检查电量,后续不会在导航或重试期间持续重查。 这个例子用于掌握顺序、重试和超时的组合;持续监控条件及任务切换,留到响应式行为章节。

装饰节点的内外顺序会改变含义

RetryUntilSuccessful [3 次]
└── Timeout [5 秒]
    └── NavigateToGoal

这是上面的结构:每次尝试分别有 5 秒时限,一次超时算一次尝试失败,Retry 可以再试。

如果交换位置:

Timeout [5 秒]
└── RetryUntilSuccessful [3 次]
    └── NavigateToGoal

则是所有尝试共享一个 5 秒时限。 只有导航自身先返回失败,Retry 才能在剩余时间内重试; 外层一旦超时,就中止整个重试子树,不再保证用完 3 次。

两种结构的节点相同,但任务约束不同。 读树时既要看子节点顺序,也要看装饰节点包住了哪一层。


当前学习进度

本篇完成了 Introduction to BTs 中 Sequence、Fallback 和装饰节点的核心规则,并补充了机器人任务常用的重试、重复与超时组合。