AI学习玩马力欧,这个实验因为 Code Bullet 的视频被很多人认识和讨论过:一个智能体从完全不懂规则开始,通过不断尝试,慢慢学会起跳、躲坑、踩敌人,最终走完关卡。Code Bullet 使用的核心方法并不是大众印象里的深度学习,而是神经进化算法 NEAT。这个项目之所以适合拿来学习,是因为它能在单个 Python 脚本里完整展示状态感知、决策模型、奖励评估、存档加载的闭环,也可以作为进入强化学习、AI Agent 开发和本地模型部署的入门项目。
这篇博客会按一条可复现的路径展开:先说 NEAT 为什么适合这个任务,再准备 Python 环境和模拟器,然后定义状态空间和动作空间,接着写出适应度评估和训练主循环,最后回放结果并处理典型问题。整个流程在本地电脑上就能跑,不需要 GPU,也不需要调用云端 API。如果你对“AI 为什么能学会玩游戏”这件事感兴趣,或者想把一个 AI 训练 demo 改造成可维护的工程实验,这篇内容会给你一条相对完整的参考线。
1. 为什么选择 NEAT 来训练马里奥 AI
1.1 规则脚本的死在细节爆炸
最直观的玩法是手写规则:看到坑就跳,看到敌人就躲。第一关前半段确实能靠几行规则跑通,但越往后越难维护。马里奥的关卡里包含管道、金币、乌龟、蘑菇、悬崖、问号砖,每个对象在不同速度、不同高度、不同状态下都会产生不同行为。规则脚本很快会变成几百个 if 分支,而且这些分支之间互相影响,改一个条件可能弄坏另一段逻辑。
更麻烦的是,规则脚本无法处理“没见过的状态”。如果模型只学会了“前方有障碍就跳跃”,遇到敌人从背后出现、砖块上方有隐藏金币这种组合情况,就会做出看起来合理、实际无效的决策。这种现象和当前 AI 领域常说的“AI 幻觉”有相似之处:模型不是真的理解场景,而是在用局部经验强行生成一个结果。规则脚本本质上就是这个问题的极端版本。
1.2 NEAT 的通俗定义和技术原理
NEAT 全称是 NeuroEvolution of Augmenting Topologies,翻译过来是“增强拓扑结构的神经进化”。它属于进化算法和神经网络的结合体。
通俗地理解,可以把每个候选模型看成一只“生物”,它的神经网络结构就是基因。不同“生物”之间可以交叉、变异。交叉会把两个表现不错的模型混合起来,变异会让某个权重、某个连接或者某个神经元发生变化。每一轮进化结束后,系统根据表现打分,分数高的“生物”更容易留下后代,分数低的则被淘汰。
和普通神经网络训练不同,NEAT 不依赖梯度反向传播,也不依赖损失函数。它只依赖一个评估函数:谁在当前任务里表现好,谁的基因就更容易被保留。这个评估函数就是适应度函数,也就是 fitness。
NEAT 的一个重要特点是它同时优化网络结构和权重。经典神经网络通常需要人工决定多少层、每层多少神经元,而 NEAT 的初始网络可以很小时,进化过程中自动增加隐层神经元、自动增加连接,逐步把网络“改”得适合当前任务。
1.3 NEAT 在游戏控制里的优势
超级马里奥的原始画面是 240×256×3,如果直接把这个高维像素矩阵作为输入,NEAT 的输入节点会非常多,交叉和变异空间也会变得巨大,最终训练效率很低。但这个任务有一个天然优势:我们不需要让模型理解整张图片,只需要让模型根据一小部分关键特征做出决策。
NEAT 正好适合这种低维输入、离散动作输出的控制问题。它有几个明显优点:
- 不需要 GPU。模型规模较小,CPU 上就能完成推理和进化。
- 不需要存储经验回放。NEAT 每一代只评估模型,不保存大量历史样本。
- 可解释性较强。训练完成后的网络结构可以直接导出,看哪些输入节点对输出影响大。
- 对不连续决策问题容忍度高。动作输出只要梯度不明感也能进化,不依赖可导函数。
1.4 与强化学习和规则脚本的对比
| 方案 | 是否需要 GPU | 样本效率 | 可解释性 | 实现复杂度 | 适合场景 |
|---|---|---|---|---|---|
| 规则脚本 | 否 | 高 | 强 | 低 | 状态有限的简单任务 |
| NEAT 神经进化 | 否 | 中 | 强 | 中 | 低维状态、离散动作、中小规模环境 |
| DQN 深度 Q 学习 | 推荐 | 低 | 弱 | 高 | 高维状态、连续控制、复杂任务 |
| PPO 策略梯度 | 推荐 | 低 | 弱 | 高 | 更稳定的深度强化学习训练 |
注意,NEAT 并不是万能方案。如果关卡规模变大、背景变复杂,手工特征很难覆盖所有信息,NEAT 的优势会下降。此时更适合转入深度强化学习,让 CNN 自动从像素中提取特征。
2. 复现前先准备环境和依赖
2.1 技术栈选型
复现一个 AI 玩马里奥项目,通常需要四类组件:
- 马里奥模拟器:可以使用
gym-super-mario-bros,它是 OpenAI Gym 风格的封装。 - 神经进化算法库:使用
neat-python。 - 图像处理和降维:可以使用
opencv-python,也可以只用 NumPy 做简单处理。 - 环境渲染和辅助:使用
pygame或matplotlib保存结果。
建议在 Python 3.9 到 3.11 的版本下操作。Python 3.12 及以上时,部分依赖包可能还没有预编译版本,需要本地编译,容易出现安装问题。这里给出的示例配置是常见组合,不是所有机器的最优解,落地前需要结合自己的系统版本确认兼容性。
2.2 创建虚拟环境并安装依赖
首先创建独立虚拟环境,避免污染系统 Python。
python -m venv .venv source .venv/bin/activateWindows 系统下激活命令不同:
.venv\Scripts\activate然后升级 pip 并安装依赖包。
pip install --upgrade pip pip install numpy==1.26.4 pip install gymnasium==0.29.1 pip install gym-super-mario-bros==7.4.0 pip install neat-python==0.92 pip install pygame==2.5.2 pip install opencv-python==4.9.0.80如果自动安装失败,可以先安装能匹配当前 Python 版本的最新版本,再逐步降级。NEAT 和模拟器这类库对版本并不是非常敏感,但 gym 与 gymnasium 的接口差异必须注意。
依赖安装完成后,可以先运行一个简单脚本,确认环境能加载。
import gym_super_mario_bros from nes_py.wrappers import JoypadSpace env = gym_super_mario_bros.make('SuperMarioBros-1-1-v0') env = JoypadSpace(env, [['NOOP'], ['right'], ['right', 'B']]) state, info = env.reset() print('state shape:', state[0].shape if isinstance(state, tuple) else state.shape) print('info keys:', info.keys() if isinstance(info, dict) else 'no info') env.close()这里有一个细节:新版 gymnasium 的reset()方法返回(state, info)两个值,旧版 OpenAI gym 的接口也类似。如果你的版本不同,返回值可能只是单个 state。看到state shape输出正常,就说明模拟器已经可以工作。
2.3 环境自检:跑一个随机动作智能体
在开始训练前,跑一个随机动作基线,确认环境本身没问题,同时也能知道纯随机策略能达到什么水平。
import random import gym_super_mario_bros from nes_py.wrappers import JoypadSpace from gymnasium.wrappers import GrayScaleObservation, ResizeObservation env = gym_super_mario_bros.make('SuperMarioBros-1-1-v0') env = JoypadSpace(env, [['NOOP'], ['right'], ['right', 'B']]) state, info = env.reset() total_reward = 0 for step in range(200): action = random.choice([0, 1, 2]) state, reward, terminated, truncated, info = env.step(action) total_reward += reward if terminated or truncated: break print('random total reward:', total_reward) env.close()随机策略通常会在很短时间掉进坑里,这很正常。这个基线能帮你确认环境是否正常。
2.4 常见安装坑
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 安装 gym-super-mario-bros 时报编译错误 | Python 版本过高,缺少预编译包 | 查看 pip 报错末尾的编译信息 | 切换 Python 3.9/3.10/3.11 |
| 运行渲染时出现黑屏 | 缺少图形环境或 pygame 版本不兼容 | 打印环境创建日志 | 更新 pygame 或改用 headless 模式 |
| reset 返回数量不对 | gym 和 gymnasium 接口混用 | 打印env.reset()返回值 | 统一使用 gymnasium API |
3. 定义状态空间和动作空间
3.1 为什么不能直接把整张图片丢给 NEAT
NEAT 的输入节点数量直接影响搜索空间大小。原始画面是 240×256×3,即使简化成灰度图,也有 240×256=61440 个像素。如果每个像素作为一个输入节点,神经网络的权重数量会暴增,进化效率会显著下降,因为绝大多数连接都对任务没有直接帮助。
比较常见的做法是降维。两种思路比较常用:
- 图像缩略图:把原始画面缩放到很小的尺寸,比如 12×12 或 16×16,每个像素作为输入。
- 手工提取特征:检测马里奥的当前位置、前方障碍物距离、是否处于跳跃状态等,通常只需要 10 到 30 个输入。
NEAT 对低维特征更友好。手工特征虽然需要花时间设计,但训练效率会高很多。图像缩略图实现简单,但信息损失大,模型容易学到“局部纹理”而不是“全局路径”。
3.2 动作空间离散化
超级马里奥的原始动作有很多个按键组合。为了减少搜索空间,通常只保留几个关键动作。
| 动作编号 | 按键组合 | 含义 | 适用场景 |
|---|---|---|---|
| 0 | NOOP | 不操作 | 观察环境,暂停 |
| 1 | right | 向右走 | 平地移动 |
| 2 | right + B | 向右跑 | 加速前进 |
| 3 | right + A | 向右跳 | 跳过障碍和坑 |
| 4 | right + A + B | 向右跑跳 | 更远距离跳跃 |
动作数量越少,NcAT 在输出层需要学习的映射关系越简单。如果你发现动作 2 和动作 4 经常导致角色撞墙,可以动态调整动作组合。
3.3 特征提取示例代码
下面这段代码只用来表达结构,具体阈值和坐标需要根据你的模拟器版本和图像颜色来调整。它会把彩色画面转换成灰度图,再缩放成 12×12,并追加一个角色在画面中的大致水平位置信息。
import cv2 import numpy as np def extract_features(obs): # obs 是模拟器返回的画面,通常是 (240, 256, 3) if isinstance(obs, tuple): obs = obs[0] gray = cv2.cvtColor(obs, cv2.COLOR_RGB2GRAY) small = cv2.resize(gray, (12, 12), interpolation=cv2.INTER_AREA) features = small.flatten() / 255.0 # 追加一个简单统计量:画面下半部分的平均亮度,用于近似判断地面状态 bottom_mean = gray[180:240, :].mean() / 255.0 features = np.append(features, bottom_mean) return features这里输入维度是 144+1=145,比 6 万个像素小得多。实际训练时,这个特征仍然可能丢失“马里奥当前是否踩到敌人”“前方是否有金币”等关键信息。更好的做法是继续补充特征,比如检测角色 bounding box、前方 10 列像素是否有非天空色障碍等。
3.4 特征设计和训练效果的关系
特征设计决定了模型的信息边界,而这直接决定了训练上限。
如果特征里没有“前方是否有坑”,模型只能靠“画面底部亮度”猜测地形,就很难学会在坑前跳跃。如果特征里没有“角色 y 坐标”,模型无法知道自己是否已经在空中,容易重复起跳,导致角色落地前按不出第二次起跳。
因此,在开始训练前,最好先画一张信息表:每个特征是什么、从哪里获取、对决策有什么用。
| 特征 | 获取方式 | 对决策的意义 |
|---|---|---|
| 角色水平位置 | 图像颜色检测或模拟器 info | 判断是否前进 |
| 角色垂直位置 | 图像颜色检测或模拟器 info | 判断是否在空中 |
| 前方障碍物距离 | 扫描角色前方像素列 | 决定何时起跳 |
| 前方是否有敌人 | 检测特定颜色区域 | 决定是否跳跃躲避 |
| 画面底部平均亮度 | 灰度图统计 | 粗略判断地面状态 |
这些特征不一定都能从info字典里直接拿到。gym-super-mario-bros 的信息字段会因版本不同而变化,建议先打印info看看有哪些可用键,再决定是自己做图像检测还是直接读 info。
4. 使用 NEAT 训练马里奥 AI
4.1 Genome 输入输出定义
在neat-python里,一个 genome 代表一个神经网络。输入节点数量和特征向量的维度一致,输出节点数量和动作数量一致。
创建网络时使用neat.nn.FeedForwardNetwork.create(genome, config),然后把特征数组传入activate()方法,得到动作概率向量。
import neat import numpy as np def action_from_network(net, obs): features = extract_features(obs) output = net.activate(features) action_index = int(np.argmax(output)) return action_index如果动作空间是 5 个,output就是长度为 5 的数组。argmax会取最大值的索引作为动作编号。
4.2 fitness 函数设计
fitness 是进化算法的唯一反馈信号,它的设计质量直接决定了模型能学到什么。最简单的方法是使用关卡内 x 坐标作为指标:模型走得越远,fitness 越高。
但只用 x 坐标有一个问题:模型可能只在开局附近左右抖动,x 坐标没有明显增长,但只要没有死亡,这个 episode 就不会结束。为了提升学习效率,通常加上时间限制和距离变化惩罚。
def evaluate_single_genome(genome, genome_id, config, env, max_steps=3000): net = neat.nn.FeedForwardNetwork.create(genome, config) obs, info = env.reset() total_fitness = 0.0 previous_x = 0 previous_y = 0 for _ in range(max_steps): action = action_from_network(net, obs) obs, reward, terminated, truncated, info = env.step(action) cur_x = info.get('x_pos', 0) cur_y = info.get('y_pos', 0) # 主要前进奖励 total_fitness += cur_x - previous_x # 给跳跃一点点奖励,鼓励模型尝试互动 if cur_y < previous_y: total_fitness += 0.1 previous_x = cur_x previous_y = cur_y if terminated or truncated: break return total_fitness这里用x_pos能否从 info 获取取决于环境实现,不保证所有版本都有。如果没有,可以退回到用reward累计。
为了避免模型“反复原地跳跃”欺骗奖励,可以在 episode 结束时检查本次最大 x 和初始 x 的差值,如果差值过小,就对 fitness 乘一个惩罚系数。
if final_x - start_x < 30: total_fitness *= 0.5这样模型只原地抖动时,即使环境不结束,它的 fitness 也会被压缩。
4.3 NEAT 配置项说明
neat-python使用配置文件来控制进化过程。一个简化版配置如下:
[NEAT] fitness_criterion = max fitness_threshold = 3000 pop_size = 50 reset_on_extinction = False [DefaultGenome] activation_default = tanh activation_options = tanh, relu, sigmoid num_hidden = 6 max_neurons = 30 weight_max_value = 3.0 bias_max_value = 3.0 compatibility_threshold = 3.0 [DefaultSpeciesSet] compatibility_threshold = 3.0 [DefaultStagnation] species_fitness_func = max max_stagnation = 15 [DefaultReproduction] elitism = 2 survival_threshold = 0.2关键参数含义如下:
| 参数 | 作用 | 调大影响 | 调小影响 |
|---|---|---|---|
| pop_size | 每代个体数量 | 搜索更全面,但速度更慢 | 训练快,但容易早熟,陷入局部最优 |
| fitness_threshold | 达到该 fitness 即停止 | 需要更长时间训练 | 可能提前停止,欠拟合 |
| num_hidden | 初始各网络的隐藏节点数量 | 起始模型更强,但进化空间变小 | 起始模型弱,但组合可能更丰富 |
| max_neurons | 单个网络最大神经元数 | 允许更复杂模型 | 限制表达能力 |
| compatibility_threshold | 物种分界阈值 | 物种更少,更激进 | 物种更多,多样性更强 |
| survival_threshold | 每代保留比例 | 淘汰少,多样性高但进化慢 | 淘汰多,进化快但容易退化 |
不需要一开始就追求最好的参数。建议先保持默认,能跑通以后,再针对“训练不收敛”和“速度太慢”两个方向单独调整。
4.4 训练主循环
neat-python的Population会自动处理物种、交叉、变异逻辑,我们只需要提供 fitness 评估函数。
import neat import pickle def eval_genomes(genomes, config): global env for genome_id, genome in genomes: genome.fitness = evaluate_single_genome(genome, genome_id, config, env) def run_training(config_file): config = neat.Config( neat.DefaultGenome, neat.DefaultReproduction, neat.DefaultSpeciesSet, neat.DefaultStagnation, config_file ) population = neat.Population(config) population.add_reporter(neat.StdOutReporter(True)) population.add_reporter(neat.StatisticsReporter()) winner = population.run(eval_genomes, n=30) with open('winner.pkl', 'wb') as f: pickle.dump(winner, f) return winner这里n=30表示直接跑 30 代。如果 30 代看不到明显进步,可以增大到 100 代。每个 episode 的最大步数也要控制,否则训练一局可能花很久。
如果机器有多个 CPU 核心,可以使用neat.parallel.ParallelEvaluator并行评估。需要注意 Windows 环境下多进程启动方式受限,容易反复创建子进程导致速度变慢甚至异常。
from neat.parallel import ParallelEvaluator def evaluate_parallel(genome, config, env): return evaluate_single_genome(genome, 0, config, env) parallel_evaluator = ParallelEvaluator(4, evaluate_parallel) population.run(parallel_evaluator.evaluate, n=30)并行评估时,每个 worker 需要独立创建环境,避免把同一个环境对象传给多个进程。
4.5 保存和加载最佳基因组
训练结束后,population.run()返回的 winner 就是一个最优基因组。保存后用下面代码加载并回放。
import neat import pickle import gym_super_mario_bros from nes_py.wrappers import JoypadSpace with open('winner.pkl', 'rb') as f: winner = pickle.load(f) config = neat.Config( neat.DefaultGenome, neat.DefaultReproduction, neat.DefaultSpeciesSet, neat.DefaultStagnation, 'neat_config.ini' ) net = neat.nn.FeedForwardNetwork.create(winner, config) env = gym_super_mario_bros.make('SuperMarioBros-1-1-v0') env = JoypadSpace(env, [['NOOP'], ['right'], ['right', 'B']]) obs, info = env.reset() for _ in range(5000): action = action_from_network(net, obs) obs, reward, terminated, truncated, info = env.step(action) env.render() if terminated or truncated: break env.close()如果保存时用的是不同的配置文件,加载时一定要使用同样的配置对象,否则网络结构可能无法还原。
5. 运行训练并评估结果
5.1 启动训练时观察什么
运行训练脚本后,控制台会输出类似下面的信息:
Generation 1: 500 individuals, best fitness 12.4, avg fitness 3.1 Generation 2: 500 individuals, best fitness 25.8, avg fitness 5.2这里最重要的两个数值是最优 fitness 和平均 fitness。最优 fitness 稳步上升说明模型在进步;平均 fitness 上升说明整个种群都在进步,多样性保持得不错。
如果出现 fitness 长期停滞,前几代上升后就不再变化,通常是以下几种原因:
- 特征信息不足,模型无法区分“安全前进”和“即将掉坑”。
- fitness 中奖励曲线太平滑,原地不动也能拿分。
- 种群缺少多样性,所有个体都收敛到一个局部最优。
5.2 日志落盘
生产环境里不会只盯着控制台输出。建议把每代最优 fitness、平均 fitness、species 数量、训练时长写入 CSV 或 JSON。
import csv import time with open('training_log.csv', 'w', newline='') as f: writer = csv.writer(f) writer.writerow(['generation', 'best_fitness', 'avg_fitness', 'species_count', 'elapsed']) def reporter(genome, config): with open('training_log.csv', 'a', newline='') as f: ...实际项目里可以用自定义 Reporter 实现,不需要在 fitness 函数里维护全局状态。这样做的好处是训练结束后可以快速画图,观察 fitness 曲线是否收敛。
5.3 评估结果:什么时候算训练成功
不要只看“能走多远”。一个模型如果卡在开局的坑前,它的视频看起来像是在犹豫,但训练日志可能显示 fitness 很高,因为它通过反复跳跃获得了跳跃奖励。
建议用三个指标评估:
- 最大 x 坐标:是否通过了第一根管道、第一个坑、甚至到达终点。
- 完成时间:是否在有限步数内到达终点,还是靠大量重复动作硬磨过去。
- 行为稳定性:重复 10 次相同评估,每次的 x 坐标是否接近。
第一次训练往往不会有太惊艳的结果,能越过第一个砖块已经算是有效学习。Code Bullet 视频里的成功不是一蹴而就,而是大量调试特征和 fitmes 之后得到的结果。
5.4 结果中的“AI 幻觉”现象
训练过程中你可能会看到模型做出一些看起来非常奇怪的行为:站在坑前快速抖动、对着墙壁反复按跳、在敌人经过时选择原地不动而不是跳过去。这种行为和 LLM 生成虚假信息时的“AI 幻觉”本质上是类似的:模型在优化 fitness 的过程中找到了一个没想到的“捷径”,而这条捷径在人类视角下并不合理。
出现这种情况时,不要急着换算法,先检查 fitness 是不是给了模型空子。比如你为跳跃加了奖励,模型就会一直跳;你为 x 坐标增加奖励,但没限制时间,模型就会站在原地慢慢蹭。修正 fitness 往往比更换拓扑结构更有效。
6. 常见问题排查
6.1 训练不收敛,fitness 一直停留在个位数
现象:训练了 20 代以上,最优 fitness 仍在 10 以下,角色几乎一出生就死。
可能原因:
- 特征向量全部近似为零,模型得不到有效输入。
- 动作空间太大,模型随机探索不到有效动作。
- 每代评估次数太少,导致随机性过大。
检查方式:
- 打印
extract_features(obs)的输出,确认特征不是全零或全一。 - 手动控制动作,比如固定向右走,确认模拟器能正常前进。
- 查看每个动作在随机策略下的平均生命周期,如果动作 1 和 2 都很快死,说明问题在环境本身。
解决建议:
- 先从最简动作集
['NOOP', 'right']开始,让模型先学会前进。 - fitness 函数先只依赖
info中的 x 坐标,不要混入太多奖励项。 - 增大
pop_size,比如从 50 调整到 200,同时增加代际数。
6.2 训练速度太慢,一局几千步要跑很久
现象:训练一局需要数秒甚至数十秒,无法接受。
可能原因:
- episode 最大步数设置得太高,模型死亡前会一直原地抖动。
- 渲染被打开,每次 step 都要刷新画面。
- 没有开启并行评估,CPU 核心没有充分利用。
检查方式:
- 训练过程中确认没有调用
env.render()。 - 打印当前 episode 实际步数,看是否大多数都抵达了
max_steps上限。
解决建议:
- 把
max_steps从 5000 降到 2000 或 1000。 - 在 fitness 中增加“原地惩罚”,让长时间不前进的 episode 提前终止。
- 使用
ParallelEvaluator并行评估多个个体。 - 如果发现很多个体存活时间过长,可以在 fitness 中扣减时间成本。
6.3 卡在墙边或无限跳跃
现象:模型学会了跳跃,但总是在同一面墙或同一个管道前反复跳,无法前进。
可能原因:
- 跳跃奖励过多,模型把“跳跃”当作最高优先级行为。
- 特征里没有距离信息,模型不知道墙已经很近了。
- total fitness 中没有“完成奖励”,模型没有动力穿过障碍。
检查方式:
- 保存训练过程中某几个 genome,分别回放到固定位置。
- 打印关键帧的特征值,确认角色前方不是置信度接近 0 的未知区域。
解决建议:
- 把跳跃奖励从 0.1 调低到 0.01。
- 在特征中加入
x_pos的变化值,让模型能感知到自己在前进。 - 增加通关奖励,例如达到管道右侧区域时 fitness 加 100。
6.4 保存模型后回放结果和训练时不一致
现象:训练时角色能穿越管道,但载入保存的 genome 回放时,角色表现完全不同。
可能原因:
- 回放环境没有使用相同的随机种子。
- 模拟器版本或环境包装器不一致。
- 训练时 online 评估和回放评价用了几套不同的特征提取逻辑。
检查方式:
- 回放脚本中打印
extract_features(obs)的维度,和训练时代码对比。 - 对比加载的
config文件是否和训练时完全一致。
解决建议:
- 把特征提取函数放到独立模块,训练和回放统一 import。
- 保存模型时同时保存 config 文件的副本。
- 尽量固定模拟器的随机种子,减少评测波动。
7. 从训练马里奥到生产级 AI 工程
7.1 把进化实验拆成可维护模块
训练马里奥只是一次实验,但真正把这种实验做成可维护的项目,需要像处理业务代码一样处理训练流程。一个推荐的文件结构是:
mario_neat/ ├── config.py ├── environment.py ├── features.py ├── fitness.py ├── genome_repository.py ├── train.py ├── replay.py ├── neat_config.ini └── logs/features.py只负责特征提取,fitness.py只负责计算适应度,train.py只负责启动训练。这样后续要修改奖励函数、调整输入特征、替换算法时,不会牵一发动全身。
7.2 从 NEAT 转向深度强化学习
当关卡变成第二关、第三关,或者加入更多敌人和可摧毁砖块时,NEAT 的搜索空间会越来越大。此时更有扩展性的方案是深度强化学习。
- 状态输入可以继续使用图像,用 CNN 自动提取特征。
- 动作输出可以通过策略网络直接得到动作概率分布。
- 奖励函数可以复用 NEAT 实验里验证过的 x 坐标差异和跳跃奖励设计。
NEAT 实验的价值不在于最终模型有多强,而在于帮你快速验证“用这种状态表示 + 这种奖励函数是否能学到行为”。这个验证结果对后续深度强化学习仍然有效。
7.3 结合 AI Agent 和 LLM 做决策
在更复杂的游戏控制场景,比如开放世界游戏,底层移动控制和上层剧情决策可以分离。底层使用强化学习模型控制角色移动,上层使用大语言模型或规则 Agent 决定目标优先级。这种架构把“感知-控制”和“规划-决策”分开,能降低单个模型的训练难度。
实际项目里也可以把这个思路应用到机器人控制、自动化测试、NPC 行为设计等领域。核心是先规划任务边界,再决定哪一层用数据训练,哪一层用规则或大模型。
7.4 本地部署和模型推理的工程化
训练结束后,模型要从“实验结果”变成“可部署服务”。本地部署时需要注意负载能力、接口设计和日志记录。NEAT 模型可以被 pickle 序列化,但为了后续跨语言或跨平台使用,也可以把神经网络结构和权重导出成 JSON,再在目标语言里重新实现前向推理。
{ "nodes": { "0": {"type": "input", "bias": 0.0}, "12": {"type": "hidden", "bias": 0.12}, "14": {"type": "output", "bias": -0.03} }, "connections": { "0-12": {"weight": 1.12, "enabled": true}, "12-14": {"weight": -0.87, "enabled": true} } }这样模型推理就不依赖neat-python,生产环境可以复用已有推理框架。对于深度学习模型则可以保存为 ONNX 或 TorchScript 格式。
8. 复盘 Code Bullet 实验中的关键工程启示
8.1 最小可复现的路线清单
如果你第一次做这个项目,建议按下面清单执行,不要在早期就追求完美。
- 先安装环境并跑通随机动作,确认模拟器可用。
- 定义 5 个动作的简化动作空间。
- 实现一个简单的特征提取函数,输出 20 到 160 维特征。
- 用固定动作“右跑”测试特征和 fitness 是否一致。
- 接入 NEAT,population 设为 50,fitness 只依赖当前 x 坐标。
- 跑 10 代后保存 winner,回放看表现。
- 根据回放结果修正特征和奖励函数。
- 重复第 6 到第 7 步,直到行为稳定。
这个清单可以复用到其他游戏 AI 项目,不限于马里奥。
8.2 “中配”复现不等于翻译视频
很多人看到 Code Bullet 的视频后,以为复现只是把英语环境改成中文。其实真正的“中配”难点在于环境适配、依赖调整、参数选择和中文日志整理。
Code Bullet 的视频展示的是实验结果,不是完整教程。直接按视频里的画面去猜测代码,会遇到大量环境差异:模拟器版本不同、Python 版本不同、Fitness 函数设计思路未公开。因此,中文复现的正确做法是先跑通最小流程,再逐步向视频效果靠拢。
这整个过程本质上是一次 AI 工程实践:不仅要懂算法,还要懂环境管理、日志分析、参数调试和模型版本控制。
8.3 给新手的练习建议
刚开始不要急着写完整的训练脚本。先完成以下五个练习,每个练习对应一个关键知识点。
- 练习修改动作空间:把 5 个动作改成 2 个,观察随机策略表现变化。
- 练习调整特征维度:从整张缩略图切到手工特征,对比训练速度和效果。
- 练习修改 fitness:把只奖励 x 坐标改成同时奖励跳跃,观察模型行为变化。
- 练习保存和加载模型:设计一个包含“过第一个管道”的评估脚本。
- 练习用日志定位问题:在训练日志里找出卡墙和原地抖动出现的代数。
这些练习做完以后,再回头看 NEAT 的论文和源码,理解会深很多。AI 训练项目的门槛不在算法本身,而在能不能把环境、状态、奖励和评估反复调整到一个自洽状态。马里奥是一个足够小、足够清晰的试验场,值得花一个周末把它做完整。