Skip to content

Wiki

控制与装饰规则

About 13 min read

Existing English translations were reused. 82 current source blocks remain in Simplified Chinese, without triggering paid translation.

1.控制与装饰规则

对应 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 文件结构后面统一学习。


2.Sequence:依次完成一组步骤

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

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

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

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

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

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

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

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

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

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

下面的 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 中自动从头重试。

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

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

Sequence
├── NavigateToGoal
└── StopRobot

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

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


3.Fallback:按顺序尝试方案

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

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

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

For example:

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

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

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

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

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

Fallback
├── IsGoalReached
└── NavigateToGoal

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

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


4.对照两种控制规则

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

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

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


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

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

5.1.Inverter、ForceSuccess 与 ForceFailure

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

子节点返回 Inverter ForceSuccess ForceFailure
SUCCESS FAILURE SUCCESS FAILURE
FAILURE SUCCESS SUCCESS FAILURE
RUNNING RUNNING RUNNING RUNNING

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

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

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

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

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

5.2.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 中继续执行。 这个节点没有自动增加重试间隔;对子节点反复检查,不等于等待环境或传感器数据变化。

5.3.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 不会自动让它们分布在三个外部更新周期,也不会自动插入时间间隔。

5.4.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() 里的同步函数自动变成可及时中止的异步任务。


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

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

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

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

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

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

如果交换位置:

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

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

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


7.当前学习进度

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