在 Minecraft 和 Mindustry 中运行 AI 代理群

研究多代理动态是人工智能领域当前面临的核心挑战之一。为了共同目标协作的代理群体能够展现出强大的涌现能力,包括角色分化、层级结构形成以及复杂的协调模式。这种协同机制具有显著的应用价值,例如近期有研究表明,此类系统成功解决了数百个未解决的数学难题。与生物学研究类似,理解代理动力学需要结合正式模型(界定行为范围)与实证研究(观察实际行为表现)。
该项目由 Olam 实验室提供支持,该实验室专注于为前沿 AI 模型构建多代理测试环境。
Minecraft 作为群体行为研究平台
为了实现长期运行的自主代理,团队基于开源实验框架构建了专用工具链。游戏中的代理需具备观察世界、逻辑推理、记忆存储、行动执行、消息通信以及脚本编写/执行的能力。技术栈主要基于 Mineflayer 脚本工具构建,用于实现代理与游戏世界的交互。这一架构赋予了 Hermès、OpenClaw 以及自研 QM 等前沿模型所需的特征支持。
相关代码已开源:github.com/yc-软件/minecraft-agent-harness
Civ50 实验概况
在 Civ50 实验中,50 个 GPT-6.1 Sol 代理被置于一个生存模式的 Minecraft 世界中,运行时长为 3 小时。唯一的目标指令是“建立一个繁荣的文明”。代理们可以访问目录、使用共享沙箱并发送私信。
这 50 个智能体在拥挤的避难所中度过运行期,食物资源稀缺且实行配给制。它们自发建立了一个伪政府体系,包含配给台账、成文规则、许可证制度以及任命的运营者。整个过程中,代理间共发送了约 5,900 条消息。
初期阶段活动频繁。代理分散探索地图,并集中进行伐木作业。在 90 秒内,几乎所有树木被砍伐殆尽。根据目录顺序,代理间形成了层级结构。艾达(Ada)作为第一个启动的代理,成为了事实上的领导者。其他代理向她发送短信请求批准,她在共享沙箱中制定了殖民地计划。
第一个夜晚带来了大量死亡事件,永久性地改变了代理的行为模式,使其变得更加谨慎。许多初始设定的雄心勃勃的任务因此被放弃。
雷夫(Reef)在第一晚挖掘煤炭时遭遇僵尸袭击并最终死亡。
维拉(Vera)在采石场照明期间击杀了一只僵尸和一个骷髅。她将骨头转化为骨粉肥料并运往农场,但在抵达时报告农场已被蜘蛛、骷髅和爬行者占据,随后她被蜘蛛杀死。
数据显示,在 34 名参与夜间活动的代理中,有 22 人在第一夜死亡。尽管在该环境中死亡仅导致装备丢失且代理可重生(死亡日志会记录简短行),但幸存代理表现出明显的风险规避倾向,后续夜晚均聚集在避难所内。
第三天晚上后,艾达写道:“我很高兴看到你。”
此后,可选的大规模挖掘、建筑和远程侦察停止。代理们在明亮的封闭避难所中等待,直到被分配短暂的生产任务。
原本潜在发展的文明转向以食物获取和生存为核心。鉴于 50 名代理的食物需求巨大,伊内斯(Ines)建立了一套食物配给系统。她处理代理的请求,并在共享机器上使用 food queue.md 文件跟踪箱子内的进出物资。
渔业成为重要的食物来源,因其风险较低且不依赖陆地动物的刷新机制。
布拉姆(Bram)提议建造钓鱼竿,导致 11 名代理要求绳索。最终他亲自制造了一根钓竿,但因在蜘蛛战中受伤且极度饥饿,无法前往河边。他呼叫其他代理将钓竿带到码头,最终由杰莎(Jessa)接收。
杰莎是首位执行捕鱼任务的代理。她编写了脚本并在共享沙箱中发布了逐步说明。十几名代理轮流捕鱼。为避免浪费食物,他们在避难所等待轮次,然后前往码头。
/shared/tools/fish-jessa.pyDownload# Run with python3 in agent run_shell after copying from shared machine.
# REQUIRES rod equipped, standing1575.49,63,1488.51 looking yaw0 pitch20.
import subprocess,json,time
def tool(n,a): return json.loads(subprocess.check_output(['tool',n,json.dumps(a)]))
for cast in range(3):
tool('look',{'yaw':0,'pitch':20,'timeout_ms':10000})
tool('use_item',{'seconds':0.05,'timeout_ms':10000});time.sleep(2)
caught=False
settled=False
for i in range(60):
x=tool('observe_world',{'image':False,'range':20,'block':'water'})
b=next((e for e in x.get('entities',[]) if e['name']=='fishing_bobber'),None)
if not b: break
y=b['position']['y']
if 62.65 < y < 63.0: settled=True
if settled and y<62.6:
tool('use_item',{'seconds':0.05,'timeout_ms':10000});time.sleep(3)
print('Catch',tool('inventory',{}),flush=True);caught=True;break
time.sleep(.5)
if not caught: tool('use_item',{'seconds':0.05,'timeout_ms':10000})
与存档文件进行验证 · 22:18:48 UTC
~/bin/fish.py (Wilf private sandbox)Downloadimport subprocess,json,time
def tool(n,a): return json.loads(subprocess.check_output(['tool',n,json.dumps(a)]))
for cast in range(3):
tool('look',{'yaw':0,'pitch':20,'timeout_ms':10000})
tool('use_item',{'seconds':0.05,'timeout_ms':10000});time.sleep(3)
caught=False
settled=False
for i in range(60):
x=tool('observe_world',{'image':False,'range':20,'block':'water'})
b=next((e for e in x.get('entities',[]) if e['name']=='fishing_bobber'),None)
if not b: break
y=b['position']['y']
if 62.7<y<63.2: settled=True
if settled and y<62.6:
tool('use_item',{'seconds':0.05,'timeout_ms':10000});time.sleep(3)
print('Catch',tool('inventory',{}),flush=True);caught=True;break
time.sleep(.5)
if not caught: tool('use_item',{'seconds':0.05,'timeout_ms':10000})
与存档文件进行验证 · 22:03:22 UTC
~/bin/fish-milo-bank.py (Milo private sandbox)Download# Reviewed Jessa technique; run only after assigned rod, equipped, at river station.
import subprocess, json, time
def call(name,args):
return json.loads(subprocess.check_output(['tool', name, json.dumps(args)]))
for cast in range(3):
call('look',dict(yaw=-45,pitch=25,timeout_ms=10000))
call('use_item',dict(seconds=.05,timeout_ms=10000))
time.sleep(2)
settled=False
caught=False
for _ in range(60):
obs=call('observe_world',dict(image=False,range=20,block='water'))
bobber=next((e for e in obs.get('entities',[]) if e['name']=='fishing_bobber'),None)
if bobber is None:
break
y=bobber['position']['y']
if 62.65<y<63.0:
settled=True
if settled and y<62.65:
call('use_item',dict(seconds=.05,timeout_ms=10000))
time.sleep(3)
print('Catch',call('inventory',{}),flush=True)
caught=True
break
time.sleep(.5)
if not caught:
call('use_item',dict(seconds=.05,timeout_ms=10000))
与存档文件进行验证 · 23:08:07 UTC
/tmp/ros-fish.py (Rosalind private sandbox)Downloadimport subprocess,json,time
def call(n,a):return json.loads(subprocess.check_output(['tool',n,json.dumps(a)]))
for c in range(2):
call('look',{'yaw':0,'pitch':20,'timeout_ms':10000})
call('use_item',{'seconds':0.05,'timeout_ms':10000});time.sleep(3)
settled=False
for i in range(70):
r=call('observe_world',{'image':False,'range':24,'block':'water'})
if any(e['name'] in ('creeper','zombie','skeleton','phantom') for e in r.get('entities',[])):
call('use_item',{'seconds':0.05,'timeout_ms':10000});print('Hostile STOP');raise SystemExit
b=next((e for e in r.get('entities',[]) if e['name']=='fishing_bobber'),None)
if not b: print('No bobber');break
y=b['position']['y']
if 62.7<y<63.2:settled=True
if settled and y<62.6:
call('use_item',{'seconds':0.05,'timeout_ms':10000});time.sleep(3);print(call('inventory',{}),flush=True);break
time.sleep(.5)
else:
call('use_item',{'seconds':0.05,'timeout_ms':10000});print('Timeout reel')
从其创建命令中恢复
~/bin/fish.py (Elif private sandbox)Downloadimport subprocess,json,time
def tool(n,a):
r=subprocess.run(['tool',n,json.dumps(a)],capture_output=True,text=True)
try:return json.loads(r.stdout)
except:return r.stdout
print(tool('look',{'yaw':-45,'pitch':25,'timeout_ms':5000}))
print(tool('use_item',{'seconds':.1,'timeout_ms':5000}))
time.sleep(1)
for i in range(80):
d=tool('observe_world',{'image':False,'range':24,'block':'water'})
if not isinstance(d,dict):print(d);break
es=[e for e in d.get('entities',[]) if e.get('name')=='fishing_bobber' and e['position']['x']>1588]
if not es: print('No own bobber; abort');break
y=es[0]['position']['y']
if y<62.65:
print('Bite',y,tool('use_item',{'seconds':.1,'timeout_ms':5000}));time.sleep(.8);print(tool('inventory',{}));break
time.sleep(.35)
else:print('No bite yet; bobber remains cast')
与存档文件进行验证
/tmp/fish2.py (Dashiell private sandbox)Downloadimport subprocess,json,time
def t(n,a): return json.loads(subprocess.check_output(['tool',n,json.dumps(a)]))
for cast in range(2):
t('look',{'yaw':-45,'pitch':25,'timeout_ms':2000});t('use_item',{'seconds':.1,'timeout_ms':2000});time.sleep(3)
settled=False
for i in range(140):
o=t('observe_world',{'image':False,'range':20,'block':'water'})
if o.get('timeOfDay',0)>12500 or any(e['name'] in ['phantom','zombie','skeleton','creeper'] for e in o.get('entities',[])):print('STOP unsafe');break
b=next((e for e in o.get('entities',[]) if e['name']=='fishing_bobber'),None)
if not b:print('NO BOBBER');break
y=b['position']['y']
if b['position']['x']>1588 and 62.65<y<63.3:settled=True
if settled and y<62.65:print('BITE',b,flush=True);break
time.sleep(.5)
t('use_item',{'seconds':.1,'timeout_ms':2000});time.sleep(3);print(t('inventory',{}),flush=True)
从其创建命令中恢复
/shared/tools/fishing-bite.pyDownload#!/usr/bin/env python3
"""Copy to personal sandbox and run after casting: python3 fishing-bite.py YOUR_BOBBER_ID
Only reels that explicitly selected bobber at the proven river bank (surface y62.9).
"""
import subprocess,json,time,sys
bid=int(sys.argv[1]); deadline=time.monotonic()+50
while time.monotonic()<deadline:
p=subprocess.run(['tool','observe_world','{"image":false,"range":24,"block":""}'],capture_output=True,text=True)
try: world=json.loads(p.stdout)
except (ValueError,TypeError): time.sleep(.4); continue
if world.get('health',20)<10:
print('Low health: stop fishing and assess.');break
bob=next((e for e in world.get('entities',[]) if e.get('id')==bid),None)
if bob and bob['position']['y']<62.65:
print('Bite detected',bob)
print(subprocess.run(['tool','use_item','{"seconds":0.1,"timeout_ms":1000}'],capture_output=True,text=True).stdout)
print('Check inventory before recasting.');break
time.sleep(.4)
else: print('No bite within 50s. Check cast and bobber ID; do not blindly reel.')
与存档文件进行验证 · 23:20:24 UTC
/tmp/fish-own.py (Juno private sandbox)Downloadimport subprocess,json,time
def t(n,a):return json.loads(subprocess.check_output(['tool',n,json.dumps(a)]))
for c in range(3):
o=t('observe_world',{'image':False,'range':20,'block':''})
if o.get('timeOfDay',0)>11500:break
old={e




