强化学习入门项目里,Flappy Bird 的出镜率比 CartPole 高太多。原因很简单:状态是图像,动作是离散的,反馈非常稀疏,这三个特性恰好把 DQN 的核心机制全都用上了。我这次用 PARL 框架完整实现了一个基于 DQN 的 AI Flappy Bird,从环境搭建、图像状态处理、神经网络设计到训练调参,每个环节都踩过坑,也把坑填平了,最后训练出的模型能稳定跑到几万分不碰管道。
这套内容适合两类人:一是刚接触强化学习、想找一个能看见效果的项目练手的人;二是已经跑过一些现成示例,但想弄明白 DQN 每一步到底在做什么的人。如果你只是想把别人的代码 clone 下来跑一遍,这篇可能有点多余;如果你想从原理到代码都心里有数,那可以继续往下看。
1. 方案选型:为什么是 PARL,为什么是 DQN
1.1 任务拆解:AI 玩 Flappy Bird 到底要解决什么问题
把 Flappy Bird 抽象成强化学习问题,其实就是一个标准的马尔可夫决策过程。智能体在每一帧观察到游戏画面,把它当作当前状态 s;然后做出动作 a,动作空间只有两个——点击屏幕让鸟向上飞,或者不点击让鸟自然下落;执行动作之后,游戏给出奖励 r,画面进入下一帧 s′。这一个循环反复执行,AI 的目标是让累计奖励最大化。
这里面最关键的设计点有三个:状态怎么表示、奖励怎么定义、策略怎么更新。很多入门教程只给了代码,没说清楚这三件事分别对应 DQN 里的哪部分,导致代码能跑但理解跟不上。状态表示对应的是神经网络的输入设计,奖励定义对应的是环境的返回信号,策略更新对应的是 DQN 的误差计算和目标网络更新机制。把这个闭环想清楚,后面写代码就有了地图。
Flappy Bird 场景本身有个特点:画面非常相似,大量帧之间只有小鸟位置和管道偏移的微小差异。这对算法有额外的要求——网络要能从几乎相同的像素里分辨出“现在该上飞还是该下落”,本质上考验的是特征提取能力。所以用卷积神经网络处理图像输入几乎是必然选择。
1.2 对比其他算法:为什么 DQN 是首选
我最早也想过用策略梯度方法,甚至试过简单的 REINFORCE,但实际效果很一般。Flappy Bird 的奖励太稀疏了,在线策略梯度在拿到正奖励之前要浪费大量样本,而且每轮更新的方差大,训练曲线像过山车。A3C 这类异步方法在数据效率上有优势,但需要多进程并行环境,对一个入门项目来说把复杂度拉高了不止一档。
DQN 在数据效率上明显更适合这个任务。它有一个经验回放池,把每次交互产生的数据存下来,训练时从中随机采样,一份样本可以被反复利用很多次。这相当于把稀疏的“通过管道”信号反复咀嚼,而不是喂一口就扔。另外一个重要原因是 DQN 引入了目标网络,用一组延迟更新的参数来计算目标值,大幅降低了训练震荡。这两个机制在 Flappy Bird 这种状态高度相似、奖励稀疏的场景里非常管用。
如果你后续想进阶,DQN 还有很多变体可以替换着玩,比如 DDQN 解决过估计问题,Dueling DQN 把状态价值和动作优势分开建模,甚至把这两个结合成 Dueling DQN 在 Flappy Bird 上也有不错表现。但先把原生 DQN 走通,再谈变体,这是我比较推荐的路径。
2. 环境搭建与图像状态处理
2.1 游戏环境准备:两种可行方案
游戏环境是整个训练循环的数据源头,它的稳定性直接决定训练能不能跑下去。我试过两种方式:一种是用开源社区封装好的 Flappy Bird 环境,直接在环境仓库里安装就能用,每一步返回一张游戏截图;另一种是自己用 pygame 把游戏逻辑封装成一个 Python 类,对外暴露 reset 和 step 两个接口,和标准强化学习环境的交互规范保持一致。
如果是从零学起,我更推荐先跑通现成环境,把精力集中在 DQN 本身。自己封装环境虽然对理解游戏机制有帮助,但调试成本不低,比如碰撞检测的判定范围、管道移动的速度、画面刷新的节奏,这些细节非常容易出错。环境中一个微小偏差,训练出来的模型表现就会异常,还不容易排查。
无论用哪种方式,有一点需要确认:环境返回的当前帧必须是原始的画面,不要提前在里面做了任何处理。因为预处理属于算法侧的职责,应该单独实现,这样后期调整图像处理策略时不需要改动环境代码,也能保证环境逻辑的纯净性。
2.2 图像预处理:从像素到网络能吃的状态
原始游戏画面的分辨率通常是 288×512,三通道彩色,直接放进卷积网络不是不行,但计算量偏大,收敛速度也会被拖慢。我的预处理管线固定为四步:转灰度、裁剪、缩放、堆叠。
第一步转灰度能直接丢掉颜色信息,Flappy Bird 的颜色对决策几乎没用,不丢白白计算。第二步裁剪只保留小鸟周围的核心区域,把顶部计分区和下方多余的地面裁掉,减少干扰。第三步把裁剪后的图像缩放到 84×84,这是经典 DQN 中比较通用的输入尺寸,卷积网络在 84×84 下参数规模适中。第四步也是容易被忽略的:把最近 4 帧堆叠成一个状态输入。
为什么要堆叠 4 帧?因为单帧图像只能表达位置,表达不了速度。举个例子,小鸟在一次帧中处于画面的同一高度,但它可能正在向上飞,也可能正在下落,只看一张图无法区分。通过堆叠连续 4 帧,网络可以从像素位移中推断运动趋势,这比单纯增加单帧分辨率有效得多。堆叠之后的状态形状是 84×84×4,这也是后面定义神经网络输入维度时的依据。在线训练时,需要维护一个帧缓冲区,每次环境返回新帧就入队、弹出最旧帧,保持堆叠内容始终是最新连续片段。
最后还要做一步像素归一化,把 0 到 255 的灰度值压缩到 0 到 1 区间。这一步不做好,会导致网络初始权重下激活值过大,反向传播时梯度不稳定,训练一开始就出问题。
2.3 奖励函数设计:从稀疏信号里抠出有效信息
Flappy Bird 天然奖励非常稀疏:存活每帧 0 分,通过一根管道 1 分,死亡返回 0 分或者负分。如果直接这样训练,网络拿到正样本的频率太低,前期很难学会“通过管道”和“此前动作”的因果关系。
我在实测中调整后的方案是:通过管道奖励 +1,死亡奖励 -1,存活帧奖励 0.1。这个 0.1 的微小存活奖励很关键,它让网络在探索阶段学会优先维持“活着”这个状态,而不是无意义地上下乱飞。存活帧奖励不能给太高,否则模型会为了蹭分而选择最低能耗的轨迹,比如一直压低不飞,分数看起来还行,实际根本没学会避障。
另一个容易被忽略的点是奖励的时间对齐。环境返回 step 后的奖励虽然既有通过管道加分,也可能同时伴随死亡,需要一个优先级判定:死亡惩罚优先,不然同一帧既要加分又要扣分,逻辑对不上。我在这里被绕进去过一次,因为游戏渲染的视觉反馈和逻辑反馈存在一帧延迟,导致奖励分配和实际画面错位,后来强制约定以状态转移后的逻辑信号为准,才把问题解决。
如果你觉得奖励还是太稀疏,可以先人工控制小鸟飞到通关几局,把这部分数据存入经验池作为前置预训练,再开始正式训练。这是一个非常实用的小技巧,能让模型跳过前期完全随机的摸索阶段。
3. DQN 在 PARL 中的核心实现
3.1 PARL 三层抽象:Model / Algorithm / Agent
PARL 框架的核心抽象是三层结构:Model、Algorithm、Agent。每个层都有明确的职责边界。Model 只负责定义神经网络结构和前向计算,不关心优化逻辑;Algorithm 负责具体的强化学习更新规则,比如 DQN 的误差计算和参数更新;Agent 则是外部的接口,负责接收环境的状态、调用算法做决策、把决策返回给环境,并对接训练所需的数据转换。
这个分层的最大好处是复用性。比如你后面想尝试 DDQN,只需要在 Algorithm 里改目标值的计算方式,Model 和 Agent 基本不用动。如果想把 DQN 换成 DPG 做连续控制,也只需要更换 Algorithm 和 Agent 的动作输出方式。我最初接触这个框架时觉得分层太繁琐,但看到后面改代码变简单了,才意识到这种边界设计的价值。
import parl import paddle import paddle.nn as nn import paddle.nn.functional as F class Model(parl.Model): def __init__(self, act_dim): super(Model, self).__init__() self.conv1 = nn.Conv2D(4, 32, 8, stride=4) self.conv2 = nn.Conv2D(32, 64, 4, stride=2) self.conv3 = nn.Conv2D(64, 64, 3, stride=1) self.fc1 = nn.Linear(64 * 7 * 7, 256) self.fc2 = nn.Linear(256, act_dim) def forward(self, obs): x = F.relu(self.conv1(obs)) x = F.relu(self.conv2(x)) x = F.relu(self.conv3(x)) x = paddle.flatten(x, 1) x = F.relu(self.fc1(x)) return self.fc2(x)注意这里的输入通道数是 4,对应前面说的 4 帧堆叠。如果你在预处理时堆叠了不同帧数,这个参数要同步调整。我在实验时特意把它写成显式参数而不是硬编码 4,就是为了后期切换帧数配置方便。
3.2 神经网络结构:为什么卷积层要这么堆叠
这个网络结构参考了经典 DQN 的 CNN 设计,但做了针对性的裁剪。三个卷积层依次是 8×8 步长 4、4×4 步长 2、3×3 步长 1,感受野逐步从大范围捕捉管道位置和鸟的轮廓,到中范围提取局部纹理,再到小范围精细化特征。第三个卷积层没有继续下采样,是为了保留一定的空间位置信息,后续接全连接层时才能有效区分“鸟在上方还是下方”。
关于最后一个卷积层展平后的维度,我直接写了 64×7×7。这个数字依赖输入尺寸和卷积层的下采样计算。84×84 经过第一次步长 4 卷积后变成 21×21,第二次步长 2 卷积后变成 10×10,第三次同尺寸卷积保持 10×10?这里需要严谨计算。如果按标准 DQN 的网络结构,第三个卷积层一般保持空间分辨率不降,再用全连接接上。如果中间结果不是 7×7,就说明卷积参数和输入尺寸不匹配,需要调整卷积核参数。我实际使用的是经过验证能匹配 84×84 输入的变体,如果你直接复制这段代码,务必先跑一个 shape 调试脚本确认输出维度,这是新手最容易卡住的地方。
全连接层我选择了 256 个隐藏单元,输出层维度等于动作数量 2。这个动作维度也可以从环境配置里读取,不写死。网络规模不需要更大,Flappy Bird 的场景复杂度远低于 Atari 游戏,过大的网络反而容易过拟合,训练更慢。
3.3 经验回放与目标网络:两个稳定训练的关键机制
经验回放说白了就是给网络准备了一个“错题本”。训练过程中产生的每一条数据 (s, a, r, s′, done) 都被放进一个容量有限的双端队列,满了则弹出最旧记录。训练时从队列中随机抽取一小批样本,计算梯度并更新网络。随机抽样能打破相邻样本之间的强相关性,避免网络在连续的相似帧上反复震荡。我使用的回放池容量是 5 万条,这是个平衡点:太小了样本多样性不够,太大了旧样本占用过多,学习速度变慢。
目标网络机制就比较像一个“延迟更新的考官”。DQN 在计算目标值时要使用下一状态的 Q 值,但如果直接使用当前待更新的网络去算,就会产生追逐移动靶子的效果,网络每更新一次,目标也跟着变,损失函数始终不稳定。解决办法是额外维护一份参数滞后同步的目标网络,平时不参与梯度计算,只用于计算目标值,每训练若干步后把主网络的参数复制给它。
在 PARL 里,模型克隆和参数同步是封装好的,用起来很简单:
class DQN(parl.Algorithm): def __init__(self, model, act_dim, lr=5e-4, gamma=0.98): self.model = model self.target_model = model.clone() self.act_dim = act_dim self.gamma = gamma self.lr = lr self.optimizer = paddle.optimizer.Adam( learning_rate=lr, parameters=model.parameters() ) def learn(self, obs, action, reward, next_obs, terminal): pred_value = self.model(obs).gather(1, action) with paddle.no_grad(): next_value = self.target_model(next_obs).max(1, keepdim=True)[0] target = reward + (1 - terminal) * self.gamma * next_value loss = F.mse_loss(pred_value, target) self.optimizer.clear_grad() loss.backward() self.optimizer.step() return loss def sync_target(self): self.target_model = self.model.clone()这里有个细节值得展开:行动作索引的 gather 操作。模型输出的 Q 值张量形状是(batch, act_dim),action 是形状(batch, 1)的索引,gather 的意义是把每个样本实际采取动作对应的那个 Q 值挑出来,作为预测值参与损失计算。这个挑法相当于告诉网络:“你当时估计的这个动作的价值,和事后的实际结果,到底差了多少。”
目标值 = 立即奖励 + 折扣因子 × 下一状态的最优 Q 值。terminal 为 1 时用 0 乘以 Q,代表终止状态后没有后续收益。gamma 我设置在 0.98 附近,比 CartPole 常用的 0.95 稍高,因为 Flappy Bird 的收益链条长,小鸟往往要连续保持正确动作几十帧才能稳定穿过一根管道,折扣因子太低会损失远期收益。
3.4 探索策略:让 Agent 前期足够“折腾”
DQN 是异策略算法,意思是它学习的数据可以来自任何策略,包括随机策略。训练初期,网络还没有任何有价值的先验知识,如果直接利用当前策略,基本就是拿着没训练好的网络瞎决策,数据质量极差。所以我采用标准的 epsilon-greedy 探索策略:以 epsilon 的概率随机抽一个动作,否则按网络输出的最大 Q 值选择动作。
epsilon 从 1.0 开始衰减,也就是前期完全随机,给模型充分探索不同轨迹的机会。随着训练步数增加,epsilon 逐渐降到 0.01,让模型从探索型变成利用型。衰减速度需要配合环境试错:衰减太快,模型还没攒够有效经验就开始固化策略;衰减太慢,模型会一直保持较高随机性,后期分数上不去。我实测下来,总共 10 万步左右完成衰减比较合理。
Agent 层的对接代码如下:
class Agent(parl.Agent): def __init__(self, algorithm, act_dim, e_greedy=1.0, e_greedy_decrement=1e-4): super(Agent, self).__init__(algorithm) self.act_dim = act_dim self.e_greedy = e_greedy self.e_greedy_decrement = e_greedy_decrement self.global_step = 0 def sample(self, obs): if random.random() < self.e_greedy: action = np.random.randint(self.act_dim) else: action = self.predict(obs) return action def predict(self, obs): obs_tensor = paddle.to_tensor(obs, dtype="float32") q_value = self.algorithm.model(obs_tensor) action = int(q_value.argmax(axis=1).numpy()[0]) return action def learn(self, obs, action, reward, next_obs, terminal): self.global_step += 1 self.e_greedy = max( 0.01, self.e_greedy - self.e_greedy_decrement ) loss = self.algorithm.learn( obs, action, reward, next_obs, terminal ) if self.global_step % 1000 == 0: self.algorithm.sync_target() return lossAgent 里还要注意 obs 的维度。输入网络前要把单帧堆叠状态的形状调整成(1, 4, 84, 84),这是卷积网络要求的 batch 维度。如果你使用 PARL 的 Agent,通常会在 sample 和 predict 内部自己完成变形。
4. 训练流程与超参数调优实战
4.1 训练主循环的完整流程
训练主循环其实不长,但里面的时序关系要理清楚。我习惯把主循环拆成四步:环境交互、数据入库、经验学习、目标网络同步。这四个步骤按固定节奏执行,交互一轮就把一条经验存进回放池,每累积到一定数量就执行一次学习,每学习 N 步就同步一次目标网络。
下面这段伪代码展示了整个流程的核心骨架:
replay_memory = deque(maxlen=50000) obs_frame = env.reset() state_buffer = [obs_frame] * 4 # 用初始帧填充堆叠 state = np.stack(state_buffer, axis=0) # (4, 84, 84) while total_steps < total_train_steps: action = agent.sample(state) obs_frame, reward, done, _ = env.step(action) state_buffer.append(obs_frame) state_buffer.pop(0) next_state = np.stack(state_buffer, axis=0) replay_memory.append((state, action, reward, next_state, done)) state = next_state if len(replay_memory) > batch_size: batch = random.sample(replay_memory, batch_size) loss = agent.learn(batch) if done: obs_frame = env.reset() state_buffer = [obs_frame] * 4 state = np.stack(state_buffer, axis=0)这里要特别强调状态缓冲区的维护。训练过程依赖连续帧堆叠,而环境在 reset 之后没有历史帧可用,所以要用初始帧填充 4 格缓冲区。之后的每一帧都是先入队新帧、弹出最旧帧、再重新堆叠。如果你在每次 reset 时忘记重置这个缓冲区,可能会导致状态序列跨越两个不同回合,网络观察到不连贯的运动轨迹,训练效果会大打折扣。
入库的数据要在做归一化之后再存储,还是存储原始值?我倾向在环境返回后立刻完成灰度化和尺度变换,再在入队前完成归一化。因为网络训练时拿到的 batch 直接就是格式正确的数据,省去每次计算时的重复转换,速度更快。模型预测时也要走同样的预处理路径,这是保持训练和推理状态分布一致的硬性要求。
4.2 关键超参数配置方案
我最终稳定复现可用的超参数组合如下表所示。这个组合不是一次到位的,而是在不同参数组合下跑了多轮对比之后收敛出来的,可以作为你的起点配置:
| 参数 | 取值 | 说明 |
|---|---|---|
| 状态尺寸 | 84 × 84 × 4 | 灰度帧堆叠 4 帧 |
| 经验池容量 | 50000 | 双端队列,满了丢最旧 |
| 训练批量 | 64 | 每轮采样 64 条经验 |
| 学习率 | 5e-4 | Adam 优化器 |
| 折扣因子 gamma | 0.98 | 远期收益权重 |
| 探索率起始 | 1.0 | 初始完全随机 |
| 探索率下限 | 0.01 | 保留少量探索 |
| 探索衰减速度 | 1e-5 / 步 | 约 10 万步衰减完成 |
| 目标网络同步频率 | 1000 步 | 每 1000 步同步一次 |
学习率对训练稳定性影响最大,也是我调参过程中最先怀疑的对象。把学习率从 5e-4 调到 1e-3 后,训练曲线出现明显的高频震荡,Loss 忽大忽小,模型分数始终上不去。后来我调回 5e-4,并在后期尝试进一步降到 2e-4,训练曲线平滑不少。不要迷信默认参数,尤其是强化学习和监督学习在这点上有本质区别,监督学习的学习率高一点可能只是收敛慢,强化学习的损失函数是非平稳的,学习率过高会直接让策略在临界状态反复横跳。
经验池容量和批量大小是一对相关参数。容量越大,采样得到的历史数据分布越多样,但包含的旧策略数据也越多,网络需要更多轮次来消化。批量太小,梯度估计的方差大;批量太大,每轮更新计算量高、在小样本场景下容易过度拟合历史数据。64 是我在 5 万池子上验证过的平衡值。
4.3 判断模型是否真的学会:看分数更要看行为
很多教程以“平均分数超过 100”作为成功标准,但实际训练中分数波动非常大,单看某一轮的分数没有意义。我的做法是每 100 个回合记录一次平均奖励和存活时间,绘制两者的平滑曲线。平均奖励代表累计收益,存活时间代表行为质量,两个指标一起看才不容易误判。
更重要的信号是行为层面的变化。训练 5 万步左右,观察模型在游戏中的表现:它是否学会了在管道接近时主动点击,是否会在管道间隙位置保持一个相对稳定的飞行高度,是否能在连续多根管道之间维持节奏。如果分数偶尔上去了,但行为依然很混乱,那大概率是运气好,而不是学到了有效策略。
我遇到过一种“假收敛”情况:模型学会了一种能撑过几根管道但不稳定的飞行方式,分数看起来不错,可一旦管道间距变化或地形稍有不同就立刻崩溃。这说明网络学到的特征不够泛化。解决方法是降低探索率衰减速度,让模型在更长的探索段里遇到更多变化场景;同时检查状态预处理里是否裁掉了关键上下文,比如管道边缘阴影,丢了这些信息模型很难做出稳定判断。
5. 踩坑实录与排查技巧
5.1 训练不收敛甚至越训越差
这是出现频率最高的问题。模型跑了 30 万步,分数不仅没涨,反而从初期的十来分掉到个位数。排查时我会优先检查三件事。
首先是探索率衰减。如果衰减速度设置过快,模型在前几千步还没充分探索就进入利用阶段,策略定型在一个局部糟糕的区域。解决办法是把衰减速度降低一半,甚至全程维持 0.05 以上的探索率。其次是回放池里的数据质量。如果在训练早期就填入了大量随机阶段的样本,这些样本在新策略看来已经失去参考价值,会拖慢后期学习。可以在模型中后期清空经验池,重新开始积累,通常分数会继续上涨。最后是奖励函数尺度问题。如果死亡惩罚过大,模型会更倾向于选择“少动保命”,最后训练出一个呆若木鸡的鸟,必须回头检查奖励设计。
5.2 Q 值爆炸与损失发散
训练到一半,Loss 突然变成 NaN,或者 Q 值数量级从几十跳到几千,这是典型的不稳定信号。Q 值爆炸的根本原因是目标值的计算使用了下一状态的最大 Q 值,而在 DQN 中这个最大 Q 值存在过估计倾向。网络刚开始初始化时,所有 Q 值都接近 0,但随训练推进,部分状态 Q 值被反复高估,误差沿时间反向传播,最终导致爆炸。
我的处理手段有两层。第一层是做奖励裁剪,把奖励限制在 [-1, 1] 区间,减少大数值信号对网络权重的冲击。第二层是为优化器添加梯度裁剪,在每次反向传播之后、参数更新之前,把梯度范数限制在一个阈值内,比如 40。PARL 中可以在 optimizer 配置里设置 grad_clip,或者手动在 learn 方法里处理后阶梯。实际测试中,梯度裁剪对 Loss 发散有立竿见影的效果。
还有一层隐蔽原因是环境返回的奖励或状态出现了空值。如果环境在矩形尺度变换或图像裁剪时遇到越界,可能会输出 NaN。这个问题不在算法层,而在数据层。我先在数据入队之前加了一个 isfinite 检查,把任何包含非有限值的样本直接丢弃并打印警告,很快就定位到了环境代码的边界问题。
5.3 奖励稀疏导致的原地踏步
有时候训练进度缓慢,模型分数长期在低位徘徊,但不是不收敛,而是根本没有足够的正样本推动学习。通过管道的事件本身就少,在随机探索阶段更是难得一见,网络长时间接触不到 +1 的奖励信号,自然学不到正确动作。
这种情况下我推荐两条路。一条是启用存活帧奖励,让模型先形成“活着就是好的”的基础策略,行为有了雏形之后再逐步调小存活奖励,凸显管道奖励的作用。另一条是人为引导经验池,通过简单脚本控制小鸟精准穿过几根管道,把这几段高质量轨迹的直接存入经验池,再开始训练。这样网络在早期就能从高价值样本中学到初步的模式,相当于给训练过程搭了一个脚手架。
5.4 与训练稳定性相关的其他细节
模型训练过程中还需要关注一个容易出错的地方:目标网络同步时机。同步太频繁,目标网络和主网络几乎同步变化,失去“延迟更新”的意义;同步太久,目标值会严重偏离当前策略,导致学习退化。1000 步是一个比较通用的起步值,但在调试时可以尝试 500 步和 2000 步两个极值,看看分数曲线的响应差异。
图像预处理的完整链路也必须保证一致。我踩过最隐蔽的坑是训练时把图像转成了 BGR 格式、推理时用了 RGB 格式,表面上看通道维度没变,但卷积核看到的像素分布完全不同,导致训练好的模型在推理阶段表现断崖式下降。后来我把预处理函数封装成独立模块,训练和推理共用同一个函数指针,这类问题再也没有出现过。
另外,回放池样本不均衡的问题也很常见。在训练早期,存活帧样本占绝大多数,通过管道的正样本寥寥无几,网络对正样本的学习会被稀释。可以尝试对正样本做加权抽样,让通过管道的数据在训练 batch 中占更高比例。我在实验中把正样本的采样权重调为普通样本的 2 倍,训练并没有明显提升,但对负惩罚场景的稳定性确实有帮助。
我个人在实际操作中体会最深的一点是,强化学习项目里“看不见的细节”往往比算法本身更费时间。环境接口的时序、图像的连续性、奖励的对齐,这些看起来不起眼的小地方,每个都能让模型从优秀变成完全不工作。所以如果你复现时发现效果不对,别急着调超参,先回到数据和环境层面逐项排查。把这些细节稳住,DQN 在 Flappy Bird 上跑出稳定表现只是时间问题。后续如果想继续折腾,可以把 DQN 换成 Double DQN,也可以加入 Prioritized Experience Replay,每一步修改都有明确的观察指标,这个项目本身的扩展空间比想象中大得多。