AI 为什么会故意犯错?Flappy Bird 强化学习里的探索、远见与下一步估值
这是 Flappy Bird 强化学习系列的第三期配套博客。
这一篇不会把视频口播稿再写一遍。视频负责把“AI 为什么会突然抽风”和“它为什么像提前看见了未来”讲得生动好懂;博客负责把这些直觉落回真实代码,讲清楚机制到底是怎么运转的。
读完这一篇,你应该能回答下面几个问题:
- 为什么小鸟明明已经很会飞了,还是会偶尔故意做出蠢动作?
epsilon 为什么不是 bug 开关,而是强化学习里故意保留的“好奇心”?
gamma 为什么能让 AI 从“只看眼前”变成“会为下一步做准备”?
next_max_q 并不是魔法,它到底是怎样从下一帧状态里算出来的?
先玩项目,再用这篇博客拆机制
如果你已经看过第三期视频,可以把这篇当成“代码讲义”;如果你还没看,也没关系,先打开 Scratch 项目跑几局,观察小鸟偶尔“抽风”的瞬间,再回来对照这里的代码,理解会更快。
一、第三期真正解决的问题:为什么“已经会了”还要继续试错
第一期,我们解决的是“AI 看见什么”。
第二期,我们解决的是“AI 认为什么叫好,什么叫坏”。
到了第三期,问题会自然升级成:
既然它已经有状态、有奖励、有 Q-Table,为什么不老老实实一直执行当前最高分动作?
答案是:如果它只会死守当前最优动作,它很可能永远停在一个“够用但不够好”的局部最优里。
比如它已经学会了一种勉强能活几根水管的飞法,这套飞法在当前经验里分数最高。但如果它从来不再试别的动作,它就没有机会发现另一种更稳、更省操作、能飞得更远的策略。
所以第三期讲的不是“AI 为什么犯傻”,而是:
- 为什么要故意保留一部分随机试错
- 为什么一次更新不能只看眼前奖励
- 为什么真正的 Q-Learning 决策,总是在“当前收益”和“未来潜力”之间平衡
二、先把状态流转看清楚:Scratch 提供快照,Python 决定动作
这一期虽然重点是 epsilon 和 gamma,但如果你没先看懂状态是怎样一帧一帧流动的,就很难真正理解 next_max_q 从哪里来。
在这套实现里,Scratch 和 Python 的分工依旧很明确:
- Scratch 负责游戏世界本身:小鸟位置、速度、水管、碰撞、奖励
- Python 负责决策与学习:把状态离散化、查 Q-Table、更新 Q 值、选出下一步动作
最关键的流程在 play_game 里:
def play_game(state_str, reward, done) -> str:
if agent is None:
return 'NONE'
is_done = str(done).lower() in ['true', '1', 't'] or done is True
reward = float(reward)
current_state_key = agent.discretize_state(state_str)
if agent.last_state_key is not None:
agent.learn(agent.last_state_key, agent.last_action, reward, current_state_key)
if is_done:
agent.save_model()
agent.last_state_key = None
agent.last_action = None
return agent.default_action
action = agent.choose_action(current_state_key)
agent.last_state_key = current_state_key
agent.last_action = action
return action
把这段代码翻译成人话,就是下面四步:
- Scratch 把这一帧看到的世界打包成
state_str 发给 Python。
- Python 先把它离散化成
current_state_key。
- 如果上一步做过动作,就拿“上一步状态 + 上一步动作 + 这一步奖励 + 这一步状态”去更新 Q 值。
- 更新完之后,再为当前状态选一个新动作返回给 Scratch。
这也解释了一个很容易误会的点:
AI 并不是先凭空想象未来,再更新现在。它只是拿“做完动作后,环境真实返回的新状态”来估计未来。
所以这里没有预言书,只有一连串高速发生的状态快照。
三、好奇心不是 bug:epsilon 负责让 AI 偶尔故意不听话
第三期视频里最抓人的画面,就是小鸟明明飞得很稳,却突然莫名其妙跳了一下把自己送走。这个现象确实很像程序写坏了,但在当前实现里,它恰恰是故意设计出来的。
看 choose_action:
def choose_action(self, state_key):
if random.random() < self.epsilon:
if self.explore_weights:
return random.choices(self.action_space, weights=self.explore_weights, k=1)[0]
return random.choice(self.action_space)
best_action = self.default_action
max_q = self.get_q_value(state_key, best_action)
for action in self.action_space:
if action == self.default_action:
continue
q = self.get_q_value(state_key, action)
if q > max_q:
max_q = q
best_action = action
return best_action
这段逻辑就是经典的 ε-greedy(epsilon-greedy)策略,不过在代码里我们看得更具体一些:
- 当随机数小于
epsilon 时,进入探索模式,故意不按当前最高分动作来
- 否则进入利用模式,选择当前状态里 Q 值最高的动作
如果 self.epsilon = 0.1,就表示大约有 10% 的概率,小鸟会说:“这次我不完全听经验,我想试一下别的可能。”
这正是强化学习里著名的 Exploration vs. Exploitation 问题:
- 利用:照着当前账本里最值钱的动作执行,稳定但保守
- 探索:给自己一点犯错空间,冒险但可能发现新路线
如果完全没有探索,小鸟会很早收敛,但它学到的不一定是最好的飞法。
四、这份实现比“纯随机探索”更聪明:探索也带偏好
很多教程在解释探索时,会直接说“随机挑一个动作”。这句并不算错,但如果你只停在这里,就会忽略当前项目里一个很实用的工程细节:
explore_weights_str = '1,9'
动作空间是:
actions_str = 'JUMP,NONE'
也就是说,在探索模式下,这只小鸟并不是 50% 跳、50% 不跳,而是更偏向于 NONE。
为什么这很重要?
因为 Flappy Bird 是一个受重力支配的游戏。如果你让探索完全平均分配,小鸟在训练初期很容易连续乱跳,直接撞到上边界。这样当然也算“探索”,但它提供的信息价值很低,很多时候只是单纯更快地死掉。
而带权重的探索本质上是在说:
我允许你尝试新动作,但不是毫无代价地乱来;在重力系统里,先把“不跳”视为更安全的默认探索方向。
这就是这套实现比教科书示例更贴近真实工程的一点。强化学习不是只会套公式,很多时候训练能不能稳定跑起来,取决于这种细节设计。
为什么默认动作也是 NONE
代码里还有一个容易被忽略的保守设计:
default_action = 'NONE'
当某个状态还没有被充分探索,或者多个动作的 Q 值都一样时,系统会优先把 NONE 当成兜底动作。这样做的目的很明确:
- 避免在未知状态下频繁乱跳
- 让小鸟先学会“别急着抽风”,再慢慢学会精细时机
这不是在削弱强化学习,恰恰是在给训练过程加一层工程上的稳定器。
五、为什么 epsilon 不能一直固定不变:好奇心也要随经验衰减
如果探索永远保持 10%,会怎样?
答案是:它可能已经成为高手了,却还是会不断因为“手痒想试试”而白白送命。
所以当前实现没有把 epsilon 写死,而是在每局结束后做衰减:
if is_done:
print(f'Game Over! 局数: {episode_count} | 状态数: {len(agent.q_table)}')
agent.save_model()
agent.last_state_key = None
agent.last_action = None
episode_count += 1
if agent.epsilon > 0.01:
agent.epsilon *= 0.99
return agent.default_action
这段逻辑表达的是一个很朴素的训练节奏:
- 新手阶段,好奇心高一点,多试几条路
- 老手阶段,好奇心低一点,尽量稳着飞
- 但永远不要降到 0,至少保留一点点对新情况的适应能力
如果把这个思想放到真实学习里也很好理解。一个完全没有探索的人,进步会停得很早;一个永远只爱乱试的人,又很难形成稳定方法。强化学习把这种平衡写成了参数衰减曲线。
一个很值得比较的反例
你可以想象三种不同设定:
epsilon = 0:从第一局开始只会死守当前最高分动作,极容易过早卡死在局部最优
epsilon = 1:每一步都纯随机,根本谈不上“利用已有经验”
epsilon 从 0.1 慢慢降到 0.01:前期多探索,后期多利用,这是当前实现选择的折中
第三种并不是唯一正确答案,但它明显比前两种更适合这类入门项目。
六、远见不是超能力,而是 gamma 把未来价值折算回现在
现在看第二个主角:gamma。
在 learn 函数里,真正决定“AI 是否有远见”的,是这几行:
def learn(self, last_state, action, reward, next_state):
if last_state is None or last_state == 'ERROR':
return
old_q = self.get_q_value(last_state, action)
next_max_q = max([self.get_q_value(next_state, a) for a in self.action_space])
new_q = old_q + self.alpha * (reward + self.gamma * next_max_q - old_q)
self.q_table[last_state][action] = new_q
用公式写出来,就是:
Q(s,a)←Q(s,a)+α[r+γamaxQ(s′,a)−Q(s,a)]第三期真正要理解的重点,不是把公式背下来,而是看懂其中这部分:
r+γamaxQ(s′,a)它的意思是:
- 先看刚才这个动作带来了多少即时奖励
r
- 再看走到下一状态
s' 之后,未来最好的动作大概还能值多少钱
- 最后用
gamma 把这份未来价值打折后,算回当前动作头上
所以 gamma 越大,AI 越愿意为了未来布局;gamma 越小,AI 越容易只盯着眼前这一步。
七、next_max_q 到底从哪里来:不是预言,而是“下一帧快照”的最佳潜力
这一期里最容易让人产生神秘感的变量,就是 next_max_q。
很多人第一次看到它,都会下意识以为:AI 好像提前“看见了”未来。但把 play_game 和 learn 连起来看,就会发现它其实非常朴素。
完整时序是这样的:
- 上一帧,AI 在
last_state_key 下选了一个动作 last_action
- Scratch 执行动作,推进游戏世界
- 下一帧,Scratch 把新的状态字符串和奖励送回来
- Python 把这个新的状态离散化成
current_state_key
learn 把 current_state_key 作为 next_state,查询这个状态下所有动作的当前估值
- 其中最大的那个值,就是
next_max_q
所以 next_max_q 并不是“AI 预知了未来发生什么”,而是:
AI 已经真实到达了下一帧状态,然后它回头评估:“如果我现在站在这个新位置上,接下来最好的动作大概还能带来多少长期回报?”
这就是视频里“灵魂出窍”的真正工程版本。
为什么这一步对 Flappy Bird 特别关键
因为 Flappy Bird 不是那种“这一帧做对了就立刻拿大奖”的游戏。很多高质量动作的价值,要过一小段时间才显现出来。
比如:
- 这一帧你提前轻轻上抬了一点
- 眼前没有立刻加分
- 但两三帧之后,你会发现自己正好对准了下一个更高的水管缺口
如果没有 next_max_q,这类“现在不赚钱、但能为后面铺路”的动作,很难被正确估值。
八、为什么把 gamma 设成 0 会变成“近视眼”
现在就能看懂视频里那个经典实验了。
如果把:
self.gamma = 0.99
改成:
self.gamma = 0
那么更新公式会直接退化成:
Q(s,a)←Q(s,a)+α[r−Q(s,a)]也就是说,AI 完全不再关心 next_max_q,只根据眼前奖励修正当前动作。
这会带来一个非常典型的问题:它会特别擅长处理“这一刻马上有反馈”的情况,却很难学会“为了后面提前准备”。
放到 Flappy Bird 场景里,你会看到它更容易表现出下面这些症状:
- 刚过一个水管就松懈下来
- 对下一根高位水管准备不足
- 只会围着眼前缺口打补丁,不会提前调整飞行轨迹
所以第三期里的“远见”,不是一个文学化的夸张说法,而是 gamma 的工程含义确实就是:
让当前动作的价值里,带上一部分对未来状态潜力的估计。
九、把第三期真正串起来:AI 每一步都在平衡三件事
如果把这一期的机制压缩成最核心的一句话,其实就是:
小鸟每做一步决策,都在同时平衡“要不要试新路”“眼前得分是多少”“这一步会不会把我送到更有前途的位置”。
对应到代码里,就是三组变量:
epsilon:我这次要不要保留一点故意试错的空间
reward:我刚才这一手眼前拿到了什么结果
gamma * next_max_q:我现在这个动作,会不会把自己送进一个更值钱的未来状态
这三者合在一起,才是第三期真正要建立的强化学习直觉。
如果少了 epsilon,它会越来越保守。
如果少了 gamma,它会越来越短视。
如果两者都没有,你得到的就不再是一个会持续改进的智能体,而更像一个只会机械重复眼前经验的控制器。
下一期,我们才会继续解决另一个很自然的问题:这些辛苦学出来的经验,能不能在关掉程序之后继续保留下来?那就是模型持久化和“前世记忆”的主题了。
建议你立刻动手做的三个实验:
- 把
epsilon 临时改成 0,观察小鸟是否会更早停止改进。
- 把
explore_weights_str 从 1,9 改成 5,5,比较探索阶段是否明显更混乱。
- 把
gamma 从 0.99 改成 0,观察它是否会更难应对连续的高低变化水管。
🧠 知识自测:你真的理解第三期了吗?
为什么训练中的小鸟有时会在安全位置突然做出看起来很蠢的动作?
解析
这正是探索机制的表现。它不是程序抽风,而是强化学习故意保留的试错空间。
当前项目里为什么没有采用完全平均的随机探索,而是给 NONE 更高权重?
解析
Flappy Bird 属于重力系统。探索如果完全平均,小鸟很容易疯狂上蹿下跳,得到的很多只是无效死亡样本。
next_max_q 在当前实现中的真实含义是什么?
解析
next_max_q 不是预言,而是到达 next_state 之后,对这个新状态里可选动作价值的最大估计。