本系列:把这两年用 Python 写的十几个小项目挨个开源、挨个说清楚。不讲”我做了什么”,讲”做的过程中被什么打脸、最后怎么想明白的”。 第 9 篇 · 弈局 ChessForge(前几篇:离阵 / 胶囊医生 / 烬塔 / 回响回廊 / 砂漏 / 纯粹数独 / 方寸 / 立方之蛇) 源码:github.com/ghostgorge/ChessForge 技术栈:Python + pygame + numpy,单文件 exe,无外部资源(棋子矢量绘制,音效 numpy 合成)
写一个国际象棋引擎,跟前面那些自制玩法最大的不同是:这个领域有几十年积累的标准答案,你的每一个环节都能被精确对拍。 而正是这一点,让我逃不掉一次很难堪的发现。
一、先讲 perft
perft(n) 是国际象棋引擎的标准自检:从某个局面出发,穷举 n 层,数总共有多少条叶子路径。这些数字全世界公认,一个不差。
我的第一版走法生成器,跑起始局面的 perft,第一层生成了 36 个走法。
正确答案是 20。
原因很蠢:兵的起始行和升变行写反了。 于是白兵可以从第 7 行往前冲两格、走到第 8 行还想再走。
真正可怕的不是这个 bug,是它的表现形式:
不崩溃、不报错、不越界。 棋盘照画、棋子照走、引擎照样返回一个”看起来挺合理”的着法。你自己下几盘,可能一整晚都发现不了。
必须对拍六个标准局面才能守住:
| 局面 | 深度 | 节点数 |
|---|---|---|
| 起始局面 | d5 | 4,865,609 |
| Kiwipete | d4 | 4,085,603 |
| position 3 | d5 | 674,624 |
| position 4 | d4 | 422,333 |
| position 5 | d4 | 2,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 道。
改成三道筛子(便宜的排前面):
- 常规搜索 0.25s,看有没有杀棋分
- 精确求解,核定步数
- 逐着验证,确认解唯一
扫 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 日志安全侦察工具。回复「继续」。
