RoboCup仿真救援多智能体系统:感知订阅、灭火决策与回归调参

发布时间:2026/10/31 15:30:00
RoboCup仿真救援多智能体系统:感知订阅、灭火决策与回归调参

简介:Robocup仿真救援代码面向参加Robocup Rescue仿真竞赛的高校学生以及AI、机器人方向初学者,提供一套可直接研读的救援智能体软件实现参考,用于理解虚拟灾害场景下的自主决策、搜索与导航流程。压缩包共43个文件,以42个java源码为主,另含1个java~备份文件,整体约74KB,体量轻巧,适合完整通读而非零散摘取。代码按仿真环境构建、算法实现、传感器数据处理、控制执行、日志评估与参数配置等层次组织,搜索策略、路径规划、目标识别、避障与通信等核心逻辑均可在其中找到对应类,便于顺着调用链理解智能体如何从感知走向行动。已有1579人学习下载,说明其在竞赛入门与课程实践中具有一定参考价值。读者可借此熟悉多智能体协作的代码骨架,并在Webots、Gazebo等仿真平台上做参数调整与策略改造,为参赛或课题复现打底。

1. 灭火队绕圈三周不动:这套 RoboCup 仿真救援代码到底在跑什么

第一次看 RoboCup 仿真救援的回放,最先注意到的不是火势,而是一支灭火队绕着路口转了三圈都没动。城市级多智能体仿真里,Agent 每周期拿到的只是残缺的感知回包和极有限的通信配额,任何一次坐标算错、目标抖动或订阅失效,都会在回放里放大成肉眼可见的原地打转。这套 RoboCup 仿真救援代码要解决的正是这件事:把内核、各物理模拟器、四类现场 Agent 和指挥 Agent 拼成一条能稳定跑完几百步的闭环,让消防、警察、救护在同一张地图上做出不打架的决策。它适合三类人:准备打仿真救援赛需要一套能改的骨架、做多智能体协同研究想要真实的消息约束、或者单纯想找一个消息驱动 + 状态机 + 路网规划都齐全的项目练手。

2. 先把拓扑搞清楚:内核、模拟器与四类 Agent 的启动链路

这套代码最容易让人卡住的地方不在于算法,而在于它是一个多进程系统。很多人把整个包解压完,IDE 里点了个 main 就以为能跑,结果只起来一个孤零零的 Agent,连不上任何东西。所以在改任何一行决策逻辑之前,先把进程拓扑和启动顺序弄清楚,后面省下的排错时间是以小时计的。

2.1 组件分工:kernel 管时钟,simulator 管物理,agent 只管决策

内核是整个仿真里唯一的时间源。它负责推进周期、维护实体注册表、把每个 Agent 的动作转发给对应的模拟器,并且强制限制每个周期内 Agent 能发多少条消息。Agent 的think()不是“想算多久算多久”,它必须在本周期结束前把动作发回内核,超时的动作会被直接丢弃且不一定报错——这一点决定了后面所有优化的方向:先砍单周期耗时,再谈决策质量。

组件进程形态主要职责需要关注的配置
Kernel单进程,监听固定端口周期推进、实体注册、动作路由、消息配额端口、地图路径、最大周期数
GIS独立进程或随内核加载提供路网拓扑、建筑轮廓地图文件路径与格式
Simulator 组每个物理子系统一个进程火蔓延、建筑倒塌、平民状态、路障、移动、救援模块清单文件
Viewer可选,只订阅不参与回放与可视化订阅开关、渲染频率
Agent每队若干个感知、建模、决策、发动作队伍编号、Agent 类型、连接地址

模拟器是拆开的原因很直接:火蔓延、建筑倒塌、平民移动三套物理规律彼此耦合度低,拆成独立进程后可以单独重启某一个而不影响整体。代价是它们之间不直接通信,全部走内核中转,所以内核一旦拥堵,最先表现出来的往往是“火势更新慢半拍”而不是报错。

2.2 编译与启动:顺序错了会得到一堆假故障

源码编译本身不难,难的是很多人先编 Agent 模块再编协议层,结果满屏找不到EntityID、EntityType的红色报错,误以为是包不完整。正确的做法是先编公共协议层,再编各 Agent 模块。

# 1) 先编协议与公共实体层,再编 Agent 模块;顺序反了会因为找不到 EntityID 报一片红 javac -encoding UTF-8 -cp "lib/*" -d build/classes $(find src/adf -name "*.java") # 2) 再编各队 Agent javac -encoding UTF-8 -cp "build/classes:lib/*" -d build/classes $(find src/team -name "*.java") # 3) 内核 + 模拟器组:-m 地图,-c 模块清单,-p 监听端口 bash start-comprised.sh -m maps/kobe.map -c config/module.cfg -p 7000 # 4) 逐队拉起 Agent,-t 队伍号必须和 config 里登记的队伍一一对应 java -cp "build/classes:lib/*" adf.core.Launcher \ -h 127.0.0.1 -p 7000 -t 1 -c config/team1.cfg

参数里有三个是会真正咬人的。-m指定的地图必须和场景描述文件里引用的那份是同一份,用错地图不会立刻报错,而是让 Agent 拿到一批指向不存在实体的 ID,跑到中后期才开始丢动作。-c的模块清单决定哪些模拟器被拉起来,少写一个火蔓延模拟器,现象是“火永远烧不起来”而不是启动失败,非常容易误判成 Agent 的决策问题。-t是队伍编号,两队用同一个编号会互相顶掉连接,日志里表现为“连上又断开”,看起来特别像网络问题。

启动顺序建议固定成:地图/GIS → 内核 → 模拟器组 → Viewer → Agent。反过来先起 Agent 也能跑,但它会不停重连,日志噪音会盖掉真正的错误。

提示:每次重启前先确认上一轮的内核进程真的退干净了。端口被残留进程占着时,新内核要么起不来,要么起来了但 Agent 连到了旧的那个,回放里就会出现“上一局的实体”。

2.3 地图与场景:ID 对不上是最隐蔽的一类翻车

地图文件描述几何和拓扑,场景文件描述初始状态——哪栋楼已经起火、哪个平民被埋在哪个建筑里。两者靠实体 ID 关联。这两份文件如果不是配套的,代码层面几乎不可能提前发现,因为内核只校验 ID 是否存在,不校验语义是否合理。

# 比对地图与场景的建筑物 ID 集合,按你手头文件的实际格式改前缀和列号 def ids(path, prefix, col=1): out = set() for line in open(path, encoding="utf-8"): if line.startswith(prefix): out.add(line.split()[col]) return out m = ids("maps/kobe.map", "B") s = ids("scenarios/kobe.scn", "B") print("地图独有:", len(m - s), "场景独有:", len(s - m)) # 场景独有 = 有实体指向不存在的建筑,初始状态会静默失效

这段检查我一般在换地图后立刻跑一遍。场景独有的数量不为零,就意味着有一批平民或火点根本没被初始化,Agent 开局看到的是一座“过于干净”的城,后面所有调参都是白调。

3. 感知层:从每周期回包到一张能用的世界模型

决策质量的上限由感知决定,这句话在这套代码里是字面意思。内核每周期下发的不是一份完整世界快照,而是“变更集 + 按需订阅”两套机制的组合。搞不清这两者的区别,写出来的 Agent 会时灵时不灵,而且极难复现。

3.1 变更集与订阅:不订阅就拿空数组,还不报错

每周期能拿到两类信息。一类是变更集,告诉你本步有哪些实体新增、变化或消失;另一类是具体属性值,但前提是你之前对那个实体订阅过对应属性。没有订阅的实体去取值,返回的是空数组而不是异常,这是最典型的静默失败。更麻烦的是订阅会随着实体离开视野而失效,所以订阅动作必须每周期重做,不能只在初始化时做一次。

// 每周期第一件事:消费变更集,把新进入视野的实体登记进订阅表 ChangeSet cs = world.getChanged(); for (EntityID id : cs.getChangedEntities()) { if (id.getEntityType() == EntityType.BUILDING) { // 只订阅决策真正用到的属性,订阅面越大单周期耗时越高 world.subscribe(id, Constants.PROPERTY_BUILDING_FIERYNESS); world.subscribe(id, Constants.PROPERTY_BUILDING_TEMPERATURE); world.subscribe(id, Constants.PROPERTY_BUILDING_BROKENNESS); } else if (id.getEntityType() == EntityType.CIVILIAN) { world.subscribe(id, Constants.PROPERTY_HP); world.subscribe(id, Constants.PROPERTY_BURIEDNESS); } }

这段代码的逻辑很直白,但有两个参数需要按场景调。一是订阅的属性集合,范围越大,每周期的序列化与反序列化开销越高,在实体数量上千的地图上这部分能占到单周期耗时的一半以上。二是重订阅的粒度,不要每周期把视野内所有实体全订一遍,只对变更集里的新实体补订、对消失的实体退订就够了。重复订阅在多数实现里是幂等的,所以宁可多订也别漏订,漏订的代价是某个属性一直是默认值,决策看起来“逻辑没错但就是不对”。

视野半径本身是内核配置,不同地图不一样,这意味着“全图规划”在这套代码里根本不成立。合格的写法是局部感知 + 地图静态记忆 + 队伍间信息共享:路网和建筑几何在初始化时读地图文件建好,属于先验知识;火势和平民状态只能靠视野内感知和别的 Agent 告诉你的消息。

3.2 三张表:建筑、平民、队伍各自维护什么

世界模型不用做得很复杂,三张哈希表就够。建筑表存 ID、坐标、火势等级、温度、损坏度、重要性;平民表存 ID、血量、伤害、埋压度、所在建筑;队伍表存编号、类型、位置、剩余水量或载重。每周期根据变更集增量更新,不要整表重建——整表重建是新手最常见的性能坟场。

火势等级是决策的核心输入,它的语义需要背下来,不然优先级会写反:

火势等级大致含义决策上怎么用
0未着火不投入任何资源
1刚刚引燃最高优先级,此时扑救性价比最高
2 至 5持续燃烧高优先级,按重要性排序投入
6 至 7接近烧穿权衡,救不下就果断放弃,避免资源陷进去
8已烧毁不再投入,只作为路障或倒塌风险来源

第六、七级这个区间是最考验策略的地方。新手 Agent 往往一路救到底,结果一支灭火队被一栋注定要塌的楼锁死十几个周期,全城其他地方全烧光了。成熟的写法是给每个目标算一个“预期收益 / 预计耗时”的比值,低于阈值就换目标,而这个阈值必须靠回归跑分来确定,不能拍脑袋。

3.3 距离与 ETA:别用欧氏距离,会差出一个数量级

移动是沿路网走的,两栋楼直线距离再近,中间隔着建筑就是走不过去。用欧氏距离估 ETA,在密集城区会低估两三倍,直接后果是 Agent 觉得“还来得及”,等赶到时楼已经烧穿了。

// 路网最短路估 ETA:初始化时预建一次,之后查表 double d = roadGraph.shortestPath(me.getPosition(), target.getPosition()); // moveSpeedPerStep 从模拟器的移动配置里读,不要写死,不同地图和版本这个值不一样 double speed = moveSpeedPerStep(); int eta = (int) Math.ceil(d / speed);

这里的坑在于预建全图最短路的代价。大图上跑全源最短路会让启动卡住几十秒,看起来像死机。常见做法是只对关键节点——避难所、消防栓、高重要性建筑——建多源最短路,其余目标用 A* 现算,算出来的结果按周期缓存。另一个容易忽略的点是moveSpeedPerStep,它是模拟器的配置项而不是代码常量,一旦硬编码,换地图之后所有 ETA 全错,而错误是渐进的,很难一眼看出来。

4. 动作层:指令怎么发,代价怎么算

感知对了,决策不一定对;决策对了,动作也不一定发得出去。动作层有三个硬约束:目标必须合法、前置条件必须满足、每周期消息配额有限。这三条任意一条没处理好,表现出来的都是“Agent 行为诡异”,但根因完全不同。

4.1 动作集合与前置条件

每个动作都有明确的合法目标类型和前置条件。把动作当成无状态函数来调用,是新手最容易犯的错。

动作合法目标前置条件常见失败原因
移动建筑或道路目标与当前位置路网连通误把平民 ID 当目标传入
灭火建筑与目标同建筑或相邻,水量大于零水量已耗尽仍在发送
救援平民与目标同建筑,埋压度大于零目标已被别的队救出
装载 / 卸载平民同位置,载重未满载重已满仍反复装载
清理路障与路障所在道路相邻路障已被清空
待命无无误以为待命能恢复状态

最后一行值得单独说。待命动作在很多人的直觉里等于“休息回血”,实际上它只是让这个 Agent 本周期不做事,用来省计算资源。指望它恢复水量或血量,会发现数值纹丝不动,然后开始怀疑感知层。这类误解一旦形成,会连带怀疑一整条链路。

4.2 一个可抄的灭火决策循环

下面这段是整套代码里最值得先跑通的部分:感知 → 更新 → 打分选目标 → 执行。结构简单,但每个环节都有可调参数。

@Override protected void think() { // 1) 感知:先消费变更集补订阅,再刷新本地三张表 refreshWorldModel(); // 2) 选目标:只在"正在燃烧且未烧毁"的建筑里挑 EntityID best = null; double bestScore = Double.NEGATIVE_INFINITY; for (EntityID b : world.getBuildings()) { int fiery = world.getFieryness(b); if (fiery <= 0 || fiery >= 8) continue; double tempNorm = world.getTemperature(b) / MAX_TEMP; // 归一化,避免量纲打架 double impNorm = world.getImportance(b) / MAX_IMPORTANCE; double distNorm = world.getRoadDistance(me(), b) / REF_DIST; // 三个权重是主要调参对象:火势、重要性、距离 double score = 0.45 * tempNorm + 0.35 * impNorm - 0.20 * distNorm; // 目标锁定:已经锁定的目标给一个加成,防止每周期来回横跳 if (b.equals(lockedTarget)) score += LOCK_BONUS; if (score > bestScore) { bestScore = score; best = b; } } // 3) 执行:不在目标建筑内就先移动,到了再灭火 if (best == null) { sendRest(); return; } if (!world.getPosition(me()).equals(world.getPosition(best))) { sendMove(best); lockedTarget = best; } else { // 灭火量取"剩余水量"和"该建筑所需"的较小值,不要一次泼光 sendExtinguish(best, Math.min(waterQuantity, requiredWater(best))); } }

逻辑上有三处值得展开。第一处是归一化,温度、重要性、距离三个量纲完全不同,不归一化直接加权,等于让数值范围最大的那个属性单独决定结果。第二处是LOCK_BONUS,这是防抖动的关键:每周期重新打分而不加锁定,Agent 会在两个分数接近的目标之间反复横跳,表现出来就是回放里的原地打转,而且分数越接近横跳越频繁。第三处是requiredWater,一次性把水全泼在一栋楼上,在火势已经被压制的情况下是纯浪费,后面真正需要的楼就没水了。

参数上,三个权重不建议一起调。固定距离权重,先扫火势和重要性的比例,观察总分变化趋势,找到平台区后再微调距离权重。REF_DIST这个参考距离要按地图尺度设,设小了距离项几乎不起作用,设大了所有目标都被距离压死,Agent 会只救眼前那几栋。

4.3 通信:每周期只有几条消息,先算价值再发

指挥 Agent 的价值全靠消息体现,但内核给每个 Agent 每周期的消息配额极少,超出部分直接丢弃,连异常都不抛。这意味着通信必须做价值排序,不能“看到什么就上报什么”。

// 每周期最多发几条,先按信息价值排序,超出的直接截断 List<Message> outbox = new ArrayList<>(); outbox.add(buildFireAlert(newFire)); // 新发现的火点,价值最高 outbox.add(buildCivilianAlert(buriedGroup)); // 被埋平民聚集点 outbox.add(buildBlockadeAlert(cutRoad)); // 主干道被切断 outbox.sort(Comparator.comparingDouble(Message::getValue).reversed()); int quota = world.getMessageQuota(); // 从内核配置读,不要写死 for (int i = 0; i < Math.min(quota, outbox.size()); i++) { sendTell(outbox.get(i)); }

价值函数我一般用“影响面 × 时效性”两项相乘。火点的影响面是周边建筑的重要性之和,时效性是距离烧穿还剩多少周期;两者相乘,既排除“重要但已经救不回”的目标,也排除“能救但不重要”的目标。实战里比单纯按距离排序有效得多,因为距离排序会让远离城中心的队伍永远在报无关紧要的小火。

5. 避坑与排查:启动失败、乱跑、分数对不上这三类问题

排错最怕的是没有分层。这套系统的错误会沿着“内核 → 模拟器 → 感知 → 决策 → 动作”一路传导,最终现象往往出现在最末端。我的习惯是先确定故障发生在哪一层,再往下钻,不然容易在决策代码里找一个下午,最后发现是模块清单少了一行。

5.1 启动阶段:连不上、连上就断、Agent 数量对不上

现象:Agent 日志停在连接中,内核端看不到新连接。原因几乎都是端口被上一轮没退干净的进程占着,新内核要么没起来,要么起来了但 Agent 连到了旧的那个。解决办法是先确认端口占用情况并清掉残留进程,再按 地图 → 内核 → 模拟器 → Agent 的顺序重来。这个坑在反复调参的阶段每天都会踩,建议直接写进启动脚本里做一次检查和清理。

现象:Agent 连上后几秒内被断开。原因是队伍编号或 Agent 类型与内核登记的配置不匹配,两队用同一个编号是最常见的。检查配置里登记的队伍数量和编号范围,逐个核对启动命令的-t参数。注意这个错误在部分实现里不打日志,只能从“连上又断”这个模式反推。

现象:只有一部分 Agent 收到感知,另一部分始终是空世界。原因是订阅没做或者做漏了,或者 Agent 数量超过了内核允许的连接上限。前者表现为“部分属性一直是默认值”,后者表现为“最后启动的那几个队完全没数据”。两种现象的区分方法很简单:看空世界的那几个队是不是最后启动的。

注意:模块清单里少写一个模拟器不会导致启动失败,只会让对应的物理过程永远静止。火不蔓延、路障不清除、平民不移动,都属于这一类。排查时先确认模块清单,再怀疑代码。

5.2 运行期:原地打转、不灭火、跑到一半折返

现象:Agent 原地不动,回放里绕圈。三个可能原因按概率排序:目标每周期重算导致抖动、移动目标传了不合法的实体类型、路网最短路算出来是无穷大(目标不可达)却没做兜底。前两个改代码,第三个必须加可达性判断,否则 Agent 会一直朝一个永远到不了的目标走。

现象:到了目标建筑但不灭火。检查三件事:水量是否为零、目标是否真的与当前位置相同或相邻、火势等级是否已经到 8。第三点特别隐蔽,因为 Agent 的判断发生在上一周期,等它赶到时楼已经烧穿了,此时发灭火动作是无效的。

现象:跑到一半突然折返去别的地方。这就是目标抖动,两个候选目标分数接近时每周期来回切换。解决办法是加锁定加成和最小锁定周期,锁定期间除非出现明显更优的目标,否则不换。

现象:某个周期之后所有 Agent 集体停摆。大概率是某个 Agent 的think()抛了异常并被外层吞掉,整个队伍卡在异常状态。加一层兜底:捕获异常后至少发一个待命动作,保证周期能继续推进,同时把异常栈打到日志里。

5.3 分数与回放:本地好好的,提交就掉分

现象:本地跑出来的路径和提交后回放不一致。用了随机数却没固定种子,或者代码里读了本地缓存的地图数据。仿真类项目对可复现性要求极高,所有随机源必须统一种子,所有外部文件读取必须走配置路径,不能有“本地有这个文件所以能跑”的隐式依赖。

现象:本地分数高,提交分数低一大截。常见两个原因:单周期计算超时导致动作被丢弃,或者依赖了某个只在本地存在的中间产物。前者要盯单周期耗时,后者要在一台干净环境里从头跑一遍完整流程。

现象:分数波动极大,同一个配置跑两次差很多。说明策略对初始条件过于敏感。这种情况不要继续调参,先把固定种子下的多次运行结果分布打出来,看是均值低还是方差大——两者的优化方向完全相反。

6. 从能跑到能打:固定种子回归与参数扫描

跑到能完整跑完一局之后,真正拉开差距的是回归方法而不是某一个巧妙的启发式。我一般做四件事。第一,给每个 Agent 打结构化日志,每周期记录当前目标、动作、打分、耗时,落成一行 JSON,方便后面用脚本聚合而不是肉眼看回放。第二,所有随机源统一种子,同一地图同一配置连跑多局,统计分数的均值和分位数,只看单局分数会被运气骗得很惨。第三,参数扫描只扫两三个维度,其余固定。灭火策略里火势权重、重要性权重、目标锁定周期这三个最敏感,其余参数先冻住,否则组合爆炸根本跑不完。

参数建议扫描范围观察到的敏感度
火势权重0.3 至 0.6高,超过 0.6 后全城只救最近的楼
重要性权重0.2 至 0.5中,偏低时避难所和主干道周边损失明显
锁定周期3 至 10 周期高,过低抖动,过高错过新火点
参考距离地图对角线的 0.3 至 0.5 倍低,但在大图上会影响目标分布

第四,做 A/B 对比时固定地图、固定种子、固定队伍配置,一次只改一个参数,跑够局数再看分位数而不是均值。这条听起来像废话,但我见过太多次“改完感觉变好了”最后发现是方差。

最后一个具体技巧:把每次运行的分数、参数组合、单周期最大耗时一起写进一张 CSV,攒够几十次之后画个散点图。你会发现分数和单周期最大耗时之间经常有一条明显的负相关——动作被丢弃这件事,从分数上看是“策略不行”,从耗时上看才是真因。

从那以后我每次改完决策逻辑,都强制先跑十局固定种子的回归再谈效果,哪怕只改了一个权重系数。希望帮到你。

本文还有配套的精品资源,点击获取