跳转至

实际工程算法

这页只写项目当前代码真实使用的算法和数值,不写抽象版本。对应代码主要在 rust-ai/src/lib.rs,入口是 search_best_move()

一步棋的实际流程

AI 每次行动时,浏览器不会把整棵搜索树交给 Rust。实际输入是:

cells           64 格棋盘,-1 黑棋,1 白棋,0 空格
is_black_turn   当前是否黑棋行动
think_time_ms   默认 4000 毫秒
allowed_moves   当前 Worker 分到的候选步

Rust 收到后按这个顺序处理:

  1. board_from_cells() 把 64 格数组转成两个 u64 bitboard。
  2. decode_allowed_moves() 解码 Worker 分到的候选步,并重新验证这些步是否合法。
  3. 外层从深度 1 开始迭代加深。
  4. 每个候选步先调用 apply_move() 得到新局面。
  5. negamax() 递归搜索对手回应。
  6. 深度到 0 时用 evaluate() 估分。
  7. 空格数不超过 14 时进入终局完全搜索。
  8. 返回 row,col,score,depth,nodes,elapsed_ms,nps

这条流程的判断标准很直接:返回的 row,col 必须在 allowed_moves 中,depthnodes 应该大于 0,elapsed_ms 通常接近 4 秒但不明显超过。

棋盘位置权重表

代码里的 SQUARE_WEIGHTS 是一个 8x8 表,按 row * 8 + col 展开。它只是一项评估输入,不是最终分数。位置分在 positional_score() 中会再乘以 10。

 120  -40   20    5    5   20  -40  120
 -40  -80   -5   -5   -5   -5  -80  -40
  20   -5   15    3    3   15   -5   20
   5   -5    3    3    3    3   -5    5
   5   -5    3    3    3    3   -5    5
  20   -5   15    3    3   15   -5   20
 -40  -80   -5   -5   -5   -5  -80  -40
 120  -40   20    5    5   20  -40  120

读这个表时可以按位置理解:

  • 四个角是 120。角一旦占住就不会再被翻,所以基础价值最高。
  • 角旁斜对角位置是 -80。这些位置常叫 X 位,角为空时容易把角送给对手。
  • 角旁边线位置是 -40。这些位置常叫 C 位,也可能让对手拿角。
  • 边上隔开角的位置是 20,内侧较稳的位置是 153

例如黑棋占了左上角 (0, 0),位置项先得到 120,进入总评估前变成 120 * 10 = 1200。如果黑棋在 (1, 1),位置项是 -80 * 10 = -800。这解释了为什么 AI 不会只因为能多翻几个子就随便贴角。

中盘评估公式

当前 evaluate() 的总分是:

positional + mobility + corners - frontier + material + parity - danger + stable

各项的实际权重如下:

material   = 当前 AI 子数差 * 12
positional = 位置权重表得分 * 10
mobility   = 合法步数量差 * 90
corners    = 角占有差 * 800
frontier   = 前沿子差 * 18,然后从总分中减掉
parity     = 奇偶性 * 55
danger     = 空角旁危险子差 * 220,然后从总分中减掉
stable     = 稳定子差 * 140

这里的“差”都站在本次搜索的 AI 视角。AI 是黑棋时,黑棋优势为正;AI 是白棋时,白棋优势为正。

几个常见判断:

  • 当前子数多不一定好,因为 material 只有 12 倍权重。
  • 合法步差很重要,因为 mobility90 倍权重。
  • 一个角的差值是 800,加上位置表里的角分,通常会压过短期翻子收益。
  • 前沿子和空角旁危险子会扣分,所以 AI 会避免一些看起来“占得多”的松散局面。
  • 稳定子是 140 倍权重,比普通子数更能代表长期优势。

评估例子

假设某个局面从 AI 视角看有这些差值:

子数差 +4
位置表净分 +70
合法步差 +3
角差 +1
前沿子差 +5
奇偶性 0
空角旁危险子差 +1
稳定子差 +2

代入公式:

material   = 4 * 12 = 48
positional = 70 * 10 = 700
mobility   = 3 * 90 = 270
corners    = 1 * 800 = 800
frontier   = 5 * 18 = 90,扣掉
parity     = 0 * 55 = 0
danger     = 1 * 220 = 220,扣掉
stable     = 2 * 140 = 280

总分:

48 + 700 + 270 + 800 - 90 + 0 - 220 + 280 = 1788

这个分数不是胜率,也不是最终子数。它只是搜索到某个叶子节点时,对局面好坏的估计。NegaMax 会把很多叶子节点的估分向上回传,最后决定根节点走哪一步。

走法排序公式

move_order_score() 只决定先搜哪一步,不直接决定最终选哪一步。实际排序分是:

TT bonus
+ killer bonus
+ history[mv]
+ corner bonus
+ SQUARE_WEIGHTS[mv] * 20
+ flips * 35

具体数值:

TT 最佳步        200000
killer 第一候选   80000
killer 第二候选   40000
角               10000
位置权重         SQUARE_WEIGHTS[mv] * 20
翻子数           flips * 35

这组数值说明一个工程取舍:排序更信任搜索历史和角,而不是翻子数。比如一步棋能翻 6 个子,只得到 6 * 35 = 210 的排序加分;如果它是角,直接得到 10000。这样 Alpha-Beta 更容易先搜真正关键的分支。

终局完全搜索

代码中的终局阈值是:

EXACT_ENDGAME_EMPTY = 14

当空格数不超过 14,搜索会尽量直接搜到游戏结束。此时不再用中盘评估函数决定叶子分,而是用真实终局子数:

diff.signum() * 10_000_000 + diff * 10_000

diff 是当前 AI 的最终子数差。赢 1 子时,分数至少是千万级正数;输 1 子时,是千万级负数。这样可以避免 AI 在终局为了位置分而牺牲真实胜负。

时间预算

浏览器默认每步给 4000 毫秒。Rust 实际会留出 30 毫秒余量:

budget = think_time_ms.saturating_sub(30).max(50)

也就是说默认搜索预算约 3970 毫秒,至少保留 50 毫秒。这样做是为了给 Worker 消息传递、页面更新和动画留一点空间。

迭代加深保证每完成一层都会保存当前最佳步。如果下一层搜索到一半超时,AI 返回上一层已经完整算完的结果,而不是返回半成品。

怎么用右侧表格验证

右侧表格能反映这些工程参数是否生效:

  • 深度:迭代加深完成到哪一层。终局空格少时通常更深。
  • 节点:实际访问局面数。排序和剪枝有效时,同样深度需要的节点更少。
  • NPS:每秒节点数。它主要反映设备和 Wasm 执行速度。
  • 耗时:通常接近 3970 到 4000 毫秒。明显过短时,要看是否已经搜完终局或合法步太少。
  • 分数:中盘通常是普通整数;终局如果出现千万级分数,说明已经算到真实胜负。

如果一局里 AI 明明有合法步,但表格没有新增记录,问题通常在 JS Worker 或 Wasm 加载。如果表格新增了记录但 depth = 0,问题通常在候选步传入或搜索入口验证。