简介:Swarm 2.1仿真工具是一款面向计算机科学、人工智能及复杂系统研究人员的多智能体系统建模与仿真平台,适用于模拟大量自主个体交互行为,帮助分析群体涌现规律、优化分布式算法与网络模型。资源压缩包共包含221个文件,以m源文件、h头文件和o目标文件为主体,辅以changelog更新日志、readme说明文档及makefile构建脚本,整体体积仅691KB,结构紧凑,便于按需查阅。目前已有203人学习下载。该包对应Swarm 2.1.1版本源码与编译产物,读者可深入研读多智能体仿真内核的编程实现,理解FIPA-ACL通信协议、Boids算法等经典模型的代码细节;同时借助内置文档与变更日志,可追踪版本演进脉络、掌握构建配置方法,为二次开发或学术研究提供扎实基础,适合具备一定编程基础的研究者与工程师使用。
1. Swarm 2.1 到底是什么:老牌多Agent仿真工具为什么还有人在用
第一次真正把 Swarm 2.1 用起来,是在一个区域劳动力流动的模拟项目里。当时我们纠结要不要从这套不算年轻的仿真框架迁到新的建模平台,最后没迁,原因是它的设计足够朴素:以 Swarm 为单位组织多 Agent,调度器控制时序,随机数生成器保证可复现,空间对象处理个体位置,图形界面负责观察。这个组合正好覆盖了“微观个体交互产生宏观现象”这一整套实验链路。Swarm 2.1 适合两类人:一类是复杂系统方向的研究者,想快速验证机制、批量跑参数;另一类是业务系统里需要把策略动作仿真成指标曲线的技术人员。这篇按我实际跑模型的经验,从原理、安装、编码写到避坑和进阶,希望能让你少走我踩过的弯路。
2. 先把 Swarm 2.1 的世界观理清:Swarm、调度器、随机数与空间
2.1 为什么叫 Swarm 而不叫“Agent 列表”
Swarm 2.1 里最核心的组织单元不是某个 Agent 类,而是 Swarm 对象。一个 Swarm 装了一组 Agent,还装了它们赖以运行的环境:随机数生成器、空间容器、调度器、输入参数。你可以把 Swarm 理解成“一个可以独立运行的实验盒子”,盒子里面的个体按照你定义的规则交互,盒子外面的人只负责观察和收集数据。
我第一次看到ModelSwarm和ObserverSwarm这两个名字时,第一反应是“为什么要把模型和观察拆成两个类”。后来在参数实验里才体会到这个设计的好处:模型 Swarm 负责纯逻辑,比如个体移动、资源消耗、繁殖死亡;观察 Swarm 负责 GUI 图表、数据导出、指标统计。两者分开之后,你可以非常干净地去掉观察层,只留着模型层跑批处理,甚至把模型 Swarm 塞进一个无人值守的仿真循环里,让它在 300 组参数下连续跑几个晚上。观察层不参与 Agent 的行为决策,也就不会因为画图卡顿而改变模型时序。
ModelSwarm 的核心方法一般有三个:buildObjects负责创建 Agent 和空间,buildActions负责把行为注册到调度器,step负责推进一个仿真周期。ObserverSwarm 则是在模型跑起来之后,以一定的频率去读取内部状态,把种群数量、平均财富这类指标画成曲线。这个“读取状态”的动作在 Swarm 里也是通过调度器完成的,不是说观察 Swarm 自己在另一个线程里随时抓数据。
2.2 调度器与时序:同步更新为什么是默认选择
Swarm 2.1 的调度器是它的灵魂。它不是一个“每个 Agent 一个并发线程”的模型,而是一个单线程事件循环:你把动作打包成ActionGroup,再把ActionGroup挂到Schedule的某个 tick 上,仿真时钟每跳一次,调度器就执行一次对应动作。这样做的直接好处是时序可预测。两个 Agent 在同一 tick 内谁先动,直接决定全局状态和随机数消耗顺序,进而决定最终结果。
调度动作大体有两种:同步更新和异步更新。同步更新是说这一 tick 内所有 Agent 先根据上一步的状态做出决策,然后再统一把新状态写回去;异步更新则是 Agent 一旦做出决策,就立刻改变共享状态,后面的 Agent 能看到前面 Agent 刚改过的东西。在 Swarm 里实现同步更新通常是用两组状态变量:一组是上一 tick 的旧状态,一组是这一 tick 的新状态。我一般会把“决策”和“提交”拆开,决策阶段读旧状态,提交阶段写新状态,这样能避免很多莫名其妙的边界结果。
下面是一段 Java 接口下典型的调度注册骨架,重点不是 API 细节,而是结构:
import swarm.Globals; import swarm.activity.*; import swarm.defobj.*; public void buildActions() { ActionGroup stepGroup = (ActionGroup) Globals.env.createBegin("ActionGroup"); // 对 agentList 里的每个个体执行 step 方法 stepGroup.createActionForEach(agentList, "step"); // 让草场对象执行 regrow 方法,恢复资源 stepGroup.createActionTo(grassSpace, "regrow"); stepGroup = (ActionGroup) Globals.env.createEnd(); ScheduleImpl schedule = (ScheduleImpl) Globals.env.createBegin("Schedule"); // 从第 0 个 tick 开始,每隔 1 个 tick 执行一次 stepGroup schedule.at(0, 1, stepGroup); schedule = (ScheduleImpl) Globals.env.createEnd(); }这里最关键的两个参数:第一个0表示仿真的起始 tick;第二个1表示重复间隔。如果你希望某些动作只在特定时刻发生一次,可以把间隔设成-1或者单独用at:createAction:注册一次性动作。使用ActionGroup打包而不是直接在 Schedule 上挂十几个动作,是为了保证多个动作之间的顺序清晰、方便复用同一组动作在不同实验里以不同频率触发。
2.3 随机数与空间:可复现实验的两个底座
做 ABM 仿真的人最怕的是“这次能复现,下次换个机器就全变样”。Swarm 2.1 对随机数这件事做得很规矩:它提供了一个独立的随机数库,支持多种生成器,其中我常用的是MT19937(梅森旋转算法)。在模型初始化时,只要固定种子,那么整条随机序列就是确定的。注意这里有个隐含要求:所有 Agent 的随机行为都必须从这个随机数生成器里取数,而不是去调Math.random()或者系统时间,否则固定种子也没用。
空间选择上,Swarm 提供了网格和连续空间两大类。网格适合元胞自动机这类离散模型,连续空间适合个体位置和距离都敏感的模型。我在做劳动力流动模型时用的是连续空间,因为个体不再限于上下左右四个方向移动,距离成为影响交互概率的连续变量。无论选哪种,都要想好边界处理:到底是撞到边界就停下来,还是环绕(wrap)过去。空间对象上设置 wrap 参数后,左上角个体的邻居可能来自右下角,这种拓扑在一些研究中合理,在另一些研究里会彻底改变结果。我见过有人在这上面翻过车:模型跑了 2000 个 tick,发现积累率异常高,排查到最后是 wrap 让两个不相关的区域产生了虚拟邻居关系。
3. 从空目录到第一个模型:安装、最小案例与运行参数
3.1 环境选择与安装检查单
Swarm 2.1 的发行形态决定了你的安装路径。最常见的是源码包,其次是带 Java 接口的二进制包。源码包在 Linux 下需要 Objective-C 编译器和 X11 开发库,Java 接口还需要对应版本的 JDK。我第一次安装时吃了不少苦头,后来整理成一张检查单,按顺序走就不会漏:
| 检查项 | 推荐做法 | 容易踩的坑 |
|---|---|---|
| 系统环境 | Linux 优先,Windows 可用预编译版本 | macOS 新版对老旧 X11 库兼容差 |
| Objective-C 编译器 | 安装 GNU Objective-C 运行时 | 缺少头文件时 configure 能过,编译必挂 |
| X11 开发库 | 安装 X11 开发包、OpenGL 相关库 | 图形接口不是必需时,可以不配 GUI |
| JDK | 先看发行包要求再选版本 | 新版 JDK 与老旧 JNI 代码不兼容 |
| 环境变量 | 必须设置 SWARMHOME | 不设置时运行阶段才报错,排查成本高 |
| CLASSPATH | 把 Swarm 的 jar 包路径加进去 | Java 版运行时报 ClassNotFound 基本都赖这 |
安装完成后,用一个最小的 Java 类验证接口是否可用,比直接跑完整模型快得多。比如写一个只创建Zone对象的类,能正常编译运行,说明基础链路已经通了。环境变量方面,我一般会在启动脚本里固定写入如下内容:
export SWARMHOME=/opt/swarm-2.1 export JAVA_HOME=/usr/lib/jvm/java-8-openjdk export CLASSPATH=$SWARMHOME/lib/*.jar:$CLASSPATH export LD_LIBRARY_PATH=$JAVA_HOME/jre/lib/server:$LD_LIBRARY_PATHLD_LIBRARY_PATH这一行经常被忽略。Java 版 Swarm 在运行时需要通过 JNI 加载 JVM 自身,找不到libjvm.so时,报错不会说“缺库”,而是告诉你“无法初始化仿真环境”。先把这个变量确认好,后面能省很多时间。
3.2 最小案例:羊吃草模型的核心代码骨架
理解了环境,接下来看一个最小的可扩展模型。我习惯用“羊吃草”作为 Swarm 的 Hello World:网格里随机撒草和羊,羊每 tick 吃周围草,吃够了就繁殖,草以固定概率再生。它麻雀虽小,但覆盖了 Agent 创建、空间放置、调度注册、随机数使用四件套。
以下是 Java 接口下的示意骨架,不同发行版本的 API 名字略有差异,读的时候请对照你本地 jar 包里的实际类名,重点看结构:
import swarm.Globals; import swarm.defobj.*; import swarm.space.*; import swarm.random.*; import swarm.objects.ListImpl; public class ModelSwarm extends SwarmImpl { public int worldX = 40; public int worldY = 40; public int agentCount = 100; public long seed = 42; public RandomImpl random; public Grid2dImpl space; public ListImpl sheepList; public ScheduleImpl schedule; public Object buildObjects() { random = (RandomImpl) Globals.env.createBegin("Random"); random.setGeneratorName("MT19937"); random.setStateFromSeed(seed); random = (RandomImpl) Globals.env.createEnd(); space = (Grid2dImpl) Globals.env.createBegin("Grid2d"); space.setSize(worldX, worldY); space.setWrap(true, true); space = (Grid2dImpl) Globals.env.createEnd(); sheepList = (ListImpl) Globals.env.createBegin("ListImpl"); sheepList.setCount(agentCount); sheepList = (ListImpl) Globals.env.createEnd(); for (int i = 0; i < agentCount; i++) { SheepAgent sheep = (SheepAgent) Globals.env.createBegin("SheepAgent"); sheep.x = random.getIntegerWithMin(0, worldX - 1); sheep.y = random.getIntegerWithMin(0, worldY - 1); sheep.random = random; sheep = (SheepAgent) Globals.env.createEnd(); space.putObject(sheep, sheep.x, sheep.y); sheepList.putObject(sheep); } return this; } }逻辑说明:先创建随机数生成器,再创建空间和个体列表,最后在循环里初始化每个 Agent 并放入空间。这里两个参数值得注意:worldX和worldY决定仿真规模,规模越大每个 tick 的计算量线性增长;agentCount决定初始个体密度,密度太低时种群可能很快灭绝,密度太高时资源竞争会让模型在一个很窄的参数区间内震荡。seed是复现实验的根,换一组实验时我喜欢以“实验编号乘 1000”作为种子偏移,保证同一批实验内不重样,同时能事后还原任意一次运行。
Agent 的step方法里写单步决策,比如随机移动到相邻格子、判断脚下有没有草、决定是否繁殖。这个方法的返回值不需要开发者关心,调度器会老老实实地在每个 tick 调用它。这里我特别提醒:不要在step里做文件读写、GUI 刷新之类的高开销操作,否则 2000 个 tick 跑下来,性能会被拖到难以接受。
3.3 运行入口与参数配置
模型写好后,需要一个入口类来创建模型 Swarm、构建对象并启动调度器:
public class Mian { public static void main(String[] args) { ModelSwarm model = new ModelSwarm(); model.createBegin(); model.createEnd(); model.buildObjects(); model.buildActions(); model.execute(); } }编译运行时,最关键的是类路径和动态库路径要完整。常见做法是写一个run.sh,把上一步的环境变量和java命令封装在一起:
javac -cp "$SWARMHOME/lib/swarm.jar" *.java java -cp "$SWARMHOME/lib/swarm.jar:." Mian如果模型需要从外部读参数,我一般用 properties 文件。这样批量实验时只需要改文件,不需要重新编译代码:
worldX=40 worldY=40 agentCount=100 seed=42 maxTicks=2000在buildObjects之前读取这个文件,把参数值赋给模型对象。你可以在main方法里用Properties.load一行搞定,也可以借助 Swarm 自带的参数对象,效果差别不大。我选择 properties 的原因只有一个:它和 Python、shell 的配合成本最低,后续做参数扫描时,用脚本改一行然后重跑,路径最短。
4. 让模型输出可分析的结果:文件导出、参数扫描与 GUI 观察
4.1 把每一 tick 的指标落成 CSV 文件
Swarm 模型跑起来之后,如果没有输出,它就是一个黑匣子。我通常不会依赖 GUI 去追溯问题,而是让每个关键 tick 都输出一行结构化指标。最简单的办法是在ObserverSwarm或者模型自身的调度器里挂一个collector方法,每隔固定 tick 调用一次。
下面的代码示意了 Java 环境下如何追加写入 CSV:
import java.io.*; public void collect(int tick) { int sheepCount = countAliveSheep(); int grassAmount = countGrassCells(); try (PrintWriter out = new PrintWriter(new FileWriter("result.csv", true))) { out.println(tick + "," + sheepCount + "," + grassAmount); } catch (IOException e) { e.printStackTrace(); } }注意这里没有频繁打开文件的写法,每次调用都会打开文件句柄。对于 2000 个 tick 来说问题不大,但如果跑到几万 tick 且每次都刷新,磁盘开销会很明显。我是这样处理的:在实验一开始打开文件,模型结束后统一关闭;如果模型崩溃,用finally块保证文件被 close。另一个细节是 CSV 文件名不要固定写死,而是带上实验编号和重复编号,比如exp_12_rep_3.csv。这样参数扫描产生的大量结果文件不会互相覆盖。
4.2 参数扫描:从一个人在 GUI 里点,到脚本批量跑
ABM 项目的核心工作往往不是写 Agent 行为,而是搞明白参数在哪个区间内能出现你关心的现象。Swarm 2.1 本身没有内置强大的参数扫描工具,但因为它是一个可以命令行启动的 Java 程序,参数扫描就退化成一个循环脚本问题。
我会让程序支持两个命令行参数:参数文件名和输出文件名。这样在 shell 里就能组合出任意实验矩阵:
for row in 1 2 3; do for seed in $(seq 1 10); do sed "s/^agentCount=.*/agentCount=$row/" base.properties > exp.properties java -cp "$SWARMHOME/lib/swarm.jar:." Mian exp.properties exp_${row}_${seed}.csv done done脚本里sed负责替换参数文件中的agentCount,然后串行跑完 30 组实验。如果机器核数足够,可以在每组实验后加&让它们并行,最后用wait统一回收。并行时要保证每一组实验使用独立输出文件,否则多个进程写同一个 CSV 会造成行交错,结果直接没法用。这个坑我踩过,后来把文件名换成${row}_${seed}带完整上下文,再也没出现过。
4.3 用 Python 快速分析结果目录
参数扫描跑完之后,分析阶段我基本不用 GUI,而是把 CSV 目录交给 Python。常规做法是先读取所有实验文件,再按“实验行、重复序号”组织成长表:
import pandas as pd from pathlib import Path frames = [] for path in Path("out/").glob("exp_*.csv"): row_id = path.stem.split("_")[1] rep_id = path.stem.split("_")[2] df = pd.read_csv(path, names=["tick", "sheep", "grass"]) df["row"] = int(row_id) df["rep"] = int(rep_id) frames.append(df) result = pd.concat(frames, ignore_index=True) summary = result.groupby(["row", "tick"])["sheep"].agg(["mean", "std"])这段代码背后的价值是:每个实验重复 10 次,才能看出某个参数条件下系统是稳定收敛,还是大起大落。只看一次运行结果很容易被随机性误导。使用聚合后,你能直接比较不同参数下种群均值曲线和方差带,这才是 ABM 实验报告里真正能说服人的材料。
4.4 GUI 观察窗口:可以用来调试,别用来做实验结论
Swarm 2.1 提供了一套 GUI 观察工具,比如用ZoomRaster显示二维空间的个体分布,用EZGraph显示种群曲线。初次上手时看着界面里羊跑来跑去确实直观,但我现在只在两种场景下用它:一是刚写模型时确认 Agent 没有叠成一团、没有越界;二是调参数时快速观察定性变化,比如“密度是不是太低了”。
GUI 的问题在于它把整个仿真节奏锁在图形刷新频率上。模型本身可以跑很快,但画图一卡,所有 Agent 的调度也跟着拖慢。更重要的是,图形界面下你很难批量复现实验。所以我的建议很直接:核心实验永远用无头模式跑,输出 CSV 文件再做统计分析。GUI 是一个调试工具,不能作为实验数据产出渠道。
5. Swarm 2.1 避坑指南:安装、运行与复现性问题的排查清单
5.1 编译阶段报错:缺头文件、缺动态库
现象:configure 检查全部通过,实际编译到一半报cannot find X11/Xlib.h或者cannot find -ljvm。 原因:开发包没有安装完整。configure 只检查基础库,分发的依赖包却需要 X11 头文件和 JVM 动态库。 解决:先安装对应开发包,再重试。Linux 下分别是libx11-dev和 JDK 的libjvm.so所在目录。检查时不要只看有没有X11目录,还要确认头文件版本和编译器架构一致。32 位与 64 位混用是在旧模型迁移时最隐蔽的坑。
5.2 GUI 起不来或者跑一会儿就崩
现象:启动模型时卡在绘图初始化的界面,或者一打开ZoomRaster就段错误退出。 原因:图形环境与 Swarm 的 GUI 库不匹配。远程终端没有转发 X11 时尤其常见,另外某些发行版对老旧的 Tk 支持不佳。 解决:如果不需要可视化,直接把 ObserverSwarm 里的 GUI 部分去掉,或者用无头模式启动。如果必须看界面,检查DISPLAY环境变量,并用xvfb-run java ...在虚拟屏幕里跑 GUI 部分,再把渲染结果截出来看。不要试图在无显示服务器的情况下强行初始化图形对象。
5.3 调度器什么都不跑,或者跑一半卡死
现象:程序能启动,但step方法根本没被调用;或者运行到某个 tick 后不再推进。 原因:多数情况下是ActionGroup没有正确注册到Schedule,或者是调度循环里某个 Agent 的行为造成了无限循环。还有一个常见坑是createActionForEach的目标列表是空的,注册时列表里还没有放入 Agent。 解决:在buildActions里打印待调度的对象个数,确认列表不为空;每次从外部取消注释一个可疑 Agent 行为,逐步定位是哪个方法卡住。我在没有日志的模型里跌过跤,后来习惯在任何耗时耗时的循环里都加 tick 计数,每 100 次输出一行心跳日志,定位效率高很多。
5.4 结果无法复现:换了机器或者换了时间就变了
现象:同一份参数文件,上午跑和下午跑,最终数据完全不同;在另一台机器上跑,差异更大。 原因:模型里使用了Math.random()或System.currentTimeMillis()作为随机源。Swarm 自带的随机数生成器只控制它自己产生的序列,你一旦绕过它,固定种子就不起作用。 解决:全局搜索代码里的Math.random和currentTimeMillis,全部替换成从 Swarm 随机对象取数。如果有些算法库内部自建随机源,最好在实验脚本层固定一个线程级随机种子,并且把模型随机源与算法随机源分开记录。
5.5 数据输出乱码与路径异常
现象:CSV 文件里中文参数名变成乱码,或者输出路径带空格时程序找不到文件。 原因:properties 文件默认按 ISO-8859-1 解码,中文没做转义就会乱;路径处理没有考虑空格和相对路径的基准目录。 解决:properties 文件里避免写中文,全部用 ASCII 键值;路径使用绝对路径。文件输出目录在每次实验开始时创建,并写入一段含实验编号的元数据,防止旧文件干扰新实验。
6. 最后一个小技巧:把 Swarm 模型封装成可复用实验台
跑通一个 Swarm 模型只是起点,真正让它在项目里产生持续价值的是把它封装成一个“无头实验台”。我的做法分三步。
第一步,把模型核心与 Agent 业务完全解耦。模型 Swarm 只负责时间推进、调度执行、随机数分配,Agent 只负责行为规则,所有指标统计通过一个Collector接口注入。第二步,所有可调参数统一走 properties 文件,代码里不出现任何魔法数字。第三步,每个实验运行写入一个结果目录,目录名带上实验编号和重复编号,并附一份参数快照。这样任何一个时间段跑出来的数据,都能追溯到当时的参数、随机种子和代码版本。
如果需要在更大范围内调参,我会在外部脚本里做两层循环:外层遍历参数组合,内层做重复实验。每次运行之间清理内存中的观察对象,避免多次实验叠加状态。跑完之后不要急着看结果,先按“同参数、不同重复”的方差做一次汇总,异常大的方差往往意味着模型进入了临界状态,这时候再去关注这个参数点附近的分岔行为,会比漫无目的地扫参高效得多。
我曾经在调整迁移成本参数时,把可视化界面打开了一个晚上,盯着屏幕等曲线变化,后来发现这个项目真正的瓶颈不是模型运行速度,而是我没把参数扫描脚本化。把无头实验台搭起来之后,原先要三天的调参工作,变成了一次半小时的批量运行加一张趋势图。那之后我养成了一个习惯:每进一个新模型,先把输出格式和批量运行脚本写好,再回头写 Agent 逻辑。顺序一换,后面省下的时间远超多写脚本那半小时。希望这些经验对你有帮助。
本文还有配套的精品资源,点击获取