第一步不是变强,而是把一手棋算对
最初的实现很朴素:规则和局面都写在 TypeScript 里,AI 生成合法着法,用一个手工评估函数给局面打分,再做有限深度的 Minimax / Alpha-Beta。五子棋看活三、冲四和中心控制,黑白棋看角、行动力与稳定子,连四棋优先识别一步胜和必须防守。评估函数并不通用,但这反而是第一条重要经验:棋类 AI 的起点不是搜索框架,而是把这款游戏里真正重要的东西说清楚。
浏览器又给这件事加了一层产品约束。搜索不能堵住棋盘动画,玩家悔棋、重开或切换页面时,上一轮计算必须立即作废;低难度不能因为局面分支多就突然想几秒。于是搜索很早就搬进了 Web Worker,按时间预算做迭代加深,并且只提交最后一个完整搜索层。超时前正在算的半层结果宁可丢掉,也不能让碰巧先算完的根着法占便宜。
那时 TypeScript 的价值非常大。规则、搜索和界面共用一套数据结构,调试一个非法着法不需要跨语言追踪内存。我们先用它建立权威规则层、可复现局面和完整回退,后来做的每一次加速才有可以对照的真相。
一套 Alpha-Beta,吃不下所有棋
对双方信息完全公开、胜负零和的游戏,Alpha-Beta 仍然是最可靠的主干。后来我们逐步补上 PVS、置换表、Zobrist 哈希、杀手着法、历史启发、静态搜索和受控延伸。真正有效的顺序通常不是先上高级剪枝,而是先减少局面复制、让 make/unmake 可逆、把好着排到前面,再让缓存保存正确的上下界。同一预算里多完成一个完整深度,往往比评估函数再精巧一点更有用。
但规则一变,搜索范式也必须跟着变。Kalah 的额外回合不能机械地翻转评分;Santorini 的移动、建造与神力要作为一个完整回合搜索;两人 Blokus 可以做 PVS,四人局却需要保留四个玩家各自效用的 MaxN。黑白棋在中盘适合 Alpha-Beta,空位变少后则值得切入精确残局。所谓‘为每款游戏写 AI’,很大一部分工作就是把回合边界、终局和评分视角处理正确。
还有一些棋盘根本不适合把全部希望押在固定深度上。Hex 早期只是根节点 UCB 加 rollout,名字像 MCTS,实际上没有真正的对手层搜索树;后来改成多层 UCT + RAVE,加入桥形知识、有限 H-search 和小残局精算。亚马逊棋的标准开局能产生 2,176 个完整的‘移动棋子+放箭’着法,我们把树拆成 queen move 和 arrow 两阶段渐进展开,并在双方领地隔离后按 chamber 求解。算法不是菜单上的标签,它必须顺着游戏的分支结构生长。
隐藏信息不是把看不见的棋子当成随机数
中国暗棋、中国军棋和 Stratego 让我们重新定义了‘局面’。公开信息棋只有一个棋盘;隐藏信息棋面对的是一组仍然可能成立的世界。AI 绝不能偷看页面内存里的真实暗子身份,再假装自己做了推理。搜索输入只能来自玩家已经看见的观察:翻过哪些子、哪些身份已排除、哪些棋子发生过战斗,以及公开剩余库存。替换未公开的真实身份后,AI 的选择应该保持不变。
三款游戏最后用了三种不同方法。中国暗棋以公共信息集 MCTS 处理翻子机会节点,暗子减少后转向信念 Negamax 或联合世界精算;中国军棋维护每枚暗子的身份域,对一批加权可能世界运行 Alpha-Beta,并复用跨回合信念;Stratego 使用 RIS-MCTS,在轮到另一方行动时重新确定化,避免搜索中的玩家得到本不该知道的信息。全明之后,它们才会回到普通 Alpha-Beta。
隐藏信息搜索还有一个很容易忽略的公平问题:一个采样世界必须原子地覆盖全部公共根着法。如果时间到了,只算完一半候选,这整个世界都要丢弃;不能让排在前面的着法多看几个有利世界。我们因此记录每个根的访问数、和、平方和与最坏值,用固定随机种子比较 TypeScript 和 Wasm 的全部根统计,而不只是比较最后选中的一步。
从 TypeScript 到 Wasm,真正的转折是移动计算边界
我们一开始也走过看似合理的捷径:搜索树仍由 TypeScript 维护,只把叶子评估函数交给 Wasm。结果并不好。棋盘要在每个叶节点编码,调用跨过 JS/Wasm 边界,短小评估的计算时间还不够抵消传输成本。同机基准里,点格棋的孤立 Wasm 叶评估只有 TypeScript 的 0.79 倍,Blokus 只有 0.06 倍,Kamisado 只有 0.21 倍。‘加载了 Wasm’和‘AI 得到了加速’完全是两件事。
真正的变化发生在完整搜索迁入 Wasm 之后:走法生成、make/unmake、规则裁决、评估、排序、置换表和递归都留在一次 Wasm 调用内部。相同测试中,点格棋的完整搜索达到约 22.55 倍,Blokus 约 9.62 倍,Kamisado 约 8.65 倍。省下来的不只是语言执行速度,更是成千上万次对象分配、状态复制和边界往返。
不同游戏的收益差距很大,所以我们从不写一个笼统的‘Wasm 快十倍’。五子棋 Rust Wasm 在三种规则的固定 d6 测试里约为 TypeScript 的 4.43、4.50 和 12.84 倍;苏拉卡尔塔棋约 28.83 倍 NPS,并在约 950 ms 的同一预算里从 d2 提到 d4;中国跳棋固定 d4 约 15.33 倍;Kalah 和 Oware 固定 d9 约 7.81 与 6.95 倍,180 ms 中盘从 d10 分别走到 d13 与 d14。这些都是固定机器、固定局面的方向性数据,不能直接换算成别人的设备速度,更不能直接换算成 Elo。
TypeScript 也没有因此被删除。它仍然是规则裁判、序列化入口、合法着法复核和完整后备搜索。Wasm 下载、自检、内存、Worker 或返回着法任何一层失败,都会在当前可用的 Worker 层切回 TypeScript;只有 Worker 本身也不可用,才进入主线程的短时应急。优化不是把退路拆掉,而是让主路更快。
- 同深度基准回答‘算法快多少’,同预算完整深度回答‘玩家多得到什么’。
- Alpha-Beta 超时只提交上一完整层;MCTS 只合并完整一轮统计。
- 所有 Wasm 返回着法都再次经过 TypeScript 合法性验证。
- 性能数字与换色自对弈分开记录,吞吐提升不冒充棋力提升。
神经网络该用在真正需要它的地方
我们没有为所有游戏训练一张万能网络。国际象棋、中国象棋、泰国象棋和将棋已经有长期打磨的成熟引擎,重复造一个弱得多的模型没有意义。国际象棋使用 Stockfish 18 Lite NNUE;中国象棋可以在约 10.7 MB 的 Fairy-Stockfish NNUE 和约 49 MB 的 Pikafish 专用 NNUE 之间选择;泰国象棋使用 Fairy-Stockfish 的 Makruk 专用网络;将棋使用 YaneuraOu 与水匠 5,并保留 Fairy-Stockfish 备用。它们都以 Wasm 形式在独立 Worker 中运行。
中国象棋的一次同机浏览器测试很能说明‘模型大小、速度和棋力不能混为一谈’:同一局面、6 线程、各搜索 1 秒,Fairy 约 300 万节点/秒、深度 17,Pikafish 约 390 万节点/秒、深度 22,两者都选择 b1c3。这个结果能证明两套浏览器引擎在该设备上正常工作,却不能直接宣布相差多少等级分。
围棋和双陆棋又是另外两种神经网络问题。围棋使用 KataGo 的 b6、b10、b18 模型,根据设备在 WebGPU、TensorFlow.js 多线程 Wasm、单线程 Wasm和 CPU 之间回退;搜索会复用真实后继子树,但必须同时核对手数、棋面、轮权、劫点和 superko 历史。双陆棋优先使用 GNU Backgammon 的接触、竞速和崩溃网络,不只决定走子,还计算 Gammon、Backgammon 概率与 cubeful equity,处理加倍、接收和拒绝。模型成功返回后,本地简单阈值不会反过来覆盖它的专业判断。
这些专用引擎有一个共同边界:我们优化加载、线程、取消、上下文传递和失败回退,但不擅自改变它们的特征、权重或输出语义。成熟引擎负责它擅长的专业判断,本站规则层负责确认结果在当前棋盘上确实合法。
服务器只负责把引擎送到你手上
所有这些计算最终都发生在浏览器里。第一次进入游戏时,页面会像下载图片一样,从本站取得当前游戏需要的 Worker、Wasm 程序和神经网络权重;之后局面、搜索树、评估结果和棋谱都留在本地设备,不需要把每一步发给服务器。页面离开时会销毁 Worker,当前游戏的线性内存和搜索表也随实例释放。
本地运行并不等于不受限制。手机可能只有一两个可用核心,桌面浏览器可能支持 SIMD、共享内存和 WebGPU,后台标签页还会被限速。自动线程数会至少为界面保留一个逻辑处理器,在五核以上设备预留至少四分之一,通常最多使用六个搜索线程;调整线程会先停止搜索再重建 Worker,而不是在引擎运行中强行扩容。围棋还会根据真实推理延迟选择模型和批量,不能把 WebGPU 的经验硬套到 CPU。
这也是我们坚持保留层层回退的原因。多 Worker 不可用可以退到单 Worker,Wasm 不可用可以在同一层改用 TypeScript,最后还有严格限时的主线程应急。玩家看到的应该是一盘仍能继续的棋,而不是一条‘你的设备不够新’的错误信息。把 AI 放到本地,不只是隐私选择,也迫使我们认真面对取消、内存、弱机和失败恢复。
引擎程序可以从服务器下载,但棋局不需要回到服务器。算力属于当前这台设备,棋谱也只属于正在下棋的人。
最后留下来的,是一套判断顺序
回头看这段历程,最有用的并不是某个剪枝公式,而是一套顺序:先把规则和完整回合写对,再找到真正的热路径;先保存可以复现的 TypeScript 基线,再决定是否值得迁移 Wasm;先证明固定深度、合法着和统计平价,再谈同预算多算了多少;最后才用换色自对弈讨论棋力方向。只要顺序反过来,速度很容易掩盖规则错误,漂亮的节点数也很容易掩盖一棵其实没有搜完整的树。
棋盘游戏已经让我们遇到确定信息、机会节点、多人效用、超宽复合回合、隐藏身份和神经网络模型。牌类游戏会把这些问题再拧紧一圈:手牌不可见、发牌带来随机性、叫牌会暴露信息,搭档还拥有自己的目标和推断。那是另一套值得单独展开的工程故事。
下一篇,我们会讨论牌类游戏的 AI:怎样处理隐藏手牌、确定化、信息集搜索、叫牌与搭档合作。