☰
仿真救援多智能体协同:灾难前30分钟的资源调度与路径规划
2026/10/31 12:00:17 网站建设 项目流程

简介:面向RoboCup Rescue仿真救援竞赛的Java代码包,适合参赛队员、机器人方向学生及希望了解多智能体决策与救援仿真的开发者,用于搭建可重复的灾害救援实验环境,研究搜索、路径规划、目标识别与避障等核心流程。包内共43个文件,以42个Java源码为主,另含1个Java备份文件,整体约74KB,体量轻便,便于按模块阅读和二次修改。已有1579人学习下载,说明该方向具有一定关注度。代码围绕仿真环境、算法实现、传感器数据处理、控制执行、日志评估与参数配置等环节组织,可帮助读者梳理救援智能体的决策链路,观察策略如何从感知输入落到动作输出,并借鉴日志与评估思路排查问题、对比算法效果,适合作为课程设计、竞赛预研和AI机器人入门实践的参考材料。

1. 仿真救援不是打怪:这套代码解决的是灾难前 30 分钟的资源调度

赛前一晚,我在一台机器上同时拉起地图服务器、仿真内核和六个智能体进程,屏幕上火光一片,消防车却在地图西北角来回打转。它明明收到了起火建筑的坐标,就是不往前走。查了四十分钟才发现:那条街道被倒塌建筑堵死,而我的路径规划还在拿灾前路网图算最短路。

这套仿真救援代码要解决的就是这类问题。它把一座城市丢进一个离散时间步的仿真内核里,让消防队、救护队、警察队三类智能体在有限的视野、有限的载重、有限的时间步里抢分:灭火、救人、清障。适合两类人,一类是想跑通多智能体协同的实验环境,另一类是准备打仿真救援类竞赛、需要一份能直接改的决策骨架。它的价值不在于模型多复杂,而在于整条链路是通的——感知、建模、决策、动作,每一步都能落到代码里。

2. 项目定位与技术栈:2D 仿真救援到底在模拟什么

先把这个项目的边界划清楚。它不是一个渲染精美的游戏,也不是一个纯强化学习训练环境,而是一套「协议客户端 + 世界模型 + 规则决策器」的单机多进程系统。内核负责物理与规则,你的代码只负责一件事:在每一次收到感知数据后,在限定时间内返回一批动作。

技术栈相当朴素:Python 3.8+ 负责任务逻辑,标准库的socket负责和内核通信,heapq实现寻路,json做配置。没有 Web 框架,没有深度学习依赖,甚至不需要 GPU。这种朴素的另一个好处是冷启动快,在本机上同时开几十个智能体进程也不会打满内存。

2.1 三类智能体与世界的拆解

仿真世界里能被感知和操作的实体是有限的几类,把它们记熟比读十遍代码管用:

实体关键字段谁的职责坑点
Building燃烧度、破损度、温度、地面面积、坐标消防队灭火、救护队判断掩埋燃烧度不是 0/1,是分级的,0 到 8 表示从完好到烧塌
Civilian血量、伤害值、坐标救护队装载与后送伤害值随时间增长,救晚了直接判死
Road头节点、尾节点、是否被阻塞警察队清障阻塞是动态的,路网不是静态图
Blockade剩余清障量、占据的单元警察队处理一个路障可能压着平民,优先级要重排
Refuge坐标消防队补水、救护队卸人数量稀少,去晚了排队
Platoon自身位置、水量、载人数、状态全部这是你的智能体本体

三类智能体的能力边界不能混淆:消防队只能对建筑喷水,水量耗尽必须回补给点;救护队一次只能载有限人数,装卸都要消耗时间步;警察队清障是渐进式的,一次动作只能削掉一部分清障量。我见过有人让消防队去搬平民,结果整个时间步都被内核拒绝,白白浪费一轮决策。

提示:感知数据里每个实体都带唯一 ID,但 ID 只在单局内稳定,别把它写进配置或缓存跨局复用。

规则层面还有一条必须记住的硬约束:当被烧毁建筑的地面总面积超过阈值,仿真会提前结束,你的智能体还没来得及动作就被判负。这条规则直接决定了决策的优先级排序——压火势永远优先于救单个平民。

2.2 目录结构:代码分成哪几层

这套代码的分层思路很直接,协议、模型、决策、动作四层,隔一层就不许跨层调用:

rescue-agent/ ├── agents/ # 三类智能体的入口进程 │ ├── fire_brigade.py │ ├── ambulance_team.py │ └── police_force.py ├── core/ │ ├── client.py # 协议层:连接、订阅、收发一帧 │ ├── world_model.py # 世界模型:把感知流解析成对象图 │ ├── planner.py # 路网构建与寻路 │ └── actions.py # 动作封装,统一格式下发 ├── config/ │ ├── team.json # 队伍配置:各类型数量、端口段 │ └── weights.json # 决策权重,调参只动这个文件 ├── scripts/ │ ├── start_kernel.sh # 拉起内核与地图 │ └── run_team.sh # 批量拉起整支队伍 └── logs/ # 每局一份,记录每步决策

core/client.py是唯一碰 socket 的地方,其它模块全部面向对象操作,这样换平台或者换协议版本时只改一个文件。core/world_model.py负责把内核下发的扁平数据转成带索引的字典,是整份代码里被调用最频繁的模块。config/weights.json单独抽出来的原因很实际:调参的时候不想重启进程,把权重外置后改完文件下一局直接生效。

特别说一下agents/下的三个入口。它们不是三个独立程序,而是同一个控制循环套了不同的策略类,通过命令行参数指定智能体 ID 和端口。这样做的代价是入口稍微多几个文件,收益是三类智能体的行为差异全部集中在策略层,不会散落在启动逻辑里。

3. 跑起来:从零编译到第一帧感知数据的完整链路

跑不通这套代码的人,九成卡在启动顺序上。内核、地图、智能体三者有严格的先后依赖,顺序错了要么连不上,要么连上了收不到数据,而且两种错误的表现几乎一样——进程活着,屏幕没输出。

3.1 环境准备与启动顺序

先确认三件事:Python 版本不低于 3.8,本机防火墙允许内核端口段被访问,以及地图和场景配置文件是配套的(版本不匹配时内核会加载失败但不一定打印清晰日志)。

# 1. 安装依赖,只有一个第三方包用于日志着色,其余全是标准库 pip install -r requirements.txt # 2. 先起内核,-c 指定场景配置,-t 指定最大时间步 ./scripts/start_kernel.sh --config config/scenario.json --steps 300 # 3. 等内核打印 "kernel ready" 之后再起地图服务 ./scripts/start_kernel.sh --map-only # 4. 最后批量拉起整支队伍,序号必须和 team.json 里的定义一致 python -m scripts.run_team --team config/team.json --host 127.0.0.1 --base-port 7000

这几个命令背后有个容易忽略的细节:--base-port 7000是起始端口,内核会给每个智能体分配一个独立端口,7000、7001、7002 依次往上。如果你的智能体数量超过 20 个,记得检查端口段有没有和系统其它服务撞上,撞上时的表现是连接被拒绝,但报错信息很难看出是端口冲突。

--steps 300是单局的最大时间步。调这个值会直接影响调试节奏:跑通阶段建议设成 50,二十秒就能出一局结果;调参阶段再放到 300 以上,否则统计样本太小,指标噪声盖过真实差异。

启动后正确的日志长这样:内核打印每步的实体统计,智能体打印「已连接、已订阅、收到第 N 帧」。如果智能体一直停在「已连接」,说明订阅没生效;如果连「已连接」都没有,先查端口。

3.2 连接握手与第一帧感知数据

协议层是最容易被跳过、也最容易翻车的一层。仿真救援的通信是长连接 + 换行分隔帧,客户端连上后要先报身份,收到确认才能订阅,订阅之后内核才会在每个时间步推送感知。少一步都不行。

import socket class AgentClient: """协议客户端:只负责连接、订阅和按帧收发,不做任何业务判断""" def __init__(self, host, port, agent_id): self.host = host self.port = port self.agent_id = agent_id self.sock = None self.buf = "" # 拼包缓冲,TCP 是流式的,一帧可能分两次到 def connect(self, timeout=10): self.sock = socket.create_connection((self.host, self.port), timeout=timeout) # 握手第一步:报上自己的全局 ID,内核据此绑定端口和实体 self._send_line(f"(AK_Connect {self.agent_id})") reply = self._read_frame() if "OK" not in reply: raise RuntimeError(f"connect rejected: {reply}") def subscribe(self, channels): # 订阅决定每帧下发哪些实体,订得越多单帧越大,解析越慢 self._send_line(f"(AK_Subscribe {' '.join(channels)})") def recv_frame(self): return self._read_frame() def _send_line(self, line): self.sock.sendall((line + "\n").encode("utf-8")) def _read_frame(self): # 标准做法是按换行切帧,且必须保留残余缓冲 while "\n" not in self.buf: chunk = self.sock.recv(65536) if not chunk: raise ConnectionError("kernel closed the connection") self.buf += chunk.decode("utf-8", errors="replace") frame, self.buf = self.buf.split("\n", 1) return frame

逐段说清楚。agent_id是内核分配的全局编号,同一个端口上重复握手会被拒绝,所以调试时不要用两个进程抢同一个 ID。subscribe的channels参数我一般传["building", "civilian", "road", "refuge", "self"],早期调试可以只订self和building,帧体积能小一个数量级,方便先看清结构再加。

_read_frame里的buf是血泪经验:不保留残余缓冲,在地图实体多、单帧超过 64KB 时必然出现半包,表现是 JSON 解析偶尔报错,且只在大型地图上复现,非常玄学。用errors="replace"而不是直接decode(),是为了内核偶尔下发非 UTF-8 字节时不会让整个进程崩掉,至少能打日志看到原始内容。

提示:create_connection的超时参数只作用于连接阶段,读数据阶段没有超时保护。生产一点的写法是给 socket 设settimeout,否则内核卡住时你的进程会无限等待。

握手成功后收到的第一帧,建议原样打到日志里看一遍。这一帧的结构决定了后面所有解析代码的形状,比任何文档都准。

3.3 跑通之后先看这三个指标

进程能跑不等于代码对了。第一次跑通,先看三个数,能省掉后面大量排查时间:

第一个是每帧的实体数量波动。世界模型解析出来的建筑数、平民数应该随时间单调下降(烧毁、死亡),如果忽然变成 0,多半是解析器在某个字段上抛了异常被吞掉。第二个是智能体的动作响应率,即内核接受的动作数除以你发出的动作数,低于 90% 说明动作格式或前置条件有问题。第三个是单帧处理耗时,用time.time()掐一下,稳定在 100ms 以内才算健康,超过 500ms 就要考虑优化解析和寻路了。

这三个指标建议做成一行日志,每 10 帧打一次。后面调参时全靠它定位问题。

4. 核心模块拆解:一个消防智能体从感知到动作的完整链路

链路本身很短:收帧、解析成世界模型、挑目标、算路径、下发动作。难的是每一环都有边界条件。下面按执行顺序拆。

4.1 世界模型:把感知流变成可查询的对象图

内核下发的数据是扁平的,你需要的查询是「离我最近且还在烧的建筑」「哪些平民压在路障下面」。这两者之间差一层索引结构,world_model.py就是干这个的。

class WorldModel: def __init__(self): self.step = 0 self.buildings = {} # id -> {fieryness, brokenness, area, pos} self.civilians = {} # id -> {hp, damage, pos, buried} self.blockades = {} # id -> {repaire, pos, cells} self.roads = {} # id -> {head, tail, blocked} self.refuges = {} self.me = {} # 自身状态:pos / water / loaded / hp def update(self, frame): """每帧全量重建,简单但可靠;实体规模大时改成增量更新""" self.step += 1 self.buildings.clear() self.civilians.clear() for item in frame.get("buildings", []): self.buildings[item["id"]] = { "fieryness": item["fieryness"], "brokenness": item["brokenness"], "area": item.get("ground_area", 1.0), "pos": item["pos"], } for item in frame.get("civilians", []): self.civilians[item["id"]] = { "hp": item["hp"], "damage": item["damage"], "pos": item["pos"], # 被压在建筑物下时内核给的标记,救人前必须先清障 "buried": item.get("buried", False), } self.me = frame.get("self", {})

update用全量重建而不是增量更新,是刻意的选择。增量更新省 CPU,但要处理实体消失(建筑烧塌、平民死亡)的逻辑,一旦漏了就会拿到过期对象,排查成本远高于省下的那点时间。等单帧耗时真的超过预算了再改增量,那时候你已经有日志能对比了。

字段里最容易误读的是fieryness。它是分级值,不是布尔。0 表示没火,中间值表示燃烧程度,最高一档表示已经烧塌。判断「要不要去灭」时直接拿它当强度用,不要写成if fieryness > 0,那样会把一处小火和半条街着火同等对待。area同理,它是地面面积,大火烧大楼和小火烧平房对总分的破坏完全不同。

4.2 决策层:目标评分与全源最短路

目标选择本质是个打分问题:火势带来的紧迫性减去过去的路程成本。这个公式简单,但足够干活,而且每一项目标都能单独调。

def score_building(me, bld, dist, w): """返回该建筑的优先级得分,越大越该去""" # 火势强度 × 面积:烧得越狠、面积越大的建筑越优先 urgency = w["fire"] * bld["fieryness"] * bld["area"] # 破损度高的建筑救回来价值低,稍微降权 value = 1.0 - w["broken"] * bld["brokenness"] # 距离惩罚,防止为了灭一处远火跑半个地图 cost = w["dist"] * dist return urgency * value - cost

三个权重的量纲不一样,调参的时候不要一起动。我的习惯是先固定dist,把fire和broken调出一个合理的取舍关系,再调dist控制活动的半径范围。weights.json里存的就是这三项,改完下一局生效,不用重启。

距离从哪来?不能每帧现算最短路,那是 O(n²) 的开销。常见做法是开局时按路网跑一次全源最短路,把结果缓存成二维表,之后每帧只做查表。

import heapq def dijkstra(adj, src): """单源最短路,返回 {node_id: 距离};开局预计算一次,之后只查表""" dist = {src: 0.0} pq = [(0.0, src)] while pq: d, u = heapq.heappop(pq) if d > dist.get(u, float("inf")): continue for v, w in adj.get(u, []): nd = d + w if nd < dist.get(v, float("inf")): dist[v] = nd heapq.heappush(pq, (nd, v)) return dist

adj是邻接表,键是路网节点 ID,值是一组(邻居节点, 权重)。权重取两个节点之间的欧氏距离或道路长度都行,前者够用,后者更准但要多解析一个字段。dist缓存后,查询建筑距离时要先把建筑坐标映射到最近的路网节点,这一步不能省——直接拿坐标算直线距离会让消防车往墙上撞。

路障来了怎么办?路网会变。我的做法是维护两张表,一张静态最短距离表开局算好,一张动态阻塞标记每帧更新;查询时如果最短路上有阻塞,退化成带黑名单的 A* 重算,只在真正需要时才付这个开销。

4.3 动作下发与状态机闭环

决策出目标之后,动作不是一次性发一条,而是要有状态。消防车在「去目标」「喷水」「回补给点」三个状态之间切换,中间被打断要能恢复。

RESERVE_WATER = 500 # 保留水量,低于这个值就回补给点 SPRAY_MIN_WATER = 200 # 低于这个值连一次喷水都放不出来 class FireBrigadeController: def act(self, wm, planner): me = wm.me # 状态 1:水量见底,放弃灭火,先回补给点 if me["water"] <= RESERVE_WATER: refuge = planner.nearest_refuge(me["pos"]) return [("AK_Move", refuge)] if refuge else [] # 状态 2:没有目标或目标已不燃烧,重新选一个 target = me.get("target") if target is None or target not in wm.buildings \ or wm.buildings[target]["fieryness"] == 0: target = planner.pick_fire_target(me, wm) me["target"] = target if target is None: return [] # 没火可灭,站着不动比乱跑安全 # 状态 3:已经贴到目标建筑,开始喷水 if planner.adjacent(me["pos"], wm.buildings[target]["pos"]): if me["water"] < SPRAY_MIN_WATER: return [] return [("AK_Extinguish", target)] # 状态 4:还在路上,继续走 return [("AK_Move", planner.next_hop(me["pos"], target))]

几个参数值得单独说。RESERVE_WATER代表的是保命线,取值要大于SPRAY_MIN_WATER加上返程耗水,否则会出现「跑到目标发现喷不动,也回不去」的死局,这在早期版本里是真实发生过的翻车。SPRAY_MIN_WATER是内核的硬门槛,低于它发动作会被静默拒绝,注意是静默——不报错,只是动作不生效,所以必须自己在客户端侧挡住。

状态 2 里那句「目标已不燃烧就重选」看着像废话,但没有它会出现一种情况:目标被队友先灭了,消防车还在原地继续喷空气,一个时间步就这么浪费了。仿真救援的每一步都是钱,这种空转累积起来非常致命。

next_hop返回的是路径上的下一个节点而不是终点,因为AK_Move一次只能走一格。想一次走远,就得自己维护一个路径队列,每步弹一个出来。

5. 避坑与排查:仿真救援里最容易翻车的五个问题

下面五条都是实际跑出来的,不是文档里抄的。每条按现象、原因、解决写。

5.1 连接与协议类问题

现象一:智能体连上后一直收不到感知数据,进程活着但没有输出。原因是握手后没有订阅,或者订阅的通道名写错了。内核不会因为通道名错误而报错,只是什么都不推。解决方式是把订阅动作和接收动作之间加一行日志,打印订阅请求的原文,同时在内核侧确认该 ID 已进入订阅列表。更省事的办法是订阅后设一个 2 秒超时,超时就打印警告并重发一次订阅。

现象二:跑半小时后偶发 JSON 解析失败,重启就好,大型地图必现。这就是半包问题。TCP 是字节流,一帧可能分多次到达,只按recv的边界切帧必然出问题。解决办法是保留接收缓冲,按分隔符切,切剩下的留到下一次。顺带一提,缓冲区用字符串累加在内核推送频繁时会有性能问题,实体量大时换成bytearray,拼接开销能降一个数量级。

5.2 决策与性能类问题

现象三:所有消防车开局全部涌向同一栋建筑,其它方向的大火没人管。原因是目标选择是各智能体独立计算的,同一个评分函数必然给出同一个答案。解决靠两点:一是给已分配目标加惩罚项,被别的队友盯上的建筑在评分时扣掉一个固定值;二是在队伍层维护一个共享的分配表,由队长进程周期性下发。前者改动小,后者效果好,先上前者验证思路。

现象四:单帧处理耗时从 80ms 涨到 1.2 秒,动作开始落后于时间步。八成是全源最短路算太勤了。检查一下是不是每帧都在调dijkstra。路网在开局和每次清障后才会变化,其余时间应该只查表。另一个常见原因是每帧都在遍历所有平民算最近目标,改成按建筑分组、按距离剪枝后耗时立刻下来。判断标准很简单:把每帧的处理拆成解析、建模、决策三段分别计时,哪段超一目了然。

5.3 崩溃判定与提交类问题

现象五:明明灭了不少火,总分却很低,甚至比不操作还差。先看崩溃判定条件。总分里建筑损毁的权重通常远大于单个平民,压火势的优先级如果排在救人后面,就会出现「救了一堆人但半座城烧没了」的结果。解决办法是在权重表里把建筑损毁项单独提出来,和救人项做一次消融对比,看哪一组在多个地图上更稳定。我的经验是在崩溃线附近必须优先压火,远离崩溃线时再转向救人,做一个基于损毁面积占比的动态切换。

再补一个提交环节的坑:日志里记录的决策和你最终提交的动作之间必须能对上。我吃过一次亏,日志打的是「选中目标 X」,实际下发的是上一帧缓存的 Y,因为状态对象被跨帧复用了。后来改成每帧下发前把最终动作原文打一条,再没出现过。

提示:logs/目录按「地图名 + 随机种子 + 时间戳」命名,别只用时间戳。调参时要复现某局,地图和种子缺一个就白搭。

6. 二次开发:把单智能体改成可协同的编队

单智能体跑通之后,下一步几乎所有人都会想加协同。最省事的切入点不是全局规划器,而是共享目标分配表,因为改动局限在决策层,不碰协议和建模。

6.1 用一张共享表切断重复决策

在core/下加一个轻量的分配器,由队伍里的一个进程兼任,每 N 帧广播一次「谁负责哪个目标」。各个消防智能体在选择目标前先查这张表,被占用的建筑直接跳过。

改造点位置改动量见效速度
目标占用表core/planner.py约 40 行立刻,重复率明显下降
距离动态权重config/weights.json只改数值下一局
状态机加「增援」分支agents/fire_brigade.py约 30 行大火场景明显

这张表有两个必须处理的细节:一是过期,占用它的智能体死了、没水了、改目标了,表项要能超时自动释放,否则会永久锁死建筑;二是争抢,两个智能体在同一帧都想认领时,用 ID 小的优先,比加锁简单得多。

6.2 验证改动是否真的有效

协同改完之后不要凭感觉说「好像好了」。固定地图和随机种子,跑十局取均值,对比改动前后的三个数:建筑总损毁面积、成功后送的平民数、每帧平均耗时。前两个应该上升,第三个不能显著变差。我一般会在config/team.json里留一个baseline_mode开关,关掉就走原来的独立决策,这样 A/B 对比只要改一个布尔值。

最后提醒一条:每次调完权重,先跑 50 步的短局看趋势,趋势对了再跑满 300 步。这个习惯帮我省下过无数次等待——有一次我信心满满地把距离权重调低,短局里火势压得漂亮,满局跑到第 220 步崩了,原因是消防车全跑去了地图另一头,补给点附近烧成白地。

从那以后我每次改决策逻辑,都强制先跑短局再跑满局,两轮数据对不上就不提交。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询