
开车这几年下来要说Apollo规划模块里头哪个设计最让我觉得“值得抄作业”不是某个花哨的优化算法而是它这套场景驱动的决策骨架。很多刚接触Apollo的同学都有一个误区以为规划模块的核心是路径生成、速度规划这些数学问题结果一头扎进DP/QP里出不来。其实真正决定一辆车“聪不聪明”的是它在每个道路环境下能不能做出符合预期的决策而这个能力恰恰就是Scenario这套框架撑起来的。这篇文章我想把Apollo规划模块里的Scenario驱动机制从源码层面拆一遍讲清楚它是怎么设计、怎么运行、怎么配置的以及你如果要自己加一个场景、调一个决策逻辑应该在哪儿动手。适合正在看Apollo代码、准备做定制开发或者想在仿真平台上复现实车决策效果的工程师。看完之后你至少能回答三个问题规划模块是怎么知道“我现在在什么场景里”的场景和stage、task之间到底是什么关系以及新增一个自定义场景要动哪些文件。1. 为什么Apollo的决策非得按“场景”来设计1.1 传统有限状态机的困境我踩过才知道早期自动驾驶决策模块最常见的做法是有限状态机维护一个全局状态比如“直行”“变道”“停车”然后靠事件触发迁移。看着逻辑挺顺但真正跑起来全是坑状态一多迁移条件相互交叉改一个状态要牵动十几个判断并且每个状态内部塞了一大堆差异巨大的行为逻辑代码根本没法测试。我印象特别深的是某次仿真里车在十字路口中间同时满足了“左转待转”和“让行行人”两个条件状态机直接在两个状态之间疯狂跳变车头左右摆得像在跳华尔兹场面极其尴尬。Apollo这套Scenario设计本质上是把“全局大状态机”换成了“场景标识加局部状态机”的组合。它不试图用一个超级状态机覆盖所有情况而是先用一套轻量级规则判断当前属于什么场景然后用场景内部的stage和task来完成精细决策。这样做最大的好处是每个场景之间是解耦的改停车场景的逻辑不会影响变道场景每个场景内部的决策链又是清晰的从进入场景到最后完成动作中间每一步都可追踪、可回放。1.2 场景驱动的核心识别、编排、执行三层分离用大白话讲Scenario这套机制干了三件事。第一件是识别每帧规划周期里根据地图、路由、障碍物和自车状态判断“我现在在哪儿、接下来要干谁”。第二件是编排一旦确定了场景类型就加载这个场景对应的stage流程stage之间互相串联像一个内部小状态机。第三件是执行每个stage里面挂了一串task这些task才是真正干活的做路径决策、速度决策、轨迹优化都是它们。这个思路很像项目管理里的“里程碑加任务包”模式。场景就是项目名stage是一级阶段计划task是分配到人头上的具体工作。你不需要让每个task知道整个项目的全貌它只需要按照当前阶段的输入干活输出给下一个阶段。这种分层带来的直观收益是代码的可读性和可调试性显著提升看日志的时候你一眼就能知道车辆当前停在哪个场景的哪个阶段问题很快就定位到了。2. 源码级拆解Scenario框架的运行主线2.1 调用链从数据进来到决策出去Apollo规划模块整个决策入口是PlanningComponent这是CyberRT框架下的一个组件负责接收感知、定位、路由等话题消息。真正跑规划算法的地方是OnLanePlanning它维护了一个Planner对象对于公开道路场景来说这个Planner就是PublicRoadPlanner。每帧的planning cycle里Planner::Plan会被调用紧接着调用的就是ScenarioManager::Update。// modules/planning/planner/public_road/public_road_planner.cc Status PublicRoadPlanner::Plan(const PlanningContext* context, Frame* frame) { // 场景管理器每帧更新 scenario_manager_.Update(frame, planning_state_); // 获取当前场景 Scenario* current_scenario scenario_manager_.mutable_scenario(); // 场景内部执行决策链 auto status current_scenario-Process(frame, planning_state_); return status; }这个调用链看着简单但它是整个决策架构的中枢神经。注意一个细节ScenarioManager::Update会传入Frame和PlanningState前者是当前帧的完整数据缓存包括参考线、障碍物、路径边界等等后者则记录了车辆上一时刻的规划状态比如当前车速、是否在变道中等。场景切换的判断依赖于这两者的组合缺一个都不行。2.2 ScenarioManager它不是Git不存历史commit很多初学者会误以为ScenarioManager会长期保存每个场景的运行记录其实它在设计上非常“健忘”。它只维护当前场景的指针、当前场景所处stage、以及一份可注册的场景列表。每一帧更新时它不会把所有历史场景都翻一遍而是基于当前帧的信息做决策决定是沿用当前场景还是切换到一个新场景。这种设计是刻意的因为规划模块每100毫秒跑一帧场景切换的实时性远比记忆历史状态重要。在Apollo早期版本里场景的注册机制比较硬编码所有场景类型用一个switch-case或者factory直接写在ScenarioManager里。后来版本逐渐演进场景开始支持依赖注入和动态配置典型比如不同城市的特殊路况场景完全可以作为独立模块接入不用改动主代码。ScenarioManager内部有一个非常重要的成员叫scenario_dependency_injector_它负责把frame、reference_line_info、vehicle_state等依赖对象注入给每个场景实例。这么做的好处是场景之间不会直接耦合每个场景只认接口不认具体对象后期的单元测试也好写直接造一个fake的frame就能跑场景逻辑。2.3 Stage与Task决策链条里的“阶段”和“动作”搞清楚Scenario的骨架后下一步是理解Stage和Task。Scenario代表“一整件事”比如STOP_SIGN这个场景代表车辆要处理一个停车标志但这个场景内部不是一步到位的它要经历接近、判断能否通过、实际停车让行、重新起步这好几个阶段这些阶段就是Stage。Stage之间通过Stage::Process的返回值来决定切换如果返回Stage::FINISHED表示当前阶段完成Scenario就会推进到下一个阶段如果返回Stage::ERROR可能就要回到上一个阶段或者进入错误处理流程。这种机制配合每一个Stage内部的ExecuteTaskOnReferenceLine等调用构成了一个严格有序的执行链。Task才是真正做计算的单元Apollo把决策与优化拆成了很多细粒度的task比如LaneChangeDecider判断当前是不是安全变道时机PathReuseDecider能否复用上一周期的路径PathLaneBorrowDecider是否借道PathBoundsDecider计算当前决策下的路径边界PathDecider将路径决策映射成障碍物决策SpeedBoundsDecider计算速度边界RuleBasedStopDecider规则性停车点生成SpeedDecider速度决策每个Stage挂的task顺序是写死在配置里的之间还有依赖关系。比如路径边界计算必须在路径复用决策之后因为是否复用路径直接决定边界计算能不能跳过。这个顺序在实际调试中非常关键我自己就吃过亏新加了一个task后路径决策一直不正常最后发现是我把它放到了PathBoundsDecider之后导致它输出的边界还没来得及被引用就被覆盖了。2.4 参考线在场景里的角色别忽略参考线ReferenceLine与Scenario的关系也值得单独说一嘴。Apollo的规划是“先有参考线后有轨迹”的范式所以每个scene在做决策时手头必须有一条参考线。这条参考线由ReferenceLineProvider生成它考虑了地图车道中心线、路由结果、前车历史轨迹等。场景决定的是“在这条参考线的约束下我要执行什么策略”。比如在LANE_CHANGE场景里参考线通常会生成两套分别对应当前车道和目标车道然后决策模块需要在两条参考线之间选择一条作为最终规划依据。这个决策发生在LANE_CHANGE场景的LANE_CHANGE_APPROACHING阶段如果你打开Apollo的日志看到lane_change_approach激活就说明车辆已经拿到两条参考线开始对比了。3. 场景配置与切换决策的全链路实操3.1 配置文件决策引擎的“剧本”Apollo的场景配置入口是modules/planning/conf/planning_config.pb.txt这份文件里定义了哪些场景在本版本中启用、每个场景使用哪个stage序列、每个stage挂哪几个task。它本质上是决策引擎的“剧本”——代码提供能力配置决定行为。// modules/planning/conf/planning_config.pb.txt片段 scenario { type: LANE_FOLLOW stage_type: LANE_FOLLOW_DEFAULT_STAGE stage_config { stage_type: LANE_FOLLOW_DEFAULT_STAGE enabled: true task_type: LANE_CHANGE_DECIDER task_type: PATH_REUSE_DECIDER task_type: PATH_LANE_BORROW_DECIDER task_type: PATH_BOUNDS_DECIDER task_type: PATH_DECIDER task_type: RULE_BASED_STOP_DECIDER task_type: SPEED_BOUNDS_DECIDER task_type: SPEED_DECIDER } }注意这里有个很重要的点Stage和Task的顺序完全由配置文件决定不是写死在代码里的。这意味着你想调整决策流程绝大多数情况下只需要改配置不需要重新编译。我在实际项目里经常干的事就是先改配置跑仿真确认效果后再去动代码这样迭代速度快很多也方便做A/B验证。3.2 场景识别不是AI是规则和计算有朋友问场景识别怎么不用深度学习的模型我的理解是场景识别本身是一个高度结构化的问题道路拓扑、交通灯状态、标志牌位置都是明确的输入用规则计算不但更稳定而且可解释。Apollo的做法是把场景识别拆成三步第一步查路由。路由模块返回的RoutingResponse里包含了当前路段、必经路口、车道信息这些信息基本确定了车辆大目标比如这条路线是否经过十字路口、是否要求变道。第二步看点云和地图。查看当前车身周围是否有停止标志StopSign、信号灯TrafficLight、人行横道等地图元素以及这些元素与自车的相对距离和状态。第三步查自车状态。自车当前速度、横向位置、上一周期状态等决定了是否满足某个场景的切入条件。举个例子LANE_CHANGE的切入条件之一是当前车道前方有低速障碍物且旁车道满足变道条件这个判断由LaneChangeDecider完成。这三步做完之后ScenarioManager通过一个注册表遍历所有候选场景按优先级挑出“当前最该响应的场景”。这个优先级在配置里体现得很明显比如紧急停车场景的优先级大于停车让行停车让行大于普通跟车普通跟车是兜底场景。3.3 切换矩阵与优先级别乱调场景切换会带来一个问题切换太频繁车辆行为就会抖动。Apollo对每个场景的进入条件设计了很多“滞回”逻辑不是遇到一点点迹象就立刻切换而是一旦进入之后要满足更苛刻的条件才会退出。比如LANE_CHANGE场景即使变道条件已经不那么充分了只要还在变道过程中就会继续维持这个场景避免发生回退振荡。我调过一次优先级当时想提高PULL_OVER的响应优先级让它在ROUTE_REQUEST里带靠边标记时更快切入结果上路一测发现车在距离目的地还有200米的十字路口就开始频繁尝试靠边因为PULL_OVER的触发条件里没有检查与终点的距离。后来我在场景切入条件里补了一个约束要求靠边点的剩余距离在阈值范围内才允许PULL_OVER场景激活问题才解决。这个例子也说明场景优先级可以调但一定得结合进入条件仔细验证只调优先级不补条件早晚会出问题。4. 实战新增一个自定义场景的完整落地流程4.1 先理顺环境和权限再动手写代码Apollo的代码结构是模块化编译如果只是改规划模块用bash apollo.sh build planning就够了。但有一个前置问题经常卡住人账号权限。公司内部如果用的是Apollo企业版或统一代码托管平台普通开发账号默认只有读权限想改配置文件一保存就报403。这里分两层看场景配置和代码修改都需要写权限至少得让管理员给账号在对应仓库下授予“developer”或“编辑者”角色。还有个更隐蔽的问题是CyberRT环境里的namespace配置我记得有次新环境怎么都收不到模块消息查了半天是配置里namespace缺失导致运行时的channel名跟上游对不上话题消息自然就断了。这类环境问题别看它小排查起来往往比业务逻辑本身还费时间。做自定义场景调试前建议先用Apollo自带的工具把现场数据保存下来。具体操作就是跑cyber_recorder record把几个关键channel录成bag文件然后无限次离线回放配合DreamView可视化逐帧定位决策跳变。这个“录现场、回放复现”的习惯能让你在仿真阶段就解决掉90%的决策类问题比直接在实车上盲试安全太多。4.2 场景类定义与注册新增场景的第一步是定义一个继承Scenario的子类。以我设计的一个园区窄道通行场景为例class NarrowRoadScenario : public Scenario { public: bool Init(std::shared_ptrDependencyInjector injector, const std::string name) override { if (init_) return true; injector_ injector; name_ name; // 加载该场景的stage配置 return Scenario::LoadConfig(); } std::unique_ptrStage CreateStage( const StagePipeline config, std::shared_ptrDependencyInjector injector, const ScenarioConfig::StageConfig stage_config, const PlanningContext* context, Frame* frame) override; };类写好后要在ScenarioManager里注册这个场景。Apollo的场景注册支持两种方式一种是在ScenarioManager::RegisterScenarios里硬编码加入构造函数另一种是通过依赖注入的配置列表动态加载。我推荐能用动态加载就用动态加载这样你的代码不需要频繁修改主干文件团队协作时冲突少。4.3 Stage和Task编排场景类搞定后要定义场景内部的状态流转。比如窄道通行场景我设计了三个StageNARROW_ROAD_APPROACHING接近窄道减速并尝试与对向车通信NARROW_ROAD_PASSING实际通过窄道保持低速度必要时借道NARROW_ROAD_CLEARING驶离窄道恢复常规速度每个Stage里会根据需要挂载task比如NARROW_ROAD_APPROACHING阶段除了默认的path和speed决策外我会额外加一个NarrowRoadSlowDownTask用于在进入窄道前强制限制速度上限。task的定义也是继承Task类在Process方法里实现核心逻辑简单点说就是“你在这个阶段想做的一件独立的事”。注意stage切换条件建议写在每个Stage的Process末尾根据处理结果返回FINISHED或ERROR。不要在Scenario层统一判断所有stage的切换那样会让代码又变回一坨全局状态机重复犯老错误。4.4 配置文件与验证场景代码写完必须在配置里把它“点亮”。需要在planning_config.pb.txt里增加一个scenario { type: NARROW_ROAD ... }块并且让配置中的场景类型枚举和代码里的枚举值一致。枚举定义在ScenarioConfigproto里编译时间点就要对应上。验证时先看日志grep scenario /apollo/data/log/planning.INFO确认当前场景名称切换到NARROW_ROAD。然后在DreamView里看车辆行为是否符合预期重点观察自车速度曲线和路径曲线是否在Stage切换点发生突变。如果切换点上有跳变大概率是前后两个Stage的task对速度或路径做出了矛盾决策这就是接下来要修的真正问题。5. 常见问题与排查技巧实录下面这张表是我在实际调试过程中遇到的典型问题很多都是网上文档里不会写的细节。现象常见原因排查思路场景一直停在默认跟随不进入目标场景目标场景的切入条件不满足或者场景优先级低于其他场景打开planning.INFO日志查看scenario_type字段检查路由、信号灯等输入数据是否正常场景在两个状态间来回跳动切换条件缺少滞回约束或者两场景切换条件同时满足且优先级接近给切入/退出条件增加速度阈值、距离阈值等滞回参数检查场景优先级修改了配置但运行没反应配置没有重新编译进二进制或者使用的是远程部署的老版本重新构建planning模块确认cyber节点的启动参数指向的配置文件路径规划轨迹在上一个stage末尾和下一个stage开头突变两个stage的task对同一变量得出了不同结果切换瞬间参考线或边界发生了跳变用planning_vis工具可视化不同stage的参考线和边界在Stage切换处增加平滑过渡逻辑新增task后编译报错找不到类没有把新task加入BUILD文件的deps检查BUILD文件里task对应的deps字段是否包含了新增的目标DreamView看不到新增场景可视化端缓存了旧的地图或场景数据清空DreamView缓存重启DreamView确认使用的地图与场景配置匹配排查的时候我习惯把时间花在日志上而不是反复跑仿真。Apollo规划模块的日志写得相当齐全每一帧的决策结果、场景信息、task执行状态都会打到planning.INFO里。遇到问题先拉日志盯着看几个周期基本能定位到是“没进场景”“进了场景但没走完stage”还是“stage走完了但task输出不符合预期”。另外建议打开--enable_scenario_visualization这类调试gflags把场景切换过程输出成可视化文本配合cyber_monitor查看拓扑消息从感知、定位到路由的链路状态一目了然。那些资料里查不到的疑难问题往往是环境问题而不是代码问题。比如channle打不通、node运行报namespace错误、账号没有写权限导致配置保存失败这些问题不是Apollo规划算法本身的问题但确实会卡住你大半个下午。我的经验是排查顺序永远先从底层向上层走先确认通道可用、数据在流、权限没问题再去纠结决策逻辑哪一行写错了。底层通了问题大概率收敛在业务代码那一小块效率会高很多。6. 关于场景调试最后分享一点我的体会做自动驾驶规划模块这些年我最深刻的感受是一辆车的决策系统稳不稳决定性因素不在某一两个算法的优劣而在于整个框架是否能把复杂问题拆得足够清晰。Apollo的Scenario机制给我最大的启发就在这里——它用“场景—阶段—任务”的层次把决策问题按照空间和语义切分成了不同粒度每个层次只解决自己该解决的问题不越界、不重叠。如果你正准备改造Apollo的规划逻辑我的建议是从改配置、看日志、调阈值开始先完全理解现有场景的切换逻辑再动手新增场景。不要一开始就追求改动很大的“架构优化”而是沿着框架已有的范式把一个具体场景做得扎实。等你亲手把一个小场景从配置到代码到验证完整跑通一遍之后再看那些设计文档和架构图会有一种“原来这里为什么这么写”的顿悟感。这个部分后续还可以继续扩的方向也很多多场景并发时的仲裁机制、场景切换与轨迹平滑的衔接、感知不确定性条件下的场景置信度评估……每一条都是可以深入研究的好问题也都有现成的代码可以动手改。希望这篇拆解能帮你省下一些摸索时间少踩几个我踩过的坑。