第一版走法生成全是错的,而它不崩溃、不报错——弈局 ChessForge

本系列:把这两年用 Python 写的十几个小项目挨个开源、挨个说清楚。不讲”我做了什么”,讲”做的过程中被什么打脸、最后怎么想明白的”。 第 9 篇 · 弈局 ChessForge(前几篇:离阵 / 胶囊医生 / 烬塔 / 回响回廊 / 砂漏 / 纯粹数独 / 方寸 / 立方之蛇) 源码:github.com/ghostgorge/ChessForge 技术栈:Python + pygame + numpy,单文件 exe,无外部资源(棋子矢量绘制,音效 numpy 合成)


写一个国际象棋引擎,跟前面那些自制玩法最大的不同是:这个领域有几十年积累的标准答案,你的每一个环节都能被精确对拍。 而正是这一点,让我逃不掉一次很难堪的发现。

一、先讲 perft

perft(n) 是国际象棋引擎的标准自检:从某个局面出发,穷举 n 层,数总共有多少条叶子路径。这些数字全世界公认,一个不差。

我的第一版走法生成器,跑起始局面的 perft,第一层生成了 36 个走法。

正确答案是 20

原因很蠢:兵的起始行和升变行写反了。 于是白兵可以从第 7 行往前冲两格、走到第 8 行还想再走。

真正可怕的不是这个 bug,是它的表现形式:

不崩溃、不报错、不越界。 棋盘照画、棋子照走、引擎照样返回一个”看起来挺合理”的着法。你自己下几盘,可能一整晚都发现不了。

必须对拍六个标准局面才能守住:

局面深度节点数
起始局面d54,865,609
Kiwipeted44,085,603
position 3d5674,624
position 4d4422,333
position 5d42,103,487

走法生成必须先用 perft 对拍。 这类 bug 没有 perft 根本发现不了——而它一旦存在,你后面所有的搜索、评估、题库全都建立在一个错误的规则之上。

二、然后是搜索层的两个坑,一个比一个隐蔽

坑一:写死的”收尾余量”,让三个难度档全部退化

迭代加深的标准写法里有一句”快到时间了就别开下一层了”:

if now > deadline - 0.12:   # 留 0.12 秒收尾
    break

看起来无害。但当 movetime = 0.12 时(低难度档就是这么配的),这个条件在第一层跑完必然成立——于是引擎永远只搜 1 层。

后果有两层:三个低难度档全部退化成随机;更离谱的是引擎赢了一大堆子也杀不死单王,KQ 对 K 连将 100 个半步,最后五十回合规则判和。

改成 min(0.12, movetime * 0.30) 之后:KQvK 21 个半步、KRvK 29、KBBvK 35、KRRvK 15,全部将杀。

所有”预留一点余量”的常数,都要写成目标值的比例,不能写绝对值。 绝对值在目标值变小时会反过来吃掉全部预算。

坑二:超时异常穿过 make/unmake,棋盘被永久改坏

搜索超时是用抛异常实现的。而根节点循环长这样:

make(move)
score = -search(...)      ← 异常从这里穿过去
unmake(move)              ← 永远执行不到

于是那一手会永久留在棋盘上

表现是:自对弈跑到中途,引擎返回了一个非法着法。而从这个现象出发,你完全看不出它跟”超时”有任何关系——它可能出现在任何一盘、任何一步。

修法是在最外层按 hist 栈长度回绕(走法 0 代表空着,用 unmake_null),不管异常从哪一层抛出来,都把棋盘恢复到进搜索之前的状态。

三、验收杀棋题,不能用带裁剪的搜索

残局题库有 51 道,其中 31 道是杀棋题。它们的步数标注是由求解器判定的,不采信我自己写的。

但这里有个陷阱:引擎里那套让它跑得快的优化,在杀棋线路上全是错的。

  • 空着裁剪的前提是”不走也不会更差”(zugzwang 除外)。这个前提在杀棋线路和逼和局面里根本不成立
  • LMR(后序着法减深度)会削掉唯一的那步安静杀着——杀棋题里那一手往往看起来平平无奇,排序靠后。

所以引擎里留了一个 exact 开关,验收题目时关掉空着裁剪、无用裁剪和 LMR,慢十倍但结果可信。

“用来下棋的搜索”和”用来证明的搜索”是两个东西。 前者可以牺牲正确性换速度,后者不行。

四、手写 FEN 是个陷阱,而且我栽得比想象中难看

第一层:非法局面

第一批 8 道手写的一步杀里,有 3 道是非法局面——全是同一种错误:未行棋方已经被将军(车或后已经沿线将着对方的王,却轮到自己走)。

这种局面在国际象棋里不可能出现,引擎一读进去行为就完全未定义。

所以加了合法性闸门,查四件事:双方各恰好一个王、未行棋方不被将、行棋方有着可走、兵不在底线。

第二层:经典局面我根本没摆对

更打脸的是这个:卢塞纳桥菲利多尔防守——两个每本残局书都会讲的经典局面——我摆错了。 菲利多尔那个连白兵都忘了放,直接变成了车对车。

处理方式:摆错的直接删,留下的按引擎实战结果定目标,三章合并成一章,不硬凑章节数。 原计划八章,最后缩成五章。

这一条我认为值得单独说:当你发现自己的”权威内容”是错的,正确的动作是删掉并缩减规模,不是补一个差不多的上去凑数。 题库的价值全在可信度,凑数就等于把可信度清零。

同理,兵类 / 车兵 / 求和这几章的目标(下成胜局还是守和)由引擎实际对局的结果决定,不由我声明。引擎下不出来的结果,不写进题面。

五、挖矿:筛子的顺序值十倍时间(又一次)

要从大量局面里挖出合格的杀棋题。第一版对每个候选局面直接上精确求解器(每个约 6 秒)——九成时间花在证明”这里没有杀”上,13 分钟只挖到 6 道。

改成三道筛子(便宜的排前面):

  1. 常规搜索 0.25s,看有没有杀棋分
  2. 精确求解,核定步数
  3. 逐着验证,确认解唯一

扫 811 个局面只放行 55 次(6.8%),12 分钟收满 32 道。

(这条教训我在砂漏那个项目里已经总结过一次,结果换个领域又踩了一遍——所以这次写进 README 了。)

六、弱档 AI 不能靠”随机走烂棋”

低难度档最容易的做法是:按概率随机走一手。但玩家的体感会很怪——前十步下得像模像样,突然莫名其妙送后,完全不像人。

正确做法是限制搜索深度 + 对根节点着法做带温度的 softmax 采样,并且分差超过 900 分的着法权重直接归零(人再菜也不会白送一个后)。于是它下起来像一个只看一两步的人:会吃明摆着的子,但看不见组合。

这里有一个非常容易踩的实现细节:

温度 > 0 的档位,必须用 collect=True 全窗口搜每一个根着法。 因为普通 alpha-beta 只保证最佳着的分数是准确的,其余着法拿到的都是上界。拿一堆上界去做 softmax 采样,采样分布是错的。

另外:能将死的时候不采样。 否则弱档会把已经赢定的棋拖成和棋——这比走烂棋更让人恼火。

七、残局评估必须有”驱王项”

这个也值得记:引擎在 KQ 对 K 的局面里,会永远在中心连将,就是不将杀。

原因很直白:将军本身不加分,把对方王逼到角落也不加分——所以引擎没有任何理由去做”缩笼子”这件事。它每一步都在做局部最优,而局部最优里没有”进展”这个概念。

加一个驱王项就解决了:

12 × (对方王到中心的距离) + 6 × (7 - 两王之间的距离)

八、其他实现

引擎:10×12 mailbox 棋盘、走法打包成 int;negamax + alpha-beta + 置换表 + 空着裁剪 + LMR + 静态搜索,约 6 万节点/秒,2.5 秒到 8–9 层。评估用 PeSTO 分阶段位置表 + 兵型 + 王安全 + 残局驱王。

六档难度:新手 / 入门 / 业余 / 俱乐部 / 高手 / 大师,约 700–2200 分。39 条开局变例展开成 387 个局面的开局库(键为 Zobrist),开头不会每盘都一样。

界面:矢量棋子(Staunton 轮廓,4 倍超采样后缩放,任何尺寸都清晰,不带图片文件)、五套棋盘配色、形势条、吃子栏、棋谱、复盘导航、升变弹窗、numpy 合成音效。

一个小 bug 值得记ScrollBox 这类控件的 end() 如果无条件调用 set_clip(self._clip),被”自己管裁剪区、没走 begin()“的调用方使用时就会 AttributeError_clip 要初始化为 None 并判空——控件的生命周期方法不能假设调用方一定成对调用。

上手

pip install pygame numpy
python main.py
python main.py --selftest    # perft 对拍 + 战术题 + 40 手自对弈 + 题库合法性
双击 build_exe.bat           # 出 dist\ChessForge.exe

打包脚本有两个细节:按 py -3.12 → 3.11 → 3.9 → python 挑解释器(PATH 里的 python 通常是 Anaconda,排最后);用”能不能 import”而不是 pip 的退出码来判断依赖是否就位(某些环境里 pip 会因为坏掉的包元数据直接崩溃,跟你的项目毫无关系)。

U 悔棋 · H 提示 · F 翻转棋盘 · ←/→ 复盘 · Home/End 跳转

九、已知取舍

  • 纯 Python,约 6 万节点/秒。大师档 6 秒能到 10 层上下,约 2200 分——够业余选手打,不够挑战专业棋手。
  • 没有残局数据库(tablebase),KBN 对单王这类需要精确技巧的残局不保证走对。
  • 没有网络对战、没有 PGN 导入。

尾巴

这个项目和前面那些自制玩法最大的区别,是它的每一层都有外部标准答案可以对拍——而我在每一层都被对拍抓到过错误:

  • 规则层:perft 抓到走法生成写反
  • 搜索层:自对弈抓到超时异常污染棋盘
  • 验收层:精确求解器抓到”带裁剪的搜索证不了杀”
  • 内容层:合法性闸门抓到 3 道非法 FEN,我自己抓到两个摆错的经典局面

一条带得走的结论:

凡是有标准答案的领域,第一件事是把对拍搭起来,而不是先把功能做完。 因为这类 bug 的共同特征是——它们不崩溃、不报错,只是悄悄地让你后面做的一切都建立在错的东西上面。

📦 源码https://github.com/ghostgorge/ChessForge


下一篇:烽燧 FengSui——从游戏切到工具,一个 Web 日志安全侦察工具。回复「继续」。