AI玩超级马里奥:NEAT进化算法训练游戏智能体 AI学习玩马力欧这个主题看起来只是一个搞笑又有观赏性的演示实际拆开以后会发现它把进化算法、神经网络、游戏模拟器和奖励设计这几件事串在了一起。Code Bullet 的中文配音版把这个过程讲得特别直观AI 从一开始乱跳乱撞到几百代之后能主动躲坑、算准跳跃时机那种进步过程非常有代表性。这篇文章不打算复述视频内容而是从一个想动手复现的开发者视角把这个项目背后的环境准备、算法思路、参数调整和常见坑点完整拆一遍。如果你一直好奇“AI 怎么学会打游戏的”或者准备用 NEAT、遗传算法做自己的游戏智能体这篇值得看完。我会先讲清楚 AI 到底在学什么然后给出一套可以跑通的最小复现流程再讲训练时怎么判断效果最后列几条我踩过之后才发现的问题。1. 先看清这个项目到底在教 AI 学什么很多人看到 AI 玩马力欧第一反应是“AI 是不是读懂了游戏规则”。实际上它根本没有理解游戏。整个过程里AI 看到的只是一堆数字和像素它做的只是不断试错然后让表现更好的网络结构保留下来。Code Bullet 中配版最有价值的一点就是把这种“试错-筛选-进化”的循环展示得很清楚。1.1 输入是一帧画面和一串坐标而不是游戏语义为了让 AI 知道当前状态输入大致分成两类。一类是画面信息比如把每帧游戏画面缩小成网格把每个格子里的像素特征作为输入节点。另一类是游戏内部状态比如马里奥当前的横向坐标、纵向坐标、速度、是否在地面、关卡时间等。MarI/O 这类经典项目主要用坐标和画面边缘信息Code Bullet 的风格更像是在这两类输入基础上做了自己的调整。选择哪种输入直接影响训练难度。如果只给一帧画面AI 需要自己从像素里提取“前面有坑”“头顶有砖”等特征神经网络必须足够大训练时间也会变长。如果直接给马里奥的坐标和速度信息简化了训练会快很多但泛化能力可能变差。很多复现项目卡在“长时间不进步”就是因为输入设计问题而不是算法本身有问题。1.2 输出是按键组合AI 根本不需要关心跳跃和跑步的语义输出层也不是“是否跳跃”“是否跑步”这种高层动作而是原始的按键状态。超级马里奥的按键包括方向键、跳跃键、加速键等网络直接输出每个按键按下或松开。AI 不知道“跳跃”是什么意思它只知道给某个输出节点一个正数就按了某个键发现这样做移动得更远于是这个连接权重就被保留下来。这种端到端的做法优点是灵活AI 可能学会玩家想不到的按键组合缺点是搜索空间大早期容易出现一堆原地抽搐的个体。因此在训练初期适应度函数和随机性设置特别重要。不要试图让 AI 直接输出“跳起来躲过敌人”这样抽象的动作因为那不是进化算法该干的事。2. 想复现这套玩法环境准备和资源占用是关键复现这种项目最麻烦的不是算法而是环境。模拟器版本、脚本接口、内存地址、屏幕刷新方式都会影响能不能跑起来。Code Bullet 中配版演示的时候看起来一气呵成实际你按同样思路搭环境时大概率会先在模拟器这一步卡住。2.1 模拟器选型BizHawk 比裸跑 Snes9x 更好挂脚本MarI/O 最早基于 Snes9x通过 Lua 脚本读取内存和发送按键。现在更推荐用 BizHawk因为它的 Lua 接口更稳定支持多种模拟核心还提供了画面和内存的访问 API。如果你只是看视频和跑现成脚本Snes9x 也行但一旦要改动状态读取、批量跑很多个体BizHawk 会顺手很多。选模拟器时还要注意你的系统。BizHawk 主要面向 WindowsLinux 和 macOS 下跑起来会比较折腾。如果你只有 Linux可以尝试 RetroArch 的 Lua 脚本模式但接口差异不小。我的建议是先确认自己用什么系统再选对应的模拟器不要一上来就照着视频抄代码。2.2 脚本语言与依赖Lua 版本、内存地址读取和界面刷新BizHawk 内置 Lua 环境有些版本还支持 C# 外部调用。以 Lua 为例你需要熟悉几个核心 APIclient.screenshot()截图用于像素输入。memory.read_u16_le(addr)读取内存地址比如马里奥的 X、Y 坐标。input.get()或joypad.set()读取或设置按键。emu.frameadvance()让模拟器前进一帧。gui.drawText()在画面上绘制训练信息。这些 API 在不同版本的 BizHawk 里会有细微差异建议先跑一个最简单的脚本每帧读取一次坐标并画在屏幕上。如果这一步能跑通后面训练循环就顺手了。2.3 低配机器能跑吗帧率、同步模式和时间缩放很多人担心这种训练需要特别高的显卡。其实 NEAT 训练马里奥主要瓶颈是 CPU因为模拟器要在每一帧计算游戏逻辑神经网络也要算前向传播。低配机器能跑但要控制种群大小和每代步数。我一般建议先把模拟器设置成“不限制帧率”或者“最大速度”模式。BizHawk 里可以关闭音视频同步让模拟器尽量快地跑帧。还要注意如果你的脚本里每个个体都做一次截图大量内存拷贝会拖慢速度低配机器上尤其明显。注意第一轮验证时不要开大种群先用 20 个个体、每个个体跑 2000 帧确认整个流程能在可接受时间内结束再逐步加大规模。3. 从零训练一个会跑会跳的马里奥智能体环境搭好后就可以按最精简的流程训练。这里我用 MarI/O 风格的 NEAT 流程来说明Code Bullet 的做法本质上也是这个路子。整个流程分成四段初始化、评估、筛选、进化。3.1 第一步用算法初始化神经网络种群NEATNeuroEvolution of Augmenting Topologies会从极小的网络开始一个输入层、一个输出层刚开始几乎没有隐藏层。每个个体有一组连接权重这些权重会决定它面对当前输入时按哪个键。种群里的每个个体权重和网络结构都不一样所以就算输入相同动作也可能完全不同。初始化时关键在于输入节点的定义。例如-- 示例假设输入向量包含马里奥坐标和前方地形信息 inputs { marioX / 10000, marioY / 10000, velocityX / 100, velocityY / 100, isOnGround and 1 or 0, -- 前方网格像素特征 ... }输入量纲要尽量控制在 0 到 1 附近。直接塞原始坐标会导致权重对数值大小特别敏感训练不稳定。这是我早期经常忽略的问题。3.2 第二步设计适应度函数不要让 AI 只顾往前跑适应度函数是训练的灵魂。最简单的函数是“马里奥最终到达的 X 坐标”。但这个函数有个问题AI 可能站在坑边不敢动或者跳进坑里但 X 坐标很大。为了避免这种情况通常会在适应度里减掉一些惩罚项比如落地后原地发抖、长时间重复同一个位置、或者反向跑。我见过一个更适合初学者的写法每一帧如果马里奥的 X 坐标比之前记录的最大 X 更大就加上一个增量如果马里奥死亡就扣掉一份时间惩罚如果到达终点额外加一个高分。这样 AI 会更倾向于前进同时避开“站着不动”的局部最优解。-- 示例累加适应度 if marioX maxX then fitness fitness (marioX - maxX) maxX marioX end if marioY -100 then fitness fitness - 20 -- 掉落惩罚 endCode Bullet 的视频里AI 有时候会形成一种很“苟”的玩法比如贴着墙慢慢蹭就是因为适应度函数只奖励了前进没有考虑操作效率。这不是 bug而是函数设计的结果。想让人眼看过去“像人在玩”就得额外惩罚多余动作和长时间停顿。3.3 第三步打乱初始位置避免背板式学习如果每一代都从同一位置开始AI 很容易背板。它记住的不是“跳跃的时机”而是“在第 100 帧按下跳跃键”。这在实际游戏中没有任何泛化能力。为了解决这个问题可以在评估开始时随机设置马里奥的起始位置或者从不同进度开始。但要注意随机起始位置会引入额外方差早期训练可能不稳定。我建议先固定起点跑通流程等适应度曲线开始上升后再加入随机起始位置。直接一开始就随机容易让所有个体都死在早期看不出差别。3.4 第四步让种群代际进化观察得分曲线和操作差异每一代种群里的个体都跑完一定步数后根据适应度排序留下表现最好的几个然后让它们生成下一代。NEAT 在这里会做两件事对网络权重做小幅变异产生新的微调版本。以较低概率改变网络结构增加新节点或新连接。训练过程中你会看到适应度曲线从接近 0 慢慢上升同时操作也从胡乱按键变成有节奏的移动。Code Bullet 的中配版里最吸引人的就是这个过程你能直接看到神经网络是怎么一步步学会“过坑”的。4. 参数调优和效率判断怎么知道它真的在变强训练能不能出效果很大程度取决于参数。参数不是越多越好也不是照搬一个值就行。你要理解每个参数在控制什么然后根据训练反馈调整。4.1 种群大小、突变率和神经网络层数的取舍种群大小决定了探索能力。小种群20-50训练快但容易陷入局部最优大种群200覆盖面广但每代耗时成倍增加。我的建议是先用 50 左右跑一个能出结果的流程再根据硬件和效果调大。突变率也不是越高越好。太高会让已经学到的结构被冲掉太低则探索不足。NEAT 里连接权重突变和节点增加的概率通常是分开的权重突变常设在 0.1 到 0.3 之间结构突变概率要比它低一个数量级。具体数字要根据遗传代数来调不要迷信某个固定值。网络层数不要一开始就追求深。NEAT 的优势就是从小网络开始逐步增加。如果你人为加三层隐藏层输入输出之间的梯度关系会变得更复杂进化效率反而下降。先把输入特征做干净让网络自己决定要不要加节点。4.2 单个个体能跑多少步以及死亡判定的设计“每个个体跑多少帧”这个参数经常被忽略。跑得太短AI 还没走几步就结束适应度区分度不够跑得太长训练时间直接爆炸。一个常见做法是设定最大步数比如 3000 帧并规定早退条件马里奥死亡。马里奥在连续 600 帧内 X 坐标没有明显变化。关卡时间耗尽。加入早退条件后无效个体能快速淘汰节省大量时间。这个思路在 Code Bullet 这类视频里看不到但实际训练时特别重要。4.3 训练速度的观察指标每代耗时、平均适应度、最大适应度训练时不要只看最终有没有通关。你需要持续观察三个指标指标判断方法每代耗时如果一代从几十秒涨到几分钟说明个体在关卡里活得更久这是进步但如果每代都很慢且适应度没涨可能卡在复杂输入或模拟器同步上平均适应度平均适应度持续上升说明整体种群都在进步如果只有最大适应度很高平均适应度不涨说明探索方向太少最大适应度最大适应度出现阶段性平台很常见连续多代不升再考虑调参数另外要记录最佳个体的“通关率”或者“最大到达位置”。比如第 50 代能到第一个检查点第 80 代能到第二个检查点这种信息比单纯适应度更直观。4.4 为什么有时候学会的玩法不是人眼喜欢的玩法这一点是很多人看完 Code Bullet 中配版后会有的疑问AI 明明越来越强却经常用很怪的操作比如反复原地小跳、贴着墙边蹭、在敌人旁边来回拉扯。原因很简单AI 只优化适应度函数不优化“看起来像人”。如果你想让它玩得更自然可以做两件事在适应度函数里加“操作变化惩罚”减少连续相同帧的重复按键。限制每次按键的最小持续时间避免高频抖动。但加这些约束也不是越多越好。约束太多AI 会变得保守通不了关。所以先让 AI 能过关再打磨操作风格。5. 常见报错、卡死和结果异常的排查顺序做这种项目最花时间的不是理解算法而是排查“为什么脚本没按预期跑”。下面这些坑我基本都遇过按排查优先级整理出来。5.1 模拟器没有响应先看内存读取、坐标地址和按键映射出现启动后黑屏、画面不动、脚本报错第一个检查点不是算法而是地址对不对。不同版本的超级马里奥游戏内存地址可能不同。你从网上找的地址如果不匹配自己的 ROM读出来的坐标就是错的。排查方式很简单在脚本里每帧把marioX和marioY画到画面上然后手动控制马里奥走几步。如果坐标数值不变化说明地址不对。如果数值正确再看按键映射确认joypad.set设置的键位和模拟器里的键位一致。5.2 训练几百代还是站在原地优先查输入范围、奖励和动作空间如果适应度一直很低个体总是卡在起点通常有几种可能输入特征没有归一化某个维度的数值范围太大或太小导致神经网络输出被某个特征主导。输出层激活函数用的不是适合二值动作的函数比如用了线性输出很多按键处于中间值。适应度函数给的奖励太稀疏AI 在早期没有足够反馈知道自己进步了。我的建议是先做一个“人类测试”手动玩一段记录从起点到第一个检查点能拿到多少适应度。如果人类能轻松拿到 100 分AI 却普遍只能拿 5 分说明奖励或者输入有问题。如果 AI 也能慢慢拿到接近 100 分就耐心多跑几代。5.3 成绩提升但真实水平不行警惕过拟合和特殊镜头状态有时候最大适应度已经很高但把同一个 AI 放回完整关卡重新测试它可能在前面就死了。这通常是过拟合了训练时使用的“起始片段”。特别是每一代都从同一个位置开始AI 只学会了处理那一段画面。改善方法是让起始位置的随机性随时间增加或者交叉验证训练过程用多个不同的起始片段最后评估时用一个全新片段。Code Bullet 的视频里也有类似的表现AI 在训练关卡里很强换到另一个关卡就表现不佳这不是 AI 变笨了而是它的训练范围本身太窄。5.4 脚本运行越来越慢检查屏幕截图、遗传日志和队列训练到后期有时会发现每代耗时越来越长。常见原因不是神经网络变复杂而是脚本做了太多“低效操作”每次评估都截图并且没有限制截图频率。遗传日志里记录了每个个体的完整输入向量导致内存暴涨。早退条件没有生效大量个体一直卡在墙上原地抖动直到最大步数才退出。优化方式也很简单截图只用于可视化训练时可以用内存地址代替日志尽量只记录关键指标不要记录帧级数据早退条件要定期检查不要每帧都做大量图像对比。6. 从“玩马力欧”到更复杂的 AI Agent几点现实边界看这类项目很容易产生一种错觉AI 很快就能学会任何游戏。真实情况是马里奥这种横版过关游戏因为有清晰的坐标、简单的按键和可量化的“前进距离”非常适合用进化算法演示。一旦换成 3D 开放世界、复杂策略游戏或者需要长期规划的任务NEAT 直接套用会非常吃力。6.1 NEAT 并不适合所有游戏像素输入和坐标输入是两条路线像素输入能保留更多信息但训练量会大很多。坐标输入信息更干净但需要人手工标注或读取内存这种依赖是很多人忽略的。Code Bullet 中配版里的训练对象是固定关卡所以坐标输入很有效。如果你想做通用 Agent单纯靠 NEAT 无法在复杂环境里稳定工作。更常见的方法是结合深度强化学习比如 DQN、PPO让神经网络直接处理像素帧。但这样做对算力的要求会明显提升而且奖励设计仍然是最关键的工作。从工程角度来说NEAT 更适合“低算力环境 简单游戏”的场景它更像一个教学和启发工具。6.2 强化学习与进化算法的区别在哪里简单来说进化算法依赖“变异 选择”不关心单次行为的好坏只看最终适应度强化学习依赖“奖励信号 时序信用分配”每一步的奖励都会传递给之前的动作。对于马里奥这种一步一步操作、即时反馈很明显的游戏两者都能用。如果项目目标是训练一个能通关的智能体强化学习通常更主流但如果你想展示一个看得到进化过程的项目NEAT 的效果更直观。Code Bullet 的中配版之所以让人印象深刻就是因为你能看到个体从随机到熟练的进化曲线。6.3 中配版视频带给我的启发演示价值大于框架价值看完中配版我最大的感受是这类项目最大的价值不是提供一个“能打游戏的 AI 框架”而是演示了如何把输入、输出、奖励、进化这四个要素组合起来。你真正学到的也不是模仿特定代码而是建立一套“先跑通最小闭环再调参数再优化效率”的调试方法。后面我自己做类似项目时对很多细节的敏感度明显提高了比如先确认输入提取正确再考虑适应度函数先跑小种群验证再扩大规模先记录日志判断瓶颈再优化代码。这套思路放到 AI Agent 开发、AI 编程提示词优化甚至调试一个复杂业务系统都同样适用。如果你也想试建议先不要追求复现一个完整的马里奥 AI而是用一个最简单的网格游戏或者横版 Demo 跑通流程。等你能稳定看到适应度曲线持续上升再去挑战完整关卡会顺利很多。