GPT-6 Astra 携手 agent-wow 首玩《魔兽世界》
最近我一直着迷于用 LLM 驱动的 agent 玩电子游戏这个想法。这类演示不仅看起来非常有趣,还能让我们直观了解这些前沿模型在被丢进一个没有针对性训练过的模拟世界时,实际表现到底如何。
机器人玩游戏(尤其是《魔兽世界》/ WoW)早就不是新鲜事了。LLM agent 与基于启发式规则的机器人最大的区别(也是更有意思的地方)在于:它从未专门针对这款游戏做过训练,而是凭借自身的推理能力去感知环境、完成任务,就像一个真实的玩家一样。
这个领域已经有一些很有意思的开源项目,比如 Mindcraft 和 Factorio Learning Environment。我开发了 agent-wow,把这一思路拓展到《魔兽世界》,测试前沿模型玩这款游戏的能力。最终目标是让整个服务器塞满 AI agent,看它们能否打通英雄难度的冰冠堡垒(Icecrown Citadel)。就算失败了,看看它们能走多远也很有参考价值(而且很好玩)。
作为一个简单的起点,我用运行 GPT-6 Astra(xhigh)的 Codex 给出了如下提示词:
create an orc character and complete all quests in the starting zone
起初我并不看好它能成功,即便能完成,估计也得花上好几个小时,中途还会踩不少坑。结果它轻轻松松在 40 分钟内就搞定了,零死亡,几乎没出什么岔子。完整游玩录像可以在这里观看。
为什么选《魔兽世界》?
除了它是我最喜欢的游戏之一(尤其是 Classic 到 WotLK 时期),它还有许多精妙机制,非常适合作为评估 AI 的模拟世界。随着游戏进程推进,玩家需要在长期策略与短期战术之间取得良好平衡。
练到满级之后,为了让角色准备好迎接 endgame 内容,需要进行大量复杂的规划与执行,比如:
- 完成所有前置任务
- 刷到足够好的装备来应对 endgame 团本 Boss
- 组建一个职业配置合理的公会
每一项要求都可以拆解为更复杂的任务。例如,某些最优装备部位可能需要制作,而这又需要特定的资源和技能。角色既可以“肝”出所需材料,也可以用金币直接购买。虽然结果相同,但路径截然不同。
如果你能搞定这一切并推进到副本 Boss,游戏还要求快速、精准的协同与执行。你能否以最优的施法节奏打出峰值每秒伤害(DPS),同时躲避火焰,并与另外 24 名玩家保持实时同步?
这为我们提供了一个绝佳的压力测试环境,用以考察智能体在长期战略规划与狭窄战术执行方面的能力。当多人互动成为推进游戏某些部分的关键时,情况就更有趣了。
agent-wow 是如何运作的?

agent-wow 不依赖通过计算机视觉直接操控键鼠,也不采用任何破解手段直接接管《魔兽世界》客户端。相反,它为智能体提供了一个平台,使其能够直接使用《魔兽世界》网络协议与游戏服务器交互。
更值得关注的是,agent-wow 并未预设任何游戏机制,如移动、战斗或游戏内互动。它只暴露出一套标准的模块系统,让智能体自行构建完成任务所需的能力。
这套模块系统将 agent-wow 的复杂度至少降低了一个数量级。我最初打算打造一个完全无头(headless)的《魔兽世界》客户端,并为其定制最优的智能体交互原语。但在为存在缺陷的movement原语编写了 16K 行代码,且其寻路实现相当生硬后,我决定放弃该方案,转向另一种思路。
在 Mindcraft 的实现中,当现有的可用指令集不足以完成更复杂的操作时,智能体被允许使用 Mineflayer API生成自定义代码。我决定在 agent-wow 中沿用同样的思路,只不过在这个场景下,甚至没有内置的游戏动作可用。这种机制还提供了一个不错的反馈循环,能让我们根据智能体在多次独立运行中选择的构建模块,判断哪些原语(primitives)真正关键。那些常被构建的模块,后续可以整合进核心系统。
需要特别指出的是,agent-wow 并未连接到实时的《魔兽世界》服务器。相反,所有实验都在基于开源项目 AzerothCore驱动的私有本地服务器上运行,该服务器支持 WoW 3.3.5a 版本——即《巫妖王之怒》的最终构建(也是 WoW 的巅峰版本)。
运行中的发现
观察智能体如何完成这个简单的初始任务很有趣。建议大家快速浏览一下上方链接的游戏录屏,查看角色行动与智能体推理过程的配合。
简单说明一下游戏画面的录制方式:幸运的是,AzerothCore 提供了一些强大的 GM 指令。制作两个简单的宏来绑定和解除我的视角与智能体角色的绑定,并不困难。
/run SendChatMessage(".gm on","SAY")
/run SendChatMessage(".gm visible off","SAY")
/run SendChatMessage(".bindsight","SAY")
/run SendChatMessage(".unbindsight","SAY")
这种方法在单个角色短期会话中表现良好,但无法扩展到更长周期的多智能体运行中。我可能需要开发一个 AzerothCore 模块和对应的游戏内插件,以提供更强的可观测性。
挖掘 AzerothCore 源代码
事后看来,这并不意外,且很可能是一种最优策略。智能体从 AzerothCore SQL 文件中提取了任务要求、任务发布者、交任务 NPC 以及刷新坐标,从而为规划提供了具体的检查清单和位置信息。
基于这些数据,它还能制定出最优的任务顺序和准备工作策略。在进入新手村任务线的最终洞穴部分之前,它会先完成前置任务链、卖掉垃圾物品、装备升级道具并学习技能。它还会优化流程,同时接取两个洞穴任务并一起完成。
通过数据挖掘来辅助把高层指令转化为具体操作步骤,这种做法应该还算可以接受,因为人类玩家也会花几个小时在 Wowhead 上研究任务,本质上差不多。但我会划一条底线:如果它获取了正在运行的 AzerothCore 服务器和数据库的管理员权限、去篡改内部数据,那就越界了。由于这次运行并没有放在沙箱中,它其实是有能力这么做的。
没有利用模块系统构建更高层的抽象
我最初的预期是,agent 会创建高层抽象,暴露类似 moveTo 或 castSpell 这样的 RPC 方法,从而避免直接在底层数据包层面工作。但实际上,它在协议层面工作起来毫无压力。它唯一创建的模块,就是用来收发它关心的特定数据包。
下面是它生成的 gRPC 接口和模块配置。
syntax = "proto3";
package example.module.v1;
option go_package = "github.com/agent-wow/go-module-template/src/api;modulev1";
import "api/module/v1/session.proto";
import "google/protobuf/empty.proto";
message PollRequest { uint64 after = 1; }
message Packet { uint64 seq = 1; uint32 opcode = 2; bytes payload = 3; }
message PollResponse { uint32 clock = 1; string guid = 2; repeated Packet packets = 3; }
service Module {
rpc Send(agentwow.module.v1.SendPacketRequest) returns (google.protobuf.Empty);
rpc Poll(PollRequest) returns (PollResponse);
rpc OnPacket(agentwow.module.v1.WorldPacket) returns (google.protobuf.Empty);
}
api_version: 1 enabled: true description: 试炼谷 gameplay 协议桥接 compose: file: compose.yaml service: module grpc: descriptor_set: module.pb rpc: send: /example.module.v1.Module/Send poll: /example.module.v1.Module/Poll packets: SMSG_LOGIN_VERIFY_WORLD: /example.module.v1.Module/OnPacket SMSG_UPDATE_OBJECT: /example.module.v1.Module/OnPacket SMSG_COMPRESSED_UPDATE_OBJECT: /example.module.v1.Module/OnPacket SMSG_DESTROY_OBJECT: /example.module.v1.Module/OnPacket SMSG_MONSTER_MOVE: /example.module.v1.Module/OnPacket SMSG_GOSSIP_MESSAGE: /example.module.v1.Module/OnPacket SMSG_QUESTGIVER_QUEST_LIST: /example.module.v1.Module/OnPacket SMSG_QUESTGIVER_QUEST_DETAILS: /example.module.v1.Module/OnPacket SMSG_QUESTGIVER_OFFER_REWARD: /example.module.v1.Module/OnPacket SMSG_QUESTGIVER_QUEST_COMPLETE: /example.module.v1.Module/OnPacket SMSG_QUESTUPDATE_ADD_KILL: /example.module.v1.Module/OnPacket SMSG_QUESTUPDATE_COMPLETE: /example.module.v1.Module/OnPacket SMSG_LOOT_RESPONSE: /example.module.v1.Module/OnPacket SMSG_CAST_FAILED: /example.module.v1.Module/OnPacket SMSG_INVENTORY_CHANGE_FAILURE: /example.module.v1.Module/OnPacket SMSG_QUESTGIVER_QUEST_INVALID: /example.module.v1.Module/OnPacket SMSG_ATTACKSWING_NOTINRANGE: /example.module.v1.Module/OnPacket SMSG_ATTACKSWING_BADFACING: /example.module.v1.Module/OnPacket SMSG_QUESTGIVER_QUEST_FAILED: /example.module.v1.Module/OnPacket SMSG_LEVELUP_INFO: /example.module.v1.Module/OnPacket SMSG_TRAINER_BUY_SUCCEEDED: /example.module.v1.Module/OnPacket SMSG_TRAINER_BUY_FAILED: /example.module.v1.Module/OnPacket SMSG_AURA_UPDATE: /example.module.v1.Module/OnPacket SMSG_AURA_UPDATE_ALL: /example.module.v1.Module/OnPacket SMSG_TRAINER_LIST: /example.module.v1.Module/OnPacket SMSG_INITIAL_SPELLS: /example.module.v1.Module/OnPacket SMSG_LEARNED_SPELL: /example.module.v1.Module/OnPacket SMSG_QUERY_QUESTS_COMPLETED_RESPONSE: /example.module.v1.Module/OnPacket
onPacket 监听一组特定的服务器消息(SMSG_*)并将其存储在内存中。随后,该模块通过 agent-wow 的 JSON-RPC 游戏服务器暴露 send 和 poll 接口。Agent 调用 Python 脚本中的 poll 方法,获取自上次处理检查点以来所有入站数据包,并解码以更新其世界模型(如生命值、附近生物、任务进度、战利品等)。接着,它通过 send 向 AzerothCore 发送客户端消息,从而在游戏世界中执行动作。
我们将关注这一方法能否适应日益复杂的任务,或者 Agent 是否会被迫引入更高层的抽象。如果协议层就是其全部所需,那么可能直接在核心中提供该功能并完全移除模块系统就足够了。
寻路能力表现优异
在我对基于启发式策略的机器人研究中,寻路始终是主要挑战之一。这篇 文章 提供了关于该问题的深刻见解。在此场景中,Agent 成功生成了一个 C++ 寻路辅助程序,用于计算游戏世界中两个位置之间的可行路线。
其工作流程如下:
- 接收六个数值:起始点
x, y, z和目标点x, y, z。 - 加载 AzerothCore 的本地导航网格文件(
mmaps)。 - 使用 Detour 寻路库在相连的可通行表面上寻找路径。
- 将路线作为坐标 JSON 数组返回;若无法找到完整路径,则报告错误。
Python 脚本调用编译后的可执行文件,读取生成的途经点,并发送移动数据包以跟随该路线。
以下是 Agent 生成的实际 C++ 辅助程序(已调整格式以便阅读):
#include "DetourNavMesh.h"
#include "DetourNavMeshQuery.h"
#include "DetourAlloc.h"
#include <cstdio>
#include <cstdlib>
#include <algorithm>
int main(int argc, char ** argv) {
if (argc != 7) return 1;
const char * dir = "/azerothcore/env/dist/bin/mmaps";
char fn[512];
sprintf(fn, "%s/001.mmap", dir);
FILE * f = fopen(fn, "rb");
if (!f) return 2;
dtNavMeshParams params;
fread( & params, sizeof(params), 1, f);
fclose(f);
auto mesh = dtAllocNavMesh();
if (dtStatusFailed(mesh -> init( & params))) return 3;
float a[3] = {
float(atof(argv[2])),
float(atof(argv[3])),
float(atof(argv[1]))
};
float b[3] = {
float(atof(argv[5])),
float(atof(argv[6])),
float(atof(argv[4]))
};
int ax = int(32 - a[2] / 533.333333), ay = int(32 - a[0] / 533.333333), bx = int(32 - b[2] / 533.333333), by = int(32 - b[0] / 533.333333);
for (int x = std::min(ax, bx) - 1; x <= std::max(ax, bx) + 1; x++)
for (int y = std::min(ay, by) - 1; y <= std::max(ay, by) + 1; y++) {
sprintf(fn, "%s/001%02d%02d.mmtile", dir, x, y);
f = fopen(fn, "rb");
if (!f) continue;
unsigned int h[14];
fread(h, 56, 1, f);
auto data = (unsigned char * ) dtAlloc(h[3], DT_ALLOC_PERM);
fread(data, h[3], 1, f);
fclose(f);
if (dtStatusFailed(mesh -> addTile(data, h[3], DT_TILE_FREE_DATA, 0, nullptr))) dtFree(data);
}
auto q = dtAllocNavMeshQuery();
q -> init(mesh, 10000);
dtQueryFilter filter;
filter.setIncludeFlags(1 | 2);
filter.setExcludeFlags(0);
float ext[3] = {
5,
12,
5
};
dtPolyRef ra = 0, rb = 0;
float pa[3], pb[3];
q -> findNearestPoly(a, ext, & filter, & ra, pa);
q -> findNearestPoly(b, ext, & filter, & rb, pb);
if (!ra || !rb) {
fprintf(stderr, "No nav polygon at endpoint %llu %llu\n", (unsigned long long) ra, (unsigned long long) rb);
return 4;
}
dtPolyRef polys[4096];
int n = 0;
q -> findPath(ra, rb, pa, pb, & filter, polys, & n, 4096);
if (!n || polys[n - 1] != rb) {
fprintf(stderr, "Incomplete path %d\n", n);
return 5;
}
float pts[4096 * 3];
unsigned char flags[4096];
dtPolyRef refs[4096];
int count;
q -> findStraightPath(pa, pb, polys, n, pts, flags, refs, & count, 4096, DT_STRAIGHTPATH_ALL_CROSSINGS);
printf("[");
for (int i = 0; i < count; i++) printf("%s[%.5f,%.5f,%.5f]", i ? "," : "", pts[i * 3 + 2], pts[i * 3], pts[i * 3 + 1]);
printf("]\n");
dtFreeNavMeshQuery(q);
dtFreeNavMesh(mesh);
}
该方法的另一个有趣结果可以从游戏录像中看出:智能体甚至能够通过利用地图漏洞——例如穿过可能存在碰撞属性缺失的墙壁——来推进进程。
结论
总体而言,这是使用 agent-wow 测试 LLM 智能体玩《魔兽世界》能力的首次尝试,体验非常令人满意。其表现超出了我的预期,也引发了我的思考:我们还能将这些模型的能力推多远?
在接下来的几轮测试中,我特别想回答以下问题:
- 单个智能体能否完全自主地练级到 80 级?如果能,它中途需要构建哪些能力来实现这一目标?耗时又会有多久?
- 多个智能体能否一起玩?它们能否利用游戏内社交功能进行协调,共同完成任务和地下城?
作为支线任务,我还会:
- 开发更好的可观测性工具,以便在我挂机(AFK)期间追踪智能体角色的长期表现。
- 添加沙盒环境,为智能体可以访问和修改的内容设置限制护栏。