
1. 项目缘起与整体设计思路第一次看到“LLM 通过写代码来打星际争霸”这个点子的时候我正坐在电脑前啃一份 BWAPI 的文档。说实话那一瞬间我脑子里蹦出来的第一个念头不是“这玩意儿能跑起来吗”而是“终于有人把大模型从聊天框里拽出来扔进一个真正有状态、有对抗、有延迟的战场里了”。这个项目的核心其实一句话就能说清楚搭一个星际争霸母巢之战的竞技场让大语言模型不是靠直接输出操作指令而是靠写代码来驱动游戏里的单位。听起来像是把“AI 打游戏”和“AI 写代码”这两件事硬捏在一起但真正拆开看它解决的是一个非常具体的问题——大模型在实时策略游戏里的动作空间太大、决策频率太高直接让它每一步都输出操作既不稳定也没法复用。而让它写代码本质上是把“决策”变成了“策略生成”模型只需要在回合之间写出一段逻辑剩下的执行交给代码去跑。我之所以对这个方向特别感兴趣是因为它踩中了一个很微妙的交叉点。星际争霸的 BWAPI 本身就是一个 C 的接口层它把游戏内部的状态暴露出来同时允许外部程序发送指令。而 LLM 最擅长的恰恰是写 C 代码——至少是写看起来能编译的 C 代码。这两者一结合就形成了一个闭环模型读游戏状态写 C 逻辑编译成动态库注入到游戏里执行然后观察结果再写下一版。这个设计思路背后有几个关键考量。第一状态压缩。星际争霸的原始状态维度极高如果直接把所有单位、资源、地图信息塞给模型上下文根本扛不住。所以项目里通常会做一个状态摘要层把游戏状态转成结构化的文本或者 JSON只保留关键信息比如当前人口、资源、敌方可见单位、己方部队构成。第二代码沙箱。模型写的代码不能直接跑在主进程里否则一个死循环或者内存越界就能把整个竞技场搞崩。所以需要一个隔离的编译和执行环境通常是每次生成代码后编译成独立的动态库加载到游戏进程里并且设置超时和资源限制。第三回合制与实时制的折中。星际本身是实时的但 LLM 的推理速度跟不上帧级别的决策。所以项目一般会采用“策略回合”的方式比如每 30 秒或者每 1000 帧让模型重新生成一次代码中间的时间由上一版代码持续控制单位。这个思路的优势在于它把 LLM 的代码生成能力和策略表达能力结合起来了。模型不需要学会微操它只需要写出“如果敌方空军多就造刺蛇如果资源够就开分矿”这样的逻辑。代码本身可以包含条件判断、循环、甚至简单的状态机。而且一旦代码写出来它就可以被复用、被修改、被版本管理。你可以想象一个场景两个 LLM 各自写自己的 C 策略然后让它们在同一个地图上打 100 局看谁的代码胜率高。这比让模型直接输出操作指令要稳定得多也更容易分析和调试。当然这个设计也有它的代价。最大的问题就是编译延迟。每次模型生成代码后都需要编译成动态库这个时间从几秒到几十秒不等。如果模型写的代码有语法错误或者链接错误还需要把错误信息反馈给模型让它重新生成。这就形成了一个“生成-编译-报错-再生成”的循环有时候要好几轮才能得到一个能跑的版本。所以项目里通常会有一些容错机制比如保留上一版可用的代码作为兜底或者限制模型只能修改特定的函数体而不是整个文件。另一个考量是游戏接口的封装。BWAPI 本身是 C 的但直接让模型写 BWAPI 调用很容易出错因为 API 的细节太多。所以项目一般会提供一个更高层的封装库比如ArenaAPI里面提供一些更语义化的函数比如getMyUnits()、getEnemyUnits()、attack(unit, target)、build(buildingType, position)。模型只需要调用这些函数而不需要关心底层的 BWAPI 细节。这个封装层通常是用 C 写的但接口设计得尽量简单减少模型犯错的概率。从影响范围来看这个项目其实打开了一个新的实验场。它不仅仅是“让 LLM 打游戏”而是提供了一个可编程的对抗环境用来测试 LLM 在长程规划、资源管理、对抗策略生成方面的能力。传统的 LLM 评测大多是静态的问答或者代码补全而这个竞技场是动态的、有对手的、有资源约束的。你可以用它来研究模型在压力下的策略稳定性也可以用来比较不同模型在同一个游戏里的表现差异。甚至可以用来做课程学习让模型从简单地图开始逐步挑战更复杂的地图。2. 核心技术点拆解与实操要点2.1 BWAPI 与 OpenBW 的角色分工要理解这个竞技场怎么跑起来得先搞清楚 BWAPI 和 OpenBW 各自负责什么。BWAPI 是暴雪官方提供的一个 C 接口它允许外部程序读取星际争霸的游戏状态并发送指令。但 BWAPI 本身是运行在 Windows 上的而且需要星际争霸的游戏本体。这就带来了两个问题一是跨平台困难二是游戏本体的版权和分发限制。OpenBW 就是为了解决这个问题而出现的。它是一个开源的星际争霸引擎重实现兼容 BWAPI 的接口但不需要游戏本体而且可以跨平台运行。OpenBW 的核心是一个 C 的库它模拟了星际争霸的游戏逻辑包括单位移动、战斗、资源采集等。你可以把它理解成一个“无头”的星际争霸没有图形界面但游戏逻辑是完整的。在这个竞技场项目里OpenBW 通常作为游戏引擎运行在后台BWAPI 作为接口层暴露给外部程序。LLM 生成的代码通过 BWAPI 调用 OpenBW 的游戏状态和指令。整个链路是这样的LLM 生成 C 代码 - 编译成动态库 - 加载到竞技场主程序 - 主程序通过 BWAPI 与 OpenBW 交互 - OpenBW 更新游戏状态 - 主程序读取状态并反馈给 LLM。这个分工的关键在于OpenBW 让整个系统可以跑在 Linux 服务器上不需要图形界面也不需要游戏本体。这对于自动化测试和批量对战来说非常重要。你可以在一台服务器上同时跑多个竞技场实例每个实例加载不同的 LLM 策略然后让它们互相对战。实操中需要注意的一点是OpenBW 的版本和 BWAPI 的版本需要匹配。不同版本的 BWAPI 接口可能有细微差异如果版本不匹配编译时会出现链接错误。我一般会先确认 OpenBW 的文档里推荐的 BWAPI 版本然后严格按照那个版本去配置环境。另外OpenBW 的编译本身也需要一些依赖比如 CMake、Boost、SDL 等在 Ubuntu 上通常需要先装好这些依赖再编译。2.2 LLM 代码生成与编译沙箱让 LLM 写 C 代码最大的风险就是代码质量不可控。模型可能会写出语法错误的代码也可能会写出能编译但运行时会崩溃的代码比如空指针解引用、数组越界、死循环。所以编译沙箱的设计就非常关键。我见过几种不同的沙箱方案。最简单的是进程隔离每次模型生成代码后启动一个新的编译进程把代码写到一个临时目录里用g编译成动态库。如果编译失败就把错误信息收集起来反馈给模型。如果编译成功就把动态库加载到游戏进程里。但这种方式的问题是如果动态库里的代码有运行时错误比如死循环游戏进程可能会卡死。所以还需要一个超时机制比如给每个动态库的执行设置一个时间上限超过就强制卸载。另一种方案是容器隔离把整个编译和执行环境放在一个 Docker 容器里每次生成代码后启动一个新的容器编译并运行然后收集结果。这种方式更安全但启动容器的开销比较大不适合高频次的回合制对战。在实际项目中我倾向于用混合方案编译阶段用进程隔离执行阶段用动态库加载加超时监控。具体来说主程序会维护一个“当前策略”的动态库句柄每次模型生成新代码后先编译成临时动态库编译成功后用dlopen加载然后替换掉旧的句柄。旧句柄在确认新句柄可用后再dlclose。执行时主程序会在每个游戏帧调用动态库里的onFrame()函数但这个调用会被包在一个超时检查里如果某个帧的调用时间超过阈值就认为策略有问题回退到上一版。这里有一个实操细节C 的动态库接口需要用extern C来避免名称修饰问题。通常会在头文件里定义几个固定的函数签名比如extern C { void onStart(); void onFrame(); void onUnitDestroy(int unitId); }模型生成的代码只需要实现这些函数而不需要关心动态库的加载细节。这样既降低了模型的认知负担也保证了接口的稳定性。2.3 状态摘要与提示词设计LLM 的上下文窗口是有限的而星际争霸的状态是海量的。所以必须做一个状态摘要层把游戏状态压缩成模型能处理的文本。这个摘要层的设计直接影响到模型的策略质量。我一般会把状态摘要分成几个部分经济状态、军事状态、地图信息、敌方情报。经济状态包括当前矿物、气体、人口、人口上限、农民数量、基地数量。军事状态包括己方单位类型和数量、敌方可见单位类型和数量。地图信息包括己方基地位置、敌方基地位置、资源点分布。敌方情报包括最近看到的敌方建筑和单位以及它们出现的时间。这些信息会被格式化成一段结构化的文本比如[Economy] Minerals: 450 Gas: 200 Supply: 32/50 Workers: 18 Bases: 2 [Military] My Units: 12 Marines, 4 Medics Enemy Units: 6 Zerglings, 2 Hydralisks [Map] My Base: (32, 48) Enemy Base: (96, 112) Expansions: (64, 64) neutral然后这段文本会作为提示词的一部分发给模型提示词里还会包含上一版代码的摘要、当前回合的目标、以及一些约束条件比如“不要使用未定义的函数”、“不要写超过 200 行的代码”。提示词的设计有几个坑。第一不要给模型太多细节。如果状态摘要太长模型会迷失在细节里反而忽略了大局。我一般会把摘要控制在 500 字以内只保留最关键的信息。第二要明确告诉模型当前的目标。比如“当前是游戏第 5 分钟你的目标是守住第一波进攻并开出分矿”。第三要提供上一版代码的反馈。如果上一版代码有编译错误或者运行时崩溃要把错误信息附在提示词里让模型知道哪里出了问题。2.4 回合制调度与实时执行的衔接星际是实时游戏但 LLM 的推理速度是秒级的。所以必须做一个调度层把实时游戏切成一个个“策略回合”。每个回合的长度可以是固定的比如 30 秒游戏时间也可以是事件驱动的比如“当敌方发起进攻时”或者“当资源足够开分矿时”。我一般会用固定回合加事件触发的方式。固定回合保证模型有规律地更新策略事件触发保证模型能对突发情况做出反应。具体实现上主程序会维护一个计时器每过 30 秒游戏时间就触发一次“策略更新”。同时主程序会监听一些关键事件比如“敌方单位进入己方基地范围”、“己方单位数量低于阈值”一旦触发就立即发起策略更新。策略更新的流程是这样的主程序暂停游戏或者不暂停但把控制权交给一个默认策略收集当前状态生成提示词调用 LLM API等待模型返回代码编译代码加载动态库然后恢复游戏。整个过程可能需要几秒到几十秒这段时间里游戏不能完全停住否则对手会占便宜。所以通常会有一个“默认策略”在模型更新期间接管控制比如让所有单位撤退到基地附近或者继续执行上一版代码。这里有一个实操心得不要让模型在游戏暂停时更新策略。因为暂停会破坏游戏的实时性而且如果模型更新失败游戏会卡在暂停状态。更好的做法是让游戏继续跑但把控制权交给一个简单的默认策略等模型更新完成后再切换回来。这样即使模型更新失败游戏也能继续只是策略暂时退化。3. 完整实操流程与关键环节实现3.1 环境搭建从零到能跑起来第一步是装依赖。我一般在 Ubuntu 20.04 或 22.04 上做这个因为 OpenBW 和 BWAPI 在 Linux 上的支持比较好。需要装的包包括sudo apt-get update sudo apt-get install -y build-essential cmake git libboost-all-dev libsdl2-dev libsdl2-image-dev libsdl2-ttf-dev然后克隆 OpenBW 的仓库编译安装git clone https://github.com/OpenBW/openbw.git cd openbw mkdir build cd build cmake .. make -j$(nproc) sudo make install接着是 BWAPI。BWAPI 本身是 Windows 的但在 Linux 上有一个兼容层通常叫BWAPI或者BWAPI4J。我一般会用 OpenBW 自带的 BWAPI 兼容层因为它已经和 OpenBW 的版本匹配好了。如果自己编译 BWAPI需要注意版本匹配问题否则会出现链接错误。编译完 OpenBW 和 BWAPI 后需要写一个最小的测试程序确认能加载游戏并读取状态。这个测试程序通常是一个 C 文件里面调用 BWAPI 的Game::getInstance()和Game::getMinerals()等函数。如果编译通过并且能输出当前矿物数量说明环境搭好了。这里有一个坑OpenBW 默认可能没有启用 BWAPI 接口需要在编译时加上-DBWAPION或者类似的选项。具体选项要看 OpenBW 的文档。另外OpenBW 需要一个地图文件才能启动游戏通常会用maps/目录下的.scm或.scx文件。如果没有地图游戏会启动失败。3.2 竞技场主程序的结构竞技场主程序是整个系统的核心它负责加载 OpenBW、管理游戏循环、调用 LLM、编译代码、加载动态库。我一般会把主程序分成几个模块游戏引擎模块封装 OpenBW 的初始化和游戏循环提供step()函数来推进游戏帧。状态摘要模块从游戏引擎读取状态生成结构化的文本摘要。LLM 调用模块把提示词发给 LLM API接收返回的代码。编译沙箱模块把代码写进临时文件调用g编译成动态库返回编译结果。策略加载模块用dlopen加载动态库调用onFrame()等函数。调度模块管理回合计时器和事件触发协调各个模块的工作。主程序的主循环大概是这样的while (game.isRunning()) { game.step(); if (scheduler.shouldUpdateStrategy()) { auto state stateSummarizer.summarize(game); auto prompt promptBuilder.build(state, lastError); auto code llmClient.generate(prompt); auto result compiler.compile(code); if (result.success) { auto newHandle dlopen(result.libraryPath.c_str(), RTLD_LAZY); if (newHandle) { strategyHandle newHandle; lastError.clear(); } } else { lastError result.error; } } if (strategyHandle) { auto onFrame (void(*)())dlsym(strategyHandle, onFrame); if (onFrame) { onFrame(); } } }这个结构的关键在于策略更新和游戏循环是解耦的。游戏循环一直在跑策略更新在后台进行更新完成后才切换策略。这样即使 LLM 调用很慢游戏也不会卡住。3.3 提示词模板与代码生成约束提示词的质量直接决定了模型生成的代码质量。我一般会用这样的模板You are a StarCraft: Brood War strategy AI. You control the Terran race. Your task is to write C code that implements a strategy for the current game state. Current game state: {state_summary} Previous code summary: {previous_code_summary} Last error (if any): {last_error} Constraints: - Only use functions provided in the ArenaAPI header. - Do not use any external libraries. - Keep the code under 200 lines. - Implement the following functions: onStart(), onFrame(). - Do not use infinite loops or blocking calls. Write the complete C code below:这个模板有几个关键点。第一明确角色和种族。星际有三个种族不同种族的策略差异很大所以必须告诉模型它控制的是哪个种族。第二提供上一版代码的摘要。如果上一版代码能跑就把它的核心逻辑摘要一下让模型知道当前策略是什么。第三附上错误信息。如果上一版代码编译失败或者运行时崩溃把错误信息附上让模型知道哪里出了问题。第四约束条件要具体。不要写“代码要高效”这种模糊的要求要写“不要使用无限循环”、“代码不超过 200 行”这种可检查的约束。模型返回的代码通常会包含 Markdown 代码块标记比如cpp ...。所以在编译之前需要先把这些标记去掉提取出纯代码。这个提取逻辑要写得健壮一点因为模型有时候会返回多个代码块或者代码块标记不完整。我一般会用正则表达式匹配cpp 和之间的内容如果有多个代码块就取最后一个。3.4 编译与错误反馈编译环节是整个流程里最容易出问题的地方。模型写的代码可能有各种语法错误比如缺少分号、括号不匹配、使用了未定义的变量。所以编译器的错误信息需要被完整地捕获并反馈给模型。我一般会用g编译命令大概是g -shared -fPIC -stdc17 -I/path/to/arenaapi -o /tmp/strategy.so /tmp/strategy.cpp-shared表示生成动态库-fPIC表示生成位置无关代码-stdc17指定 C 标准-I指定 ArenaAPI 头文件的路径。编译输出会被重定向到一个文件里如果编译失败就把这个文件的内容作为错误信息。错误信息通常很长直接发给模型可能会超出上下文窗口。所以需要做一个错误摘要只保留最关键的几行。我一般会提取错误信息里的error:行以及它们前面的文件名和行号。比如/tmp/strategy.cpp:45:10: error: attack was not declared in this scope /tmp/strategy.cpp:52:5: error: expected ; before } token这样的摘要既能让模型知道哪里错了又不会占用太多上下文。如果编译成功但运行时崩溃比如段错误那就需要另一种反馈机制。我一般会在动态库里加一个信号处理器捕获SIGSEGV和SIGFPE然后把崩溃信息写到一个日志文件里。主程序在下一轮更新策略时会读取这个日志文件把崩溃信息附在提示词里。3.5 对战与结果评估当两个 LLM 策略都准备好后就可以让它们对战了。对战的方式通常有两种同进程对战和跨进程对战。同进程对战是把两个策略加载到同一个游戏实例里一个控制玩家 1一个控制玩家 2。跨进程对战是启动两个游戏实例通过网络或者共享内存同步状态。同进程对战实现起来简单但有一个问题两个策略共享同一个进程空间如果一个策略崩溃整个游戏都会崩溃。所以更稳妥的方式是跨进程对战每个策略跑在自己的进程里通过一个中间层同步游戏状态。但跨进程对战的延迟更高实现也更复杂。我一般会先用同进程对战做快速测试确认两个策略都能跑起来然后再用跨进程对战做正式评估。评估的指标包括胜率、平均游戏时长、资源采集效率、单位交换比。这些指标可以从游戏日志里提取出来然后做成表格对比。指标策略 A策略 B胜率65%35%平均游戏时长12:3012:30平均矿物采集85007200单位交换比1.81.2这样的表格能直观地看出两个策略的优劣。如果策略 A 的胜率高但资源采集低说明它可能更偏向进攻如果策略 B 的资源采集高但胜率低说明它可能更偏向防守但进攻乏力。4. 常见问题与排查技巧实录4.1 编译失败模型代码的典型错误模型写的 C 代码最常见的错误有这么几类。第一类是语法错误比如缺少分号、括号不匹配、字符串引号不闭合。这类错误编译器会直接报出来把错误信息反馈给模型后通常下一轮就能修好。第二类是未定义函数模型可能会调用 ArenaAPI 里不存在的函数比如buildMarine()而不是train(UnitTypes::Terran_Marine)。这类错误需要把 ArenaAPI 的头文件内容附在提示词里让模型知道有哪些函数可用。第三类是类型错误比如把int传给需要Unit的函数。这类错误比较隐蔽因为 C 的隐式类型转换有时候会让代码编译通过但运行时行为不对。我踩过的一个坑是模型有时候会写#include BWAPI.h但竞技场环境里可能没有这个头文件或者路径不对。所以我会在提示词里明确告诉模型“不要包含任何头文件ArenaAPI 已经自动包含了。”然后在编译时用-include选项强制包含 ArenaAPI 的头文件。4.2 运行时崩溃死循环与空指针运行时崩溃比编译失败更难排查因为编译器不会报错但游戏会卡死或者闪退。最常见的崩溃原因是死循环。模型可能会写while (true) { ... }这样的代码导致onFrame()永远不返回。解决办法是在调用onFrame()时加一个超时检查比如用std::async或者fork来执行如果超过 100 毫秒就强制终止。另一个常见原因是空指针解引用。模型可能会写auto unit getUnit(id); unit-attack(target);但没有检查unit是否为空。解决办法是在 ArenaAPI 里把getUnit()的返回值设计成智能指针或者可选类型让模型必须处理空值情况。或者在提示词里明确要求“在调用任何单位方法之前先检查单位是否存在。”还有一个坑是内存泄漏。模型可能会在onFrame()里new一个对象但忘记delete导致内存持续增长。虽然单次对战时内存泄漏可能不明显但长时间运行后会导致游戏变慢甚至崩溃。解决办法是在 ArenaAPI 里尽量用栈对象和智能指针避免暴露裸指针给模型。4.3 LLM 调用超时与重试LLM API 的调用有时候会很慢尤其是当提示词很长或者模型负载很高的时候。如果调用超时整个策略更新流程就会卡住。我一般会设置一个超时时间比如 30 秒如果超过就放弃这次更新继续用上一版策略。同时会记录超时次数如果连续超时多次就降低更新频率比如从每 30 秒更新一次改成每 60 秒更新一次。重试策略也很重要。如果 LLM 返回的代码编译失败不要立即重试而是把错误信息附在提示词里让模型在下一轮更新时修正。如果连续多轮都编译失败可以考虑回退到一个“安全策略”比如一个简单的防守策略保证游戏能继续跑。4.4 游戏状态同步问题在跨进程对战时游戏状态的同步是一个大问题。两个进程需要看到同一个游戏状态否则策略会基于错误的信息做决策。我一般会用共享内存来同步状态一个进程作为“主机”运行游戏引擎另一个进程作为“从机”读取状态并发送指令。主机每帧更新共享内存里的状态从机每帧读取状态并写入指令。这个方案的难点在于同步时机。如果从机读取状态的时候主机正在更新状态可能会读到不一致的数据。解决办法是用双缓冲或者原子操作来保证状态的一致性。我一般会用两个缓冲区主机写一个从机读另一个每帧交换。这样从机读到的永远是一个完整的状态快照。4.5 常见问题速查表问题现象可能原因排查方法解决方案编译失败报未定义函数模型调用了 ArenaAPI 里不存在的函数检查错误信息里的函数名在提示词里附上 ArenaAPI 的函数列表游戏卡死无响应模型代码里有死循环用调试器 attach 到进程看堆栈给 onFrame 加超时检查游戏闪退空指针解引用或数组越界查看崩溃日志里的信号类型在 ArenaAPI 里加空值检查LLM 调用超时提示词太长或 API 负载高查看 API 响应时间设置超时和重试降低更新频率策略不更新编译一直失败或 LLM 一直超时查看日志里的错误信息回退到安全策略检查提示词两个策略行为一样状态摘要没有区分玩家检查状态摘要里的玩家标识在摘要里明确标注“你是玩家 1”5. 经验总结与扩展方向这个项目我断断续续折腾了几个月最大的体会是LLM 写代码的能力比我想象的要强但让它写能跑的 C 代码还是需要很多工程上的辅助。你不能指望模型一次就写出完美的代码但你可以通过好的提示词、好的错误反馈、好的沙箱设计让它在几轮之内收敛到一个可用的策略。另一个体会是状态摘要的设计比代码生成本身更重要。如果状态摘要给得不好模型再强也写不出好策略。我试过把完整的游戏状态塞给模型结果它完全迷失在细节里写出来的代码全是微操没有大局观。后来我把摘要精简到只保留经济、军事、地图、敌方情报四个部分模型的策略质量明显提升。这个项目后续还可以往几个方向扩展。一是多模型对战让不同厂商的 LLM 在同一个竞技场里比拼看谁的策略更强。二是课程学习从简单地图开始逐步增加难度让模型在对抗中进化。三是策略分析把模型生成的代码收集起来分析它们的共同模式和差异看看 LLM 在策略生成上有没有一些通用的偏好。最后分享一个小技巧如果你也想搭一个类似的竞技场建议先从单机模式开始让一个 LLM 策略对抗游戏自带的 AI。这样你可以先验证整个链路能不能跑通再考虑双 LLM 对战。单机模式的调试成本低很多而且游戏自带的 AI 行为稳定适合做基准测试。等单机模式跑稳了再上双 LLM 对战这时候你已经有了一套完整的日志和评估工具排查问题会容易很多。