自制象棋打谱与AI分析工具:python-chess+Stockfish实战教程
如果你只想“看别人怎么处理一盘棋”,那打开任何一个棋谱网站就够了;但如果你想“把自己下过的棋、收藏的棋谱系统整理成数据库,并用 AI 对关键局面做复盘分析”,市面上的免费软件要么老得不能再老,要么捆绑广告、格式不兼容,要么停更好几年连现代引擎都接不进去。
这篇教程要解决的就是这个问题:不依赖商业软件,自己做一个“能读棋谱、能浏览着法、能调起 AI 引擎分析局面”的打谱分析工具。我先给一个明确判断:自制象棋打谱与 AI 分析软件,真正的技术难点不是界面,而是“棋谱格式解析、棋盘状态管理、引擎协议对接”这三件事。这三件事在开源社区里早已有了成熟方案,初学者完全可以在几百行代码内跑通一个最小可用版本。
需要提前说明的是:下文用国际象棋作为演示对象,因为 python-chess 和 Stockfish 这套组合的生态最完整、资料最多、最容易跑通。但文章后半部分会专门讲如何把同一套架构迁移到中国象棋——二者在棋谱、棋盘、引擎协议上的差异,恰恰是初学者理解“抽象与复用”的最好教材。
读完这篇文章后,你将得到:
- 一套可运行的“命令行打谱器”:加载 PGN 棋谱、逐手前进后退、对当前局面发起 AI 分析。
- 对 PGN / FEN / UCI 协议 / 开源引擎这几个关键概念的透彻理解。
- 一份从「国际象棋跑通」到「中国象棋迁移」的完整路线图。
1. 这篇文章真正要解决的问题
1.1 打谱不是“看一遍棋谱”,而是“整理棋谱”
象棋爱好者的典型烦恼是:棋谱积累到一定数量后,全散了。有的是截图,有的是文本,有的是 QQ 聊天记录里翻出来的,想按开局分类、想快速定位到某个中局局面,基本靠人工翻。这就是“打谱”软件的原始需求:把棋谱读进来、按着法走、能前进后退、能保存和检索。
很多初学者会误以为,要自己做一个打谱软件,就得从零实现棋盘、棋子、胜负判断、着法生成……这会把入门门槛抬得极高。但真实情况是:棋盘状态管理这件事,拿来主义才是正道。成熟的棋类库已经帮你处理了“每一步是否合法”“如何生成所有走法”“如何判断将军/将死”等底层细节,你要做的只是把它们接入自己的产品逻辑。
1.2 AI 分析解决的是“不知道哪一步下错了”
打谱软件的另一半需求,是对局面做 AI 评估。以前学棋,复盘只能靠老师或自己看。现在完全可以让引擎告诉你:
- 当前局面的红方/白方优势多少?
- 最佳着法是哪一步?
- 如果换了一种走法,局面评分会发生什么变化?
这里的核心不是“AI 有多强”,而是“协议要打通”。引擎是一个独立的进程,它通过标准协议和外部程序通信。你会写出一个“客户端”,把当前局面发给引擎进程,引擎把评估结果和推荐着法返回给你。这个通信过程不涉及任何商业 API,全部在本地完成。
1.3 谁是这篇文章的读者
这篇文章最适合下面三类人:
- 会一点 Python 基础的象棋爱好者:不想再忍受旧软件,想做一个自己能掌控的工具。
- 想通过实际项目学习软件架构的初学者:这个项目麻雀虽小,但涉及格式化解析、外部进程通信、UI 与逻辑分离,能学到很多课本上不讲的工程细节。
- 想给中国象棋做分析软件的人:我先带你跑通国际象棋这条路,然后再告诉你中国象棋要替换哪些部件。
一句话:这不是一篇“介绍某个成品软件”的推荐文章,而是一篇“从需求到代码”的完整实现教程。
2. 核心概念与整体架构
在写代码之前,要先建立几个关键概念。初学者最容易被一堆英文缩写劝退,其实它们背后的逻辑非常简单。
| 概念 | 通俗解释 | 在项目中的作用 |
|---|---|---|
| PGN | 棋谱的“文本格式”,记录对局信息和着法序列 | 打谱软件的输入文件格式 |
| FEN | 用一串字符描述棋盘上某个局面的快照 | 在“当前局面”和“引擎分析”之间传递数据 |
| UCI | 引擎与外部程序之间的通信协议 | 让 Python 程序能控制 Stockfish 引擎 |
| Stockfish | 开源的棋力引擎,计算能力强,且免费 | 提供 AI 分析能力 |
| python-chess | Python 开源库,封装了棋盘、棋谱、引擎客户端 | 省去自己实现棋盘和着法生成的痛苦 |
2.1 棋谱文件:PGN 与 FEN
PGN(Portable Game Notation)可以理解成“棋谱界的纯文本格式”。它由两部分组成:
- 头信息区:用方括号记录对局时间、选手、赛事等。
- 着法区:按“1. e4 e5 2. Nf3 Nc6 …”这样的格式记录每一步棋。
FEN(Forsyth–Edwards Notation)则是棋盘局面的快照。它把双方棋子位置、轮走方、王车易位权、吃过路兵等信息压缩成长度为几段的字符串。比如国际象棋的初始局面 FEN 是:
rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1为什么 FEN 重要?因为你要把“当前局面”传给 AI 引擎,不可能用一张图片传过去,一个标准字符串就搞定了。在你自己的软件体系里,FEN 也是“棋谱层”和“引擎层”之间的通用接口。
2.2 引擎协议:UCI
如果一个引擎想被各种棋软调用,它就得讲一种大家都听得懂的语言。国际象棋开源的交流协议叫 UCI(Universal Chess Interface)。
用大白话说,UCI 协议就是一套“命令行对话规则”。你启动 Stockfish 进程后,往它的标准输入里写position startpos moves e2e4 e7e5,它就会记住局面;你再写go depth 15,它就开始思考,并把思考结果(最佳着法、评估分数、主变着法)写到标准输出里。
python-chess 的chess.engine模块已经替你封装好了这套对话。你要做的只是:
engine = chess.engine.SimpleEngine.popen_uci("stockfish") info = engine.analyse(board, chess.engine.Limit(time=1.0))这两行代码的背后,就是本地进程的创建、UCI 协议的握手、局面下传、结果接收和解析。初学者不需要关心每个细节,但必须理解:引擎不是库,而是一个独立进程,程序通过协议和它说话。
2.3 整体架构:三层分离
我建议把整个软件拆成三层:
交互层(命令行 / 界面) ↓ 应用逻辑层(加载棋谱、前进后退、调用分析) ↓ 能力层(棋盘规则库 + 引擎进程)这样拆的好处非常明显:以后你想从命令行换成图形界面,根本不需要改动棋谱解析和引擎调用代码;想把国际象棋换成中国象棋,也只需要替换“能力层”和“棋谱格式解析”部分,交互逻辑大体可以复用。
3. 技术选型与环境准备
3.1 技术组合
这里选择的是最容易让初学者跑通的组合:
- Python 3.8 以上版本。
- python-chess 库:负责棋盘状态、PGN 解析、UCI 引擎客户端。
- Stockfish 引擎:负责计算最佳着法和评估分数。
- 交互方式先做成命令行,跑通后再考虑界面。
对中国象棋方向,可以提前知道一个结论:国际象棋这套组合跑通后,迁移到中国象棋时,棋盘和棋谱部分需要换成中国象棋实现,引擎从 Stockfish 换成支持 UCCI 协议的开源中国象棋引擎(如皮卡鱼等)。这部分在最后一节专门展开。
3.2 安装 python-chess
pip 安装即可:
pip install python-chess安装完成后,执行一次导入验证:
python -c "import chess; print(chess.__version__)"如果输出一个版本号(例如1.999或更高),说明安装成功。版本号以你实际安装到的版本为准,本文的代码基于 python-chess 1.x 的公开 API 编写。
3.3 安装 Stockfish 引擎
Stockfish 不是一个 Python 包,而是一个可执行的二进制程序。安装方式依操作系统而定。
Linux 上,如果软件源里有该包,可以直接安装:
sudo apt install stockfishWindows / macOS 上,推荐去 Stockfish 官方网站下载对应平台的最新稳定版压缩包,解压后把stockfish.exe放到一个固定目录,然后在 Python 代码里使用绝对路径指向它。
验证引擎是否能被调用:
stockfish # 进入引擎交互界面后输入 uci,回车 # 如果引擎正常,会输出一堆以 id 和 option 开头的文本这一步非常重要。很多初学者在 Python 代码里报“引擎启动失败”,最后发现根本原因是引擎本身没有安装好,而不是代码问题。
3.4 准备一份演示棋谱
为了方便测试,我准备了一份标准的国际象棋 PGN 演示文件。复制到项目文件夹下,命名为demo.pgn:
[Event "Demo Game"] [Site "CSDN"] [Date "2024.01.06"] [Round "1"] [White "PlayerA"] [Black "PlayerB"] [Result "*"] 1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6 5. O-O Be7 *这份棋谱是西班牙开局的前几个回合,格式完全合法,足够验证后面的所有功能。
4. 核心流程拆解
从“打开一个棋谱”到“得到 AI 分析结果”,整个流程可以拆成四步:
- 解析 PGN 文件:用 python-chess 的
chess.pgn.read_game()把文件内容解析成Game对象。 - 重建棋盘状态:从初始局面开始,按顺序执行棋谱里的每一步着法,直到当前浏览的位置。
- 发起 AI 分析:把当前局面转换成 FEN,交给
engine.analyse()。 - 解析评估结果:把引擎返回的分数转换成人类可读的格式(比如“白方 +0.35 兵”或“黑方第 3 步杀棋”)。
为什么要拆成这四步?因为每一步都对应一类独立的问题。如果你一上来就写一个 500 行的界面程序,一旦出错,你根本分不清是棋谱解析的问题、棋盘状态更新的问题,还是引擎通信的问题。先拆流程、再写代码,是初学者最应该养成的习惯。
4.1 棋谱解析:为什么不能直接读文本
PGN 文件本质上是文本文件,但你不能用正则表达式简单匹配“1. e4 e5”就完事。原因在于 PGN 里有各种分支变例、注释、NAG 符号(如!和?),还有可能出现的换行格式不统一。chess.pgn.read_game()可以帮你处理这些边界情况,并生成一棵“棋局树”——主线是一个分支,每个变例是一个子分支。
4.2 棋盘状态管理:从零重建还是增量前进
浏览棋谱时,有一个容易犯错的设计选择:前进到第 20 手时,是“在之前的棋盘上再走一步”,还是“从初始局面重新走到第 20 手”?
推荐后者。原因很简单:如果用户在某一步退回去,选择了棋谱中的另一个分支,局面的“历史”就变了。从初始局面重建可以避免状态污染。虽然性能上多了一些计算,但一盘棋最多几百手,现代计算机完全可以忽略这个开销。后面的代码正是采用“从初始局面重建到当前索引”的方案。
5. 完整示例与代码实现
这一节给出完整的可运行代码。建议按照文件拆分的方式组织项目:
chess-tool/ ├── demo.pgn ├── pgn_loader.py ├── analysis.py └── main.py5.1 棋谱加载模块:pgn_loader.py
这个文件负责读取 PGN,并按着法顺序重建棋盘。
# 文件:pgn_loader.py import chess import chess.pgn def load_pgn(path): """读取 PGN 文件,返回 chess.pgn.Game 对象。""" with open(path, encoding="utf-8") as f: game = chess.pgn.read_game(f) if game is None: raise ValueError("PGN 文件为空,或格式无法解析") return game def replay(game, max_ply=None): """按着法顺序重建棋盘,并逐手打印局面。""" board = game.board() move_count = 0 for move in game.mainline_moves(): board.push(move) move_count += 1 if max_ply and move_count >= max_ply: break print(f"第 {move_count} 手: {board.san(move)}") return board if __name__ == "__main__": import sys path = sys.argv[1] if len(sys.argv) > 1 else "demo.pgn" game = load_pgn(path) print("对局信息:", game.headers.get("White", "?"), "vs", game.headers.get("Black", "?")) replay(game, max_ply=10)关键逻辑说明:
chess.pgn.read_game()只读第一局棋。如果文件里有多局棋,需要循环调用直到返回None。game.mainline_moves()返回主线上的着法迭代器,它只会走主线分支,不会进入变例。这是打谱软件最基础的部分。game.headers是一个字典,保存了 Event、White、Black 这些头信息。
5.2 AI 分析模块:analysis.py
这个文件负责启动引擎、分析局面、格式化结果。
# 文件:analysis.py import chess import chess.engine # 如果 stockfish 不在 PATH 中,请改成绝对路径 # 例如 Windows: r"C:\stockfish\stockfish.exe" ENGINE_PATH = "stockfish" def make_engine(path=ENGINE_PATH): """启动 UCI 引擎,返回 SimpleEngine 客户端。""" return chess.engine.SimpleEngine.popen_uci(path) def analyze_fen(engine, fen, time_limit=1.0): """分析一个 FEN 局面,返回最佳着法和评估分数。""" board = chess.Board(fen) info = engine.analyse(board, chess.engine.Limit(time=time_limit)) # 从信息中提取最佳着法(主变第一手) best_move = info.get("pv", [None])[0] # 分数默认从当前轮走方视角给出,这里转换为白方视角 score = info["score"].pov(chess.WHITE) if score.is_mate(): mate_in = score.mate() score_text = f"杀棋,{mate_in} 步" else: score_cp = score.score() score_text = f"{score_cp / 100.0:+.2f} 兵" return best_move, score_text def format_score(score): """将引擎分数格式化为可读字符串。""" if score.is_mate(): return f"# {score.mate()}" return f"{score.score() / 100.0:+.2f}" if __name__ == "__main__": engine = make_engine() try: best, text = analyze_fen(engine, chess.STARTING_FEN) print("初始局面最佳着法:", best) print("白方优势:", text) finally: engine.quit()关键逻辑说明:
SimpleEngine.popen_uci()会创建一个子进程,并完成 UCI 握手。这一步如果抛异常,最常见的原因就是路径不对。engine.analyse()返回的info是一个字典。info["score"]是相对当前轮走方视角的分数;通常我们统一转成白方视角,否则会误解“谁领先”。info.get("pv", [None])[0]取主变着法的第一步,也就是引擎认为当前局面的最佳着法。- 引擎分数可能有两种形态:
cp(centipawn,百分之一兵)或mate(杀棋步数)。代码里分别处理,这是初学者最容易忽略的坑。
5.3 命令行打谱器:main.py
最后把前两个模块组合起来,形成一个可以交互的命令行打谱工具。
# 文件:main.py import chess import chess.pgn import chess.engine from pgn_loader import load_pgn from analysis import make_engine, format_score ENGINE_PATH = "stockfish" class MoveHistory: """管理棋谱着法序列与当前浏览位置。""" def __init__(self, game): self.game = game self.moves = list(game.mainline_moves()) self.index = 0 def current_board(self): """从初始局面重建到当前索引。""" board = chess.Board() for move in self.moves[: self.index]: board.push(move) return board def next(self): if self.index < len(self.moves): self.index += 1 return self.current_board() def prev(self): if self.index > 0: self.index -= 1 return self.current_board() def status(self): return f"{self.index}/{len(self.moves)}" def main(): pgn_path = input("请输入 PGN 文件路径(直接回车使用 demo.pgn): ").strip() or "demo.pgn" try: game = load_pgn(pgn_path) except Exception as e: print("加载失败:", e) return history = MoveHistory(game) print("白方:", game.headers.get("White", "?"), "黑方:", game.headers.get("Black", "?")) try: engine = make_engine(ENGINE_PATH) print("引擎启动成功:", ENGINE_PATH) except Exception as e: engine = None print("引擎启动失败,AI 分析不可用:", e) while True: board = history.current_board() print("\n===== 当前局面 =====") print(board) print("当前进度:", history.status()) cmd = input("[n]下一步 [b]上一步 [a]AI分析 [q]退出: ").strip().lower() if cmd == "q": break elif cmd == "n": history.next() elif cmd == "b": history.prev() elif cmd == "a": if engine is None: print("引擎未启动,无法分析") continue print("引擎思考中,请稍候...") try: info = engine.analyse(board, chess.engine.Limit(time=1.0)) best = info.get("pv", [None])[0] score = info["score"].pov(chess.WHITE) print("最佳着法:", best, " 白方分数:", format_score(score)) except Exception as e: print("分析出错:", e) if engine is not None: engine.quit() if __name__ == "__main__": main()这段代码把前面两个模块串成了一个完整工具。MoveHistory类封装了“当前看到第几手”这个核心状态;current_board()每次从初始局面重建,保证了状态一致性;engine.analyse()被放在 try 块里,避免分析异常导致整个程序退出。
6. 运行结果与效果验证
6.1 运行顺序
在项目目录下依次执行:
python pgn_loader.py demo.pgn预期输出类似:
对局信息: PlayerA vs PlayerB 第 1 手: e4 第 2 手: e5 第 3 手: Nf3 ...这个命令用于验证 PGN 解析和着法重建是否正常。
验证 AI 分析模块:
python analysis.py预期输出类似:
初始局面最佳着法: e2e4 白方优势: +0.30 兵需要注意:具体数值和最佳着法会因引擎版本、分析时间、线程数而不同,这不代表程序出错。
6.2 交互式验证
启动主程序:
python main.py输入demo.pgn后,你会看到 ASCII 棋盘。按n下一步,按b上一步,按a对当前局面进行引擎分析。
如果一切正常,按a后终端里会出现类似下面的内容(数值是示意,不同引擎与机器会不同):
最佳着法: d4 白方分数: +0.28判断成功的关键是:你确实能看到“最佳着法”和一个可读的分数,而不是异常栈。
6.3 如果失败,先看哪里
出现问题时,最优先看两个位置:
- 引擎是否能在命令行单独启动:如果
stockfish在你的终端里都跑不起来,那 Python 里必然是启动失败的。 - 错误来自哪里:如果报
FileNotFoundError,是引擎路径问题;如果 PGN 解析后game is None,是棋谱格式问题;如果KeyError出现在info["score"],可能是引擎版本对 python-chess 的兼容性问题。
7. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
FileNotFoundError | 引擎路径错误,或 stockfish 不在 PATH 中 | 在终端执行 stockfish 命令,或检查绝对路径 | 修改代码中ENGINE_PATH为引擎二进制绝对路径 |
| 引擎启动成功,但分析时卡住 | ELO 设置过高、线程数过大,或引擎等待输入 | 观察 CPU 占用,缩短Limit(time=...) | 换用Limit(time=1.0)或Limit(depth=10)限制计算量 |
| PGN 加载后显示“文件为空” | 文件编码不是 UTF-8,或文件里没有完整棋局 | 用文本编辑器查看文件编码 | 另存为 UTF-8;或使用encoding="gb18030"兼容中文环境 |
分数显示为# -3类似文本 | 引擎返回的是“杀棋步数”而不是兵分 | 检查代码是否调用score.is_mate() | 使用分析模块中的format_score()统一处理 |
| 棋谱中的变例全部消失 | 只读取了mainline_moves() | 审阅game.variations结构 | 后续功能中递归遍历变例树 |
| Windows 下路径含空格导致报错 | 路径字符串没有正确转义 | 打印传入的路径字符串 | 使用原始字符串r"C:\stockfish\stockfish.exe"或统一正斜杠 |
| 中文 PGN 注释出现乱码 | 文件编码与读取编码不一致 | 查看原始文件用什么编码保存 | 统一使用 UTF-8 保存,或按实际编码读取 |
8. 最佳实践与工程建议
8.1 先把“逻辑”和“界面”分开
这篇文章的代码虽然是命令行版本,但分类已经体现出分层思想:pgn_loader.py只负责棋谱解析,analysis.py只负责引擎通信,main.py负责交互。将来做图形界面时,你只需要替换main.py中的交互部分,核心代码可以原封不动复用。初学者常见的反面教材是:把所有代码写在一个文件里,且界面渲染、棋谱解析、状态管理混在一起,最后改一个界面 bug 要动全局。
8.2 统一用 FEN 作为各层之间的“接口”
在一个完整的打谱软件里,棋盘状态的传递建议统一用 FEN 字符串。无论是“从棋谱重建第 20 手局面”,还是“把当前局面发给引擎”,都用 FEN 作为中转。
这样做的优势是:国际象棋的棋盘表示、中国象棋的棋盘表示,甚至前端展示层的棋盘组件,都可以通过 FEN 对接。你不需要为每一层设计一套单独的数据结构,极大的降低了系统复杂度。
8.3 引擎进程的生命周期管理
引擎进程是昂贵的资源,启动一次要几十毫秒到上百毫秒,频繁开关会严重影响体验。在桌面软件里,应该保持一个长期运行的引擎实例,并确保程序退出时调用engine.quit()。使用try/finally是标准做法。
另外,如果你需要同时对多个局面做分析(例如批量分析一整局棋的每一步),不要串行地反复调用engine.analyse()。可以先启动引擎的一个实例,利用engine.analyse()的并发能力;更稳妥的做法是维护多个引擎进程组成一个小进程池。初学者从这个项目的规模出发,先保证try/finally正确释放资源就已经足够。
8.4 不要把引擎“玩坏”了
AI 分析的结果天然有随机性。同一局面,同一引擎,分析时间越长通常越准,但“准”不等于“唯一正确答案”。做复盘分析时,建议:
- 给每次分析设定固定的时间或深度限制,否则批量分析会慢到无法接受。
- 对关键胜负转折点,用更大时间限制做深度分析。
- 不要迷信单次评估分数,可以多次分析取一致性结论。
8.5 从国际象棋迁移到中国象棋的具体路径
如果你最终目标是做一个中国象棋打谱与分析软件,下面的对应关系可以直接参考:
| 能力 | 国际象棋方案 | 中国象棋方案 |
|---|---|---|
| 棋盘规则与着法生成 | python-chess | 自己实现 9×10 棋盘,或使用开源中国象棋库 |
| 棋谱格式 | PGN | XQF / CBR / 中国象棋扩展 PGN |
| 局面快照格式 | FEN | 中国象棋有类似的局面串,但棋子编码不同 |
| 引擎协议 | UCI | UCCI(中国象棋版协议) |
| 推荐引擎 | Stockfish | 开源中国象棋引擎(如皮卡鱼等) |
迁移时,界面逻辑和交互流程基本不需要大改,主要工作是替换“能力层”。这也是为什么我在前面反复强调要分层:你不可能第一天就写出一套中国象棋的完整实现,但先把国际象棋的最小体系跑通,你就掌握了“棋谱解析、状态管理、引擎协议”这三板的底层套路。
8.6 后续扩展方向:界面、批量分析与打包
跑通命令行版本之后,可以按下面的顺序继续扩展:
- 图形界面:先用 Tkinter 做一个简单棋盘窗口;熟练后换 PySide6 或 Pygame。
- 分支变例浏览:递归遍历
game.variations,支持进入变例、退出变例。 - 批量分析:写一个脚本,把一整局棋的所有局面依次交给引擎分析,生成每手评分曲线。
- 开局库与残局库:给常用开局建立索引;残局局面则交给引擎深度计算。
- 打包发布:用 PyInstaller 把 Python 程序和引擎二进制一起打包,分发给同样有打谱需求的朋友。
9. 总结与后续学习方向
这篇文章讲清楚了一件事:自制象棋打谱与 AI 分析软件的难点不在“写一个棋软”,而在把“棋谱解析、状态管理、引擎进程通信”这三块能力拼装成完整流程。文中用 python-chess 和 Stockfish 搭建了一个最小但完整的命令行工具,你可以直接跑起来看效果;后面又从工程角度给出了分层设计、引擎生命周期管理、以及迁移到中国象棋的对应关系。
建议你的下一步不是急着加各种酷炫功能,而是亲手把main.py里的交互逻辑改成你想要的模式,比如加入“保存当前局面的注释”“把某个局面的 FEN 复制到剪贴板”“每次分析后自动显示主变前 5 手”。把这些小功能做好,你对这个项目的理解会远超“能跑通”的阶段。
如果你后续打算走中国象棋路线,可以先在纸上画出“棋盘类”“棋谱解析类”“引擎客户端类”三个模块的接口,再对照本文的代码逐模块替换。过程中遇到任何报错,先回到第一性原理:是棋谱没过,是棋盘状态不对,还是引擎通信没通?这三条主线清晰了,剩下的就只是耐心调试的问题。
