简介:东南大学RedSun团队在机器人救援仿真国际赛中的智能体仿真代码,面向人工智能、机器人技术与多智能体系统研究者,适合备战救援仿真竞赛的队伍参考。压缩包共918个文件,以146个Java源文件、147个编译后的class文件及411个HTML工程文档为主体,辅以配置、日志和少量依赖库,整体仅6.18MB,便于在仿真环境中快速部署。代码贯穿多智能体协同任务分配、路径规划、环境感知与实时决策等关键技术,覆盖通信协议、冲突解决、基于角色的协作模型,并包含对主要仿真平台的接口与场景建模。完整呈现了从传感器信息处理到搜索救援策略执行的工程链路,同时提供性能评估与算法优化思路,可迁移至实际灾难救援、智能交通、安防等领域。目前已有1121人学习,适合希望深入理解救援仿真框架并开展二次开发的研究者与竞赛选手。
1. 把「RoboCup救援仿真国际赛代码」拆明白:它究竟在赛什么、写什么
很多人第一次听到「RoboCup救援仿真国际赛」,会下意识以为这是一个机器人硬件比赛,或者是一场需要提交「完整救援系统」的软件大作业。实际完全不是。这个赛项的核心,是让参赛者在一套灾后城市仿真环境里,写出一组能自主决策的智能体(Agent)程序,让它们在限定的仿真时间内尽可能多地救助被困者、扑灭火灾、打通道路。比赛最终只认一个指标:仿真结束时系统打出的分数。我当年第一次碰这个题目时,最颠覆认知的一点是——你写的代码不是「救人程序」,而是「每个决策周期都要替虚拟世界的救援队员做选择的决策器」。本文就按这条主线,把某高校队伍的这套救援仿真代码从机制、架构、写到参数调优,最后讲清楚那些不看代码根本不知道的坑。
2. 救援仿真赛的机制与代码边界:先搞懂内核怎么给分,再谈写代码
2.1 三类智能体与计分逻辑:为什么救护车、消防队、警队不是各自为战
RoboCup救援仿真里,参赛队伍控制的是一支完整的救援力量,通常分为三类角色。我习惯用一张表去对应各自的行为边界:
| 智能体类型 | 核心能力 | 在比赛中的职责 | 计分权重倾向 |
|---|---|---|---|
| Ambulance Team | 将受伤被困者运送到医院 | 搜索伤员、救援、转运 | 被困者存活数量直接影响基础分 |
| Fire Brigade | 对燃烧建筑灭火 | 控制火势、防止建筑倒塌 | 建筑完好与火烧面积相关 |
| Police Force | 清除道路阻塞、修复通道 | 为其他智能体打通移动路径 | 间接影响全局救援效率 |
这听起来各自分工明确,但如果代码真按「救护车只管救、消防队只管灭」的方式写,比分一定垫底。原因是计分函数里面存在大量耦合:道路被废墟堵住,救护车就算发现了伤员也过不去;火势蔓延到医院附近,救护车转运途中把伤者送进火场,分反而倒扣。我一般会把这三类智能体看成「同一个决策大脑下的不同执行器」,而不是三个独立程序。代码真正的难点不是在某个智能体内部做局部最优,而是怎么让三类智能体在信息受限的情况下共享态势、互相配合。
这里有一个每条代码分支落地前都要想清楚的问题:比赛运行在固定的仿真地图上,地图与伤亡分布在一次运行中是固定的,不同队伍的成绩比的是「同样灾难下谁救得更多」。所以代码里做的每一个优先级判断,本质上都是在和计分公式博弈。优先救谁、先灭哪个建筑、哪条路值得优先清障,这些都必须能落到数值上,例如「HP衰减速度」「火势增长斜率」「路径长度」,否则后面写的逻辑全是玄学。
2.2 仿真内核的Cycle与感知API:代码每帧该在哪一步做决策
救援仿真内核的运行方式和游戏引擎类似,但时序是离散的,一般叫Cycle(有的版本叫Tick)。每个Cycle里,内核会推进一次世界状态,然后把「每个智能体当前能感知到的信息」通过API交给你的代码,你的代码必须在规定时间内返回动作,超时该回合动作会被丢弃。
常见做法是,官方内核提供一个抽象类或接口,让参赛队伍实现一个Agent基类。某个版本的Java内核调用逻辑简化后大概是这个结构:
public class RescueAgent extends AbstractAgent { // 每个Cycle开始时,内核会调用这个方法,传入当前世界模型的感知快照 public void think(int currentCycle, WorldModel world) { // 1. 从world中读取该智能体可见范围的信息 // - humanPosition: 被困者坐标 // - buildFirePower: 建筑的火势强度 // - blockedRoads: 当前阻塞的道路列表 Perception myView = world.queryVisibleRange(this.getCoordinate()); // 2. 根据策略选择下一步动作 Action nextAction = decideNextAction(myView); // 3. 动作结果写回,内核在下一Cycle执行 this.applyAction(nextAction); } }这段代码的妙处在于:你的程序并不是连续运行的循环,而是被内核周期性地「唤醒」。这意味着代码里的全局变量、状态缓存、决策树,都必须围绕「下一个Cycle可能没有新消息」这个前提来设计。参数方面,我最先关注的是currentCycle的数值,因为内核会在某些Cycle触发特殊事件(例如新建筑起火、道路二次坍塌),策略需要在特定周期切换。
还有一个非常容易理解错的点:world.queryVisibleRange()返回的不是全局地图,而是「该智能体在当前Cycle能看到的局部区域」。内核通过通信、视觉范围、已知信息三种方式让智能体获取数据。如果代码把局部感知当成全局真相,后面所有决策都会错位。这就是救援仿真里最经典的「黑匣子问题」——信息不全,且你永远不知道信息缺在了哪一块。
2.3 信息不完整与通信限制:黑匣子问题为什么是比赛的第一考题
敢直接说它是第一考题,是因为几乎所有新手队伍第一次跑完整仿真,都会发现一个怪现象:明明自己队伍的策略逻辑无敌,分数却比简单策略还低。原因常常不是决策算法弱,而是智能体掌握的信息是错的或滞后的。内核模拟了真实救灾的通信约束:一个智能体发出的消息,只有在一定通信半径内的队友才收得到,且通信信道有带宽上限和延迟。
所以代码架构里必须有一个「信息管理模块」,通俗讲就是Agent的记忆系统。每条消息都要带时间戳,因为地图里建筑着火状态是动态的,收到一个15个Cycle之前「火已扑灭」的消息,和没有这条消息一样危险。我一般会让每个智能体维护一张增量的世界状态表,每次收到消息就更新对应信息的时间戳,决策时只信任时间戳新于某个阈值的信息。
下面这段代码展示了通信消息的去重与时效处理:
# 用伪代码描述消息管理逻辑 class WorldMemory: def __init__(self, max_age=10): self.records = {} self.max_age = max_age def update(self, message): # 每条消息带有实体ID、属性、值、时间戳 key = (message.entity_id, message.attr) # 只接受时间戳更新的信息,旧信息直接丢弃 if key not in self.records or self.records[key].timestamp < message.timestamp: self.records[key] = message def query(self, entity_id, attr, current_cycle): msg = self.records.get((entity_id, attr)) # 如果超过max_age没更新,视为未知 if msg is None or current_cycle - msg.timestamp > self.max_age: return None return msg.value参数max_age在比赛调试中非常关键。设太小,真实有效的信息会被过早遗忘;设太大,过时信息会造成决策抖动。我常用的经验值是在10~20个Cycle之间,如果地图尺寸小、道路通畅,可以调低;如果地图大且通信中断频繁,需要调高。
信息问题处理完之后,下一个核心问题是:这些信息给到决策器后,决策器该怎么跑。这就引入代码架构的搭建思路。
3. 把项目代码拆成可维护的四层架构:从启动入口到智能体决策
3.1 目录结构与模块职责:仿真内核、场景配置与Agent插件怎么分层
先给出一份典型目录结构,它不是我凭空设的,而是多个比赛队伍方案里最常见的组织方式。它分四层:启动配置层、内核交互层、决策逻辑层、工具库层。
src/main/java/ ├── config/ │ ├── ScenarioConfig.java # 读取地图与救援参数 │ ├── AgentConstants.java # 通信频率、路径规划参数等 ├── kernel/ │ ├── AgentLauncher.java # 启动入口,注册智能体类型 │ ├── PerceptionHandler.java # 封装与仿真内核交互的消息读写 ├── decision/ │ ├── AmbulanceStrategy.java # 救护车决策 │ ├── FireBrigadeStrategy.java # 消防决策 │ ├── PoliceStrategy.java # 清障决策 │ ├── TaskAllocator.java # 目标分配器 ├── util/ │ ├── PathPlanner.java # A*寻路 │ ├── MessageBus.java # 通信消息编码与解析这个结构要解决的核心问题,是让你的代码在赛事期间可以快速迭代策略,而不需要每次改动都牵连到内核交互。竞赛准备期非常忌讳把策略直接写在AgentLauncher里,因为临场换策略时你会发现自己根本不敢改代码,一改就影响全世界。把交互和决策硬隔离,是这项日子的第一层后悔药。
内核交互层最值得注意的类是PerceptionHandler。它把所有消息类型收敛成几个统一接口,比如getBuildingState()、getHumanState()、getRoadState()。这样做带来的直接收益是:后面换地图调试时,你不用对着不同地图的实体ID改写业务逻辑,只需要在配置层替换场景数据。
3.2 读懂一份最小Agent代码:注册、感知、决策、执行
许多入门材料直接让你看完整项目源码,结果一打开就被上千行代码淹没。我建议反过来:先亲手写一个「什么都不做,只会朝第一个看到的目标移动」的最小Agent,跑通比赛仿真,再一步步加决策。因为代码编译调试本身就有门槛,先把通道打通,后面才有心情调算法。
以Java实现的一个最小救护车Agent为例:
public class DummyAmbulance extends AmbulanceTeam { @Override public void think(int cycle, WorldModel world) { // 感知:获取当前坐标与可见实体列表 Coordinate myPos = world.getSelfLocation(); List<Human> visibleHumans = world.getVisibleHumans(); if (visibleHumans.isEmpty()) { // 没有任何可见目标,随机选方向移动探索 Action moveAction = new MoveAction(myPos, exploreDirection()); sendAction(moveAction); return; } // 找到最近的一个可见被困者 Human target = visibleHumans.stream() .min(Comparator.comparingInt(h -> distance(myPos, h.getPosition()))) .get(); // 决策:移动到目标旁边,准备救援 Action moveToTarget = new MoveAction(myPos, target.getPosition()); sendAction(moveToTarget); } }这段代码虽然简陋,但已经包含了救援仿真Agent的标准四步:感知、决策、执行、发送。很多新手栽在最后一步:写完了决策逻辑,忘了调用sendAction(),结果整个仿真周期里智能体完全不动。建议刚跑通代码时,先在think()第一行加一条日志输出,确认每个Cycle都有进来,再逐步加策略。
这里有个需要注意的细节:world.getVisibleHumans()返回的只是「当前智能体视野内」的实体。在比赛地图上,一般设定为有限半径,比如半径300个仿真单位,超出这个范围的被困者,只能通过队友消息获知。所以最小Agent跑通后,第一个要升级的就是信息共享机制,否则你的救护车永远只救眼前人。
3.3 事件驱动的竞赛框架:你写的其实是「每tick回调」而非启动程序
这一点看似只是编程范式问题,但它直接决定你代码能撑到多复杂。救援仿真赛的代码,本质是事件驱动风格:内核发一个世界状态快照,你的代码返回一个动作序列。没有事件驱动的概念,就会写出一个巨大的while(true)循环,这在仿真里根本行不通——因为内核不会等你跑完一整轮逻辑,它只等你的返回值,超时就跳过。
所以,代码内部的决策模块最好也做成「有状态的事件驱动」。每个智能体维护自己的状态机:空闲、前往目标、执行动作、等待、返回。再抽象一点,用一句话概括:你的程序是「每tick回调」的集合,而不是传统意义上的一个完整应用。这个认知转变,是能够看懂比赛源码、进行优化和调试的分水岭。
状态机的典型实现用整型常量就好,不需要引入过重的框架。例如救护车的状态定义为:0表示空闲、1表示去救援的路上、2表示正在搬运、3表示送往医院。每个Cycle根据当前状态和感知到的最新信息,决定是维持原状态还是跳转。看起来很简单,但这套机制能让代码在应对突发情况时保持稳定——比如任何人都可能收到消息说前方道路被堵,此时决策器需要检查状态并决定绕路还是继续等待。
4. 让智能体真正会「救」:决策模型、路径规划与目标分配的落地参数
4.1 灭火与救援的优先级调度:从贪心到带权目标评估
决策模型是整套代码的灵魂。最朴素的策略是贪心:救护车永远去救最近的被困者,消防队永远去灭最近的火。但这种策略在复杂地图上很快就原地打转,因为救援路径常常被封锁,火势又在蔓延,你今天选中的「最近目标」很可能到明天就是死路一条。
我一般采用带权目标评估函数,为每个候选目标计算一个综合得分,选出最高分执行。公式不复杂:
score = w1 * 时间紧迫度 + w2 * 目标价值 - w3 * 到达代价
关键是要把这几个参数调到一个合理的量纲。时间紧迫度可以用被困者的HP残留百分比表示;目标价值可以根据角色类型改变,比如对救护车来说,被困者数量是价值,对消防队来说,燃烧建筑周围未燃烧建筑的密度是价值;到达代价直接用A*算出的路径长度。
给一个可供直接抄作业的评估函数示意:
def evaluate_target(target, agent, world): # 时间紧迫度:HP越低越该救,归一到0~1之间 urgency = 1.0 - (target.hp / target.max_hp) # 目标价值:救援效用,普通群众1.0,高级角色1.5 value = get_entity_value(target.role) # 到达代价:用A*路径长度估算 path_length = path_planner.path_length(agent.pos, target.pos) cost = min(path_length / 1000.0, 1.0) # 归一化 # 权重可调,这是我在类似地图上常用的初始值 w1, w2, w3 = 0.45, 0.35, 0.20 return w1 * urgency + w2 * value - w3 * cost权重w1、w2、w3不是死的。地图小、建筑密、道路复杂时,w3应该提高,因为路径代价差异大;地图大、被困者分布分散时,w1应该提高,因为HP衰减是全局最硬的约束。调参时不要靠感觉,我一般跑三局不同参数的仿真,把分数记录下来对比,一次调一个权重。
4.2 路径规划在道路图上的实现:A*与道路封锁的失效处理
救援仿真的地图是一张图:建筑是节点,道路是边。智能体的移动不是自由坐标,而是沿道路移动。因此路径规划通常用A*算法在道路图上搜索,边权重是道路长度,遇到道路封锁则把对应边的权重设成无穷大。
一个常见的翻车点是:A*规划出一条路径,因为封锁信息延迟,走到半路才发现路不通。处理方式不是让智能体原地等待,而是健全「路径重规划」机制。我习惯给每条路径设定一个生命周期,比如20个Cycle内没走到终点就强制重算一次。
public class PathPlanner { public List<RoadNode> plan(Coordinate start, Coordinate goal, RoadGraph graph) { // 标准A*:f = g + h,g为已走距离,h为到目标的欧氏距离估计 PriorityQueue<Node> open = new PriorityQueue<>(Comparator.comparingDouble(n -> n.f)); open.add(new Node(start, 0, heuristic(start, goal))); Map<Coordinate, Coordinate> cameFrom = new HashMap<>(); Map<Coordinate, Double> gScore = new HashMap<>(); gScore.put(start, 0.0); while (!open.isEmpty()) { Node current = open.poll(); if (current.pos.equals(goal)) { return reconstructPath(cameFrom, current.pos); } for (RoadEdge edge : graph.getOutEdges(current.pos)) { // 如果道路被封锁,直接跳过 if (edge.isBlocked()) continue; double tentativeG = gScore.get(current.pos) + edge.getLength(); if (tentativeG < gScore.getOrDefault(edge.getTo(), Double.POSITIVE_INFINITY)) { gScore.put(edge.getTo(), tentativeG); open.add(new Node(edge.getTo(), tentativeG, heuristic(edge.getTo(), goal))); cameFrom.put(edge.getTo(), current.pos); } } } return null; // 无路径可达 } }参数方面值得说的是heuristic的选择。在地图复杂度一般时用欧氏距离足够;如果地图有明显的堵车或禁区,则要改用加权曼哈顿距离。我见过不少队伍在A*里加入一个「避让系数」,规划出来的路径会倾向于绕开火灾区域,这对消防队和救护车都很有用。代价是计算时间上升,但在单个Cycle内仍然可以接受。
另一个关键点是reconstructPath得到的路径不能直接用,因为救援仿真中的智能体每一步只能移动到相邻节点。所以路径要切成「当前节点到下一节点」的单步动作,每Cycle只发送一步。这样好处是,遇到突发情况(比如下一节点突然起火)时,只需要临时改一步,不需要重算整条路径。
4.3 通信策略的实战调参:广播频率、信息量上限与时效性
前面讲过通信带宽受限,这意味着「什么消息值得发」本身就是一道决策题。如果三个智能体都疯狂广播自己的所见,很快通信带宽就会被占满,真正关键的救援信息反而传不出去。
通信调参的三个核心变量是:广播频率、单条消息长度、消息时效阈值。我的经验是做优先级分层:
| 消息类型 | 发送频率 | 优先级 | 典型内容 |
|---|---|---|---|
| 被困者发现 | 发现即发,到达后停止 | 高 | 位置、HP、是否需要救援 |
| 火势变化 | 火势变化超过阈值时发 | 中 | 建筑ID、火势等级 |
| 路径封锁 | 发现时发一次 | 中高 | 道路ID、阻塞程度 |
| 任务状态 | 每10个Cycle汇总一次 | 低 | 当前目标、完成度 |
在编码层面,通信消息最好采用二进制或紧凑字符串编码,不要直接传JSON,否则带宽消耗成倍增加。我曾在某次模拟中测试过,同一个消息用JSON传和用紧凑字符串传,可支持的每秒消息量差了近四倍。比赛场景,优先保证单位时间内有效消息量。
时效性参数也值得单独讲一下。收到信息的智能体会把信息写入记忆,但记忆里的信息如果长期没更新,必须有一个过期机制。我给每个实体记录一个last_updated_cycle,在决策时,如果这个时间已经超过了当前时间20个Cycle,就标记为「置信度低」。置信度低的信息不是不能用来决策,而是需要降低它在目标评分中的权重。这样处理比直接丢弃更稳,因为在地图火势大范围蔓延时,旧信息至少代表「那里曾经有价值目标」。
5. 从运行到排名:5个必踩的代码坑与排查方法
5.1 决策超时被内核静默跳过:现象、定位与解法
现象:Log里显示某个智能体连续几十个Cycle没有动作输出,但程序没有报错;看状态,它一直停在上一个坐标。
原因:仿真内核给每个智能体的思考时间有限制(不同赛事版本不一样,常见是单Cycle不能超过N毫秒)。你的决策代码里如果用了复杂的最优化算法或者大量对象创建,就会超时。超时后内核不抛异常,只是静默丢弃该次动作,这在日志里非常隐蔽。
解决方案:在think()入口和出口各记录一次时间戳,计算消耗时长。凡是超过预计时长的分支,都要做剪枝或加缓存。另一个办法是把重计算任务拆到多个Cycle分步完成,而不是在一个Cycle里全部算完。我在项目里习惯加一条启动参数:当检测到单个Cycle耗时超过阈值的80%时,自动降级策略复杂度,比如A*退化到曼哈顿距离直接走。
5.2 火势蔓延模型导致的误判:废墟、燃烧物与范围取值
现象:消防队明明一直在扑灭同一个建筑的火,结果表明建筑还是倒塌了,而且火势呈爆发式增长。
原因:比赛内核的火势蔓延模型里,燃烧建筑的倒塌会让周围建筑同时被火焰波及,扑救权重与建筑结构、风向都有关系。很多队伍的代码判断「火势等级大于某值就该放弃」,但这个阈值如果设得太小,把还未蔓延开的建筑放弃了,后续就全烧起来了。
解决方案:不要把火势等级当成布尔值处理,要把它当成连续量带入评分。我采用的方法是:火势评分公式里同时考虑当前建筑的燃烧程度、周围建筑数量、以及周围建筑平均燃烧程度。当周围大量建筑都在燃烧时,附近刚起火的建筑不要救,直接划为「牺牲区」,把消防力量集中在隔离带方向。
5.3 被困者HP衰减期的救援顺序:先救近的,还是先救生命值低的
现象:队伍发现救护车一直在城市里穿梭,每趟都救到人,但最后统计得分时发现,大批被困者在救援到达前就死亡了。
原因:被压在废墟下的被困者HP衰减不是线性的,而是分阶段的。在初期衰减慢,后期急剧下降。如果代码只按「当前HP比例」选目标,就会优先救了远处的低HP被困者,浪费了大量路径时间,近处的高HP被困者反而拖到危险期。
解决方案:目标评分必须同时考虑HP绝对值和伤残趋势。我的做法是把HP衰减曲线拟合成分段函数:第一段(HP>70%)衰减率低,第二段(30%~70%)中等,第三段(<30%)极高。评分时给第三段的救援价值乘以一个额外权重,同时用路径代价除以该被困者的「预计剩余生存时间」,得出「紧急效率比」。这个值越大的先救。
5.4 全局广播导致通信过载:带宽预算与消息压缩
现象:比赛后半段,队友频繁丢失新消息,一些智能体开始回头去救已经救过的人。
原因:通信信道是一个固定总量的共享资源,全局广播相当于每个智能体都向所有其他智能体重复发送自己知道的一切信息。消息越多,每一条的传递成功率越低。
解决方案:给每个智能体设定一个「每Cycle发送预算」。例如10个Cycle内最多发3条消息,每条不超过256字节。发送前先做内容聚合:同一个建筑的状态更新只发最新值,多个被困者的位置合并到同一个数据包。同时让上报消息带上发送者ID,接收方可以根据ID做去重,避免同一信息被转发多次。
5.5 多次运行成绩方差大:评分函数的随机分量与实验次数
现象:同一套代码连续跑三次,得分分别是1200、800、1100,差距大到没法判断策略改好改坏。
原因:救援仿真地图虽然固定,但初始化的伤病员分布、起火点位置可能带有随机性。另外智能体的探索顺序、消息传递也存在随机扰动。用单次成绩评价策略不是科学方法。
解决方案:固定随机种子。比赛允许在本地调试时固定种子,这样对比两个策略时,看的是同一场灾害下谁表现更好。种子验证出优势后,再换多个种子跑,确认普适性。我个人的习惯是:每个参数组合至少跑5个不同随机种子,取平均分和中位数综合排序。没有这个步骤,你所有的调参都等于掷骰子。
6. 进阶技巧:搭一个离线复盘脚本,把每一局比赛拆成可解释的决策日志
6.1 用日志重放关键时间片:周期、坐标与动作对齐
比赛仿真跑完后,内核会输出一份完整的仿真日志,里面记录了每个智能体每周期看到的和做的。但这份日志直接打开非常难读,因为信息量太大。我一般会写一个离线复盘脚本,把日志解析成结构化表格,再叠加地图坐标,做成可视化的路径重放。
核心代码是解析日志中的动作记录,并按周期对齐:
import csv def parse_simulation_log(log_path): # 日志格式:cycle, agent_id, type, x, y, action, target_id records = [] with open(log_path, 'r') as f: for line in f: parts = line.strip().split(',') records.append({ 'cycle': int(parts[0]), 'agent': parts[1], 'type': parts[2], 'x': float(parts[3]), 'y': float(parts[4]), 'action': parts[5], 'target': parts[6] }) # 按周期和智能体ID排序 records.sort(key=lambda r: (r['cycle'], r['agent'])) return records def find_wasted_cycles(records, agent_id, threshold=15): # 统计该智能体连续没有任何动作输出的周期段 prev_cycle = None waste_start = None for rec in records: if rec['agent'] != agent_id: continue if rec['action'] == 'NONE': if waste_start is None: waste_start = rec['cycle'] else: if waste_start is not None and rec['cycle'] - waste_start >= threshold: print(f"Agent {agent_id} 在周期 {waste_start}~{rec['cycle']} 空转了 {rec['cycle'] - waste_start} ticks") waste_start = None这段脚本能快速暴露两个问题:哪个智能体经常空转、哪条路线反复折返。修决策逻辑时,不需要跑完整仿真,直接看这段重放就能定位到具体的Cycle区间。再把那个区间的决策日志单独拉出来,配合地图坐标看,基本就能还原出当时智能体「为什么做了这个选择」。比如发现救护车在一个十字路口来回走了8个Cycle才转向,多半是路径重规划没有触发,这就直接指向规划逻辑的学习循环有问题。
6.2 用后验分析调参的两个习惯,以及参数全忘的教训
我吃过最大的亏是「参数全忘」。某次比赛前一周,我为了回归一个旧版本,把内存中的全局参数表清空重写,结果所有经过验证的权重都没了,新参数完全凭感觉。后来我养成了两个习惯,第一个是每次调参都在代码里留一段注释,写明这次改动的理由和期望效果;第二个是用文本文件记录所有实验组的参数与成绩对应表——哪怕只是顺手写进提交说明里,都比「等比赛复盘时再回忆」强得多。
第二个习惯是「先看异常,再看分数」。分数是高度聚合的结果,掩盖了中间过程的诸多细节。而离线重放脚本能让你一眼看出智能体在哪个时间片做了错误决策。很多时候,某个Cycle里救护车选择放弃一个极高价值目标,是因为那个目标的信息过期了20个Cycle,而过期阈值设得太低。这种问题只看最终分数完全无感,但一旦定位到具体决策点,修复就是改一行代码的事。
说到底,RoboCup救援仿真代码的打磨是一条漫长的调试链:理解内核给分逻辑、搭好架构、写好决策、调准参数、复盘异常。如果你照着这条线走,至少能绕开我当年走过的那些弯路。希望帮到你。
本文还有配套的精品资源,点击获取