我做2048的AI起因特别单纯某天下午手机上滑到4096手指一抖把棋盘滑死了气得我直接打开电脑——你既然是个规则完全确定的游戏唯一的随机就是新方块掉落那理论上机器就应该能比我玩得好。事实证明这玩意确实不难而且做到稳定8192一点都不玄学。这篇东西写给三类人想入门游戏AI但不想一上来啃复杂算法的人已经在写2048 AI但卡在4096、想冲更高分的人以及做自动化测试、想给游戏加“机器人陪跑”的开发者。我会把我自己的建模过程、搜索算法、评估函数这些关键部分全部拆开讲最后附上实测调优记录。我的最终版本跑出来的成绩大致这样在搜索深度5到6、带优化评估的情况下模拟100局里能稳定出现8192方块最高单局到过16384再往上没戏因为4096合成8192之后棋盘基本满了。下面从每一块核心逻辑说起。1. 先说结论8192不是魔法是“搜索 评估”1.1 人类为什么卡在4096AI却能继续往上走先把话说透。人类玩2048的问题在于——我们做的每个决定都只能看到眼前一步。看到一个能合并的块就忍不住点过去棋盘一旦出现几个孤立的小数大脑又开始疲劳然后全局崩盘。AI不一样它可以预先“脑补”未来几部选择某个方向之后棋盘会变成什么样新方块可能出现在哪几个位置在这个新局面上再走一步又有哪些可能一直到未来五、六步把所有可能的路径摊开然后挑一条“平均展望最好”的路走掉。这不是什么高深能力本质上就是“有限深度搜索 启发式评估”。任何一个决策游戏只要状态空间能表示成明确的棋盘数组、走法能枚举、且结果可以打分这套框架就成立。2048简直就是为这个框架量身定制的教学案例。1.2 整体框架三步走我用到的AI核心分三层状态建模把4×4棋盘变成一个程序能处理的数据结构并实现移动、合并、随机掉落、结束判定搜索算法用期望最大化算法Expectimax展开未来若干步的决策树AI每走一步都在选期望分数最高的方向评估函数给“某个棋盘状态”打分分数越高说明局面越好。搜索到叶子节点时就靠这个分数判断死活。这三层里建模决定AI跑得快不快搜索决定AI看得远不远评估决定AI“眼光”好不好。我一开始犯的错误就是只在意搜索深度、忽略评估函数结果深度加到6照样频繁暴毙。后来才明白——没有一套好评估看得再远也白搭。2. 棋盘建模先把“规则”翻译成代码2.1 状态表示4×4的二维数组就够了我用的数据结构非常简单board[y][x]一个4×4的二维数组每个格子存当前方块的数值2、4、8……空格就是0。没有用什么位运算压缩、哈希优化至少在这个项目阶段完全不需要。board [ [0, 2, 0, 4], [0, 0, 0, 0], [2, 0, 0, 2], [0, 0, 0, 0] ]这个表示方法足够直观而且对Python的列表操作来说也很友好。后面要做性能优化时再考虑用整数位运算表示状态但那是后话。2.2 移动合并的细节坑合并只能发生一次移动逻辑是最容易写错的地方。以向左移动为例拆成三个动作去掉0把每一行非零数字按原顺序压到左侧相邻合并从左往右扫如果两个相邻数字相同就合并成它们的和并且被合并过的格子不能再参与合并补0把行尾空缺填回0。举个例子[2, 2, 2, 2]向左移动结果是[4, 4, 0, 0]不是[8, 0, 0, 0]。这是2048的硬性规则——合并只做一次从左到右扫描时每对数字合并后后面的数字就不会再跟结果合并。很多初版AI写错全在这合并逻辑一旦错了整个AI的决策基础就崩了。四个方向的处理我用的经典技巧向左直接按上面的逻辑处理每一行向右把每行反转按左移逻辑处理完再反转回来向上把整个棋盘转置按左移处理完再转置回来向下转置反转处理完再反向恢复。def move_left_row(row): # 去掉0 filtered [x for x in row if x ! 0] # 合并 i 0 result [] while i len(filtered): if i 1 len(filtered) and filtered[i] filtered[i 1]: result.append(filtered[i] * 2) i 2 else: result.append(filtered[i]) i 1 # 补0 return result [0] * (4 - len(result))2.3 随机掉落与游戏结束判定2048唯一的随机性是每次移动之后在棋盘空位上生成一个新方块90%概率是210%概率是4。搜索算法要把这种随机性当作“对手”来对待一会儿讲Expectimax时会细说。结束判定的标准棋盘满了且四个方向都不能再移动——也就是说任意相邻格子上下左右没有相同数字行里也没有空格。判断方法其实很简单尝试执行四个方向的移动如果移动前后棋盘完全一样那就说明这个方向是无效走法四个方向全无效游戏结束。def is_game_over(board): for direction in [up, down, left, right]: if move(board, direction) ! board: return False return True这个判定很方便因为移动函数自己会返回一个新棋盘对比一下就知道某个方向有没有触发变化。AI在搜索时也用同一个函数判断走法合法性。3. 期望最大化Expectimax面对随机性的最优决策3.1 为什么不是Minimax很多人一听到“博弈搜索”就想到Minimax——两个玩家轮流走一方最大化分数另一方最小化分数。但2048有点特殊它确实有两个“玩家”一个是你玩家回合选择方向另一个是新方块掉落随机回合生成2或4。但随机掉落不是“故意”来坑你的它不带恶意只是随机。Minimax会把随机掉落当成一个“总是给你最坏结果”的对手这就太悲观了。比如明明随机掉2的概率是90%Minimax却只考虑掉4的最坏情况导致决策过于保守。正确做法是Expectimax——随机节点不是取最小值而是取数学期望把新方块出现在每个空位、每种取值的概率乘上对应的后续收益最后求和。这样AI既不会盲目乐观也不会过度悲观它基于概率分布做最优决策。3.2 核心递归逻辑伪代码长这样def expectimax(board, depth, is_player_turn): if is_game_over(board): return -100000 # 死局给一个很大的负分 if depth 0: return evaluate(board) if is_player_turn: # 玩家回合选四个方向里分数最高的 best -100000 for direction in [up, down, left, right]: new_board move(board, direction) if new_board board: continue # 无效走法跳过 score expectimax(new_board, depth - 1, False) best max(best, score) return best else: # 随机掉落回合计算期望值 expected 0 empty_cells get_empty_cells(board) for cell in empty_cells: for value, prob in [(2, 0.9), (4, 0.1)]: new_board copy_board(board) new_board[cell.y][cell.x] value expected (prob / len(empty_cells)) * expectimax( new_board, depth - 1, True ) return expected每次AI真正走棋时就是用当前棋盘对四个方向分别计算一遍期待值然后选最大的那个方向执行。搜索深度设为3到5就比较合适。3.3 搜索深度和性能瓶颈不剪枝就等死这里有个非常现实的问题期望节点展开的数目太大了。第1层有4个方向第2层每个方向都要枚举所有空格×两种取值第3层再乘以4个方向……理论上到第5层就会产生几十亿个节点。不剪枝Python当场死给你看。我实际采用的优化手段有三个采样空位不枚举所有空位只随机挑若干位置作为“掉落候选”。深度4时我每层抽8个空位深度5时抽6个。随着搜索加深抽的位置太多时间爆炸抽太少精度下降8个是一个经验平衡点。缓存transposition table同一个棋盘状态可能从不同路径到达把计算结果存在字典里状态重复时直接返回避免重复计算。无效方向剪枝四个方向里大多数时候只有两到三个有效方向先快速算一遍移动结果过滤掉没有变化的减少分支数。这么一改单步决策时间从十几秒降到了200毫秒以内——对AI来说完全够用毕竟2048没有超时限制。4. 评估函数AI的“价值观”决定上限4.1 为什么评估函数比搜索深度更关键搜索只负责“看得远”但最终要给每个叶子节点打分。如果分数定得不好看再远也没用。我在调优过程中最大的教训就在这里——最初我把“空格数量”当成唯一指标AI确实会下意识保留空格但打法极其保守经常为了不填空格错失合并机会最后分数卡在2048。后来我把评估拆成了五个维度的加权和。下面是我最终用的组合空格数空格多意味着未来操作空间大必须给正权重单调性每一行、每一列的数字尽量从大到小或从小到大排列不要忽高忽低。2048的棋盘本质是“蛇形路径”数字应该像河流一样从高到低流动平滑性相邻格子的数字差距越小越好。数字相近意味着这些格子将来有机会合并棋盘不会出现“断层”角落加成最大方块放在角落是经典策略这样大数周围的空间不容易被“搅乱”死局惩罚如果当前状态已经无路可走直接给很低的分数这比等到搜索到底再判负要高效得多。每个维度都需要调权重。我自己的调参过程特别笨先固定搜索深度为4然后一次只调一个权重跑100局看平均分和最高方块反复迭代。最终用的权重大致是评估维度权重说明空格数270给未来留空间单调性470最核心的指标平滑性140让数字流动顺畅最大方块位置100鼓励大数在角上死局惩罚-100000极端负分这几个权重的绝对值不是关键关键是它们之间的比例关系。单调性权重最大因为真正决定你能否上8192的是你能不能把棋盘维持成一条“从大到小流动”的蛇形。4.2 权重矩阵让高分值“长”在正确的位置上面说的“角落加成”比较粗糙还可以进一步细化成一个4×4的权重矩阵。AI给棋盘状态打分时不光看数字本身还要看数字落在哪个格子。我们希望大数出现在某个固定的角落小数渐渐往反方向排开。这个排布刚好对应2048玩家常说的“蛇形排列”weight_matrix [ [16, 15, 14, 13], [ 9, 10, 11, 12], [ 8, 7, 6, 5], [ 1, 2, 3, 4] ]权重值越大说明这个位置“越尊贵”。评估时把棋盘每个格子的数值乘上对应权重全部加起来得到一个位置偏向分。这样AI会慢慢学会把大数堆在权重最高的左上角形成蛇形结构。但这里必须提醒一个坑权重矩阵别给得太极端。我试过把左上角的权重设置成其他格子的十倍结果AI死守左上角连右侧的合并机会都放弃最终整个棋盘右上区域全是小碎片积重难返。蛇形排列是一个整体策略不能只靠“某一格特别值钱”来驱动。4.3 平滑性和单调性的实现细节平滑性我的实现思路是遍历所有相邻格子计算每对相邻数字之差的绝对值然后求个平均值。差的绝对值越小平滑性越好。实际实现时我还会给“差值小的对”更高的权重——比如相邻数字是2和4和相邻数字是128和256虽然差值的绝对值都是2但后者的合并潜力其实更大这需要按数值比例来调整。单调性的实现稍微绕一点。最直接的做法是逐行逐列扫描统计“数字从大到小”和“从小到大”两种方向的连续程度取较大值作为得分。比如一行是[1024, 512, 256, 128]这就是非常标准的单调递减给高分反过来[1024, 2, 512, 4]就零分。很多实现直接用“与该行期望排列的总差值”来算一个惩罚项差值越小惩罚越少效果也不错。def monotonicity(board): # 每行每列如果数字顺着某方向单调加分 score 0 for i in range(4): row_count 0 col_count 0 for j in range(3): if board[i][j] board[i][j 1]: row_count 1 if board[j][i] board[j 1][i]: col_count 1 score max(row_count, col_count) return score这是简化版但思路没错——它判断的是“整行整列的数字到底有没有形成一个有序梯度”。5. 实测记录与调优过程5.1 从经常翻车到稳定8192我经历的几个阶段我把调优过程分阶段列出来每个阶段都对应一版明显的成绩变化阶段配置100局平均分最高方块1深度2 只考虑空格数4600512偶尔10242深度4 空格单调210002048基本稳定4096偶发3深度5 完整评估函数520004096稳定8192偶发4深度6 权重精调 采样优化860008192稳定16384偶发看得出第一阶段的AI基本是个“智障”只会保空格不懂布局第二阶段加入了单调性之后AI才算真正“会玩”第三阶段加入平滑性和角落加成以后AI才真正摸到8192的门槛第四阶段则是在性能允许范围内把深度拉高把稳定指数又提了一截。5.2 翻车案例AI为什么会在“快成功”时突然暴毙调优过程中最刺激的就是眼看合成出4096然后十步之内全盘崩掉。我复盘了三个典型的翻车模式翻车模式一死守角落失去全局调度能力。AI一旦把大数锁在左上角就极度抗拒往右、往下移动。但2048这个游戏有时候你必须先“乱动”让新的小方块出现在合适的位置才能继续合成。如果AI过于保守棋盘右侧会堆满小碎片最终所有方向都失去变化空间。翻车模式二过度追求平滑性不敢破坏“接近合并”的布局。比如棋盘上有两组128它们离得很近但不在同一直线上。理论上的最优做法是先把其他区域稍微打乱一下让两组128同列再合并。但“打乱”的行为扫描到评估函数里平滑性分数会掉一点AI于是选择维持现状结果就是两组128永远合并不了整盘棋盘跟着一起僵死。翻车模式三采样位置不均匀导致AI短视。深度搜索时如果只采样前几个空位AI可能会“看不见”某个关键空位上随机掉落带来的风险从而选了一个局部安全的走法结果下一步被随机掉落堵死。后来我把采样逻辑改成“分层采样”——在棋盘的四个象限里均匀抽空位避免扎堆。这三个翻车模式说明评估函数不只是“一堆权重加一加”它本质上是在给AI灌输一种大局观。权重调歪了即使算法一样表现也会天差地别。5.3 性能优化一览真正做项目时AI的性能直接决定了搜索深度能用多少。我做的优化可以总结成一张表优化点做法收益移动逻辑用行操作反转/转置避免写四份重复代码代码简洁且不易出错状态缓存字典哈希缓存节点的评估结果深度5时减少60%重复计算空位采样每层抽6-8个空位而非全部单步决策从3秒降到0.3秒无效方向剪枝先判断移动后棋盘是否变化平均每次决策少算一半分支深度限制固定深度动态深度混合接近后期用更浅深度保证每步反应快速这中间“状态缓存”特别有意思2048的棋盘状态虽然多但许多不同的移动序列会殊途同归落到同一个局面。第一次算出这个局面的分数后存起来后面再用就直接取省掉了整整一棵子树的计算。实测这个优化在搜索深度5时能把期望时间缩短一半以上。6. 技术延伸这套方法不止能玩2048做完这个项目之后我最直观的感受是Expectimax 启发式评估这个组合几乎可以推广到任何“单机策略决策”场景。自动解谜游戏像“华容道”“推箱子”这类游戏同样是有限状态空间同样可枚举行动换一套评估函数就能套用机器人路径规划如果状态里有随机障碍物期望最大化天然能处理“某条路有概率被堵”的不确定性游戏测试领域现在很多游戏公司开始用AI做“自动跑测”比如用AI替代人工去反复通关关卡。2048 AI就是一个绝佳的入门练习——规则简单但决策空间足够复杂完全可以在它上面验证各种算法后再迁移到正式项目里。进阶方向把启发式评估函数换成神经网络打分用深度学习拟合状态价值搜索框架不动效果还能更猛。这个方向就是AlphaGo系列里“价值网络”的思想雏形。如果你只是想让AI玩得不错Expectimax就足够了没必要上强化学习。我见过不少朋友一上来就搞DQN花了一整天调网络最后跑出来的效果可能还不如一个写了几百行的Expectimax。因为2048的随机性比较可控枚举和期望计算本身就是最高效的手段。强化学习真正发挥优势的场景是状态空间大到你无法展开搜索决策树的时候。最后再分享一个我测试时的技巧不要只看“100局平均分”要额外关注“最高方块分布”。平均数高可能只是稳定在4096但如果你想知道自己的评估函数有没有能力冲击8192甚至16384就应该单独记录每局最高方块做一个分布统计。我从阶段2到阶段3的最大变化不是平均分涨了多少而是最高方块从“偶尔4096”变成了“偶发8192”——这才是评估函数真正变强的信号。另外如果你想看AI有没有“灵性”可以把搜索深度临时加到8然后开一局让AI自己玩观察它遇到交叉路口时犹豫选哪条路。深度大幅增加之后AI的走法会明显变得更“有耐心”——它愿意花好几步去铺垫一次关键合并这正是评估函数生效的表现。如果这个行为没有出现说明你的评估权重依然有问题别加深度了回头调参吧。
