ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RoboCup 3D冠军代码解析:多智能体感知决策执行与面试考点

RoboCup 3D冠军代码解析:多智能体感知决策执行与面试考点

简介:这份2012年RoboCup 3D仿真足球赛冠军团队(南京邮电大学)的可执行代码包,面向机器人足球仿真、多智能体协同与强化学习方向的学习者及参赛队伍,用于复盘冠军球队的决策逻辑、战术组织与场上协作实现。压缩包共175个文件、约29.13MB,主体是76个rsg球员行为脚本与69个svn-base版本快照,另有5组format、entries、all-wcprops等Subversion元数据,以及5个rb、4个sh构建与运行脚本、1个apollo3d仿真器相关文件和一个so动态库,基本还原了基于Apollo3D平台的工程结构与版本管理痕迹。已有900人学习下载。读者可据此对照冠军代码的模块划分,理解球员感知、决策、执行链路如何具体实现,观察多智能体配合与实时策略调度在代码层的组织方式,并借助脚本与库文件尝试本地复现运行,为自建球队代码、调试仿真环境以及迁移到无人车编队等多智能体场景提供参考。

1. 2012 robocup3d apollo3d 冠军可执行代码:这场考试到底在考什么

很多人第一次接触 3D 仿真足球这条线,是从一份 2012 robocup3d apollo3d 冠军可执行代码开始的:压缩包里是一堆二进制、配置文件、启动脚本和几段日志,双击能跑,但一被追问「这个周期里到底发生了什么」就哑了。它考的从来不是你会不会敲启动命令,而是你能不能把一支多智能体球队拆成感知、决策、执行三层,并说清每一层为什么这样设计、参数为什么取这个值。文档与协议类题目最稳,动作生成和调参最拉分,而真正把人和人区分开的,是你能否用一段跑得起来的代码解释自己的判断。这份资源适合两类人:准备多智能体、机器人仿真方向面试的,以及要复盘一支完整球队架构的。

2. 感知-决策-执行三层架构:三个必考模块的拆法

这套代码的价值不在「冠军」两个字,而在于它是一个闭环完整、周期极短、状态不可完全观测的系统。面试里凡是问架构,最后都会落到这三层上。你只要能把每层的输入、输出、频率和失败模式讲清楚,架构题基本不会再扣分。反过来,最常见的失分是三层混着说:把定位说成决策、把调参说成学习,面试官听两句就会打断。

2.1 世界模型:带噪感知怎么变成可用状态

服务器下发给每个 agent 的东西是残缺的:视觉只在部分周期更新,球和球员的位置带距离相关噪声,角度存在量化误差,身后是视野盲区;身体感知能拿到关节角度和躯干姿态,但同样有延迟。也就是说,这是一个典型的部分可观测问题,agent 手里从来没有真值,只有历史观测序列。世界模型这一层的任务,就是把「我看到了什么」变成「我相信现在是什么状态」。

面试官最爱追问的一句是:为什么不直接读仿真器的真值?答案不是「作弊不好」,而是规则层面就不允许,agent 只能通过协议规定的感知通道获取信息,任何绕过通道的读取在评测环境里都拿不到。这一句答出来,后面才好继续。

具体做三件事。第一是自定位,用场地上的标志线做几何解算,远距离标志线的角度误差会被放大成很大的位置误差,所以要按距离加权、并对多组解做融合。第二是球状态估计,球会滚动、会撞墙、会被球员挡住,遮挡期间必须靠运动模型外推,滚动的速度衰减系数是个必须标定的参数。第三是维护队友和对手的位置记忆,看不见的球员按最后观测加时间衰减保留,衰减太快会导致传球犹豫,衰减太慢会导致撞人。

下面这段是球在单一方向上的一维匀速模型更新,用来把有噪声的位置观测变成平滑的速度估计:

import numpy as np dt = 0.02 # 仿真周期,单位秒,与协议周期一致 F = np.array([[1.0, dt], [0.0, 1.0]]) # 状态转移:位置由速度积分得到 H = np.array([[1.0, 0.0]]) # 观测矩阵:只能看到位置,看不到速度 Q = np.diag([1e-4, 1e-2]) # 过程噪声:位置小、速度大,速度更容易突变 R = np.array([[4e-3]]) # 观测噪声,随观测距离增大应同步放大 x = np.zeros((2, 1)) # 状态向量 [位置, 速度] P = np.eye(2) * 0.5 # 初始协方差,表示一开始什么都不信 def update(z, r_scale=1.0): global x, P R_eff = R * r_scale # 距离越远,观测越不可信 x = F @ x P = F @ P @ F.T + Q K = P @ H.T @ np.linalg.inv(H @ P @ H.T + R_eff) x = x + K @ (z - H @ x) P = (np.eye(2) - K @ H) @ P return float(x[0, 0]), float(x[1, 0])

逻辑上分两步:先按匀速模型把上一周期的状态推到当前周期,再用当前观测做修正,修正力度由卡尔曼增益 K 决定。参数里最需要解释的是Q和R的相对大小:Q的速度项大,意思是「球的运动随时可能被踢变向,我不太信匀速假设」;R随距离放大,意思是「远处的观测不可信」。面试时被问到为什么不用简单差分求速度,答案就在这里——差分会把观测噪声放大成剧烈抖动的速度,而滤波器会把噪声压住。如果你把这段换成粒子滤波,要能说清代价:粒子数一上去,11 个 agent 乘上每个周期都要更新,CPU 预算就不够了。

2.2 决策与执行:角色分配、动作序列与关节控制

决策层要分层,不能一个函数包打天下。通常拆成三层频率:战术层负责阵型和角色,几赫兹到十几赫兹;技能层负责「追球、带球、传球、射门」这些原子行为的切换,二十赫兹左右;关节控制层与仿真周期同步,负责把目标角度变成指令。频率分层的理由很实在——战术决策变化慢,算一次能用好几个周期;关节控制必须每周期都算,否则姿态直接崩。

角色分配是高频考点。静态阵型只是基线,球一移动,整条阵型要跟着平移和压缩,否则前锋和后卫之间会拉出巨大空档。真正的分水岭是动态分配:谁来追球、谁补位、谁盯人。最简单的写法是「谁离球近谁去」,但这会带来一个致命问题——两个 agent 的估计位置来回交替时,追球角色会疯狂抖动,两个人一起冲、一起停。解决办法是代价函数加滞回:除了距离,把当前角色、转身代价、是否越位都算进去,并且给现任角色一个折扣,只有挑战者的代价明显更低才切换。

def assign_roles(players, roles, cost, hysteresis=0.15): """按优先级贪心分配角色;hysteresis 让现任角色有留任优势,抑制抖动。""" used, plan = set(), {} for role in sorted(roles, key=lambda r: r.priority): candidates = [] for p in players: if p.id in used: continue c = cost[p.id][role.name] if p.current_role == role.name: c *= (1.0 - hysteresis) # 现任打折,避免频繁换人 candidates.append((c, p.id)) if not candidates: break _, chosen = min(candidates) plan[role.name] = chosen used.add(chosen) return plan

这段不是最优分配,是实时可用的分配。为什么不用全局最优的匹配算法?因为角色之间带优先级和硬约束(守门员不能去打前锋),强行套通用匹配算法反而要写一堆约束,收益不明显;而且在 11v11、周期几十毫秒的场景下,贪心加滞回的稳定性比理论最优更值钱。参数hysteresis的取值要能解释:取太大,球员赖着不换位,该补防的不补;取太小,等于没有,抖动照旧。

执行层考的是动作怎么落地。技能通常写成关键帧序列:起脚、摆腿、触球、收腿,每帧给一组目标关节角,中间做插值,再交给每个关节的位置环去跟。这里有两个必答点。一是踢球必须提前触发,因为动作有前摇,等你算准了球到脚下再起脚,腿还没摆到位球已经过去了;所以要基于球的状态估计做前向预测,提前若干周期启动。二是关节不能只给目标角,要有反馈,否则被撞一下姿态就歪了,后面的动作全部连锁失效。

class JointPID: """单个关节的位置环:把目标角度逐步逼近,积分项限幅防饱和。""" def __init__(self, kp=1.2, ki=0.02, kd=0.08, limit=0.6): self.kp, self.ki, self.kd, self.limit = kp, ki, kd, limit self.integ, self.prev_err = 0.0, 0.0 def step(self, target, current, dt): err = target - current self.integ = max(-self.limit, min(self.limit, self.integ + err * dt)) deriv = (err - self.prev_err) / dt self.prev_err = err return self.kp * err + self.ki * self.integ + self.kd * deriv

参数说明要能落到现象上:kp偏小表现为关节跟不上关键帧、动作软绵绵;kp偏大会引起超调和振荡,机器人站立时肉眼可见地抖;ki用来吃掉重力带来的稳态误差,但必须限幅,否则长时间偏差会把积分项顶满,一松腿就过冲;kd抑制到达目标时的过冲,噪声大的关节上不能给太大。面试时如果被问「为什么不用力矩控制」,就答这套接口给的是角度指令,力矩层面的控制在仿真里不是必需,且调参成本高、可复现性差。

2.3 真题演练:三道架构题的标准答法

第一题:「给你几十毫秒的周期,你怎么分配计算预算?」答题思路是先分层再定量:关节控制每周期必算,占用最小但不可省;技能层每两到三个周期跑一次;战术层可以更慢,用事件触发(球权变化、阵型破坏)而不是死循环。然后补一句失败模式:如果战术层跑得太频繁,会出现「刚决定传球又改回带球」的反复;如果太慢,反击时整队反应迟钝。这样答,比背一句「分层设计」有说服力。

第二题:「十一个 agent 怎么避免同时追球?」答题思路分三层递进。机制上靠统一分配,每个 agent 只认自己被分到的角色,不自行判断;信号上靠通信,谁拿到球权要广播,拿不到就退到接应位;兜底上靠滞回和距离阈值,最近的人如果还在转身或者摔倒,第二近的顶上。最后补一句:只靠「谁近谁去」在估计误差存在时必然抖动,这是这类系统最常见的翻车点。

第三题:「球被挡住了,你怎么知道它在哪?」答题思路是先承认不可观测,再说外推:用最后一次观测的位置和速度,按摩擦衰减模型推演,同时把不确定性随时间放大,传给下游决策——不确定性大的时候不要传直塞球,改打安全球。这一句往往是加分项,因为它说明你知道估计结果是要带置信度往下传的,而不是一个光秃秃的坐标。

3. 仿真环境与通信协议:送分题区里的三个硬考点

协议和时序类的题目,答对了不加多少分,答错了直接暴露你没跑过这套东西。这一章的内容几乎全部来自实际启动和读日志的经验,光看文档很难有感觉。面试官问这类问题,本质是在确认一件事:你是真的运行过完整对抗,还是只读过介绍。

3.1 服务器-代理架构:一个周期里到底发生了什么

整套系统是典型的服务器加多客户端:仿真服务器维护物理世界,按固定周期推进,把每个 agent 的感知分别打包下发;每个 agent 是独立进程,收到感知后计算,回传指令;服务器收齐指令后统一推进下一步。这个结构决定了三件事,全是要点。

第一,通信是不可靠的。底层用的是无连接的数据报,丢包是常态而不是异常。所以协议设计上,每帧消息都带有效期,过期就丢弃,绝不重传;决策层也必须容忍「这一周期没收到队友喊话」的情况,用上一帧的缓存顶上。面试被问「为什么不用可靠传输」,标准答法是:重传带来的延迟比丢一帧的损失更大,实时系统里过期的数据等于错误的数据。

第二,时序必须对齐。agent 收到的感知是上一周期的快照,你算完发回去,服务器要到下一周期才生效,所以任何动作天然带一拍延迟。做预测的时候必须把这一拍算进去,否则永远慢半拍。这也是为什么踢球要提前起脚的技术原因之一。

第三,进程是隔离的。一个 agent 崩了不影响其他 agent,但会影响整队配合。所以工程上要有心跳和降级:某个 agent 连续若干周期没有回传,其余 agent 要在角色分配里把它当成不可用,临时补位。这条经常被忽略,但一问「你的队伍怎么处理单个球员卡死」就能区分出有没有真跑过。

3.2 可执行代码怎么落地:配置、启动与日志

拿到一份可执行代码,第一件事不是改代码,是把环境和日志跑通。目录结构通常是:服务器可执行文件、agent 可执行文件、每支队伍的配置文件、监控工具、以及对抗产生的日志。配置文件里放的是阵型参数、技能阈值、PID 增益、通信端口这些可调项,改行为优先改配置,不到万不得已不动二进制。

# 先起服务器,指定端口和日志目录,输出重定向便于排查启动失败 ./sim_server --port 3100 --log-dir ./logs > logs/server.out 2>&1 & sleep 2 # 等服务器就绪,起太快 agent 会连不上 # 再拉起两支队伍,每个 agent 独立进程、独立日志 for i in $(seq 1 11); do ./agent_bin --host 127.0.0.1 --port 3100 \ --team A --number "$i" \ --config ./config/team_a.cfg > "logs/a_$i.out" 2>&1 & done # 全程录制,赛后按周期回放,定位是哪一拍做的错误决策 ./monitor --host 127.0.0.1 --port 3100 --record logs/match.rcd

参数说明和排查顺序要能背下来。--port必须三处一致,端口错是最常见的「连不上」原因;--number是球员编号,重复编号会导致两个进程抢同一个身份,现象是行为诡异但日志不报错;--config指向的配置文件里如果有拼写错误,很多实现是静默取默认值,表现为「改了参数没反应」。启动失败先看服务器日志有没有绑定端口失败,再看 agent 日志有没有握手超时,最后才怀疑代码逻辑。这个排查顺序在面试里说出来,比任何架构图都有说服力。

日志的价值在于复盘。每个周期记录:世界模型输出的球位置和速度、当前角色、当前技能、下发的关节指令。有了这四列,任何一次失误都能定位到是估计错了、分配错了还是执行没跟上。血泪经验是:不记录世界模型输出的队伍,调参基本靠猜,改完一个参数分数掉了也说不清原因。

3.3 真题演练:协议与时序题

第一题:「你的 agent 收到的球位置是旧的吗?旧多少?」答题思路是把链路拆开算:服务器采样时刻、打包传输耗时、agent 处理耗时、指令回传、服务器下一周期生效,逐段给出量级,最后给结论——通常是一到两个周期。关键是承认延迟存在并给出补偿手段(前向预测),而不是含糊地说「很快」。

第二题:「队友通信丢包了怎么办?」答题思路分三层:数据上带时间戳和有效期,过期即弃;逻辑上对关键信息做冗余,比如球权状态连续广播三帧;降级上定义无通信模式,退回纯本地感知加默认阵型。最后补一句:不要把关键决策完全押在通信上,否则一次丢包就是一次失位。

第三题:「怎么判断一次对抗是参数问题还是代码问题?」答题思路是先看可复现性:固定随机种子重跑,如果现象稳定复现,多半是参数或逻辑确定性错误;如果每次都不一样,先怀疑时序和通信。再看日志分层:世界模型输出如果已经错了,问题在感知层,别去调 PID。这个「先定位层、再定位参数」的顺序,是这类题最想听到的。

4. 调参与学习:从手工参数到自动搜索的四个考点

这支球队的分数有很大一部分不在代码里,而在几十个阈值和增益里。面试官问调参,不是想听你罗列算法名,而是想知道你有没有形成「指标—实验—结论」的闭环。答这类题的通用结构是:先定义评价指标,再控制变量,再谈搜索方法,最后说怎么防止过拟合到某一个对手。

4.1 手工调参:为什么改一个参数会拖垮整支队伍

参数是强耦合的。举几个真实存在的连锁反应:把追球的距离阈值调大,追球的人变多,队形被拉散,防守空档变大,丢球数上升;把传球的力量上限调高,短传变成半长传,接球成功率下降,控球率反而掉了;把关节的kp调大让动作更干脆,落地冲击变大,摔倒次数上升。所以单参数扫描往往得出错误结论——你看到的变差可能不是这个参数造成的,而是它牵动了别的模块。

正确的做法是分组调。第一组是感知参数:噪声缩放、标志线权重、记忆衰减时间。第二组是战术参数:阵型宽度、压迫距离、角色滞回系数。第三组是执行参数:PID 增益、动作触发提前量。每次只动一组,其他组冻结,用固定种子跑足够多的场次,记录净胜球、控球率、射门次数、摔倒次数四个指标。只看胜负是不够的,胜负的方差太大,一场比赛说明不了问题。

还有个容易被忽略的点:参数的适用范围。在对手弱的时候调出来的高压迫参数,遇到强队会被打身后。所以调参必须带对手多样性,至少两类:一类是站桩型,一类是反击型。这一点答出来,说明你理解泛化,而不是只会在一个对手身上刷分。

4.2 自动调参:遗传算法、CMA-ES 与强化学习各自的位置

先说结论:参数少、维度低、评估便宜,用随机搜索就够;维度中等、参数连续、评估有噪声,用进化策略类方法;要学的是策略而不是参数,才轮到强化学习。这个排序能答出来,比背三个算法名有用得多。

随机搜索的优势被严重低估。在十几个连续参数的空间里,随机采样在相同评估预算下经常打得过精细的网格搜索,因为网格在高维下会爆炸,而随机搜索每个维度都在有效探索。它的实现成本几乎为零,也最容易复现。

import random def evaluate(params, matches=8): """跑若干场对抗,返回平均净胜球;真实实现里要固定对手和随机种子。""" total = 0.0 for _ in range(matches): total += run_match(params) # 外部调用,返回单场净胜球 return total / matches def random_search(space, rounds=300, keep_top=5): best, best_score, top = None, float("-inf"), [] for _ in range(rounds): cand = {k: random.uniform(lo, hi) for k, (lo, hi) in space.items()} score = evaluate(cand) top.append((score, cand)) top.sort(key=lambda t: t[0], reverse=True) top = top[:keep_top] # 只留最优的几组,便于人工比对 if score > best_score: best, best_score = cand, score return best, best_score, top

逻辑说明:外层是采样,内层是评估,matches决定评估噪声——场次太少,好参数可能被运气埋掉;场次太多,搜索轮数不够。keep_top保留多组近优解,是因为参数之间可能等价,保留备选能在换对手时快速回退。参数空间要给合理上下界,边界给太宽会浪费大量采样在明显不可行的区域。

CMA-ES 的位置:当参数之间存在明显相关性(比如两个增益要一起调才有效),协方差自适应能沿着相关方向搜索,比独立采样省很多评估。代价是实现复杂、每轮要跑一批候选、对评估噪声更敏感。面试被问「什么时候上 CMA-ES」,就答:参数维度上到几十、评估预算允许批量并行、单场结果方差大需要多场平均的时候。

强化学习的位置要摆正。整套系统的周期极短、动作空间连续、奖励稀疏,端到端从零学一支球队在有限算力下不现实。可行的用法是分层:用学习替代某个子技能,比如起身、转身、近距离射门的触发时机,其余部分仍用规则。奖励设计是这类题的必考内容,要点是别只给进球奖励,要加过程奖励(靠近球、面向球门、保持站立),否则前期全是稀疏零奖励,学不动;同时要给惩罚项防止刷分(比如原地转圈刷朝向奖励)。样本效率上,仿真里可以并行开几十个环境加速采样,这一点必须提到。

4.3 真题演练:奖励设计与样本效率题

第一题:「给射门设计奖励,你会怎么写?」答题思路是先分层:进球给大奖励,射正给小奖励,射偏给零或轻惩罚,被断球给惩罚。然后说防作弊:不加朝向类连续奖励,避免原地刷分;进球奖励不要随距离线性放大,否则会出现无脑远射。最后加一句评估方式——看射门转化率而不是射门次数,这一句经常是分水岭。

第二题:「训练不收敛,你怎么查?」答题思路按顺序排查:先看奖励曲线有没有上升趋势,没有就查奖励是否稀疏、有没有正负抵消;再看观测是否包含决策必需信息,缺信息的话再好的算法也学不出来;然后查动作范围是否合理,动作幅度过大导致全是摔倒;最后才怀疑超参。这个顺序的价值在于,它把「调算法」放到最后,因为大多数不收敛不是算法的问题。

第三题:「规则方法和学习方法怎么取舍?」答题思路是看可解释性和迭代成本:规则方法调一个参数能预期影响,出问题能定位;学习方法上限高但黑匣子,出了怪行为很难归因。竞赛场景下通常是规则打底、学习补点,理由是周期短、要可复现。这个回答不得罪任何一方,同时表明你知道边界在哪。

5. 易错点:五类一答就失分的题

第一类,把「可执行代码」当源码讲。错在只描述目录里有几个文件、启动脚本长什么样,把工程说明当成了技术回答。为什么错:面试官关心的是模块边界和决策频率,不是文件清单。怎么答对:拿到任何一份二进制,先讲清它由哪几个进程组成、各自负责什么、以什么频率交换什么数据,再谈文件。

第二类,定位题上来就写卡尔曼。错在没判断问题性质就套公式。为什么错:观测有延迟、有非线性、有视野盲区,直接套线性滤波会把误差算进状态里,越滤越偏。怎么答对:先说清问题属于部分可观测、观测带延迟、噪声随距离变化,再说选什么方法、为什么,最后说怎么验证估计是否可信。

第三类,角色分配答成「谁近谁去」。错在忽略估计误差和切换成本。为什么错:两个 agent 的估计位置交替时,追球角色会高频抖动,整队跟着乱。怎么答对:给出代价函数(距离、转身、越位、当前角色),加上滞回折扣,并说明折扣系数的两个极端后果。

第四类,调参题答「多试几次就好了」。错在没有指标、没有控制变量、没有实验设计。为什么错:胜负方差大,单场结论基本是噪声。怎么答对:先定指标(净胜球、控球率、摔倒次数),再固定种子和对手,一次只动一组参数,跑够场次,最后说怎么防过拟合。

第五类,通信题只答「无连接比面向连接快」。错在只谈性能不谈语义。为什么错:实时系统里,过期的数据比丢包更危险。怎么答对:讲有效期、讲丢包降级、讲时序对齐和一拍延迟的补偿,把「快」换成「时效性优先」。

这五类的共同点是一样的:都在用名词代替判断。名词谁都会说,判断才是分。

6. 答题技巧:把一段跑得起来的代码讲成一条能得分的逻辑链

面试里最吃亏的情况不是不会,而是会但讲散了。我后来固定用一个四步模板,任何一道跟这套系统相关的题都往上套:先定位层,再给输入输出,再说失败模式,最后给验证手段。四步走完,哪怕细节记不清,逻辑链也是完整的。

步骤要说什么常见丢分点
定位层这属于感知、决策还是执行,频率多少三层混着说
输入输出吃什么数据,吐什么指令,周期多长只讲算法不讲接口
失败模式什么情况下会错,错了表现成什么样只讲正确路径
验证手段用什么日志、什么指标证明它对答「跑一下就知道」

拿「怎么提高射门成功率」这个题练一遍。定位层:横跨技能层和执行层。输入输出:技能层吃球和球门的估计状态,输出起脚时机和踢球方向;执行层吃关键帧目标角,输出关节指令。失败模式:估计滞后导致起脚晚、被后卫封堵、力量过大打飞。验证手段:记录每次射门的起脚时刻与球的实际位置偏差、射正率、被封堵率,先看偏差分布是不是系统性偏晚,是的话调提前量,不是的话问题在决策不在执行。这样答完,面试官基本会顺着你的框架往下问,主动权就在你手里。

再补一个押题方向。这套系统里,最可能被延伸问到的是估计与决策的耦合:不确定性怎么往下传。标准答法是——世界模型输出的不只是一个坐标,还要带一个置信度或者方差;决策层根据这个方差改变策略,方差大就打安全球、拉开距离,方差小才做精细配合。能主动说出这一层,说明你不是把模块当成流水线,而是当成带反馈的系统。

还有一个习惯值得养成:任何参数,都要能说出「调大调小分别会看到什么现象」。这是最能体现真跑过的地方。比如角色滞回系数,调大是球员赖位、补防不及时,调小是角色抖动、两人抢球;比如动作提前量,调大是空踢、调小是踢空。这些现象描述比参数名字有用一百倍。

从那以后我每次准备这类面试,都强制走一遍「层—接口—失败—验证」这四步,哪怕是临时被问到的一个小点,也先在纸上画一遍再开口。练熟了之后你会发现,能不能答对取决于细节,能不能答得让人信服取决于结构。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进