简介:这份 Robocup 仿真救援代码面向参加 RoboCup Rescue 仿真竞赛、希望了解救援机器人自主决策与算法实现的学生和开发者,围绕虚拟灾害场景中的搜索、导航、危险评估与任务执行等核心问题提供可运行的代码参考。压缩包共 43 个文件,以 42 个 Java 源文件为主体,另有 1 个 Java 备份文件,整体约 74KB,体量轻便,适合直接阅读与二次整理;代码按仿真环境、算法、传感器模型、控制、日志评估、配置等模块组织,能帮助读者理解从环境建模到行为控制的完整链路。已有 1579 人学习,说明其在相关课程设计与竞赛入门中具有一定参考价值。读者可从中梳理救援 Agent 的目录结构与模块职责,学习搜索策略、路径规划、目标识别和避障等思路,并借助配置与评估部分搭建可重复的实验流程,为参赛或课题研究提供基础。
1. 仿真救援的第一问:第 87 步你的救护车在干什么
面试官翻你的回放,往往不从算法问起,而是把进度条拖到某一帧,问“这一步你的救护车在干什么”。仿真救援的答辩和面试,考的是你能不能把每一步决策讲成一条可复现的链路:感知到什么、在世界模型里记成什么、谁把任务派给了谁、为什么是这个目标而不是另一个。拿分顺序很固定,先把平台时序和稳定性讲清,这是及格线;再把受限感知下的地图与通信讲透,这是分水岭;最后才轮到任务分配和参数调优,这是加分项。打过赛要答辩的、拿这个项目去面多智能体或算法岗的、要给下一届做培训的,都按这个顺序准备,别一上来就讲你的优先级公式有多优雅。
2. 平台时序:五个角色、一条时间线,谁在等谁
2.1 一个 timestep 里到底发生了什么
仿真救援跑起来,屏幕上是一张城市地图,几十个智能体在动。真正决定分数的,是每一步里那几段固定时序:内核把这一帧的感知分发给所有智能体,智能体在自己的进程里想完,把指令发回去,内核结算,进入下一步。观看器只是把结果画出来,它掉线不影响成绩;智能体掉线,直接出局。
把角色分清楚,后面所有问题都好定位。启动器负责把内核、仿真器、智能体、观看器按配置拉起来,它自己不做决策;内核管时钟、消息路由和存活判定;仿真器管物理规则——火怎么烧、废墟怎么压人、路障怎么堵;智能体是你写的部分;观看器只读。
很多人答辩时把内核和仿真器混着说,面试官一句“那是谁算的”就问穿了。区分方法很简单:凡是“规则”层面的(火势蔓延速度、废墟清除速率、结算公式)归仿真器,凡是“时序和连接”层面的(谁该收到消息、谁还活着、下一步什么时候开始)归内核。
| 角色 | 负责什么 | 出问题时的表现 | 你该不该改 |
|---|---|---|---|
| 启动器 | 按配置拉起进程、分配端口 | 少起一个智能体,队伍人数不对 | 一般只改配置 |
| 内核 | 时钟、消息路由、存活判定 | 心跳丢、掉线、步数不同步 | 不改,只能适配 |
| 仿真器 | 火势、废墟、路障、结算 | 世界和你以为的不一样 | 不改,只能建模 |
| 智能体 | 感知、建模、决策、发指令 | 卡住、超时、乱跑 | 你的主战场 |
| 观看器 | 回放与可视化 | 界面卡、看不到画面 | 只用来排查 |
提示:答辩前把这张表背下来。面试官问“你们系统架构是什么”,照着这张表从上往下说一遍,比画一堆框准确得多。
2.2 面试题:心跳为什么会丢,丢了之后发生什么
心跳是这套系统里最不起眼、也最容易出错的部分。智能体要定期告诉内核“我还在”,间隔通常比一个 timestep 短。内核连续若干周期收不到,就把这个智能体从仿真里移除,之后的感知不再发给它,它发出的指令也没人接。表现就是“车停在原地不动”,而你的日志里可能什么错都没有——因为进程还活着,只是被移除了。
写心跳的关键不是发,而是别被主循环挡住。
import threading, time class Heartbeat(threading.Thread): """独立线程发心跳:主循环被一次重计算卡住时,存活信号仍然能出去""" def __init__(self, sender, agent_id, interval=0.5): super().__init__(daemon=True) self.sender = sender # 已建好的 UDP 发送器 self.agent_id = agent_id self.interval = interval # 取 timestep 的 1/3 左右 self.running = True def run(self): while self.running: # 心跳包只带身份和时间戳,几十字节,不挤占通信带宽 self.sender.send(f"HB {self.agent_id} {time.time():.3f}") time.sleep(self.interval) def stop(self): self.running = False几个参数值得在面试里主动说明。interval取 timestep 的三分之一,太密会跟业务消息抢带宽,太疏遇到一次调度抖动就断。心跳包体要小到可以忽略,绝不能顺手把世界模型塞进去。最关键的是独立线程——本地跑的时候主循环从来没超过 100 毫秒,上了评测机 CPU 被其他进程抢,一次全图寻路卡了 1.2 秒,心跳就断了,车原地站到终局。这类问题属于典型的“本地一切正常、线上全崩”,讲出来面试官会认为你真的跑过东西,而不是只交了份课程报告。
停止时要显式调用stop,别指望进程退出就自动收尾;很多赛题的评测脚本会先杀智能体再读日志,线程没关会留下半截输出,回放对不上步数。
2.3 真题演练:把“每步只做三件事”写死
面试常让你现场讲主循环。别讲成“我用了强化学习做决策”,讲成一条流水线:更新世界模型、决策、发送指令,每段都有时间预算。
def step(self, perception): t0 = time.perf_counter() limit = self.step_limit * 0.6 # 只花 60% 的时间在“想”上 deadline = t0 + limit self.world.update(perception) # 1. 增量更新,不做全量重建 if time.perf_counter() > deadline: self.log("update_over_budget") # 打点,别静默降级 plan = self.decide(deadline=deadline) # 2. 决策,内部所有搜索循环都看 deadline self.send(plan) # 3. 只发指令,不发解释 self.log(f"step_cost={time.perf_counter() - t0:.3f}") return planstep_limit是赛题给的每步时间上限;乘0.6是给消息序列化和通信抖动留余量,剩下 40% 不是浪费,是保险。deadline必须一路传进所有搜索循环里,搜索到点就返回当前最优,而不是“再找两轮”。log那一行别省,掉分的时候这是唯一的依据。
回答这类题有个固定句式很好用:我宁可要一个 80 分的解按时交,也不要一个 95 分的解超时被丢。超时的惩罚通常比决策平庸重得多,这句话能直接把话题从“算法多先进”拉回到“工程上可靠”。
3. 世界模型:感知只有一格,地图靠什么长出来
3.1 三张表,和一个“未知”的概念
智能体的感知是局部的,还有通信半径限制,所以你永远拿不到全图。世界模型要做的事,是把零散、带时间戳、可能过期的观测,拼成一张能寻路、能估价的图。落到实现上就是三张表。
节点表记编号、坐标、是否可通行;边表记起止节点、长度、是否被路障堵住、最后一次被观测到的步数;实体表记建筑的火势、燃烧状态、被困人数、结构强度,以及车辆的坐标、水量、载员、当前任务。
| 表 | 关键字段 | 更新时机 | 常见错法 |
|---|---|---|---|
| 节点 | id、坐标、可通行 | 感知到该节点时 | 把没见过的节点直接删掉 |
| 边 | 起止、长度、是否被堵、last_seen | 经过或遥测到邻接关系时 | 认为边一旦可通就永远可通 |
| 实体 | 火势、被困数、车辆状态 | 每帧感知中出现的实体 | 用旧值覆盖新值,不做时序判断 |
最容易被扣分的是“未知”这一列。没观测到的区域不等于不存在,更不等于可通行。正确做法是保留占位、给高代价,让寻路可以“赌一把但要付代价”,而不是把未知区域当成空白,让车一头扎进去卡死。last_seen是第二个关键——一条 30 步前还通的边,现在很可能已经被废墟堵了。
注意:面试官问“你的地图怎么维护”,如果你只答“用哈希表存”,基本就凉了一半。要答出三张表各自的更新时机和过期策略。
3.2 真题演练:增量更新 + 带惩罚的寻路
这道题常以“给一段代码,说说哪里会出问题”的形式出现。
def relax(self, node_id, neighbors, now): """把一次观测到的邻接关系并进全局图;已存在则只刷新时间戳""" for nid, length in neighbors: key = (node_id, nid) old = self.edges.get(key) if old is None or length < old.length: # 更短的路才覆盖,避免抖动 self.edges[key] = Edge(length, last_seen=now) else: old.last_seen = now def cost(self, edge, now): c = edge.length if edge.last_seen is None: c *= 1.8 # 完全没见过的边,给 80% 惩罚 elif now - edge.last_seen > 30: c *= 1.3 # 过期边,给 30% 惩罚 if edge.blocked: c *= 4.0 # 已知被堵,除非绕行代价更高,否则不走 return c def dijkstra(self, src, dst, now): dist, prev, pq = {src: 0.0}, {}, [(0.0, src)] while pq: d, u = heapq.heappop(pq) if u == dst: break if d > dist.get(u, float("inf")): continue for (a, b), e in self.edges.items(): if a != u: continue nd = d + self.cost(e, now) if nd < dist.get(b, float("inf")): dist[b], prev[b] = nd, u heapq.heappush(pq, (nd, b)) return dist, prev三个惩罚系数是这套代码的核心。未知边乘 1.8,意思是“可以赌,但赌输了别怪算法”;过期边乘 1.3,让模型在旧路和新路之间自然偏向新路;被堵边乘 4.0,通常足以让寻路绕开,但当绕行距离超过四倍时它仍然会选堵路——这时候就需要警察先去清障,这是下一章的话题。
relax里那个if old is None or length < old.length也是常考点。为什么要判更短?因为感知有噪声,同一段路两次回报长度略有差异,如果无脑覆盖,路径代价会来回跳,车辆行为抖得像个醉汉。这类抖动在回放里特别明显,评分不会告诉你原因,只能自己看。
代码里遍历self.edges.items()找以u为起点的边,是为了让逻辑一眼看懂。正式提交必须换成邻接表,否则单步耗时随边数平方增长,地图一大直接超时——这也是最容易出现的性能问题,答辩时主动承认并说明替换方案,比被面试官指出来强。
3.3 面试题:带宽被限死,消息里到底传什么
通信量受限是这题的固定设定:每条消息有长度上限,每个周期能发的条数也有限。答这题要有层次,优先级从高到低。
第一层是意图,也就是“我打算去哪、大概多久到”。这一层解决的是重复派遣:三辆救护车各自闷头冲向同一个被困者,到了发现只需要一个人。第二层是事件,比如“建筑 1024 的火势突然变大”“这条边被堵了”,这些是别人模型里没有的增量。第三层才是原始观测,能不发就不发。
一个够用的消息格式长这样:
TARGET FB3 B1024 ETA=14 T=87 BLOCK R3056 T=87第一行是意图,FB3是消防车编号,B1024是目标建筑,ETA=14表示预计 14 步到,T=87是时间戳防止旧消息覆盖新状态。第二行是事件。两条加起来不到三十字节,一个周期全队广播一遍也扛得住。
面试官最想听的是“传差异不传全量、传结论不传原始数据”这句话背后的判断:你的队友不需要知道你怎么算出来的,只需要知道结论和它什么时候有效。把时间戳带上,是因为消息会延迟、会乱序,没有时间戳的增量等于污染。
4. 任务分配:消防、警察、救护谁先动
4.1 消防的优先级:不是最近的楼,是“还救得回来的楼”
新手写消防,第一版大概率是“找最近的着火建筑,开过去,喷水”。这版能跑,分数很低。原因在于建筑的价值差异极大:同样在烧,一栋小仓库和一栋住满人的楼,救错顺序直接把存活人数打下去。
一个够用又讲得清的优先级公式:
priority = value * (1 + 0.5 * buried) / (1 + eta / urgency)value是建筑基础价值,buried是被困人数,eta是预计到达时间,urgency是“还能撑多久”的估计——火势越大、结构越弱,这个值越小,分母里的eta / urgency就越大,优先级被迅速压下去。直观含义是:越值钱、人越多、越快能到、越快烧完的,越先救。
必须加一个截断:当eta > 预计烧毁时间时直接把优先级置零。救不回来的建筑不要派人,这是评分上的硬道理——派过去的车不但拿不到分,还占用了本来能救别处的资源。面试里主动说这一句,基本能证明你调过参而不是抄了公式。
4.2 面试题:三辆消防车扑同一栋楼,怎么改
这是出现频率最高的场景题。现象描述得很具体:三辆消防车围着同一栋楼喷水,旁边另一栋楼烧没了。问你怎么在有限改动内解决。
本质是缺少冲突检测的贪心。每辆车独立算优先级,算出同一个第一名,于是都去了。修法是引入“投标 + 去重”。
def assign(self, agents, targets, now): bids = [] for a in agents: for t in targets: eta = self.eta(a.pos, t.pos, now) # 含未知与堵路惩罚的行程时间 if eta > t.burn_out_in: # 赶不上就别投标 continue value = t.value * (1 + 0.5 * t.buried) / (1 + eta / t.urgency) bids.append((value, a.id, t.id)) bids.sort(reverse=True) plan, used_a, used_t = {}, set(), set() for value, aid, tid in bids: if aid in used_a or tid in used_t: # 贪心去重,避免一窝蜂 continue plan[aid] = tid used_a.add(aid) used_t.add(tid) return planeta用的是 3.2 里带惩罚的寻路结果,不是直线距离,这一点很关键:直线距离会让车以为五分钟能到,实际被堵路绕了十五分钟。burn_out_in是预估剩余可救时间,用来过滤掉注定失败的投标。排序后贪心去重,一行continue就解决了重复派遣。
面试官可能追问“为什么不用匈牙利算法求全局最优”。诚实答:规模小的时候贪心够用,而且可解释、方便加约束(比如“每栋楼最多两辆车”这种硬规则,匈牙利还得改矩阵)。全局最优解在动态环境里几秒就过期了,收益不如把约束写清楚。
4.3 警察与救护:任务之间是有依赖的
救护车和消防车的目标选择可以互相独立,警察不行。警察的价值不在自己拿分,而在于“把别人走不通的路打通”。一条被路障堵死的边,会让救护车的整条路径失效,车会在路口原地打转或者绕一大圈,等它到了人已经没了。
所以分配要带前置依赖:救护车的任务不只是“去救 B1024”,而是“先等 R3056 被清通,再去救 B1024”。实现上给任务加一个requires字段,决策时先检查依赖是否满足,不满足就把车放在安全位置待命,而不是让它一遍遍重算路径。
顺序上有个大致规律:先清关键路径上的路障(判断标准是“清掉它能缩短多少条现有路径”),再救被困人数多且还能撑住的,最后处理大面积着火但没人被困的建筑。这个顺序不是死的,不同地图的权重不一样,但讲得出判断依据,比背一个固定顺序强。
4.4 回放定位:分数掉了 15%,先看哪三处
被问“你分数从 62 掉到 47,怎么查”,别答“我调了参数”。答排查顺序。
第一看掉线数:有几个智能体在中途被移除,掉一个基本等于全队少一条腿。第二看无效指令比例:指令发给了不存在或已失效的目标,或者重复发同一栋楼的灭火指令。第三看单步耗时分布:不看平均值,看 P95,偶尔一次卡顿就足以让心跳断掉。
这三项都正常,再去看重复派遣率和平均到达时间。前者高说明分配有冲突,后者高说明寻路或路障处理有问题。我在排查时习惯按这个顺序走一遍,比漫无目的地改权重快得多。
5. 失分点:五个最常见的翻车现场
这一章里的五条,每一条都在答辩现场被问过,也每一条都能在回放里找到对应画面。
5.1 把“全图可见”当成前提
错在哪:决策代码里直接遍历所有建筑算优先级,包括从没被任何智能体观测到的那些。
为什么错:感知有范围、通信有半径,没观测到的实体在你的模型里根本不该有确定状态。拿全局真值当输入,本地跑分很高,换一张图或者关掉“调试模式”立刻崩。面试官只要问一句“这个建筑你是怎么知道的”,就答不上来。
怎么答对:所有决策只能读世界模型,模型里每条记录都必须有last_seen。答这题时主动说“我的决策层不允许直接访问仿真器真值,只读三张表”,这句话的分量比调参技巧重。
5.2 单步耗时超标,本地好好的线上被杀
错在哪:本地跑 200 步用了 3 秒,觉得性能没问题,提交后大量步骤超时。
为什么错:本地是独占 CPU,评测机是多个智能体抢核。你测的是平均值,被杀的是 P95。更隐蔽的是内存——世界模型里缓存了每帧的完整感知包做回放,跑几百步后垃圾回收一抖,单步耗时直接翻倍。
怎么答对:所有耗时统计看 P95 和最大值,不看平均;每个循环都带 deadline;日志按步写,别在内存里攒。被问到性能,先报 P95 再报平均,顺序反过来会显得没经验。
5.3 消息按全量广播写,带宽一满全队没消息
错在哪:每个周期把自己的完整世界模型序列化后广播出去,觉得“信息越多越好”。
为什么错:消息有长度和条数限制,超了就被截断或丢弃。更要命的是丢的往往是排在后面的包,而心跳可能正好在后面。于是队友收不到你的状态,你也收不到队友的,大家一起退化成单机。
怎么答对:分层发送——意图优先、事件其次、原始观测基本不发;每类消息定长、带时间戳;心跳走独立通道。答的时候可以现场报一下你的消息平均长度和每周期条数,数字比形容有用。
5.4 任务分配只看距离,忽略“能不能完成”
错在哪:优先级等于“距离最近”,谁近谁去。
为什么错:距离近但赶不上,等于白跑。消防车到的时候楼已经烧完,救护车到的时候人已经没了,这类任务不但零分,还占用了本来能救别处的车。评分不看你跑了多少路,只看结果。
怎么答对:优先级里必须含时间可行性判断,eta超过剩余可救时间就置零。回答时强调“我宁可不派,也不派一个必然失败的任务”,这句话体现的是对评分函数的理解,不是对算法的理解。
5.5 只调参不看回放,分数变得难以解释
错在哪:改了三个权重,分数涨了,但说不清是哪一处起了作用。
为什么错:仿真有随机性——火势蔓延、初始分布、通信丢包都有扰动。单次跑分差异可能纯属噪声,把它当成改进依据,后面必然出现“改了反而降”的情况。
怎么答对:固定随机种子,同一配置跑多局取分布;一次只改一个变量;每局都保存回放和关键指标。面试官问“你怎么验证改进有效”,答“固定种子跑 N 局看中位数和分位数”,比答“我跑了一次涨了 5 分”可信得多。这套做法看着笨,是唯一能让你在答辩时不被问住的。
6. 押题与答题模板:把这段经历讲成一段能过的话
最后一章给的是话术骨架。这类项目在面试里被问到,问题几乎都能归进四类,每类的答法不一样。
| 问题类型 | 面试官想听 | 答题骨架 |
|---|---|---|
| 架构类:“你们的系统怎么跑起来的” | 你分得清哪些是自己写的 | 角色表 → 一个 timestep 的时序 → 你负责的那一段 |
| 调试类:“分数掉了你怎么查” | 你有排查顺序,不是瞎改 | 掉线数 → 无效指令率 → 单步耗时 P95 → 重复派遣率 |
| 设计类:“三辆车抢一个目标怎么办” | 你知道冲突从哪来 | 现象 → 根因(无冲突检测的贪心)→ 投标去重 → 为什么不用全局最优 |
| 反思类:“重来一次你改什么” | 你踩过坑并且记住了 | 具体一个坑 → 现象 → 根因 → 现在的做法 |
反思类这题最容易答砸。别说“我会用更强的算法”,要说具体的一件事。比如我会答:重来一次我第一件事是把心跳和日志做出来,而不是先写决策。我们前两周的分数波动完全是难以解释的,因为掉线了也看不出来,日志里只有成功路径,失败路径全被吞了。补上掉线检测和每步耗时打点之后,才第一次看清“车不动”到底是没派任务、还是被移除了、还是路径算不出来。
还有一个通用技巧:被问到不会的细节,别猜参数,把问题还原成现象。比如记不清通信条数上限,就说“具体数字我记不准,但我们当时是按每周期十几条来设计的,因为超了之后观察到的是队友状态整体滞后两三步”。承认记不清但给出判断依据,比编一个数字安全。
从那以后我每次碰这类多智能体项目,都强制走一遍“先能观测、再能决策”的顺序,先把掉线、耗时、指令成功率三个指标打出来,再谈策略。没有观测能力的调参都是靠猜。希望帮到你。
本文还有配套的精品资源,点击获取