
第一次看到 MiroFish 这个名字我脑子里蹦出来的画面是一缸鱼几百条挤在一起没有指挥官没有全局地图谁也不知道整体队形长什么样可一遇到障碍物就自动分流一遇到捕食者就整体转向。这种没有中心的协调恰恰是很多人在做群体行为仿真、多智能体协同、人群疏散、游戏 AI 时最想复刻的东西。MiroFish 要干的事本质就是把这种局部规则驱动全局涌现搬到代码里——用一群结构极其简单的个体通过有限视野内的几条规则跑出肉眼可见的、有组织的群体行为。这篇内容不打算写成一份 API 说明书。我更想聊的是这个项目为什么值得琢磨、它的分层到底该怎么切、个体规则怎么参数化、空间查询这种性能瓶颈怎么破、LLM 决策层要不要接、接了之后成本怎么控。适合三类人看一是做游戏或影视人群特效、需要大量 NPC 又不想写死动画的开发者二是研究多智能体强化学习、群体机器人、复杂系统需要一个人造沙盒做对照实验的人三是想在自己项目里塞一点涌现感的产品开发者——比如让一堆资料卡片自己聚成簇、让一群虚拟角色在场景里自然分布。下面按我实际拆解和复现的顺序来写代码都是能直接跑的最小版本参数是我反复调出来的经验值坑也基本都踩过一遍。1. MiroFish 到底在解决什么问题1.1 单体智能的天花板在哪里先说清楚为什么需要一群而不是一个。假设你要模拟一个广场上 500 个人的疏散过程。写一个超级智能体让它同时计算 500 个人的最优路径——理论上可行实际上你会立刻撞上三堵墙第一计算量随人数呈平方甚至更高增长实时性根本保证不了第二任何个体的行为变化都要重新求解全局场景稍一改动就得重算第三最要命的是真实的人群疏散里本来就没有人掌握全局信息个体只知道我左边有人、前面有墙、后面在推我你用全局最优去逼近反而丢掉了很多真实的局部拥堵现象。MiroFish 的思路完全反过来每个个体只关心自己视野半径内的几个邻居规则简单到可以用几张纸写清楚但成千上万个这样的个体叠加在一起宏观上就会出现结队、绕行、分流、拥堵、涡旋。这就是涌现——整体行为不是被设计出来的而是被局部规则养出来的。你不需要为每种场景写一套逻辑只要把规则和参数调对新场景是自然长出来的。从工程角度看这个转向带来了巨大的好处计算复杂度从全局耦合降到局部耦合天然适合并行行为逻辑可以随场景插拔扩展成本低而且系统的智能是分布式的删掉几个个体整体照样跑。1.2 三层能力拆解个体、交互、涌现我把这类系统统一拆成三层MiroFish 也逃不出这个框架理解这三层后面所有参数和代码都会变得好懂。个体层决定一个单位自己能干什么。最小的能力集其实只有三样感知视野内有什么、决策下一步想往哪走、执行位置和速度怎么变。个体的状态通常就是一串数字——位置、速度、朝向、能量再加上业务自定义的属性。交互层决定个体之间怎么互相影响。这层是 MiroFish 的灵魂。最经典的是三规则分离别撞上邻居、对齐和邻居朝一个方向走、聚合向邻居的中心靠拢。这三条规则本身极其粗糙但权重一调就能出现从一盘散沙到铁板一块到绕圈打转的连续谱。涌现层决定整体长什么样。这层不写代码只做观测和度量群体极化度、平均最近邻距离、队形宽度、回转响应时间。很多人复现这类项目失败不是因为个体规则写错了而是因为没有可观测量调参全靠肉眼一换场景就崩。三层分清楚之后你会发现一个反直觉的结论个体层要尽量简单交互层要尽量可解释涌现层要尽量可度量。凡是把宏观目标的逻辑塞进个体脑袋里的写法最后都会变成一堆无法维护的 if-else。1.3 什么场景该用它什么场景别硬套不是所有多单位问题都值得上群体智能。我的判断标准很粗暴如果每个单位的行为都能被清晰地写成看邻居→调整自己的局部规则并且整体的可预测性要求不高那就适合如果场景需要严格的全局最优、需要保证不出现任何碰撞、或者单位数量本来就很少十来个那你直接写规则或跑规划算法更省事。具体来说适合的典型场景包括场景装饰性的人群填充、鱼类鸟群兽群的动画、机器人编队的底层避障行为、复杂系统教学演示、以及需要看起来自然而不是算得精确的各种模拟。不太适合的包括高密度行人疏散中的安全边界验证、需要严格时序的多 AGV 调度、以及任何对结果的确定性有硬要求的仿真——这类场景里涌现出来的行为是不可复现的你会被为什么这次跑出来不一样折磨到怀疑人生。提示如果你既要自然感又要可复现唯一靠谱的办法是固定随机种子并且保证每一帧的更新顺序、浮点运算顺序完全一致。这一点在多线程环境下几乎做不到后面性能那一节会细讲。2. 整体架构怎么切才不返工2.1 分层架构与数据流我见过太多这类项目写到中期就烂掉根因几乎都是世界状态、个体行为、渲染观测三件事搅在一个大循环里。MiroFish 这类系统比较稳的切法是四层数据单向流动第一层是世界容器只负责持有全局状态时间步、边界、障碍物、空间索引。它不知道任何业务逻辑。第二层是行为规则每个规则是一个纯函数输入是我的状态 邻居状态输出是一个加速度或转向力。注意是纯函数没有副作用、不直接改位置这样规则才能自由组合、单独测试。第三层是积分器统一把所有力累加、限制、更新速度和位置。第四层是观测与适配器负责把内部状态推给可视化、日志、评估指标或者从外部拉取新的指令。数据流是单向的世界状态 → 规则计算力 → 积分器更新状态 → 观测层输出。没有任何一层反向修改上层的数据。这个约束看起来很教条但它换来的是两个非常实际的好处调参时你可以固定世界只换规则复现问题时你可以回放状态序列接可视化时你可以随时把观测层摘掉做无头压测性能数据立刻就干净了。2.2 决策层选型三种方案别混着上个体怎么决定下一步这件事工程上有三条主流路线成本、可控性、表现力差别极大千万别在一个项目里混用。方案单步计算成本可控性表现上限典型用途规则驱动Boids 类极低纯算术很高参数即行为中等靠调参大规模背景群体、避障强化学习策略中等一次前向推理中等靠训练约束高能学复杂策略编队、对抗、协作任务LLM 决策极高毫秒到秒级低输出不稳定高有语义理解少数关键个体的意图我的实际结论是这样运动层面永远用规则决策层面可以局部用学习或 LLM。原因很简单运动是每帧都要跑的高频操作几百个个体乘以每秒几十帧任何一点开销都会被放大成灾难。而我要去哪个区域、跟谁结盟、要不要撤退这种低频、语义化的决策才是 LLM 真正有价值的场景而且这种决策每秒只需要几次。这个分层有个非常好用的类比规则层是脊髓反射LLM 层是大脑皮层。反射负责别撞墙皮层负责今天往哪走。你让皮层去管每一块肌肉的收缩几秒钟就过热了。2.3 时间步模型固定步长是底线仿真里的时间推进有两种写法固定步长每个 tick 前进固定的 dt比如 1/60 秒和变步长按真实渲染帧间隔推进。这类涉及力累加和局部交互的系统必须用固定步长否则你会遇到一个非常隐蔽的 bug机器卡了一下那一帧的 dt 变大三倍个体的速度积分结果直接翻倍鱼群瞬间炸开下次又慢回来行为完全不可复现。另外积分方式也有讲究。显式欧拉先更新位置再用旧速度更新速度在力比较大时会能量不守恒群体振得很厉害半隐式欧拉先用新速度更新位置稳定性好很多开销几乎一样。再往上还有 Verlet 和 RK4前者适合有约束的系统后者精度高但每步要算四次力对我们是浪费。我在实际操作里的配置是dt 固定 1/60 秒半隐式欧拉单帧最多允许跑 3 个 tick防止卡顿累积若累积超过就丢弃多余时间只跑 3 个——宁可仿真变慢也不让 dt 乱跳。2.4 解耦内部时钟和外部指令别绑死还有一个容易忽略的设计点如果 MiroFish 要接 LLM 或者接外部控制台那外部指令的到达时间一定是随机的、非等间隔的。千万别让指令直接改个体状态那样又会破坏可复现性。正确做法是加一个指令队列外部把个体 42 的目标改为 A 区域塞进队列主循环在每个 tick 开始前统一出队、批量应用。这样一来无论外部是快是慢、来一条还是来一百条仿真内部的执行顺序都是确定的。同时你还获得了一个意外的好处可以把指令队列持久化出问题时完整回放整个施加过程。3. 核心细节三规则、邻域与参数整定3.1 分离、对齐、聚合的参数化写法三条规则的原始版本非常朴素但直接抄原版一定会出问题我把它参数化后是这么写的。分离对视野内距离小于sep_radius的每个邻居产生一个反向力强度按距离反比——越近推得越狠。对齐把邻居速度的平均方向作为目标只取方向分量不取大小。聚合把邻居质心方向作为目标同样只取方向。关键在三个细节。第一所有力都要做归一化只控制方向不控制大小否则邻居多的时候合力会爆掉。第二用转向力steering force而不是直接改速度也就是先求出期望速度再用期望速度 - 当前速度得到转向力并限制最大转向力max_force。第三速度要限幅max_speed否则个体为了追赶对齐会无限加速。def separation(self, me, neighbors): force np.zeros(2) for j, dist in neighbors: if dist self.sep_radius and dist 1e-6: diff self.pos[me] - self.pos[j] force diff / (dist * dist) # 距离反比越近越强 return self.limit(self.normalize(force) * self.max_speed, self.max_force) def alignment(self, me, neighbors): avg np.zeros(2) for j, dist in neighbors: avg self.vel[j] return self.steer_towards(me, avg) def cohesion(self, me, neighbors): center np.zeros(2) for j, dist in neighbors: center self.pos[j] return self.steer_towards(me, center / max(len(neighbors), 1))注意steer_towards里那句期望速度减当前速度这是整个 Boids 体系里最重要的一行代码。它保证了个体不会瞬间转向而是有一个惯性过程正是这个惯性让群体运动看起来活着而不是一群被程序硬拽着走的点。3.2 邻域查询性能瓶颈基本都在这里朴素实现里每个个体都要和其他所有个体算一次距离复杂度 O(N²)。N500 时每帧 25 万次距离计算还能忍N5000 时就是 2500 万次Python 里直接卡成幻灯片。所以邻域查询是这类项目唯一真正需要认真优化的地方。结构查询复杂度更新成本适合规模备注朴素全遍历O(N²)无N 300实现最简单先跑通再优化均匀网格O(N·k)每帧重建N 千到十万密度均匀时最优首选四叉树/八叉树O(N·logN)每帧重建密度差异大稀疏密集混合场景更好KD 树O(N·logN)每帧重建静态点集个体每帧都动重建成本偏高排序扫描O(N·logN)排序一次维度低、轴对齐适合长条形分布我绝大多数时候选均匀网格理由是它把邻域这件事变成了查 3×3 个格子逻辑简单到不可能出错而个体每帧移动距离远小于格子边长这个前提在有速度上限的仿真里天然成立。格子边长怎么定经验值是取视野半径的 1.5 到 2 倍。太小了要查 5×5 甚至 7×7 个格子太大了单个格子里塞的人太多退化成朴素遍历。def build_grid(pos, cell): buckets {} inv 1.0 / cell for i, (x, y) in enumerate(pos): key (int(x * inv), int(y * inv)) b buckets.get(key) if b is None: buckets[key] [i] else: b.append(i) return buckets def neighbors_of(i, pos, grid, cell, radius, cols, rows): inv 1.0 / cell cx, cy int(pos[i][0] * inv), int(pos[i][1] * inv) r2 radius * radius out [] for dx in (-1, 0, 1): for dy in (-1, 0, 1): ax, ay cx dx, cy dy if ax 0 or ay 0 or ax cols or ay rows: continue for j in grid.get((ax, ay), ()): if j i: continue d pos[j] - pos[i] d2 d[0] * d[0] d[1] * d[1] if d2 r2: out.append((j, d2 ** 0.5)) return out这段代码每次查询只做一次内部字典查找比先算所有距离再筛选快一个数量级。实测在同样个体数下从朴素版本换成网格版本单帧耗时从几十毫秒掉到几毫秒帧率直接翻了几倍。3.3 视野不是圆盲区、视角与优先级很多人把感知半径做成一个完美的圆结果群体行为总显得太聪明——前后左右的邻居权重一样。真实生物是有视野角度的比如大多数鱼类视野接近 300 度但正后方有盲区鸟类前方优先。加一个视野角参数非常简单计算邻居方向与自身朝向的夹角超出半视野角就丢弃。这个改动只要三行代码但对行为真实感的提升非常大尤其是绕障时你想看到打头阵的个体先转向后面的逐步跟上这种波浪式传递。再进阶一点可以做邻居优先级。比如按距离加权越近权重越高或者按前方邻居权重 1.0侧方 0.5后方 0.2甚至给特定类型的邻居单独加权——比如给领航者类型更高权重就能做出有人带队的群体。这些权重全部作用在力的累加阶段不影响空间查询改动成本极低收益极高。注意视野角过滤一定要在距离过滤之后做不要反过来。因为夹角计算涉及反三角函数比平方距离比较贵得多先便宜后昂贵的顺序在大规模下差异明显。3.4 参数整定从炸群到稳态的手动流程调参是这类项目里最耗时间、最没有理论指导的部分我总结了一套比较可用的手动流程基本能在半小时内让一群鱼从乱窜变成像样的群体。第一步先把对齐和聚合权重归零只留分离。这时候你会看到个体互相排斥、均匀散开像气体一样充满整个空间。这一步的目的是验证分离半径和max_force是否合理——如果个体依然重叠说明分离力太小或者邻域查询半径小于分离半径如果个体被推得满屏乱飞说明分离力太猛。第二步把分离权重降到很小慢慢加聚合。你会看到个体开始往中间聚聚成一个不断收缩的球。这时候关键现象出现了如果聚合权重太大球会缩成一个点然后整群抖动如果刚好球会维持一个稳定的半径。这个稳定半径其实就是分离力和聚合力的平衡点记下这个权重比例。第三步加入对齐从小到大扫。对齐权重很低时是一团原地蠕动的云达到某个阈值后整团突然开始朝一个方向平移——这就是文献里说的极化相变。我在自己的环境里观察到这个相变通常发生在对齐权重占到总权重的三成到四成之间并且个体数越多、相变越陡峭几千个体的系统几乎是咔一下整体转向的。第四步固定共生参数后调速度。最大速度影响的是群体看起来有多急最大转向力影响的是群体转弯有多灵活。这两个参数会影响边界处理速度太大、转向力太小个体撞墙后会被卡在边界上摩擦转向力足够大群体就会沿着墙自然地滑过去。我的参考起点是分离半径 12视野半径 45对齐权重 1.0聚合权重 0.8分离权重 1.6最大速度 3.0最大转向力 0.15dt 取 1/60。你可以从这组值开始改一个参数观察一个现象别一次动三个。4. 实操从零跑起来一个最小可用的群体仿真4.1 环境准备与依赖清单这部分没什么玄机但有几个坑值得提前说。我用的组合是 Python 3.11 NumPy 做数值计算可视化和交互用 pygame 或者直接推给前端渲染压测时用无头模式不启动任何渲染。如果你的个体数会超过几千建议一开始就把核心循环写成 NumPy 向量化版本后期再改代价很大。依赖清单numpy必需所有批量数值运算靠它、pygame可选本地快速看效果、matplotlib可选画序参量曲线、pytest建议加规则函数是纯函数测试成本极低。安装就一行pip install numpy pygame没什么好说的。需要提前定下来的是坐标系和单位。我建议用抽象单位不绑定像素或米视野半径 45 就是 45 个抽象单位。好处是换渲染分辨率时逻辑完全不用动坏处是第一次看到数字时没有直观感受——我的经验换算大概是一屏宽 1200 个单位视野半径 45 差不多是屏幕宽度的三十分之一看起来像小鱼的感知距离。4.2 世界与个体初始化初始化最重要的是别让所有个体从同一个点出发也别让它们完全随机分布到整个空间。前者会在第一帧产生巨大分离力把整群炸开后者会让群体前几十帧都在找组织浪费调参时间。我习惯的做法是在中心区域用一个高斯分布撒点初始速度全零。import numpy as np class World: def __init__(self, n800, width1200, height800, cell60.0, seed42): rng np.random.default_rng(seed) self.n n self.W, self.H width, height self.cell cell self.pos rng.normal( loc[width / 2, height / 2], scale[width / 8, height / 8], size(n, 2), ) self.pos[:, 0] np.clip(self.pos[:, 0], 0, width) self.pos[:, 1] np.clip(self.pos[:, 1], 0, height) self.vel np.zeros((n, 2)) self.acc np.zeros((n, 2)) self.obs [] # 障碍物圆形元素为 (x, y, r) self.cmd_queue [] # 外部指令队列cell取 60正好是视野半径 45 的 1.3 倍左右落在前面说的合理区间里。注意我把随机种子暴露出来了这是复现问题的前提——同一个种子必须跑出逐位相同的结果否则排查问题时会疯掉。4.3 主循环力的累加、限幅与积分主循环的顺序非常重要我把它固定成六步中间任何一步都不能挪应用外部指令 → 重建空间索引 → 对每个个体计算合力 → 力限幅 → 半隐式欧拉积分 → 边界与障碍处理。def step(self, rules, dt1.0 / 60.0): # 1. 应用外部指令批量、确定顺序 for cmd in self.cmd_queue: cmd(self) self.cmd_queue.clear() # 2. 重建空间索引 grid build_grid(self.pos, self.cell) cols int(self.W // self.cell) 1 rows int(self.H // self.cell) 1 # 3. 累加力 total np.zeros_like(self.acc) for i in range(self.n): nb neighbors_of(i, self.pos, grid, self.cell, self.view_radius, cols, rows) if not nb: continue for r in rules: total[i] r.weight * r.compute(i, nb, self) # 4. 限幅 t np.linalg.norm(total, axis1, keepdimsTrue) total total * np.minimum(1.0, self.max_force / np.maximum(t, 1e-9)) # 5. 半隐式欧拉 self.vel total * dt v np.linalg.norm(self.vel, axis1, keepdimsTrue) self.vel self.vel * np.minimum(1.0, self.max_speed / np.maximum(v, 1e-9)) self.pos self.vel * dt # 6. 边界环绕还是反弹二选一 self.wrap()第 4 步的限幅是很多人漏掉的。不限制合力一群密集的个体会在某个瞬间产生巨大的叠加力速度瞬间飙到几千下一帧直接飞出屏幕看起来像爆炸。加上这句以后无论多密集都不会失控最坏情况只是挤在一起慢慢挪。第 6 步的边界处理有两种做法环绕从右边出去从左边进来和反弹撞墙后速度反向。环绕会让群体行为非常连贯适合做无边界感的流体但会产生穿墙瞬移的视觉问题反弹更符合直觉但个体容易贴着墙堆积。我的做法是反弹时加一点点随机扰动避免整排个体在墙上形成一条僵硬的直线。4.4 观测层没有指标就没法调参前面反复强调可度量具体来说我在观测层至少看四个指标每 30 个 tick 采样一次写进日志。第一个是极化序参量把所有速度向量加起来取模再除以速度和模的总和结果是 0 到 1。接近 1 表示整群朝同一个方向接近 0 表示各自为政。这个数字是我判断是不是个群体的第一指标比肉眼看靠谱得多。第二个是最近邻平均距离。太大会显得松散太小会显得重叠而且它的时序曲线能提前暴露震荡——如果曲线周期性上下摆说明分离和聚合的权重比例处在不稳定点附近赶紧调。第三个是群体包围盒面积用来观察群体是收缩、扩张还是稳定。稳态系统这个值应该在一个小区间内波动。第四个是边界接触个体数用来判断群体是不是被边界效应主导了。如果这个数字持续占到总数的 10% 以上说明你的视野半径或速度相对于世界尺寸偏大测出来的行为其实主要是墙的形状不是群体的性质。4.5 接不接 LLM 决策层怎么接前面说过运动层不要碰 LLM。但如果你的场景需要有意图的群体可以这样接把群体按空间聚类成 20 到 50 个决策单元每个单元选一个代表比如离质心最近的个体只让代表调用 LLM 决定接下来 30 秒往哪个区域移动然后把结果广播给单元内所有个体作为聚合规则的一个附加目标点。这样调用频率就从每个个体每秒几十次降到每个单元每几十秒一次成本降了四个数量级。工程上有三个必须做的防护。第一异步非阻塞LLM 请求走独立任务结果通过指令队列注入主循环永远不等网络。第二缓存把场景特征 → 决策做键值缓存相似状态下直接复用实测在稳定场景里命中率能到七成以上。第三降级请求超时或返回无法解析时直接回退到默认目标点绝不能让仿真卡住等答案。async def decide_unit(unit_id, cluster_ctx, cache, llm): key hash_ctx(cluster_ctx) if key in cache: return cache[key] try: raw await asyncio.wait_for(llm.ask(build_prompt(cluster_ctx)), timeout3.0) target parse_target(raw) cache[key] target return target except Exception: return default_target(cluster_ctx) # 降级不抛异常提示别让 LLM 输出速度向量或具体坐标数值。让它输出前往左上方的开阔区域这种语义化描述再在本地映射成目标点。数值输出不稳定语义输出稳得多。5. 性能与成本不同规模下的调优手段5.1 复杂度分析和我的实测数据理论上朴素邻域查询是 O(N²)均匀网格在密度均匀时接近 O(N)。实测4 核 8 线程的普通开发机纯 Python未做向量化大致是这样的个体数朴素实现单帧耗时网格实现单帧耗时提速倍数300约 5 ms约 4 ms1.2x1000约 60 ms约 7 ms8x3000约 520 ms约 22 ms24x10000不可用约 80 ms无法比较看这张表有个很关键的结论小规模下优化毫无意义。300 个个体时网格版本只快了两成但代码量多了三倍。所以别一上来就上空间索引先用朴素版把规则调对个体数破千了再换这是最省时间的路径。反过来如果你已经确定要跑上万个体那从一开始就得用网格加 NumPy 向量化不然中途重构会很痛苦。5.2 向量化与并行的边界在哪里向量化能带来第二次数量级提升但有个重要前提所有个体必须用同一套规则。一旦你需要有些个体是鱼、有些是船、有些是障碍向量化代码里就会充满掩码和分支可读性急剧下降收益也打折。我的经验是把最重的计算邻域距离、力累加向量化把规则的选择逻辑保留在 Python 层两者混着用。# 批量计算到某个中心点的转向力避免逐个 for def steer_batch(pos, vel, target_pos, max_force, max_speed): desired target_pos - pos norm np.linalg.norm(desired, axis1, keepdimsTrue) desired desired / np.maximum(norm, 1e-9) * max_speed steer desired - vel s np.linalg.norm(steer, axis1, keepdimsTrue) return steer * np.minimum(1.0, max_force / np.maximum(s, 1e-9))多进程并行要谨慎。个体之间有交互不能简单地把个体切片分给不同进程——你需要先做空间分区把不接壤的格子分给不同进程并且只在分区边界交换数据。实现复杂度很高我一般不建议在这个阶段就上多进程因为一旦并行前面强调的逐位复现就基本泡汤了浮点求和的顺序会因为调度而变。如果你真的需要并行GPU 是更合适的方向但那是另一个量级的工程投入。5.3 成本控制三个立竿见影的手段如果接了 LLM 决策成本会变成主要矛盾。除前面说的聚类、缓存、异步三点之外还有三个手段效果明显。决策周期和仿真步长解耦。仿真跑 60 tick/s决策每 300 tick 一次也就是每 5 秒一次那么 50 个决策单元每秒只产生 10 次调用。这个数字是可以接受的。千万别让决策频率跟着帧率走。分层决策。让少数个体做高频决策比如领航者每 1 秒一次大部分跟随者做低频决策每 10 秒一次中间用对齐规则把意图传导下去。因为对齐规则会自动把领航者的转向传递给整个群体所以你不需要每个人都想只要有人想其余靠物理传导。预生成与回放。对于确定性高的场景比如一段固定路线的人群展示完全可以离线跑一遍把决策序列录下来线上直接回放成本为零。这在产品演示和影视制作场景里非常实用。6. 常见问题与排查实战6.1 现象、原因、解决对照表现象最可能的原因处理办法群体瞬间炸开速度飙到极大合力未限幅或初始位置过度重叠加 max_force 限幅初始化用高斯撒点个体互相重叠穿模分离半径小于个体物理半径分离半径至少设为物理半径的 2 倍整群原地抖动不前进聚合权重远大于对齐权重提高对齐权重通常到总权的 0.3 以上群体贴墙摩擦不走转向力太小撞墙后无法脱离增大 max_force或边界反弹加随机扰动帧率随个体数断崖下降邻域查询退化为 O(N²)引入均匀网格cell 取视野半径 1.5 倍每次跑结果都不一样更新顺序或浮点求和顺序不稳定固定种子、固定遍历顺序、避免多线程归约群体在一处反复打转存在局部吸引子避免规则的力形成闭环检查是否有障碍物形成的凹陷加一点噪声扰动换场景后行为全乱参数过拟合到原场景用序参量做跨场景回归别只靠肉眼6.2 几个只有踩过才知道的坑第一个坑零邻居导致的除零。当某个个体视野内一个邻居都没有时聚合规则里的质心计算会除以零结果是 NaN然后 NaN 会像瘟疫一样通过相邻传播几十帧后整个群体全部变成 NaN屏幕上一条鱼都看不见。这个 bug 非常隐蔽因为初始几帧完全正常。解决办法是所有除法都加max(count, 1)或max(norm, 1e-9)我现在的习惯是写任何除法都先想一下分母会不会为零。第二个坑格子数量和坐标越界。个体被挤到边界外一点点时int(x / cell)可能算出超范围的格子下标邻域查询直接索引错误。必须 clip 格子坐标或者在边界处理里保证位置永远在合法范围内。我因为这个 bug 浪费了整整一个下午现象是随机位置出现一个幽灵邻居群体莫名偏向那个方向。第三个坑同时读取和写入。如果你在遍历个体 A 的时候直接用邻居 B 的当前帧已经更新过的速度那么结果就依赖于遍历顺序——排在后面的个体看到的是新速度排在前面的看到的是旧速度。这会让整个系统不可复现而且行为会偏向某个方向。正确做法是用双缓冲所有个体都从上一帧的状态读取计算完再统一写入。这条是硬要求尤其在空间上相邻的个体之间最明显。第四个坑视野角和朝向的耦合。视野角依赖朝向而朝向通常由速度方向决定。如果个体速度为零初始化时就是这样朝向没有定义夹角计算会是 NaN。要么给初始速度加一个极小的随机值要么在朝向提取时对零速度做兜底。这是那种九成情况下没事偶尔崩一次的典型 bug。第五个坑把渲染帧率和仿真步长绑在一起。前面提过但值得再说一遍因为它是最常见的性能陷阱。一旦你把 dt 设成渲染帧的实际间隔那么性能好的机器上行为正常性能差的机器上群体行为完全不同测试时的结论完全不可信。6.3 怎么判断仿真得像建立回归基线调参调到后期人眼会麻木这时候必须靠指标。我的做法是给每个场景建立一条基线记录固定种子、跑一万个 tick、记录四个观测指标的均值、方差和自相关时间。之后每次改参数都跑同样的流程比对偏离超过阈值就标记为可疑变更。具体阈值怎么定取决于场景。做背景人群时极化序参量稳定在 0.5 到 0.75 之间比较自然——完全极化像阅兵太低又像散沙。最近邻平均距离稳定在视野半径的 0.3 到 0.5 倍之间太近会看起来挤成一坨太远就失去了群体感。包围盒面积的变化率控制在百分之几以内说明群体处在稳态。这套基线还有一个意外的好处它能自动发现那些看起来没事但其实已经坏了的情况。有一次我改了一行归一化代码视觉上完全看不出区别但极化序参量的方差翻了一倍说明群体的转向变急躁了。如果不是有这个基线这个改动会一直留在代码里直到某天在别的场景里爆出来。我个人在这类系统上折腾一圈之后最大的体会是群体仿真里 90% 的工作量不在写规则而在建立可观测、可复现的实验环境。规则本身半天就能写完真正花时间的是让每一次行为的差异都能被归因到某个具体的参数或代码变更上。这一点上先把确定性做扎实、先把指标定下来的做法短期看是拖慢进度长期看是唯一能持续往前的路。另外一个小建议把参数全部塞进一个配置文件每次实验自动把配置快照和指标一起落盘你会省下大量上次那个效果好的参数到底是多少的回忆时间。