第一次看到“拦截失控机器人”这个标题,我第一反应是:这不就是多智能体博弈里的追逃问题吗。在仓储物流或安防巡检里,假如某台AGV制动失灵、像没头苍蝇一样乱窜,其他机器人要围住它,而不是撞毁它。任务的关键就是“多追捕者捕获多个逃逸者”,既要快速分配“谁去追谁”,又要考虑围捕路线,避免逃逸者从包围缝隙溜走。标题里提到的算法,本质上是在解决追捕者数量有限、逃逸者还可能互相干扰下的协同追捕问题。这篇文章我会把问题建模、核心算法思路、Matlab实现和调参经验一次性讲清楚,看完你可以在自己的脚本里跑起来。
1. 先把问题说清楚:多追捕者如何抓住多逃逸者
1.1 这不是单纯的追逐问题,而是组合调度问题
很多人第一次听到“多追捕者捕获多个逃逸者”,第一反应是“让追捕者各自追最近的目标不就行了”。我在刚开始做这个仿真时也是这么想的,实际跑起来才发现,事情远没有这么简单。
假设有4个追捕者、2个逃逸者。如果每个追捕者都盯着“当前距离自己最近”的逃逸者,很容易出现两个甚至三个追捕者扎堆去追同一个逃逸者,另一个逃逸者完全没人管。逃逸者一旦脱离接触,就会从空隙里溜走。更麻烦的是,逃逸者不是静态目标,它也在根据追捕者的位置实时改变逃跑方向。今天的最近目标,0.5秒后可能就不是最近的了,于是追捕者中途换目标,队伍阵型被打乱,形成“大乱斗”。
所以这个问题的本质不是“怎么追”,而是“怎么分配追击任务 + 怎么协同围捕”的组合调度问题。追捕者数量大于等于逃逸者数量时,至少要保证每个逃逸者都被分配到至少一个追捕者;有多余追捕者时,还要决定把增援派给谁,才能让总体捕获时间最短。
1.2 为什么不能直接套用教科书里的最优博弈解
从数学角度看,追逃博弈可以建模成微分博弈:每个智能体都有一组运动学约束,追捕者试图最小化捕获时间,逃逸者试图最大化存活时间。这是典型的同时决策、动态对抗问题。理论层面确实可以用Hamilton-Jacobi-Isaacs方程去描述,但在实际工程里,这套数学框架很容易被两个现实问题击穿。
第一个是计算复杂度。多智能体追逃的最优策略哪怕是简化成离散时间博弈,解空间的维度也会随智能体数量指数增长。控制器需要实时输出指令,通常只有几十毫秒甚至几毫秒的计算窗口,等不起最优解。第二个问题是信息噪声。工程环境中定位有误差、通信有延迟、速度测量有抖动,理论上的最优策略对这些扰动往往非常敏感。一个小误差可能导致围捕阵型崩溃。
所以工程上普遍采用的是“分层式追捕架构”:上层用匹配算法做任务分配,下层用带约束的运动控制器去执行追击。每一层都不追求全局最完美,但组合起来足够鲁棒,也能满足实时性要求。这也是标题里那个算法最值得学习的点:它把“博弈”拆成了可计算的“分配 + 围捕”两个子问题。
1.3 方案选型:分层式追捕架构
我在这套Matlab代码里采用的分层架构,具体拆成三层:
- 感知与状态估计:每个智能体知道自身位置、速度以及所有其他智能体的位置。实际工程中可以由中心节点汇总后广播,也可以基于分布式估计。
- 目标分配:每个控制周期计算一次追捕者到逃逸者的距离矩阵,用贪心策略或者二分图匹配算法,给每个追捕者指定一个追捕目标。
- 底层围捕控制:追捕者不是笔直冲向逃逸者当前所在位置,而是先计算出围捕点,再跟踪围捕点。围捕点的引入,让多个追捕者可以从不同侧翼接近,压缩逃逸者的逃逸角度。
这套架构的好处是模块清晰,替换任意一层都不会影响其他层。你想把贪心分配换成最优匹配,只需要改一个函数;想把点模型换成带转向约束的车模型,也只需要改底层控制器。在后面的章节,我会按这个顺序把每个模块的实现细节讲透。
2. 算法核心:目标分配与围捕控制
2.1 运动学模型与控制约束
仿真里的每个智能体都用最简单的二阶点模型近似:位置随时间变化,速度直接由控制指令决定。离散化后可以写成:
p(k+1) = p(k) + v(k) * dt这里p是二维位置向量[x; y],v是速度向量,dt是仿真步长。为了贴近真实执行器限制,速度向量需要被限制在最大速度内:
||v|| <= v_max追捕者的最大速度vmaxP通常要大于逃逸者的最大速度vmaxE,否则从纯几何角度很难追上。我代码里取vmaxP = 1.0,vmaxE = 0.75,速度优势不算大,但足以通过围捕策略形成夹击。捕获判定也很直接:当某个追捕者与某个逃逸者的欧氏距离小于等于捕获半径captureR时,认为该逃逸者被捕获。
捕获半径的物理含义很直观:它模拟的是机械臂展开范围、抓捕网兜半径或者“安全制动距离”。这个值设得过大,追捕者的速度优势会被掩盖;设得过小,在离散仿真里可能永远捕获不了。具体怎么定,我在第4章会专门讲。
2.2 目标分配:用匹配算法解决“谁追谁”
目标分配要做的事情,是给每个追捕者指定一个未捕获的逃逸者编号。一个直观的代价函数是“让所有追捕者到其指定目标的距离之和最小”。如果把追捕者和逃逸者看成二分图的两个点集,边的权重就是两者之间的距离,这就是标准的二分图最小权匹配问题,可以用匈牙利算法在线性规划角度上求最优解。
不过在我的代码里,为了减少外部工具箱依赖,使用的是简化版贪心匹配:
- 先按逃逸者编号顺序,为每个未捕获逃逸者找到距离最近且尚未分配目标的追捕者,保证每个逃逸者至少有一个追捕者负责。
- 如果还有剩余追捕者,把它们分配到距离最近的未捕获逃逸者那里,形成多对一的增援。
这样做的结果不一定是全局最优,但胜在稳定、快。在智能体数量不大(比如4到6个)的情况下,贪心匹配的解通常已经足够好。如果你的场景里追捕者数量很多,或者对捕获时间有硬性要求,可以把assignTargets函数里的贪心逻辑替换成匈牙利算法,输入输出接口都不用变。
值得特别提一句的是,目标分配不能每个控制周期都完全重新计算。因为分配结果一旦频繁变化,追捕者会像无头苍蝇一样来回跑。我自己在调试时踩过这个坑:两个逃逸者靠得很近,追捕者每步都在换目标,整个编队呈现“抖动”状态。后来我想到一个简单有效的办法:在代价矩阵中给“上一周期分配结果”加一个小的惯性偏置,比如额外减去一定百分比的距离,追捕者就不会轻易换目标。这个方法本质上是在目标分配的公式里加了一道“阻尼”。
2.3 围捕点计算:别正面撞,去侧翼
如果所有追捕者都直接冲向逃逸者的当前位置,结果往往是谁都追不上。原因很简单:逃逸者看到追捕者从正面来,只需要向正后方逃,就能最大化逃离速度。两个追捕者一前一后追击时,后面那个追不上,前面那个如果没挡住,就会被突破。
正确的做法是让追捕者瞄准逃逸者未来可能被压缩的“侧翼区域”。在我的代码里,getEncirclementPoint函数实现了类似思想。对于某个追捕者i和逃逸者j,首先计算从追捕者指向逃逸者的单位方向向量:
d = (pE_j - pP_i) / ||pE_j - pP_i||然后取这个方向的垂直向量:
perp = [-d(2); d(1)]围捕点就定义在逃逸者位置沿垂直方向偏移一小段距离margin处:
G_i = pE_j + (-1)^i * margin * perp这里的(-1)^i是关键。它让编号为奇数的追捕者从左侧接近,编号为偶数的追捕者从右侧接近,从而在逃逸者周围形成夹击之势。相当于把逃逸者往“背离追捕者”的方向逼,但追捕者又不直接站在逃逸者的逃跑路径上,而是堵住两侧。逃逸者如果执意向前冲,就会一头撞上一个侧翼追捕者;如果减速转向,另一个侧翼追捕者就会趁机靠近。
margin的物理含义是“侧翼围捕点到逃逸者当前位置的距离”。margin设得越小,追捕者的路径越接近正面冲击,围捕效果越弱;margin设得越大,追捕者会绕很远,可能在绕圈过程中丢失目标。我建议取追捕者速度优势的1到2倍作为初始值,再根据仿真效果微调。
2.4 逃逸者行为:朝存活概率最高的方向跑
为了让仿真更接近真实对抗,逃逸者不能站着不动。它采用的策略是“背离所有追捕者的质心方向逃窜”。具体做法是计算所有追捕者位置的平均值,得到虚拟质心,然后让逃逸者向远离这个质心的方向移动。
这个策略非常符合直觉:当一个人被一群人围堵时,最安全的逃跑方向就是让自己和“包围圈中心”的距离拉大的方向。当追捕者从多个方向逼近时,质心会接近逃逸者本身,此时逃跑方向会变得不稳定,但这恰好模拟了包围圈缩小导致逃逸者走投无路的状态。
在更复杂的版本里,还可以用“最大空隙角度”代替质心反向。思路是:把所有追捕者相对逃逸者所在的方位角求出来,找到角度间隔最大的一个缺口,然后朝缺口中心逃。这个策略在追捕者数量多、包围圈成型时,表现得比质心反向更聪明。代码里为了清晰,我保留了质心反向策略,你可以在escapeDirection函数里自行替换成最大空隙算法。
3. Matlab实现:从零开始写出一套可跑模型
3.1 主程序结构:参数、初始化和可视化
我习惯把整个仿真的运行骨架放在一个脚本里,函数全部定义在脚本末尾。这样复制到Matlab里直接运行即可,不需要额外建一堆文件夹。下面这段代码是一个完整的可运行版本,包含了4个追捕者、2个逃逸者的默认场景。
%% 多追捕者捕获多逃逸者仿真主程序 clear; clc; close all; rng(2024); % ---------- 仿真参数 ---------- Np = 4; % 追捕者数量 Ne = 2; % 逃逸者数量 dt = 0.05; % 仿真步长 Tmax = 20; % 最大仿真时间 vmaxP = 1.0; % 追捕者最大速度 vmaxE = 0.75; % 逃逸者最大速度 Kp = 1.2; % 追捕者位置反馈增益 Ke = 1.0; % 逃逸者位置反馈增益 captureR = 0.15; % 捕获半径 margin = 0.25; % 围捕侧翼偏移距离 % ---------- 初始化状态 ---------- posP = [-3 3 -3 3; % 追捕者初始位置,每列为[x;y] 3 3 -3 -3]; posP = posP(:, 1:Np); posE = [-0.3 0.5; % 逃逸者初始位置 0.2 -0.3]; posE = posE(:, 1:Ne); velP = zeros(2, Np); velE = zeros(2, Ne); targetIdx = zeros(1, Np); % 每个追捕者当前追击目标编号 captured = false(1, Ne); % 逃逸者捕获状态 % ---------- 可视化窗口 ---------- figure('Color','w'); hold on; axis equal; grid on; xlim([-5 5]); ylim([-5 5]); hp = plot(posP(1,:), posP(2,:), 'bs', 'MarkerFaceColor','b', 'MarkerSize',8); he = plot(posE(1,:), posE(2,:), 'ro', 'MarkerFaceColor','r', 'MarkerSize',8); legend([hp(1), he(1)], {'追捕者','逃逸者'}, 'Location','best'); % ---------- 主循环 ---------- for t = 0:dt:Tmax activeE = find(~captured); if isempty(activeE) fprintf('所有逃逸者在 t=%.2fs 被捕获\n', t); break; end % 1. 目标分配 targetIdx = assignTargets(posP, posE, captured); % 2. 追捕者控制 for i = 1:Np eIdx = targetIdx(i); if eIdx == 0 || captured(eIdx) continue; end g = getEncirclementPoint(posP(:,i), posE(:,eIdx), i, margin); des = Kp * (g - posP(:,i)); velP(:,i) = saturate(des, vmaxP); end % 3. 逃逸者控制 for j = 1:Ne if captured(j), continue; end des = vmaxE * escapeDirection(posP, posE, j); velE(:,j) = saturate(des, vmaxE); end % 4. 位置积分 posP = posP + velP * dt; posE = posE + velE * dt; % 5. 捕获判定 for j = 1:Ne if captured(j), continue; end dists = sqrt(sum((posP - posE(:,j)).^2, 1)); if any(dists <= captureR) captured(j) = true; fprintf('逃逸者 %d 于 t=%.2fs 被捕获\n', j, t); end end % 6. 刷新图像 set(hp, 'XData', posP(1,:), 'YData', posP(2,:)); set(he, 'XData', posE(1,:), 'YData', posE(2,:)); title(sprintf('t=%.2fs', t)); drawnow; pause(0.02); end if ~all(captured) fprintf('仿真时间用完,仍有逃逸者未被捕获。\n'); end这段主程序的执行顺序很简单:先分配目标,再根据目标计算追捕者速度,同时计算逃逸者逃跑速度,接着更新位置,最后检查捕获。从控制角度看,控制周期dt就是一次状态更新的时间,每个周期都重新做目标分配,所以算法天然支持逃逸者被动态捕获后重新分配任务。
3.2 目标分配函数:贪心匹配的代码实现
主程序调用的assignTargets函数,是整段代码里最影响协同效果的部分。实现的逻辑分两轮:
第一轮保证每个逃逸者都有人追。遍历所有未捕获逃逸者,每次只在“尚未分配任务的追捕者”集合里去选距离最近的那一个。这样即使两个逃逸者距离很近,也会被强制分给不同的追捕者。第二轮处理多余追捕者,把它派给当前距离最近且未捕获的逃逸者,形成增援。
function targetIdx = assignTargets(posP, posE, captured) Np = size(posP, 2); activeE = find(~captured); targetIdx = zeros(1, Np); if isempty(activeE) return; end % 第一轮:保证每个未捕获逃逸者至少有一个追捕者 assignedP = false(1, Np); for e = activeE free = find(~assignedP); if isempty(free) break; end dists = sqrt(sum((posP(:, free) - posE(:, e)).^2, 1)); [~, loc] = min(dists); p = free(loc); targetIdx(p) = e; assignedP(p) = true; end % 第二轮:剩余追捕者增援最近的未捕获逃逸者 for p = find(targetIdx == 0) dists = sqrt(sum((posE(:, activeE) - posP(:, p)).^2, 1)); [~, loc] = min(dists); targetIdx(p) = activeE(loc); end end这个函数的输入是三个矩阵:追捕者位置、逃逸者位置、捕获状态。输出是1×Np的目标编号向量。需要注意,如果Np < Ne,第一轮结束后追捕者已经全部分配完,某些逃逸者永远没有目标追捕者,最终肯定无法全部捕获。所以使用这个代码时,请确保追捕者数量不少于逃逸者数量,这也是多追捕者捕获问题成立的基本前提。
3.3 围捕点函数:让追捕者从两侧包夹
围捕点计算是整个策略的精髓。getEncirclementPoint输入当前追捕者位置、目标逃逸者位置、追捕者编号以及偏移距离,输出一个二维围捕点。追捕者的期望速度,就是指向这个围捕点的速度。
function g = getEncirclementPoint(pursuer, evader, idx, margin) d = evader - pursuer; nd = norm(d); if nd < 1e-6 g = evader + [margin*cos(idx*pi/2); margin*sin(idx*pi/2)]; return; end d = d / nd; perp = [-d(2); d(1)]; side = (-1)^idx; g = evader + side * margin * perp; end需要特别提醒的是,idx这里用的是全局追捕者编号,这在实际多对一围捕时可能不够平衡。比如两个追捕者编号都是偶数,它们会被分到同一侧。如果你希望同一逃逸者的多个追捕者一定左右分布,可以修改这个函数,把side改成根据“当前追击同一目标的所有追捕者中的排列序号”来决定。不过在默认的4追2逃场景下,编号奇偶交替通常够用。
3.4 逃逸者方向与速度饱和函数
逃逸者的逃跑方向,我采用的是“背离追捕者质心”策略。这个策略计算量极小,却足以模拟逃逸者的避让行为。在智能体数量很少时,它和最大空隙策略的表现差距也不大。
function dir = escapeDirection(posP, posE, idx) centerP = mean(posP, 2); away = posE(:, idx) - centerP; if norm(away) < 1e-6 away = [1; 0]; else away = away / norm(away); end dir = away; end速度饱和函数saturate的作用是限制最终速度向量的模长,防止控制量超出执行器能力。如果期望速度本身在最大值以内,就原样保留;如果超出,就按比例缩放方向向量。
function v = saturate(vIn, vmax) n = norm(vIn); if n > vmax v = vIn / n * vmax; else v = vIn; end end饱和函数看起来简单,却是让仿真稳定运行的关键。没有饱和限制时,位置反馈增益一旦设大,追捕者会一步“飞”到目标旁边,产生锯齿轨迹,捕获判定也会失真。
4. 调参经验与常见问题排查
4.1 捕获半径怎么定才合理
很多刚开始复现的人会随手把captureR设成0.01,结果跑满20秒也抓不到一个;或者设成1.0,开局就全部捕获,完全没体现出围捕过程。捕获半径需要和速度、步长配合起来看。
一个经验公式是:
captureR >= vmaxP * dt * 1.5也就是至少大于追捕者在一个控制周期内能移动的距离。我代码中vmaxP=1.0,dt=0.05,一个周期最多移动0.05米,所以captureR=0.15约等于3倍步长距离,物理上代表追捕者“伸出机械臂”或“释放抓捕网”的作用范围,足够被发现且不会一步穿透。
4.2 追捕者反复“换人”导致的震荡
我在第2章提到过,高频重新分配目标会让追捕者来回摆振。具体现象是:两个追捕者分别追两个逃逸者,两个目标位置一旦交错,距离矩阵改变,追捕者A的目标变成之前追捕者B的目标,两者相当于交换了猎物,轨迹会画出一个“麻花”。
解决办法有几个,按效果排序分别是:
- 在目标分配代价矩阵中加入上一时刻分配结果的奖励,让保持原目标更划算。
- 降低目标分配频率,不要每个控制周期都重新分配,比如每隔0.2秒分配一次。
- 增加一个“最短连续追击时间”约束,分配结果至少保持固定时间。
在实际工程模型里,我一般用第一种,因为它不需要改变控制周期,还可以保持分配的快速响应。
4.3 逃逸者扎堆逃向同一方向怎么办
当多个逃逸者互相之间没有通信,只按“背离质心”逃跑时,它们可能会朝同一边缘跑,最终全部缩在角落里。追捕者要冒很大风险冲进去拆散它们,否则容易被逐个突破。
一个实用改进是给逃逸者加上逃逸者之间的排斥力。比如让每个逃逸者不仅远离追捕者质心,也稍微远离其他逃逸者。这样初始位置的随机性会被放大,逃逸者会更快散开,给追捕者制造更多分割包围的机会。另一个办法是在逃逸者的方向向量上叠加一个小幅随机噪声,模拟真实系统的不确定性,也会让仿真更接近实战。
4.4 边界和障碍物会让追捕者“撞墙”
我的默认代码用的是无限平面,但真实场景里机器人肯定有活动范围。一旦加上边界,追捕者和逃逸者都可能撞墙。最简单的方法是加“边界排斥势场”:当智能体靠近边界时,在速度指令上叠加一个指向场地内部的力,且离边界越近力越大。
更高级的做法是直接在围捕点计算中加边界限制,让追捕者不会跑到界外去包抄。这两种方式我在实际项目里都试过,建议先用排斥势场,因为改动最小、参数直观,也容易调试到稳定。
5. 这套代码还能往哪扩展
代码跑通之后,有几个很自然的扩展方向。如果你想让系统更接近真实,可以把点模型换成运动学小车模型,在速度更新函数里加入转向角约束;如果场地里有很多静态障碍物,可以改造成基于势场法的避障控制器;如果想进一步提升追捕策略的智能度,可以试着把目标分配函数改写成强化学习智能体,让系统自己学习如何在不同局面下分配目标。
我个人在调试这套模型时,最大的体会是:多追捕者问题里,“配合”往往比“单兵速度”更重要。几个速度稍慢的追捕者如果用好了围捕点策略,完全能击败一个单速度很快的逃逸者。这也是为什么这类算法不只是玩具仿真,它在仓储机器人调度、无人机协同控制、安防巡逻这些领域都有非常实际的应用潜力。如果你在复现时发现某些参数的默认效果和我的描述有差异,先别急着改算法,试着把比例增益和围捕偏移量调几轮,很多时候问题会自己消失。