实际工程算法¶
这页只写项目当前代码真实使用的算法和数值,不写抽象版本。对应代码主要在 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 收到后按这个顺序处理:
board_from_cells()把 64 格数组转成两个u64bitboard。decode_allowed_moves()解码 Worker 分到的候选步,并重新验证这些步是否合法。- 外层从深度 1 开始迭代加深。
- 每个候选步先调用
apply_move()得到新局面。 negamax()递归搜索对手回应。- 深度到 0 时用
evaluate()估分。 - 空格数不超过 14 时进入终局完全搜索。
- 返回
row,col,score,depth,nodes,elapsed_ms,nps。
这条流程的判断标准很直接:返回的 row,col 必须在 allowed_moves 中,depth 和 nodes 应该大于 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,内侧较稳的位置是15或3。
例如黑棋占了左上角 (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倍权重。 - 合法步差很重要,因为
mobility是90倍权重。 - 一个角的差值是
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,问题通常在候选步传入或搜索入口验证。