1. 为什么需要 Agent-Reach
先说个我自己的场景。前几年在做一个多无人机协同巡检项目,四台机器围绕一个厂房编队飞行,任务要求两两之间始终保持 3 米以上安全距离。当时团队第一版方案用的是"每帧检测距离 + 若太近就刹车"的思路,看起来没毛病,直到某次风场模拟里,两架飞机在转向动作重叠时,最短距离跌破到了 1.2 米。问题出在哪?刹车动作本身需要响应时间,等到距离报警触发时,相对速度已经大到制动来不及了。我们需要一种方法,在动作执行之前,就算清楚"接下来的数秒里,系统绝对不可能进入危险状态",这就是 Agent-Reach 这类可达性分析真正发挥作用的地方。
Agent-Reach 并不是某个现成软件的名称,它更像是一类技术思路的总称:在多智能体系统里,对每个智能体在给定控制输入和外扰范围内的所有可能运动轨迹做集合层面的推演,算出未来一段时间内系统状态所有可能到达的区域,再拿这个区域去和安全约束、任务目标做交集判断。核心关键词就是可达集(reachable set)。如果说常规仿真解决的是"某一条具体轨迹跑出来会怎样",Agent-Reach 解决的是"所有可能轨迹组成的包络长什么样"。前者回答单个故事,后者回答边界问题,而安全验证恰恰只关心边界。
这个思路很适合四类人参考:做机器人编队和无人机集群的工程师,做自动驾驶决策验证的同学,研究多智能体协同算法的研究生,以及对形式化验证感兴趣但一直没找到落地切口的技术爱好者。不需要你是纯数学出身,高中数学里的集合运算加上一点控制论直觉,就能把这套东西用起来。
我当年第一次接触这个概念时,第一反应是"这不就是把物理引擎里所有随机种子都跑一遍吗"。后来才意识到,可达性分析和蒙特卡洛采样是两条完全不同的路。蒙特卡洛模拟再多样本也不可能覆盖所有连续控制输入组合,而可达集给出的是数学上严格的完备覆盖。换句话说,模拟告诉你"我测过的 10 万种情况都没撞",可达性告诉你"整个连续空间里就不存在会撞的情况",这两句话的含金量完全不是一个等级。
2. 方案选型与核心设计
2.1 两种主流路线:找边界 vs 追包络
我在项目里实际对比过两条技术路线,一条是 Hamilton-Jacobi 可达性分析,另一条是集合传播(set propagation),理解清楚它们的区别,你才知道什么时候该用哪条。
Hamilton-Jacobi 方法的思路非常优雅:把"系统状态是否可能进入危险区域"转化为一个偏微分方程的最优解问题。你把安全区域边界定义成一个值函数的零水平集,然后让这个值函数沿着系统动力学反向传播,最终得出的是"某个状态出发,是否存在一条路径能导致进入危险区"的精确答案。优点是对强非线性系统非常友好,比如倒立摆、有饱和约束的无人机模型都能处理;缺点也直接,它需要把状态空间网格化,计算量随着维度指数上涨,我做四维系统的时候已经要跑一个多小时,六维以上基本得用超级计算机或者非常巧妙的降维手段。
集合传播路线的思路更工程化:把初始状态集合(比如位置初始误差范围)、控制输入范围、外部扰动范围喂进去,沿着时间步长做一步一步的集合运算,每一步得到的都是一个实实在在的集合。这个集合通常用多胞形、椭球或者带支撑函数的凸集来表示。优点是计算效率高很多,我后面会给出一个两智能体场景的完整实现,在普通笔记本上跑两秒就能出结果;缺点也有,对非线性系统的处理要费些功夫,通常需要对动力学做线性化,或者用区间算术包出一个保守边界。
Agent-Reach 在我这个项目里,最终采用的是以集合传播为核心、局部用 HJ 方法做校验的混合架构。原因很实际:我们的系统维度不算低(单机状态至少 6 维),又需要在线快速计算,但系统动力学本身相对平滑,线性化误差可以被区间界覆盖住。如果系统里存在强非线性或者开关切换,我建议老老实实上 HJ 方法,尤其是那些对精度要求极高、但维度可以压缩到 4 维以下的场景。
2.2 集合的表示方式,直接决定计算能不能跑起来
这里有个很多人一开始想不到的门道:可达集计算能不能落地,关键往往不在算法多先进,而在于你用什么东西去表示那个"集合"。
最自然的一种表示方式是网格。把状态空间切成很多小格子,每个格子标一下"可达/不可达"。这种方式实现简单、和三维可视化直接对应,但是致命的缺点也明显:维度一高,格子数量爆炸。二维平面上切 100x100 只需要 1 万个格子,到了五维空间哪怕每维只切 30 个格子,就是 2400 多万个格子,每一步还要重复判断一次,根本没法用于在线计算。
我实际用的方案是用凸多胞形来表示。核心思路是把可达集近似成一个凸集,用一组线性不等式来描述:A·x ≤ b。这样集合的求交、求并、线性变换,全部可以转化为矩阵运算,非常快。麻烦的是,真实的可达集往往不是凸的。比如两架飞机都有左转和右转两个方向的控制输入,演化出的可达集可能是两瓣分离的形状,简单用一个凸多胞形包起来就会包含大量实际上到不了的状态,保守性太强,常常出现误报警。
解决这个问题的工程手段是"分段凸包络":把时间轴切成多个小段,每段用独立的凸多胞形去包络,段与段之间可以重叠,最后把整条可达带表示为一系列多胞形的并集。这样既保留了凸集计算的效率,又能覆盖非凸的实际形态。我记得在某次编队避障实验里,用 20 段凸包络包出来的区域,面积比用 1 个大凸包络小了约 37%,也就是说误报率降低了很多,而计算量只增加了不到 5 倍,完全可接受。
2.3 需要关注的三个关键参数
用 Agent-Reach 做实际系统验证,有三个参数必须想清楚,它们直接决定结果的可靠程度。
第一个是时间步长。步长越小,集合演化的精度越高,但计算量也线性增加。更关键的是,步长必须和真正的动力学控制频率匹配。我踩过一个大坑:无人机底层控制器每 0.1 秒发送一次指令,我却把可达集计算步长设成了 0.5 秒,导致计算出来的可达集看起来"安全",但实际在 0.5 秒内的中间时刻,系统早就穿过了危险面。后来我把步长设定为 0.02 秒,宁可多算五步,也保证任何相邻两步之间状态变化不会太大。
第二个是控制输入的量化精度。真实的控制指令通常是连续可变的,但可达性计算里要对输入范围做离散化或者取边界包络。如果只是简单地把连续控制范围的两端拿出来算,中间幅度的控制也可能导致完全不同的轨迹形态,比如某些振动模态只在特定控制量附近被激发。我的建议是至少把控制输入分成三档:最小、最大、中间值,分别算一遍取并集,并用区间形式把中间值到上下限之间所有值产生的状态都覆盖住。
第三个是安全阈值的定义。多智能体系统里通常有"距离阈值"这类要求,但阈值应该加在可达集的包络上,还是加在期望状态的中值上?我见过很多新手直接把阈值和标称轨迹比较,结果自然是假阳性率极高。正确做法是先把可达集算出来,然后用可达集边界和威胁区域做交集测试,这才是理论上有保证的验证流程。
3. 从零搭建一个轻量级 Agent-Reach 验证流程
3.1 环境和工具链怎么选
市面上没有直接叫 Agent-Reach 的现成工具箱,但有两套基础库组合起来非常好用:一套是基于 Python 的多面体运算库,另一套是 Julia 生态里的形式化验证工具集。
Python 路线我用的是pycddlib(对多胞形的线性不等式做转换)和shapely(对二维平面区域做几何运算),配合numpy做矩阵迭代。这套组合的好处是代码好读,适合快速验证想法;缺点是上了三维以上,shapely就不够用了,需要切换到pypolyhedron或者直接调cddlib的底层接口。
Julia 路线值得单独说一下。Julia 生态里有LazySets.jl这个包,专门用于可达集运算,支持多维凸集、支撑函数、闵可夫斯基和等运算,性能上比 Python 快一个数量级以上。我实际用下来,同样是做 20 步的二维集合传播,Python 版要 2.1 秒,Julia 版只需 0.2 秒。做大规模验证我建议认真学一下 Julia,语法本身不复杂,数学功底好的话一天就能上手。
3.2 一个两智能体追逐-逃避场景的完整实现
这次我写一个可复现的代码示例,场景是经典的追逐-逃避:两个智能体在一段直线轨道上运动,追击者希望缩短距离,逃逸者希望拉开距离,我们要验证的是"追击者在指定时间内是否可能追上逃逸者"。为了聚焦可达性分析本身,我把模型简化为一维定速运动,控制输入是速度的变化范围。
import numpy as np # 时间配置 dt = 0.1 horizon = 50 # 总步数,对应 5 秒钟 # 状态维度: [追赶者位置, 追赶者速度, 逃逸者位置, 逃逸者速度] # 追赶者控制输入范围 (加速度) u_chase_low = np.array([-1.0]) u_chase_high = np.array([2.0]) # 逃逸者控制输入范围 u_escape_low = np.array([-0.5]) u_escape_high = np.array([0.5]) # 初始状态集合: 用每个状态变量的最小/最大值表示一个box init_low = np.array([0.0, 5.0, 10.0, 3.0]) init_high = np.array([0.3, 5.5, 10.4, 3.4]) # 简单的线性离散动力学 # x_{k+1} = x_k + v_k * dt # v_{k+1} = v_k + u_k * dt def compute_interval_reachable_set(init_low, init_high, u_low, u_high, steps): low = init_low.copy() high = init_high.copy() sets = [] # 记录每一步的区间包围盒 for step in range(steps + 1): sets.append((low.copy(), high.copy())) # 区间传播下一时刻 new_low = np.zeros_like(low) new_high = np.zeros_like(high) # 位置更新: x_next = x + v * dt # 使用区间的下界和上界分别计算 new_low[0] = low[0] + low[1] * dt new_high[0] = high[0] + high[1] * dt new_low[2] = low[2] + low[3] * dt new_high[2] = high[2] + high[3] * dt # 速度更新: v_next = v + u * dt # 追赶者加速度范围 [u_chase_low, u_chase_high] new_low[1] = low[1] + u_low[0] * dt new_high[1] = high[1] + u_high[0] * dt # 逃逸者完成同样更新 new_low[3] = low[3] + u_escape_low[0] * dt new_high[3] = high[3] + u_escape_high[0] * dt low, high = new_low, new_high return sets reach_sets = compute_interval_reachable_set(init_low, init_high, u_chase_low, u_chase_high, horizon) # 验证目标:在 5 秒结束时,两个智能体的最小间距是否大于 0 final_low, final_high = reach_sets[-1] # 两智能体可能的距离范围 dist_low = final_low[2] - final_high[0] # 逃逸者最靠左时,追赶者最靠右 dist_high = final_high[2] - final_low[0] # 逃逸者最靠右时,追赶者最靠左 if dist_low < 0 and dist_high > 0: print("不安全:存在追击者追上逃逸者的可能") else: print(f"安全:追击者与逃逸者的可达集区间相距 [{dist_low:.2f}, {dist_high:.2f}]")这段代码的核心思路非常直白:用区间传播去计算每个时间步的可达集合边界。实际工程里当然不能只用这么粗的 box,但理解这个"区间套"思想之后,再切换到真正的凸多胞形运算就顺理成章了。你可以看到,最后的安全判断模式是固定的:验证安全性,本质上是看两个可达集的交集是否为空。交集为空,绝对安全;交集不为空,说明存在风险场景,需要进一步缩小不确定性范围或改变控制策略。
3.3 结果怎么读,以及如何避免误判
上面代码跑出来如果打印"安全",不代表万事大吉。区间方法给的是保守结果,它把所有变量独立取值的最坏情况都包进去了,很可能把实际不存在的风险也报出来。我碰到过一次:追击者和逃逸者的距离,区间法算出来最小可能值是 -0.3 米,但实际做 10 万次蒙特卡洛模拟,最坏情况也只到 0.7 米。不是模拟不够,而是区间法的相关性丢失导致的:追击者速度最大时,往往意味着它的加速度贡献已经用完,不可能和位置的极端值同时出现。
处理方式有两种。第一种是改进集合表示,从 box 换成更紧密的凸多胞形,这样状态变量之间的耦合关系就能保留一部分。第二种是在线校验:先用快速的区间法做前置筛选,筛掉明显不安全的场景,把剩余的安全边界场景送入更精确但更慢的 HJ 方法做精确判定。这种两阶段方案我实测下来,既保证了安全验证的完整性,又把平均计算耗时压缩到了一分钟以内。
还有一类结果误读是时间维度上的。"5 秒内的可达集安全"和"整个任务期的可达集安全"是完全两回事。如果只跑了一个时间窗口,必须明确标注结论的有效边界,不能拿一次验证结果覆盖整个任务。我在给团队写结论模板时,固定要求写清楚"什么时间窗口、什么控制输入范围、达到什么置信水平",这一条曾经帮我们挡下过一个和我当初同款的低级错误:有人拿着 3 秒窗口的验证结果想去覆盖 20 秒的任务,导致某个极端转弯路径完全不在验证范围内,差点出事。
4. 常见问题与排查技巧实录
4.1 状态维度过高,算不动怎么办
这是所有接触 Agent-Reach 的人都会撞上的一堵墙。四维系统已经吃力,六维系统就要用上 HPC 资源,更多维度的系统几乎无从下手。我的经验是三条路依次尝试:降维、解耦、分布式。
降维的关键是找到状态变量里的"慢变量"和"快变量"。很多多智能体系统里,姿态角的变化速度远快于位置变化,那么在研究一段较长时间窗口的安全性时,姿态可以直接用它的稳态可达集替代,甚至用控制不变集来包住,这样自由度一下子降下来。我在某个 12 维的双机系统里,用奇异摄动分析把姿态相关维度消掉,最后只保留了 5 个慢变量,计算时间从两小时降到四分钟。
解耦是针对多智能体系统的特有手段。如果两个智能体的动力学相互独立,只是通过约束(比如间距限制)耦合,可以在计算每个智能体的单机可达集之后,再做一次笛卡尔积层面的交集判断。单机 4 维、双机 8 维,解耦后是两次 4 维计算而不是一次 8 维计算,收益非常大。条件是两个智能体之间的控制通道没有物理耦合,如果有气流扰动互相影响,那就不能这么干。
分布式计算这条路更直接。集合传播的每一步在时间尺度上是有依赖关系的,但不同的初始条件分支可以并行。如果你的任务是做参数扫描,比如要验证 20 组不同初始不确定性下的安全性,完全可以并行跑 20 个进程,每个进程走同样的代码,最后汇总结果。这部分我用的就是 Python 的multiprocessing,没有任何黑魔法,纯粹把 CPU 核心用满。
4.2 非线性动力学怎么处理才不发散
有的系统线性化之后的误差特别大,比如控制量接近饱和、或者动力学里有明显的速度平方项,直接用线性模型做集合传播,很容易得到过度保守甚至完全不合理的可达集。处理思路我推荐区间算术加反馈线性化的组合。
区间算术的做法是把非线性函数在区间上求其值域。比如速度平方项 v²,当 v 在 [-2, 3] 这个区间内,v² 的值域不是对端点平方再取最小最大那么简单,而是 [0, 9],因为 0 在区间内部。这个看起来微不足道的细节,在代码里如果没处理,算出来的可达集边界偏差会大到离谱。
反馈线性化是另一种思路:如果能设计一个控制律把非线性项抵消掉,让系统在闭环状态下呈现线性关系,那么基于线性模型的可达性分析精度会大幅提升。缺点是反馈线性化需要精确的系统模型,模型有误差时反而可能放大不确定性。我的建议是结合使用:主线用反馈线性化降低非线性度,残余不确定性用区间算术包一个外边界,这样既有精度又有完备性。
4.3 计算结果形态异常时怎么排查
如果你发现算出来的可达集形状特别古怪,比如突然出现一条细长的分支、或者在包围盒应用下反而缩小了,优先怀疑三个地方:离散化的数值精度、集合运算的容差设置、坐标系的不一致。
数值精度问题最常见。集合运算里通常有"求凸包"和"判断包含"两个环节,绝大多数实现里都有一个容差参数,默认值可能在 1e-9 到 1e-12 之间。状态量数值通常是 10 的量级,但加速度控制量可能是 0.1 的量级,两者相差几十倍,默认容差可能让某些本应被包含的小集合被判定为分离,结果造成可达集断裂。排查方法是把中间态的集合大小和位置打印出来,看是否在某一跳出现跳变。
坐标系不一致的问题更隐蔽。比如用 GPS 经纬度换算的平面坐标时,不同时刻的参考原点可能有细微变化,导致前后两帧的可达集不匹配。解决方式是所有集合运算统一在一个固定的世界坐标系下进行,任何局部坐标必须在入口处完成变换。
还有一次特别诡异的排查经历,最终发现是库的缓存问题:cddlib在重复计算大量同规模多胞形时,内存里的临时矩阵没有清理干净,某些极端的算法路径会读取到旧数据。重启进程就好了,后来我改成每批计算结束后主动释放内存,再也没出现过。
4.4 实测踩坑速查表
我把这几年做 Agent-Reach 相关项目遇到的典型问题整理成一个表格,方便你快速定位。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 可达集比预期大很多 | 区间传播丢失相关性 | 改用凸多胞形,或多段包络 |
| 可达集出现非连通碎片 | 数值容差导致集合断裂 | 调大容差参数,检查中间态 |
| 判断结果与仿真严重不一致 | 时间步长过大 | 步长设为控制频率的 1/5 到 1/10 |
| 非线性系统下结果明显失实 | 线性化误差未被覆盖 | 增加区间余量或换 HJ 方法 |
| 高维系统内存溢出 | 网格化或集合对象过多 | 降维、解耦、批量释放内存 |
| 多智能体间耦合导致过保守 | 把耦合当作独立变量处理 | 用解耦后交集验证替代整体笛卡尔积 |
4.5 我的几个土办法
说几个不太常出现在文档里但很管用的习惯。一是无论用什么库,先写一个最简单的单智能体单步传播验证,跑通后再逐步增加复杂度。这样一旦出现异常,你清楚地知道问题出在哪一层,而不是面对一个几千行代码的黑盒束手无策。二是在计算可达集的同时,把对应时刻的蒙特卡洛模拟结果也画在图上。可达集应该完整包住所有模拟轨迹,如果出现了模拟点在集合外面的情况,说明你的可达集算错了,这一步 10 秒的事,能帮你省下几个小时的试错时间。三是养成给所有参数存档的习惯。可达性分析的结果对参数极度敏感,同样的代码不同的步长可能得出天壤之别的结论。我用一个 JSON 文件记录每次实验的完整参数组合,连库的版本号都存进去,这个习惯多次救了我:有一天同事跑出来的结论和前一天完全相反,一查,是他环境里库更新后默认容差变了。
我自己搞这件事最大的体会是,Agent-Reach 这类思路真正值钱的地方,不在于算出一个漂亮的几何图形,而在于它逼着你把"什么情况下算安全"这个问题想得特别清楚。没有可达性分析的时候,你验证的是几条精心挑选的轨迹;有了它,你验证的是一整片无限多可能性的云端。这个转变带来的安心感,是做多智能体系统的人最需要的东西。最后打个彩蛋:这套分析方法不只适用于机器人,你在做任何"多方博弈、受限资源、要求安全边界"的系统时,都可以想想能不能用可达集的思路重新审视一下问题,很多时候答案比你想的更清晰。