ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

开源象棋引擎选型与实战:从Stockfish编译到UCI对弈

开源象棋引擎选型与实战:从Stockfish编译到UCI对弈

简介:这是一份面向象棋游戏开发爱好者与编程学习者的开源象棋引擎代码,核心为公开源码的计算机象棋程序,可模拟对弈逻辑并达到一定棋力水平,适合初学者研究完整引擎架构,也便于进阶开发者借鉴搜索与评估策略。压缩包共37个文件,约56KB,以16个h头文件、4个cpp源文件为主,辅以txt说明、bas与frm界面模块、rc资源脚本及ico图标等;头文件分别承载搜索算法、棋步生成、棋盘结构与开局预生成等核心定义,源文件则实现引擎主逻辑与输出重定向,并包含UCI标准接口,便于对接图形界面。目前已有790人学习下载。通过研读这套代码,读者可掌握棋局评估、Alpha-Beta剪枝、位棋盘表示等关键实现,理解从用户交互到搜索决策的完整链路,为自研象棋程序提供可复用的参考基础。

1. 好的开源代码象棋引擎:从零跑通一个能下赢你的对手

你打开 GitHub 搜“chess engine”,跳出来几百个仓库,star 从个位数到几千都有。随手 clone 一个,make一下,跑起来发现它连三步杀都看不出来;再换一个,编译都过不了。这不是你运气差,而是“好的开源代码象棋引擎”这个筛选条件本身就模糊——什么叫好?棋力高、代码干净、文档全、能嵌入到自己的项目里,这四个维度往往互相打架。我做过一个需要把象棋引擎嵌进教学工具的项目,前后试了七八个开源实现,最后稳定用的是 Stockfish 的裁剪版加一个轻量级 UCI 封装。这篇笔记就按我实际踩过的路,把“选哪个、怎么编译、参数怎么调、棋力怎么验证、坑在哪”一次讲清楚。适合两类人:想拿现成引擎做二次开发的工程师,以及想读源码学搜索算法的开发者。下面所有操作都在 Linux 和 macOS 上验证过,Windows 用 WSL 或 MSYS2 也能跟。

2. 选型:为什么 Stockfish 仍然是默认答案,以及什么时候不该用它

2.1 开源象棋引擎的三个梯队

把市面上活跃的开源象棋引擎按棋力和维护状态分一下,大致是这么个格局。第一梯队是 Stockfish,常年霸占各类引擎排行榜首位,代码用 C++17 写,搜索和评估分离得很干净,UCI 协议支持完整,社区活跃到每周都有 PR。第二梯队是 Leela Chess Zero,走的是神经网络路线,棋力接近 Stockfish 但硬件门槛高,没有像样的 GPU 基本跑不动,而且模型文件动辄几百 MB。第三梯队是一堆教学向或实验向的引擎,比如 Giraffe、Sunfish 这类,代码短小精悍,适合读算法,但棋力大概在业余初段到业余三段之间,拿来做产品不够用。

如果你要的是“能下赢大部分人类”的引擎,Stockfish 是默认答案。但如果你要的是“能塞进嵌入式设备”或者“代码量控制在两千行以内方便自己改”,那 Stockfish 就太重了。我见过有人把 Stockfish 往 STM32 上搬,最后砍到只剩 alpha-beta 搜索加简单子力评估,棋力掉到业余入门水平,但确实跑起来了。所以选型的第一步不是看 star 数,而是先问自己:目标平台的算力预算和内存预算分别是多少。

2.2 编译 Stockfish 的最小命令与参数含义

假设你已经决定用 Stockfish,从源码到可执行文件其实就三步。先拿源码,再编译,最后验证。

# 克隆仓库(这里用官方镜像地址的通用写法,实际以你本地能访问的源为准) git clone <stockfish-repo-url> cd stockfish/src # 编译:默认会针对当前 CPU 做指令集优化 make -j$(nproc) build ARCH=x86-64-modern # 验证:跑一个内置的基准测试,看每秒搜索节点数 ./stockfish bench

make那行里的ARCH参数是关键。x86-64-modern会启用 BMI2、AVX2 等指令集,搜索速度比x86-64通用版快 30% 到 50%。但如果你编译出来的二进制要分发到老机器上,就得用x86-64甚至x86-64-old,否则会直接非法指令崩溃。-j$(nproc)是并行编译,核多就快,但内存小于 4G 的机器建议改成-j2,不然链接阶段容易 OOM。bench命令跑完会输出一个Nodes/second数值,这个数就是你的硬件上引擎的搜索吞吐量,后面调参数时要以它为基准。

2.3 什么时候该放弃 Stockfish 转向轻量引擎

有三种情况我会建议你换掉 Stockfish。第一种是目标设备内存小于 64MB,Stockfish 的哈希表默认就要占几十 MB,砍到最小也会影响棋力。第二种是你要把引擎嵌进一个已经用其他语言写的项目里,比如 Python 或 JavaScript,这时候用 Stockfish 的 C++ 二进制通过子进程通信虽然可行,但部署复杂度高,不如找一个同语言的轻量实现直接调函数。第三种是你主要目的是教学,想让学员在一周内读懂全部代码,那 Stockfish 的代码量会直接劝退。轻量引擎里我比较推荐的是 Sunfish,纯 Python 写的,核心搜索不到 200 行,棋力大概业余 1 级,但用来讲 alpha-beta 剪枝和局面评估足够了。

3. 把引擎跑起来:UCI 协议对接与第一个可交互对局

3.1 UCI 协议的最小交互流程

开源象棋引擎绝大多数都实现了 UCI 协议,这是一个基于文本行的标准接口。你不需要读引擎源码,只要会发命令和读输出就能控制它。最小交互流程是这样的:启动引擎进程后,先发uci,引擎会返回一堆id和option行,最后以uciok结束。然后发isready,等readyok。接着用position startpos moves e2e4 e7e5设置局面,用go movetime 1000让引擎思考一秒,引擎会输出bestmove加具体着法。整个过程就是纯文本往返,用任何语言都能实现。

import subprocess # 启动引擎进程,路径换成你编译出来的可执行文件 engine = subprocess.Popen( ['./stockfish'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, bufsize=1 ) def send(cmd): engine.stdin.write(cmd + '\n') engine.stdin.flush() def read_until(token): lines = [] while True: line = engine.stdout.readline().strip() lines.append(line) if line.startswith(token): return lines # 握手 send('uci') read_until('uciok') send('isready') read_until('readyok') # 设置局面并让引擎走一步 send('position startpos moves e2e4 e7e5') send('go movetime 1000') result = read_until('bestmove') print(result[-1]) # 输出形如 bestmove g1f3 ponder ...

这段代码里bufsize=1和text=True是必须的,否则 Python 的缓冲会导致读不到实时输出。read_until里用startswith而不是==,是因为引擎返回的行可能带额外空格或参数。go movetime 1000表示给引擎 1000 毫秒思考时间,时间一到必须返回着法。如果你想要固定深度而不是固定时间,可以换成go depth 10,但深度和耗时不是线性关系,深度每加一层,耗时可能翻好几倍。

3.2 用 Python 封装一个可对战的命令行棋手

有了上面的基础,封装一个能和你下棋的命令行程序就不难了。核心逻辑是维护一个着法列表,每次你走一步就追加到列表里,然后让引擎也走一步再追加。棋盘显示可以用简单的字符画,不需要图形界面。

def board_to_text(moves): # 这里用一个极简的棋盘表示,实际项目建议用 python-chess 库 # 仅示意流程,不处理吃子和合法性校验 board = [ ['r','n','b','q','k','b','n','r'], ['p','p','p','p','p','p','p','p'], [' ',' ',' ',' ',' ',' ',' ',' '], [' ',' ',' ',' ',' ',' ',' ',' '], [' ',' ',' ',' ',' ',' ',' ',' '], [' ',' ',' ',' ',' ',' ',' ',' '], ['P','P','P','P','P','P','P','P'], ['R','N','B','Q','K','B','N','R'], ] return '\n'.join(' '.join(row) for row in board) moves = [] while True: print(board_to_text(moves)) user_move = input('你的着法(如 e2e4,输入 q 退出):') if user_move == 'q': break moves.append(user_move) send('position startpos moves ' + ' '.join(moves)) send('go movetime 1000') best = read_until('bestmove')[-1].split()[1] print(f'引擎走:{best}') moves.append(best)

这段代码省略了着法合法性校验和棋盘更新,实际用的时候建议直接上python-chess库,它能把 UCI 着法转成棋盘对象,还能判断将杀和逼和。go movetime 1000对新手来说已经足够强了,如果你想让它更弱一点,可以改成go depth 1,但即使深度 1,Stockfish 也能秒杀大部分没学过棋的人。想让它故意走差棋,得用Skill Level这个 UCI 选项,下一节会讲。

3.3 引擎选项的读取与设置

UCI 协议里引擎会通过option行声明自己支持哪些可调参数。Stockfish 常见的选项包括Threads(搜索线程数)、Hash(哈希表大小,单位 MB)、Skill Level(棋力等级,0 到 20)、MultiPV(同时输出多个候选着法)。设置格式是setoption name <选项名> value <值>,注意name和value是关键字,选项名里有空格也要原样写。

# 把引擎棋力降到 5 级,哈希表设为 64MB,单线程 send('setoption name Skill Level value 5') send('setoption name Hash value 64') send('setoption name Threads value 1') send('isready') read_until('readyok')

Skill Level设为 0 时引擎几乎随机走棋,设为 20 是全力。我实测下来,设为 5 大概相当于业余 3 级,设为 10 大概业余 1 段。Hash设太小会影响中局搜索深度,设太大在低内存设备上会拖慢启动。Threads不是越多越好,超过物理核数反而因为调度开销导致搜索速度下降。这些参数改完必须发isready并等到readyok才生效,直接发go可能会被忽略。

4. 避坑:编译、对接和棋力验证中最容易翻车的五个地方

4.1 编译报错 “illegal instruction” 或直接崩溃

现象是编译成功,但一运行就提示非法指令,或者跑bench到一半突然挂掉。原因几乎都是ARCH参数选得太激进,编译时用了当前 CPU 支持但目标机器不支持的指令集。比如你在支持 AVX2 的机器上编译了x86-64-modern,拿到只支持 SSE4 的老机器上跑,第一条 AVX2 指令就会触发异常。解决办法是编译前先确认目标机器的 CPU 指令集,用cat /proc/cpuinfo | grep flags看有没有avx2和bmi2。如果没有,就老老实实用ARCH=x86-64。如果是要分发给不确定的机器,用x86-64-old最保险,代价是搜索速度慢一些。

4.2 引擎进程卡死,readline 一直阻塞

现象是发了go之后,Python 的readline永远等不到bestmove。原因通常是引擎在思考时输出了大量info行,而你的读取逻辑只读固定行数就停了,导致管道缓冲区被填满,引擎写不进去就卡住。解决办法是把读取逻辑改成循环读到bestmove为止,并且不要在中间做耗时操作。另一个可能原因是bufsize设成了默认值,Python 在缓冲模式下会等缓冲区满才返回,改成bufsize=1并加text=True就能实时读取。如果还是卡,检查引擎进程是不是已经因为非法着法崩溃了,可以在read_until里加超时。

4.3 棋力验证时用错基准,误判引擎强弱

现象是你觉得某个引擎很弱,因为它在你写的测试局面里走了几步臭棋。原因很可能是你给的测试局面本身就不公平,比如让引擎在少一个车的局面下走棋,它怎么走都是输。验证引擎棋力要用标准开局和标准测试集,比如bench命令自带的局面集,或者用go depth 10在初始局面下看它选什么着法。更严谨的做法是让两个引擎互相对弈若干局,用 Elo 计算工具算分差。我一般会跑 100 局快棋,每步 100ms,然后看胜率。如果两个引擎胜率在 45% 到 55% 之间,说明棋力接近;如果一边倒,那差距至少在 100 Elo 以上。

4.4 哈希表设置过大导致启动失败

现象是setoption name Hash value 2048之后引擎没反应,或者直接退出。原因是 32 位编译的引擎最大只能寻址 4GB 内存,设 2048MB 哈希表加上其他开销很容易超过限制。解决办法是确认你的引擎是 64 位编译的,用file ./stockfish看输出里有没有64-bit。如果是 32 位,哈希表最大设 1024 甚至 512。另外,即使 64 位系统,如果物理内存只有 2GB,设 2048MB 哈希表也会导致频繁换页,搜索速度反而下降。哈希表大小一般设为可用物理内存的 1/4 到 1/2 比较合适。

4.5 多线程搜索反而变慢

现象是Threads从 1 调到 4 之后,bench的Nodes/second不升反降。原因是 Stockfish 的多线程搜索有同步开销,线程数超过物理核数时,操作系统调度会让线程互相抢 CPU,导致缓存命中率下降。解决办法是Threads不要超过物理核数,超线程的核按物理核算。比如 4 核 8 线程的 CPU,设 4 就行,设 8 大概率变慢。另外,Hash太小时多线程也会变慢,因为多个线程抢同一个哈希表锁。如果一定要多线程,哈希表至少设 256MB。

5. 进阶:用 MultiPV 做局面分析,以及一个验证引擎是否“理解”棋局的技巧

5.1 MultiPV 的用法与输出解读

MultiPV是 Stockfish 里我最常用的分析功能。默认情况下引擎只输出它认为最好的那一步,但设了MultiPV之后,它会同时输出前 N 个候选着法及其评分。这对复盘和教学特别有用,因为你能看到引擎认为“第二好”的着法差在哪里。

send('setoption name MultiPV value 3') send('isready') read_until('readyok') send('position startpos moves e2e4 e7e5 g1f3') send('go depth 12') lines = read_until('bestmove') for line in lines: if 'multipv' in line and ' pv ' in line: print(line)

输出里每一行multipv会带一个序号、一个cp或mate评分,以及后续的pv着法序列。cp是厘兵分,100 代表一个兵的优势。mate后面跟的数字表示多少步内将杀,正数是我方将杀,负数是被将杀。注意MultiPV设太大会拖慢搜索,因为引擎要维护多个候选线的完整搜索,一般设 3 到 5 就够了。设成 10 以上,搜索深度会明显下降,分析结果反而不准。

5.2 用“让子测试”判断引擎是否真的理解局面

有一个我常用的土办法验证引擎是不是在“背谱”而不是“理解”:从初始局面开始,手动拿掉引擎的一个车,然后让它执先走。如果引擎真的理解局面,它会表现得保守,尽量简化局面争取和棋;如果它只是背开局库,拿掉车之后它会走一些看似正常但实际加速失败的着法。具体操作是发position startpos moves后面不跟任何着法,然后用setoption name UCI_Chess960 value false确保标准规则,再手动构造一个少车的 FEN 局面发给引擎。

# 构造一个白方少一车的 FEN,白方走棋 position fen rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/1NBQKBNR w KQkq - 0 1 go depth 15

这个 FEN 里白方原本的 a1 车被拿掉了。观察引擎的输出评分,如果评分在 -200 到 -400 之间,说明它意识到自己劣势但还能下;如果评分直接跳到 -800 以上,说明它评估函数对子力差距很敏感。这个测试不能量化棋力,但能快速判断引擎的评估函数是不是正常工作。我一般会在换引擎版本或者改编译参数之后跑一次,确认没有把评估模块改坏。

5.3 把引擎嵌进自己项目的两种集成方式

最后说集成。如果你只是想在本地工具里调引擎,用子进程加 UCI 文本通信是最省事的,任何语言都能做,缺点是每次调用要启动进程,频繁调用时开销大。另一种方式是把引擎编译成动态库,直接调函数,速度快但接口不标准,每个引擎的 API 都不一样,换引擎就要重写对接层。我一般会先用子进程方式跑通功能,等性能瓶颈出现了再考虑动态库。如果目标平台是移动端,Stockfish 有 Android 和 iOS 的编译脚本,但需要交叉编译工具链,坑比较多,建议直接找现成的封装库,不要自己从源码编。

我自己的习惯是每换一个引擎版本,先跑bench记录节点数,再跑 100 局快棋看胜率,最后用让子测试确认评估函数没坏。这三步走完,才敢把它放进产品里。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进