
场景驱动这四个字已经成了Apollo规划模块的代名词。如果你想读懂Apollo Planning的源码或者正打算在上面做二次开发第一关就是要搞明白Scenario这套框架到底是怎么把整个决策流程组织起来的。这篇文章我不打算讲API怎么调而是想先从架构层面拆一下趁着我最近刚把规划模块相关源码又翻了一遍把里面最容易绕晕的几层关系讲透顺便把我踩过的一些坑也交代清楚。1. 为什么是场景驱动规划模块从规则堆砌到分层决策的演化逻辑1.1 老的Public Road版本规划器为什么难以为继Apollo规划模块并不是一开始就叫场景驱动。早期版本里整个开放道路规划基本上由一个巨大的Planning类单点维护里面塞满了各种if-else逻辑前面有红绿灯怎么办、前面有障碍物怎么办、要变道怎么办、要通过路口怎么办……每一个新需求进来就是在那个类里再加一个分支条件。代码越来越长状态越来越多最后到一个什么程度呢假如你加入一个停车让行功能看似只是新增一个分支但你会发现它跟已有的减速让行跟车停走变道等待这些功能之间彼此都有状态耦合。这种设计的核心问题在于所有规则摊平在一个平面里互相之间没有优先级、没有上下文边界。车辆正在路口内和车辆正在停车让行两者在真实交通里是有先后关系的但在平面if-else里它们只会被简单叠加于是就会出现又让行又停车甚至变道过程中忽然被路口逻辑打断这种奇怪行为。1.2 场景引擎带来的架构收益后来Apollo干脆把决策过程重新做了切分提出场景驱动的架构。这个思路用一句话讲就是先把当前车辆面对的路况归到某一个场景里去然后只执行这个场景自己定义好的Stage序列和Task集合。场景与场景之间是有边界的一旦归属到停车让行路口逻辑就算触发也只是提供约束而不会直接主导轨迹决策。这个重构带来的直接收益有三点上下文隔离每个场景内部的状态只影响自己场景之间通过明确的切换逻辑交接不会互相污染。配置化扩展新增场景多数情况下只需要改配置文件、注册代码、写新Stage/Task逻辑不需要动主干框架。Debug边界清晰线上问题可以明确说这辆车停在了StopSign场景的STAGE_APPROACH阶段而不是笼统地讲规划出了问题。所以我一直认为读懂Scenario框架等于拿到了Apollo规划模块的总钥匙。后面所有的Decider、Optimizer、约束逻辑都是在这个框架里按部就班跑的。2. Scenario/Stage/Task/Decider四层模型Apollo场景引擎的骨架2.1 四个概念的职责边界先说最容易混淆的四层关系。代码里常看到的Scenario、Stage、Task、Decider其实分别对应不同的抽象层级层级英文对应业务含义生命周期场景级Scenario车辆当前面对的一整类交通环境比如路口、变道、靠边停车从切入到切出可能持续十几秒到几分钟阶段级Stage场景内部按业务流程切分的阶段比如接近停止线、等待通过、通过路口通常3-5个Stage串成一个场景流程任务级Task每帧规划中要执行的具体原子任务比如生成路径边界、搜索路径、生成速度边界一个Stage内按顺序执行决策器Decider/Task任务的具体决策实现输出约束或决策结果每帧执行随Task调度这张表看下来你大概能感觉到Scenario是最上层的分类器Stage是场景内部的业务流程Task/Decider则是真正干活的最小单元。一个Stage里可以同时配置多个Task每帧规划都会按顺序跑一遍。这里有个容易混淆的点在Apollo里Task和Decider经常混着叫。严格说凡是配置在Stage的task列表里的都是Task而其中负责决策的Task又习惯性命名为*Decider。例如PathDecider、SpeedDecider、RuleBasedStopDecider它们本质都是Task但它们做的是决策而不是优化。另外还有PathReuseDecider、PathLaneBorrowDecider这类辅助决策器也在Task列表里出现。2.2 一条配置怎么串起整个场景生命周期我们随便看一个scenario_lane_follow_config.pb.txt它是LaneFollow场景的配置。结构大概长这样scenario_type: LANE_FOLLOW stage_type: LANE_FOLLOW_DEFAULT_STAGE stage_config: { stage_type: LANE_FOLLOW_DEFAULT_STAGE enabled: true task_config: { task_type: PATH_REUSE_DECIDER } task_config: { task_type: PATH_BOUNDS_DECIDER } task_config: { task_type: PATH_DECIDER } task_config: { task_type: RULE_BASED_STOP_DECIDER } task_config: { task_type: SPEED_BOUNDS_DECIDER } task_config: { task_type: SPEED_DECIDER } }这段配置透露的信息量很大。首先scenario_type声明了场景类型其次它直接指定了场景内唯一的StageLaneFollow只有这一个Stage最后这个Stage里挂了6个Task规划每帧就按这个顺序执行。整个流程串起来之后一帧规划的执行路径大概是ScenarioManager按当前环境判断场景归属找到对应的Scenario实例调用它的Process方法Scenario拿到当前的Stage调用Stage::ProcessStage把自己配置里的Task逐个执行一遍产出路径决策、速度决策最后交给PathOptimizer和SpeedOptimizer做平滑优化再拼成一条可执行的轨迹。2.3 场景切换是谁说了算你可能要问了车辆行驶过程中场景不是一成不变的怎么知道什么时候该从LaneFollow切到TrafficLight这个决策权在ScenarioManager。ScenarioManager每帧做两件事第一检查当前场景是否还能继续运行第二从所有注册的场景里挑一个优先级最高的候选场景把它激活。这个看菜下饭的过程在代码里叫ScenarioManager::Update。具体逻辑大概是这样如果当前场景还是合法的而且它自己也不打算退出就继续用当前场景。否则遍历所有已注册场景每个场景提供一个CanEnter判断或者说新版本里通过ScenarioInjector来注入候选场景谁满足进入条件就选谁。默认情况下LaneFollow是一个兜底场景任何情况都满足进入条件所以当其他场景都不匹配时就会自动回到LaneFollow。所以要理解场景切换是谁说了算答案不是某个配置文件而是ScenarioManager里每帧的调度逻辑。新版本里场景的进入条件有很大一部分被抽取到ScenarioInjector系列里例如TrafficLightScenarioInjector、StopSignScenarioInjector它们的职责很单一判断当前位置附近有没有对应元素有就注入候选场景。3. 默认场景库逐一拆解哪些场景常驻各自解决什么问题3.1 LaneFollow所有场景的兜底LaneFollow是我建议你第一个读通读透的场景因为它是默认的兜底场景也是其他场景的骨架参考。它全程只有一个Stage一帧做路径边界生成、路径决策、速度边界、速度决策几个动作最后输出轨迹。表面上看LaneFollow只是沿车道走但真正关键的是它内部的约束条件判断。当车道上有静止障碍物时PathBoundsDecider会生成一个带有借道边界的路径边界允许车辆越过车道线借道绕行当前车速度较慢时SpeedBoundsDecider和SpeedDecider会让车辆保持跟车距离。也就是说LaneFollow并不是傻傻地跟着车道中心线走而是已经具备了开放的场景内自适应能力。在实际工程里很多看似场景的功能最终落地都是放在LaneFollow里兜底处理的。比如跨车道借道的动态判断、障碍物绕行、跟车启停这些能力全部并进LaneFollow而不是单独开新场景这样能有效减少场景切换带来的抖动。3.2 LaneChange与LaneMerge变道合流怎么协调LaneChange场景是专门处理主动变道的。和你想象的想做变道就切个场景不一样LaneChange场景里面分了三个阶段LANE_CHANGE_APPROACH接近变道点、LANE_CHANGE_STAGE执行变道、LANE_CHANGE_RETURN变道结束返回之类。具体阶段命名在不同版本里有调整但核心思想一致变道不是一蹴而就的动作先要到合适的变道点再从目标车道后方切入最后整理回正常行驶状态。LaneMerge处理的是匝道汇入主路。Apollo在合并场景里有一个很有用的决策点合流时车辆会提前让行或者选择可插入间隙进入主路。注意这里没有强制的主路让行逻辑而是通过感知、预测和决策器协同产生一个可插入间隙然后规划在间隙里执行并入。我在仿真里测LaneMerge时最明显的感受是这个场景对参考线质量要求极高。如果上游参考线在合流点没有做平滑连接路径优化阶段会直接报出很差的曲率导致车辆合并时姿态很别扭。排查时不要一上来就怀疑场景逻辑先看参考线生成结果往往更高效。3.3 Crosswalk、StopSign、TrafficLight、BareIntersection路口四件套这四个场景是Apollo默认场景库里和路口强相关的四个它们之间既有分工又有协作。Crosswalk场景处理的是人行横道和行人的让行关系。它的核心是当车辆接近人行横道检测到目标行人可能通过时将停车线提前设定在横道前生成减速避让逻辑。里面有行人状态的判断包括人在路侧、正在穿越、已离开等。StopSign场景面向停车让行标志。它的流程分为接近停止线停车确认路口安全后启动通过路口三个阶段。难点在于停车后的确认安全信号来自于感知和预测场景只负责在决策层等待这个信号一旦信号到位场景会切换到通过阶段。TrafficLight场景负责红绿灯路口的通行控制。这个场景的棘手之处在于灯色判断和停车线位置不能简单绑定。车在多车道接近路口如果只看正前方灯可能漏看左转专用灯等灯转绿后又涉及路口内是否还有残车的清空判断。BareIntersection是无信号灯路口场景。没有灯、没有牌纯粹靠驾驶规则和车辆之间的博弈。Apollo里通常在BareIntersection内部实现先停车观察确认安全再通过的逻辑也会根据车辆到达路口的先后次序决定谁有优先路权。这个场景在开源版本里的优先级排得不高很多情况下如果路口没有明确标志会落到LaneFollow的让行逻辑所以你会发现BareIntersection在仿真里触发概率并不高。这四个场景放在一起看你会发现它们的通用套路是先通过感知/地图拿到路面元素然后进入场景流程在流程内部用约束型决策把让、等、走的动作拆成Stage最后通过一系列Decider把决策翻译成轨迹。场景触发核心条件典型Stage流程主要决策点Crosswalk前方有人行横道实体接近横道、停车让行、通过行人状态、停车线位置StopSign附近有停止标志接近停止线、停车确认、通过停车位置、安全确认TrafficLight车道对应红绿灯接近路口、等待绿灯、通过路口灯色、路口清空判断BareIntersection无信号灯、无标志路口停车观察、安全确认、通过路权优先、残车清空3.4 PullOver、ValetParking、DeadEnd、Emergency低速与极端场景PullOver场景是很多做Robotaxi的人会重点关注的它处理靠边停车这一整段过程。这个场景内部要解决的问题比想象中多寻找合适的停车位置、判断是否能压路沿/占用非机动车道、规划从当前车道到停车位置的横摆路径、最后控制停车精度。由于Apollo的PullOver通常允许完全停在路沿边和部分占用相邻车道两种模式决策逻辑会依据道路宽度、障碍物位置、后方来车情况做选择。ValetParking场景面向的是低速自动泊车或者代客泊车。它和前几个场景最大的不同是在停车场内部往往没有清晰的参考线或者参考线非常短且断裂。Apollo的解决思路是在ValetParking场景里使用OpenSpacePlanner等停车规划算法配合可视化标记点来选择泊车位生成停车轨迹。这个场景内部Stage一般包括行驶到泊车起点启动泊车搜索泊车入位等每个阶段对车辆速度和方向控制约束差异很大。DeadEnd场景处理死胡同掉头。车辆驶入尽头、没有空间直行通过时场景会触发多段前进后退的组合规划直到把车头调转过来。这个场景的算法复杂度比普通变道高一个量级因为它要在一个极小的空间里同时考虑多个方向的碰撞边界和车辆最小转弯半径。Emergency场景比较特殊它不是由路况触发而是由上游模块如监控模块、控制模块上报异常状态触发。比如车辆故障需要紧急靠边停车、驾驶员接管请求等。这个场景的决策优先级最高它会直接打断当前其他场景的执行。我建议在做实车测试时给自己留一个强制触发Emergency场景的口子这是保命的逻辑比任何优先权都高。4. 场景内部的决策流水线一个典型Stage从进入到退出都干了什么4.1 Stage像状态机的状态Task是每帧必做的动作如果看状态机的角度Stage就是状态机里的状态。一个Scenario内部维护一个当前Stage指针每个Stage在Process返回时可以返回三种结果RUNNING表示继续执行当前StageFINISHED表示本场景成功完成、准备退出ERROR表示场景流程出错。此外Stage内部可以通过配置next_stage_type把流程推进到下一个阶段。我举个StopSign的例子。StopSign场景大致是这样STAGE_APPROACH车辆接近停止线决策器规划减速停车点。STAGE_STOP车辆停在停止线前持续观测路口情况。STAGE_PASS条件满足车辆缓缓驶入路口并完成通过。这三个Stage按配置顺序排列每个Stage执行完自己的任务后判断下一步。只有在STAGE_STOP里感知到安全窗口打开才会触发next_stage_type: STAGE_PASS。如果一直没等到安全窗口车辆就一直保持停止状态。在这个机制里要注意一个点Stage的执行函数每帧都会被调用它不是等一个Stage内的所有动作做完才返回而是每帧返回一次状态。所以你在Stage里写的业务逻辑本质上是帧驱动的不能假设上一个状态还保存着如果跨帧需要记忆得靠自己维护状态。4.2 核心Decider的职责边界每个Stage里通常会配置一批Decider它们按顺序跑前一个的输出是后一个的输入。按我的理解可以按功能把它们分成三类规则型决策器直接根据当前状态生成决策结果不涉及复杂优化。例如RuleBasedStopDecider会根据前方目标、行人状态、交通规则生成是否必须停车PathLaneBorrowDecider判断当前是否可以借道。边界生成器把约束翻译成优化问题的边界条件。例如PathBoundsDecider生成了路径优化可行驶范围SpeedBoundsDecider生成了速度优化的ST图边界。目标决策器在边界基础上选定最终目标。例如PathDecider根据路径边界和障碍物分布决定最终是跟着障碍物停、绕过去还是变道SpeedDecider根据ST图生成最终的速度曲线。这里容易踩的一个坑是很多人看到PathDecider这个名字以为它就是选一条路径但其实它不直接生成曲线它做的是把障碍物和已生成的候选路径结合起来判断每条路径上障碍物是否可避让最终确定路径障碍物决策。真正生成平滑曲线的是后面的PathOptimizer这一类优化器。理解决策和优化的分工你读源码时才不会把函数调用链弄混。4.3 一个典型Stage周期从感知到轨迹输出我把一个典型Stage的处理流程拆成六步看起来更直观从Frame拿当前帧的全部输入包括感知障碍物、预测轨迹、参考线、路口元素、地图信息。做场景内必要的状态判断确认当前处于本Stage的哪个子阶段。按配置顺序执行每个Task/Decider逐步建立路径、障碍物决策、速度约束。把决策结果交给优化器生成满足约束且平滑的路径和速度曲线。合并路径和速度生成DiscretizedTrajectory交给评估器做最终碰撞检查。Stage返回下次状态。这个流程几乎每个Stage都一样不同的是每个Stage里Task的类型、参数和顺序。所以你在读代码时要养成的习惯是一个Stage的性质看它的task_config列表和对应的Stage类实现而不是看它叫什么名字。名字叫APPROACH也可能干着决策停车的事关键还是看配置和代码逻辑。5. 二次开发实战如何往场景库里加一个自己的场景5.1 配置文件从哪来场景配置的加载路径Apollo规划模块的场景配置按目录管理常见路径是modules/planning/conf/scenario/下面的文件命名和场景类型一一对应例如scenario_lane_follow_config.pb.txtscenario_traffic_light_config.pb.txtscenario_stop_sign_config.pb.txtScenarioManager初始化时会把所有配置文件读进来构建成一个ScenarioConfig集合。场景类型和配置的映射关系由Scenario类根据当前injector的注入结果来做匹配。所以假设你要新增一个名叫SchoolZone的场景最简单的方式就是模仿现有场景加一份配置例如scenario_school_zone_config.pb.txt里面定义场景类型、Stage列表、每个Stage的Task列表然后注册场景类型、写SchoolZone的CanEnter判断逻辑。5.2 新增场景的三步走基于开源的常见代码结构我建议按下面三个步骤来第一步注册场景类型。在场景相关的枚举或者注册表里加一个SCHOOL_ZONE。不同版本注册方式略有差异但核心都是让ScenarioManager知道有这么一个场景可以参与匹配。第二步实现场景进入条件。写一个SchoolZoneScenario类继承场景基类在CanEnter逻辑里判断当前位置附近是否存在学校区域元素。判断手段可以是地图服务查询、感知结果、路由结果哪个都行关键是这个条件要稳定不要因为单帧抖动频繁进入退出。第三步编排Stage和Task。如果学校区域主要动作就是减速通过那你可以仿照LaneFollow只做一个Stage里面挂SPEED_BOUNDS_DECIDER和SPEED_DECIDER把速度限制从配置里读出来。如果还有停车等待学生通过的需求那就再加一个Stage先接近减速再停车等待然后通过最后退出场景。这三步做完重新执行planning的单元测试或者用DreamView仿真跑一个小场景基本上就能看到车辆进入学校区域时按新场景的决策行驶了。5.3 开发调试中的三个实用技巧技巧一善用场景切换日志。Apollo的planning日志里会记录当前场景和Stage状态你grep一下Entered scenario或者UpdateStage基本就能看到场景切换的完整时间线。如果发现场景没有进入先查这个场景的CanEnter条件再看注入器的输出。技巧二把ScenarioManager当成可观测对象来用。我习惯在自建场景时把一个观测器挂到ScenarioManager上每帧记录当前场景、当前Stage、各候选场景能否进入。这样在一次实车或仿真运行后可以离线回放每个候选场景的进入条件状态定位为什么没有切入。技巧三别把高频动作用在场景切换上。例如检测到前方有行人这种瞬时事件不建议直接触发场景切换。场景切换是有代价的Stage内部状态、参考线绑定、决策记录都可能重置。正确做法是在场景内部用Stage/Decider去处理只有宏观路况变化如车道合并结束、进入路口才动用场景级切换。6. 场景切换不符合预期我的排查思路与踩坑记录6.1 典型现象该进路口场景却一直LaneFollowTriaged最常见的问题是车辆已经开到路口了从DreamView窗口看图形上已经显示TrafficLight或者StopSign的元素但规划模块日志里还是LANE_FOLLOW车辆没有进入对应场景。这个问题的特点是越接近路口的时候规划越淡定没有任何要停下来的迹象。很多刚接触Apollo的朋友会怀疑是不是场景代码没调好可实际排查起来真正的原因往往不是场景本身而是场景进入条件不满足。6.2 完整排查链路我会按下面这个顺序一层一层往下打基本能快速定位问题第一步看场景注入器输出。TrafficLight场景进入的前提是在车辆当前参考线前方的一定范围内存在红绿灯实体。如果感知或者地图服务没有把这个实体挂到参考线上注入器就不会触发。我在调试时经常发现地图元素坐标漂移了几米导致注入器拿到的距离超过了阈值。第二步看注册场景的优先级。如果同时满足多个场景ScenarioManager会选择优先级最高的那个。有时候StopSign和TrafficLight在路口附近同时出现如果StopSign的优先级设置不当会抢在TrafficLight前面进入场景结果车辆跑到有信号灯路口却跑出了一套停车让行的逻辑。第三步看当前场景是否主动拒绝切换。每个场景在运行时可以决定自己还没结束不让你切走。比如StopSign场景正在观察路口的半路新的TrafficLight注入过来但当前场景还在RUNNING状态就可能卡在当前场景直到它FINISHED或者ERROR。这时候检查日志里有没有interruptible或者场景退出的相关字段就能定位。第四步看配置文件是否有对应Stage。有些场景虽然进入了但由于配置里stage_type和代码注册的Stage类不匹配初始化阶段直接失败然后被ScenarioManager当成非法场景丢弃最终回落到LaneFollow。这种错误通常是配置和代码版本不一致导致的我升级Apollo版本时踩过好几次。6.3 几个容易踩的坑与预防手段坑一参考线问题引起的假性场景失效。场景切换依赖参考线如果参考线在路口处断掉、换线或者严重偏移注入器找不到参考线上的关联元素场景就永远进不去。排查时先打开参考线显示看看路口处的参考线是否正常。坑二配置文件里enabled:false。有些版本的Apollo默认会把部分场景关掉比如ValetParking、DeadEnd在配置文件里默认不启用。如果你改了配置却没有生效先检查是不是有个enabled字段被置成了false。坑三Stage切换的checkpoint没做。如果你自己写场景的多个Stage返工最多的地方是Stage切换前的状态没有保存好。比如你从STAGE_STOP切到STAGE_PASS需要在切换前把停车时的障碍物状态、目标位置记录下来否则下一帧Stage_Process重新执行时会发现状态全丢了导致车辆行为跳变。坑四注入器判断用了单帧数据。临时的感知噪声或者预测抖动会导致注入器发射一个进入信号又立刻消失造成场景反复横跳。比较好的做法是给进入条件加一个持续帧数的确认机制比如连续10帧都满足才切入可以有效防抖。这些坑其实都不是Apollo的bug更多是场景框架使用方式的问题。你只要养成场景切换是有条件、有成本、需要上下文保存的思维习惯排查和开发新场景都会顺手很多。最后说点我个人感受。Apollo规划模块这套场景驱动框架如果只是跑仿真、看效果可能体会不到它有多重要真正到了自己要往上加策略、做产品级功能的时候你才会意识到一个清晰的场景StageTask结构比任何花哨的算法都值钱。建议你手里拿到源码后第一件事别急着跑Demo先把LaneFollow和StopSign两个场景的代码一行一行读懂再把上面提到的配置和流程对应起来。做透这两个场景后面大部分规划改动你都可以顺着这套骨架找到落点。