给 Flappy Bird AI 存档:模型持久化、继续训练与环境适应

这是 Flappy Bird 强化学习系列的第四期配套博客,也是这一轮入门闭环的收束篇。

这一篇不会把视频口播稿改写成字幕稿。视频负责把“前世记忆”“数字生命”这些直觉化表达讲得更有画面感,而博客负责把这些比喻压回真实代码,讲清楚它究竟保存了什么、什么时候保存、为什么换了环境之后还能继续学。

读完这一篇,你应该能回答下面几个问题:

  • 为什么 AI 关机后还能接着练,不是因为“会了”,而是因为某些数据被真正写进了文件。
  • 当前项目保存的到底是什么,为什么不是把整个游戏过程都存下来。
  • Scratch 和 Python 在“继续训练”这件事上分别承担什么职责。
  • 为什么旧经验在更难环境里不会完全作废,但也不一定能原封不动照搬。

先跑项目,再看这篇拆机制

这一期更适合边跑项目边看。你可以先打开 Scratch 项目,让小鸟飞几局,再停掉重开,观察它是否还保留之前的水平;然后再对照这篇文章去理解,保存和继续训练到底是怎样接起来的。


一、第四期真正解决的问题:经验怎么跨越“关机”这道坎

前三期其实已经把一个最小可用的 Q-Learning 闭环搭起来了:

  1. 第一期开了“眼睛”,让小鸟知道该看哪些状态。
  2. 第二期开了“价值感”,让它知道什么结果值得追求。
  3. 第三期开了“好奇心”和“远见”,让它不会太保守,也不会太短视。

但只做到这里,训练仍然有一个很现实的问题:

如果程序一停,所有经验都跟着消失,那它每次启动都只是重新投胎的新手。

所以第四期不是额外加一个花哨功能,而是在解决工程上非常关键的一步:把训练出来的经验从内存搬到文件里,让它在进程结束后还能存在。

这件事在强化学习里通常叫模型持久化(Model Persistence)。在当前项目里,它保存的不是神秘的“灵魂”,而是一张已经被训练过很多轮的 Q-Table。


二、当前项目到底保存了什么:不是整局录像,而是价值记忆

很多初学者一听到“存档”,会下意识以为程序要把整局游戏的每一帧、每一次跳跃、每一根水管都完整记下来。当前实现并不是这样。

它真正保存的是 q_table。也就是:

在某个离散状态下,执行某个动作的长期价值估计。

核心数据结构就在 Python 里:

def get_q_value(self, state_key, action):
    if state_key not in self.q_table:
        self.q_table[state_key] = {a: 0.0 for a in self.action_space}
    return self.q_table[state_key][action]

这意味着每个状态键下面,都会挂一组动作价值。例如当前项目动作空间是 JUMPNONE,那么某个状态最终可能长成这样:

{
  "1_2_8": {
    "JUMP": 4.7,
    "NONE": 1.2
  }
}

它记录的不是“这一步一定要跳”,而是“在这个状态下,跳一下长期看起来更划算”。

为什么只保存这张表就够了

因为强化学习真正需要延续的,不是历史画面本身,而是历史经验提炼出来的价值判断。

你不需要把过去一万局的每个细节都永久留着,只要把它们最终沉淀出来的 Q 值留住,下一次启动时就能站在旧经验上继续学,而不是重新从 0.0 开始。

哪些东西反而没有被保存

当前实现并不会持久化这些运行期状态:

  • last_state_key
  • last_action
  • episode_count
  • 正在进行中的那一局位置与速度

这其实是合理的。因为程序重开时,本来就应该从新的一局重新开始;真正值得跨局、跨进程保留下来的,是累积出来的策略经验,而不是半局游戏的临时上下文。


三、保存和读取是在代码里怎样实现的

第四期最核心的新增能力,集中在这两个函数上:

def save_model(self):
    try:
        with open(self.model_file, 'w') as f:
            json.dump(self.q_table, f)
    except Exception as e:
        print(f'保存失败: {e}')

def load_model(self):
    if os.path.exists(self.model_file):
        try:
            with open(self.model_file, 'r') as f:
                self.q_table = json.load(f)
            print(f'已读取前世记忆,脑容量: {len(self.q_table)} 条。')
            if len(self.q_table) > 1000:
                self.epsilon = 0.01
        except Exception as e:
            print(f'读取失败: {e}')
            self.q_table = {}

这两段代码非常朴素,但工程意义很大。

save_model 做了什么

  • 把内存里的 self.q_table 序列化成 JSON。
  • 写入 self.model_file 指向的文件。
  • 如果写盘失败,就打印错误而不是让程序直接崩掉。

load_model 做了什么

  • 启动时先检查模型文件是否存在。
  • 如果存在,就把之前保存的 JSON 重新读回 self.q_table
  • 如果表已经很大,说明经验比较丰富,于是把 epsilon 直接降到 0.01,减少无意义的乱试。

这一段还有一个很值得注意的工程细节:

self.model_file = os.path.join('/host', model_file)

也就是说,模型文件不是随便写到某个临时目录,而是约定写到 /host 下。在“希妈阿Q · 启程”这个 Scratch + Python 混合编程平台里,这一点可以被用成一个很直接的工程能力:你可以在 Python 编辑窗口选择一个本地目录,平台会自动把这个目录挂载到 Python 环境的 /host

这样一来,像 ai_q_table.json 这样的模型文件,就会直接落到你选定的本地文件夹里。它的好处不是抽象意义上的“可持久化”,而是非常具体的两点:

  • 关闭当前会话后,模型文件还在本地目录里。
  • 下次重新打开项目时,Python 仍然可以从同一个 /host/ai_q_table.json 继续读取旧经验。

四、不是随时乱存:这套项目在什么时候读取、什么时候落盘

博客如果只讲 save_modelload_model 两个函数,很容易让人误会成“反正有这两个函数就行”。真正重要的是,它们被放在了正确的时机上。

1. 启动时读取

当 Scratch 点击小绿旗时,会先调用 Python 初始化接口:

当 @greenFlag 被点击
init model_file: [ai_q_table.json] actions_str: [JUMP,NONE] default_action: [NONE] buckets_str: [15,1,15] explore_weights_str: [1,9] :: #3087ab
将 [_累积游玩局数 v] 设为 [0]
开始一局游戏 :: custom

对应的 Python 初始化函数会创建 GenericQAgent,而构造函数最后一行就是:

self.load_model()

这意味着小鸟不是先空着脑袋飞两局,之后才想起来读存档,而是一出生就先把旧经验装回来。

2. 每局结束时保存

真正落盘发生在 play_game 里,而且只在 done 为真时执行:

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

这个时机选得很合理。因为一局结束时:

  • 本局产生的更新已经基本写进了 Q-Table。
  • 游戏正好回到一个天然的边界点。
  • 保存频率不至于高到每帧都写盘。

为什么不采用两个更“天真”的方案

第一种天真方案是:每一帧都保存。

这当然最稳,但 I/O 开销会很高,而且 JSON 文件越来越大时,会明显拖慢训练节奏。

第二种天真方案是:只在程序退出时保存。

这在桌面应用里有时可行,但 Scratch + 浏览器式工作流里,用户完全可能直接关页面、刷新、断电。把保存押在“优雅退出”上,风险很高。

当前实现选的是更实用的折中:以回合结束作为持久化边界。


五、Scratch 和 Python 在“继续训练”这件事上是怎样分工的

第四期很容易被说成“Python 负责存档”。这句话不算错,但还不够完整。真正让继续训练成立的是 Scratch 和 Python 的契约没有断。

Scratch 负责什么

Scratch 继续负责环境本身:

  • 生成和移动水管
  • 维护小鸟的位置、速度、碰撞
  • 计算奖励 reward
  • 拼出当前状态字符串 state_str
  • 在每一帧把状态、奖励、完成标记送给 Python

核心调用还是这一句:

将 [_操作 v] 设为 (play game state_str: (_状态字符串) reward: (_奖励分) done: (_完成) :: #3087ab reporter)

Python 负责什么

Python 并不接管游戏世界,它只负责:

  • 把状态字符串离散化成状态键
  • 根据 Q-Table 选择动作
  • 用奖励和下一状态继续更新 Q 值
  • 在合适时机保存与读取模型

所以“继续训练”并不是单独的保存按钮,而是这条循环仍然在继续工作:

  1. Scratch 产生新状态和反馈。
  2. Python 读取旧经验并给出动作。
  3. Python 再用新反馈修正旧经验。
  4. 每局结束后把修正后的经验重新落盘。

只要这条链路不断,模型就不是静态档案,而是一个会被持续改写的经验库。


六、为什么换了更难的水管,它还能重新适应

视频里有一个很重要的演示:你把环境改难了,比如水管更宽、空隙更窄,AI 不一定立刻飞得和原来一样稳,但它不会完全回到第一天那个乱撞的新手状态。

这背后的原因,不是“它突然理解了难度变化”,而是两件事情同时成立:

1. 旧经验仍然有部分可迁移性

只要状态定义、动作空间和奖励体系的大框架没变,Q-Table 里很多经验仍然有参考价值。

比如:

  • 偏高时别乱跳
  • 快撞下边界时要及时抬升
  • 接近关键水管时动作要更谨慎

这些规律在难度提高后通常仍然成立,只是最佳时机和价值大小需要重估。

2. 模型不是只读档案,而是会继续更新

别忘了,play_game 里的学习逻辑没有因为读取旧模型就停掉:

if agent.last_state_key is not None:
    agent.learn(agent.last_state_key, agent.last_action, reward, current_state_key)

这意味着旧经验只是起点,不是终点。环境一旦变化,新的奖励信号会持续把旧 Q 值往更合适的方向推。

从学习过程上看,它更像:

  • 先带着旧笔记进入新考场。
  • 发现有些题型还适用,有些需要修正。
  • 一边做题,一边把笔记继续改写。

这就是“适应”真正对应的代码含义。不是模型神奇觉醒,而是加载旧经验 + 持续更新


七、但适应不是万能的:哪些改动会让旧存档价值下降

视频里说“AI 会重新适应”,这句话在当前项目里是成立的,但前提是改动没有把建模基础直接推翻。

下面这些改动,通常还能让旧模型有一定价值:

  • 调整水管间隙大小。
  • 改变出现高度分布。
  • 小幅修改速度或节奏。

但如果你改的是这些内容,旧存档的参考价值就会明显下降:

  • 状态字符串的组成变了。
  • buckets_str 的分桶规则改了。
  • 动作空间从 JUMP,NONE 变成了完全不同的一组动作。
  • 奖励函数的目标发生根本变化。

原因很简单。Q-Table 里的键和值,本来就是依赖“当前这套状态解释方式”和“当前这套动作定义”训练出来的。你要是把坐标系换了,却还硬拿旧表来查,很多经验就不再是同一件事。

一个很实用的工程习惯

如果你准备做大改版,最稳妥的做法通常不是强行复用原来的模型文件,而是换一个新的 model_file 名字。例如:

init(model_file='ai_q_table_hard_mode.json', ...)

这样你就可以同时保留旧版本经验和新版本实验,而不会把两种不兼容的训练结果混在一起。


八、第四期其实还做了一件更大的事:把 AI 引擎做成了可复用接口

如果只从 Flappy Bird 角度看,这期像是在讲“怎么给小鸟存档”。但从工程角度看,第四期真正有长期价值的地方,是这个 Python 文件已经被写成了一个通用 Q-Learning 外壳。

它对外暴露的接口只有两个:

__all__ = [
    'init',
    'play_game',
]

再配合:

play_game.__scratch_sync__ = True

你就能在 Scratch 里把 Python 函数直接生成成可调用积木。

初始化接口也不是写死给 Flappy Bird 的:

def init(model_file: str = 'ai_q_table.json',
         actions_str: str = 'JUMP,NONE',
         default_action: str = 'NONE',
         buckets_str: str = '15,1,15',
         explore_weights_str: str = '1,9'):

这几个参数基本就把一个表格型强化学习小项目最常见的可调部分暴露出来了:

  • 模型文件名
  • 动作集合
  • 默认动作
  • 状态分桶规则
  • 探索权重

这也是为什么视频里说它不只是能玩 Flappy Bird。只要你换一套 Scratch 状态编码和奖励设计,这个外壳完全可以迁移到赛车、迷宫,甚至更接近硬件控制的小游戏里。

所以第四期除了教“存档”,其实还在悄悄教一个更重要的工程习惯:把算法逻辑写成接口,而不是写死在某一个项目细节里。


九、把第四期真正串起来:所谓“数字生命”,在代码里其实是这条链路

如果把视频里的浪漫表达压缩成工程语言,第四期真正讲清楚的是下面这条链路:

  1. Scratch 继续产生状态、奖励和终局标记。
  2. Python 把这些输入映射到 Q-Table 的更新上。
  3. 每局结束时,Q-Table 被写入 JSON 文件。
  4. 下一次启动时,JSON 又被重新读回内存。
  5. 旧经验不是终点,而是在新环境里继续被修正。

这条链路一旦成立,AI 就不再只是“这一局里会飞一下”的脚本,而是变成了一个可以跨会话持续积累经验的系统。

所以“前世记忆”不是玄学包装,它确实对应着一个非常具体的事实:

上一轮训练得到的价值估计,被可靠地保存下来,并参与了下一轮决策。

而“会重新适应”也不是一句鸡血台词,它对应着另一个事实:

旧价值估计会在新奖励信号下继续被更新,而不是被冻结成一份只读历史文档。

如果你能把这两个事实看清,第四期就真的吃透了。


十、这一轮系列学完后,你真正带走的能力是什么

到第四期为止,这个系列其实已经把一个最小强化学习项目里最关键的部件都串起来了:

  • 第一期开的是状态建模。
  • 第二期开的是奖励设计。
  • 第三期开的是探索与远期价值。
  • 第四期开的是持久化与再适应。

如果你现在回头再看这只小鸟,它不再只是一个“会自己飞”的演示,而是一套很清晰的架构:

  1. Scratch 负责世界。
  2. Python 负责决策。
  3. Q-Table 负责记住经验。
  4. JSON 文件负责让经验跨越关机。
  5. 持续训练负责让旧经验适应新环境。

这就是第四期真正想补齐的那一块。它不是在教你一个独立小技巧,而是在告诉你:只要接口设计得足够清楚,学习系统就可以从“一次性演示”升级成“可持续成长的程序”。


建议你立刻动手做的三个实验:

  • 先训练一段时间,再关闭项目重新打开,观察它是否能明显快于冷启动状态恢复水平。
  • 保持状态和动作不变,只把水管难度调高,比较“直接加载旧模型继续练”和“删掉模型从零开始练”的差异。
  • 复制一份新的模型文件名做实验,体会为什么大改动时分开存档会更稳妥。

知识自测:你真的理解第四期了吗?

当前项目真正持久化到文件里的核心内容是什么?

解析

第四期保存的是 q_table,也就是强化学习真正沉淀下来的经验,而不是整局过程录像。

为什么当前实现把保存时机放在每局结束,而不是每一帧都保存?

解析

以回合结束作为持久化边界,既能保留本局训练成果,又避免每帧写盘带来的高频 I/O 成本。

为什么把环境调难之后,加载旧模型的小鸟通常还能继续适应,而不是完全作废?

解析

旧模型提供的是起点而不是终点。只要建模框架还兼容,已有经验可以迁移,而新的训练循环会继续修正这些价值估计。

分享