资讯动态

揭棋AI引擎实现:蒙特卡洛搜索与暗子编码实战

发布时间:2026/10/26 15:05:13 来源:尧图企业网站定制
简介棋类AI通常依赖确定性的局面评估与alpha-beta剪枝但当棋盘上存在未知暗子时传统搜索算法会因信息缺失而失效。蒙特卡洛模拟通过随机补全未知信息用统计平均代替精确计算为不完全信息博弈提供了可行的求解路径。本文从棋盘编码、走法生成、胜负判定等基础环节出发逐步构建一个支持暗子翻面的揭棋引擎并重点讲解单层UCT搜索的实现原理与参数调节方法。针对实际开发中常见的局面哈希失效、将帅判定遗漏、规则版本不统一等关键坑点给出可直接落地的解决方案。无论是改造象棋引擎还是设计新的博弈AI这套基于随机采样与自对弈验证的工程思路都值得参考。1. 揭棋是什么chiness_chess_jieqi-master 想解决的问题chiness_chess_jieqi-master 这个项目名拆开看很有意思chiness_chess 是中国象棋jieqi 是揭棋master 通常指项目主分支或“主程序”。揭棋不是普通象棋换皮而是把所有棋子背面朝上随机摆回原始位置走一步翻一步吃到对方将帅直接判胜。也就是说你面对的是一个“信息不全的棋盘”对方的马可能蹲在你兵线上你的炮可能翻出来才发现是象。传统象棋引擎最值钱的开局库、位棋盘、残局库在这里大面积失效甚至会把搜索带偏。这篇笔记的目标读者是想写揭棋 AI、做象棋引擎改造或者只是想把一个象棋引擎接到揭棋规则上的人。我按自己实际搭过的方案讲从 90 格棋盘编码、明暗子走法生成到单层 UCT 采样搜索最后列出最容易翻车的五个坑。2. 揭棋棋局表示棋盘数组、暗子编码与走法生成2.1 90 格棋盘上用 16 位值编码明暗子我一般会把棋盘直接做成一个长度为 90 的整数数组每格一个 int0 表示空位。棋子的信息用二进制位拆第 0 位表示是否已经翻开第 1 到 3 位表示棋子类型第 4 位表示红黑方。一个 int 同时记住“这颗子真实是什么”和“现在有没有翻面”对后续模拟非常关键因为暗子虽然你看不见但引擎内部必须知道它的真身否则吃子后没法做蒙卡补全。FACE_UP 1 TYPE_MASK 0b1110 COLOR_MASK 0b10000 EMPTY 0 RED COLOR_MASK TYPE [P, H, E, A, K, C, R] TYPE_BIT {t: i 1 for i, t in enumerate(TYPE)}这段编码里TYPE_BIT[R]得到的是二进制1100和TYPE_MASK配合就能取出类型。判断红黑用piece COLOR_MASK红方返回 16黑方返回 0。判断翻没翻用piece FACE_UP。这种紧凑写法的好处是复制局面时可以直接board[:]搜索里频繁拷贝也不会把对象层级搞得很难受。初始局面的随机化要注意红黑双方各 16 枚棋子只在自己一侧的原始位置上洗牌不能混到对方半场去。我用的初始位置固定为底线 9 个位置加炮位 2 个加兵位 5 个然后分别把红黑各自的 16 个类型随机填入。ROWS, COLS 10, 9 RED_BASE [(9, c) for c in range(COLS)] [(7, 1), (7, 7)] [(6, c) for c in range(COLS) if c % 2 0] BLACK_BASE [(0, c) for c in range(COLS)] [(2, 1), (2, 7)] [(3, c) for c in range(COLS) if c % 2 0] def make_init_board(seedNone): if seed is not None: random.seed(seed) board [EMPTY] * (ROWS * COLS) def fill(base, color): pieces [H,H,E,E,A,A,K,C,C,R,R,P,P,P,P,P] random.shuffle(pieces) for (r, c), t in zip(base, pieces): board[r * COLS c] color | TYPE_BIT[t] fill(RED_BASE, RED) fill(BLACK_BASE, 0) return board这里的关键点是洗牌时所有子都不带FACE_UP开局全部暗置。seed参数一定要留不然自对弈无法复现后面做回归测试时你会想给自己留颗后悔药。2.2 两类走法生成器明子按棋规暗子一步翻揭棋最容易起分歧的是暗子走法不同平台规则出入很大。我给自己的实现定的是暗子只能上下左右走一格走到空位或对方棋子所在格移动后立刻翻面如果落点有对方子则对方子也被揭晓并吃掉同时允许原地翻开作为独立动作。这个规则集在本地引擎和自对弈模拟里自洽接具体平台时再按对方协议开关。明子走法按普通象棋写但我默认打开宽松模式士可出九宫象可过河帅可以满棋盘走直线一格。原因很简单揭棋随机摆子后士和象很可能出现在九宫外、河界对面死守传统棋规会让大量棋子成为废子。你如果接的是保守规则平台把对应判断加回来即可。def gen_clear_moves(board, r, c, cfg): p board[r * COLS c] color p COLOR_MASK t p TYPE_MASK moves [] if t TYPE_BIT[R]: for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]: nr, nc r dr, c dc while 0 nr ROWS and 0 nc COLS: target board[nr * COLS nc] if target EMPTY: moves.append((r, c, nr, nc)) else: if (target COLOR_MASK) ! color: moves.append((r, c, nr, nc)) break nr dr; nc dc elif t TYPE_BIT[C]: for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]: jumped False nr, nc r dr, c dc while 0 nr ROWS and 0 nc COLS: target board[nr * COLS nc] if not jumped: if target EMPTY: moves.append((r, c, nr, nc)) else: jumped True else: if target ! EMPTY: if (target COLOR_MASK) ! color: moves.append((r, c, nr, nc)) break nr dr; nc dc elif t TYPE_BIT[H]: for dr, dc in [(-2,-1),(-2,1),(-1,-2),(-1,2),(1,-2),(1,2),(2,-1),(2,1)]: leg_r, leg_c dr // 2, dc // 2 nr, nc r dr, c dc if not (0 nr ROWS and 0 nc COLS): continue if board[(r leg_r) * COLS c leg_c] ! EMPTY: continue target board[nr * COLS nc] if target EMPTY or (target COLOR_MASK) ! color: moves.append((r, c, nr, nc)) elif t TYPE_BIT[E]: for dr, dc in [(-2,-2),(-2,2),(2,-2),(2,2)]: er, ec r dr // 2, c dc // 2 nr, nc r dr, c dc if not (0 nr ROWS and 0 nc COLS): continue if board[er * COLS ec] ! EMPTY: continue target board[nr * COLS nc] if target EMPTY or (target COLOR_MASK) ! color: moves.append((r, c, nr, nc)) elif t TYPE_BIT[A]: for dr, dc in [(1,1),(1,-1),(-1,1),(-1,-1)]: nr, nc r dr, c dc if not (0 nr ROWS and 0 nc COLS): continue target board[nr * COLS nc] if target EMPTY or (target COLOR_MASK) ! color: moves.append((r, c, nr, nc)) elif t TYPE_BIT[K]: for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]: nr, nc r dr, c dc if 0 nr ROWS and 0 nc COLS: target board[nr * COLS nc] if target EMPTY or (target COLOR_MASK) ! color: moves.append((r, c, nr, nc)) elif t TYPE_BIT[P]: fwd -1 if color RED else 1 candidates [(r fwd, c)] if (color RED and r 4) or (color 0 and r 5): candidates [(r, c - 1), (r, c 1)] for nr, nc in candidates: if not (0 nr ROWS and 0 nc COLS): continue target board[nr * COLS nc] if target EMPTY or (target COLOR_MASK) ! color: moves.append((r, c, nr, nc)) return moves这里有一个必须注意的细节炮的循环里遇到第一个非空格子后只是把jumped置为 True然后继续往后探第 2 个非空格子才是可吃目标中间那个空位不能落子所以jumped分支里目标为 EMPTY 时不会 yield。马腿和象眼检查都是看中间那颗棋子是不是空这与普通象棋一致。兵过河后的横移判断红方以r 4为准黑方以r 5为准和数组从上往下排列的行号对应。暗子走法生成器要简单得多def gen_hidden_moves(board, r, c): moves [(r, c, r, c)] # 原地翻开 color board[r * COLS c] COLOR_MASK for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]: nr, nc r dr, c dc if not (0 nr ROWS and 0 nc COLS): continue target board[nr * COLS nc] if target EMPTY or (target COLOR_MASK) ! color: moves.append((r, c, nr, nc)) return moves暗子的移动统一走一步不区分车马炮。原地翻开的作用是给引擎一个最低限度的合法动作避免出现整局卡死也方便后期调试残局。如果你接的平台不支持原地翻子把这个动作从列表里去掉就行不影响搜索逻辑。2.3 吃子、翻子与胜负判定先定规则再写代码执行一步棋时源格子里的棋子无论明暗一律在落点里置为已翻开。返回被吃掉的子这样主循环可以直接判断是不是吃到了将帅。def apply_move(board, move): r, c, nr, nc move src, dst r * COLS c, nr * COLS nc piece board[src] taken board[dst] board[src] EMPTY board[dst] piece | FACE_UP return taken胜负判定上我用“将帅被吃立即判负”作为唯一硬规则另外检查是否无合法动作。因为揭棋将帅初始位置随机很多局面根本不存在“将军”的概念传统象棋的将帅同线照面、九宫禁手在宽松规则下都可以不写。注意apply_move返回taken之后如果taken类型是 K这一步的走子方直接获胜。无合法动作的判定不必单独写一个is_terminal做全局检查在主循环和模拟函数里每次gen_all_moves的返回值如果为空就说明当前方输了。3. 用 Python 写一个最小揭棋引擎蒙特卡洛采样与单步决策3.1 为什么选蒙特卡洛不选 alpha-beta暗子信息缺失下的搜索普通象棋引擎的 alpha-beta 剪枝依赖一个前提博弈树节点上的局面是确定的评估函数能给出静态价值。揭棋开局后双方各 16 颗暗子你只知道自己的棋和已经翻开的部分对方暗地里可能蹲着一颗车。你让 alpha-beta 在某个节点上算出来的“进攻路线”实际对局里可能因为一颗你没看见的炮而完全不存在。我试过直接把开源象棋引擎的搜索引擎挪过来用结果很惨它会疯狂走那些“看起来能吃子”的路线实际执行时发现对方暗子根本不是那么回事。换成蒙特卡洛后一次模拟里把未知棋子随机补全成某个确定局面然后随机走到底用大量统计平均消掉单次补全的偏差。这是揭棋这类不完全信息博弈里比较朴素但有效的路线。方法对暗子的处理主要问题alpha-beta 象棋评估无法直接表示未知子节点局面不确定剪枝结果失真蒙特卡洛 随机补全每局模拟前洗牌补全模拟次数不够时统计噪声大完整信念状态求解维护对手暗子的概率分布实现复杂状态空间爆炸3.2 随机补全 UCT 选步C 值和模拟次数怎么定先给一个能跑的最小实现不是完整 UCT 树而是“根节点采样 UCB 公式选步”。每一轮从当前局面的所有合法动作里用 UCT 公式选一个动作然后从执行动作后的局面开始随机 playout把输赢回填到这个动作的统计里。这个做法足够验证规则也容易扩到多层树。import random, math, time def opposite(color): return 0 if color RED else RED def gen_all_moves(board, color): moves [] for idx, piece in enumerate(board): if piece EMPTY or (piece COLOR_MASK) ! color: continue r, c divmod(idx, COLS) if piece FACE_UP: moves.extend(gen_clear_moves(board, r, c)) else: moves.extend(gen_hidden_moves(board, r, c)) return moves def playout(board, color, max_ply200): while max_ply 0: moves gen_all_moves(board, color) if not moves: return opposite(color) move random.choice(moves) taken apply_move(board, move) if taken and (taken TYPE_MASK) TYPE_BIT[K]: return color color opposite(color) max_ply - 1 return None def select_root_move(stats, parent_visits, c0.8): unexplored [m for m, st in stats.items() if st[1] 0] if unexplored: return random.choice(unexplored) best_move, best_ucb None, -1e9 for move, (wins, visits) in stats.items(): exploit wins / visits explore c * math.sqrt(math.log(max(1, parent_visits)) / visits) ucb exploit explore if ucb best_ucb: best_ucb ucb best_move move return best_move def uct_search(board, color, sims300, c0.8): moves gen_all_moves(board, color) if not moves: return None, {} stats {move: [0, 0] for move in moves} for i in range(1, sims 1): move select_root_move(stats, i, c) sim_board board[:] taken apply_move(sim_board, move) winner color if not (taken and (taken TYPE_MASK) TYPE_BIT[K]): winner playout(sim_board, opposite(color)) if winner color: stats[move][0] 1 stats[move][1] 1 best max(moves, keylambda m: stats[m][0] / max(1, stats[m][1])) return best, statsselect_root_move里 c 是探索常数我常用 0.8。c 越大越偏向探索冷门动作越小越偏向选当前胜率高的动作。模拟次数sims建议先在开发机上用 50 跑通再按时间预算提到 300 到 500。playout里max_ply设 200 是防止随机模拟陷入无限循环毕竟揭棋里棋子少时会绕圈。这个方法本质上是单层蒙特卡洛真正的 UCT 树需要在每次模拟后把子节点统计一起回传等规则验证没问题后再扩。3.3 连起来跑一盘主循环与落子日志有了搜索函数自对弈主循环就很好写了。每步先生成合法动作如果没有直接判负有则让uct_search返回这一步执行后用apply_move的返回值判断是否吃到了将帅。def self_play(sims100, seed1): board make_init_board(seed) turn RED log [] while True: moves gen_all_moves(board, turn) if not moves: log.append((black_win if turn RED else red_win)) break move, stats uct_search(board, turn, simssims) if move is None: log.append((black_win if turn RED else red_win)) break taken apply_move(board, move) r, c, nr, nc move log.append((turn, (r, c), (nr, nc), taken)) if taken and (taken TYPE_MASK) TYPE_BIT[K]: log.append((red_win if turn RED else black_win)) break turn opposite(turn) return log print(对战结束, self_play(sims50, seed7)[-1])我一般会让log里记录走子方、起止格子和被吃子类型。调试时最有用的是被吃子那一列能看出引擎是不是在开盲盒似的乱吃如果它频繁吃出士象但不去追杀对方将帅说明搜索深度或者随机补全的分配逻辑有问题。日志保存成文本后我还会自己写个十行左右的统计脚本统计每盘棋平均步数、吃子数、胜负方这些指标比单盘棋更有说服力。另外主循环里每次uct_search都要重新生成一次合法动作搜索内部还会反复生成这个重复在 debug 阶段无所谓真要提高速度就把gen_all_moves的结果传到搜索函数里缓存。纯 Python 版在开发机上 sims 开到 300 之后每步耗时可能到一两秒自对弈一局会比较久这是正常的别急着上 2000 sims。4. 揭棋引擎最容易翻车的五个坑哈希、将帅判定与配置对齐4.1 暗子翻面改变局面哈希现象搜索跑到一半缓存里的节点反复失效内存涨得飞快。原因很多改造象棋引擎的人直接把整盘棋盘当作哈希键翻面前后同一个位置的值从暗子变成了明子哈希完全变了导致搜索树无法复用。普通象棋不存在这个问题因为所有子都是明的。解决做局面缓存时对暗子把FACE_UP位遮掉再参与哈希或者只对“已公开信息”做键。对局者视角里暗子翻没翻是真正的信息变化但引擎内部做模拟补全时这个位不该成为缓存暴增的元凶。def board_key(board): key 0 for piece in board: if piece EMPTY: key key * 131 0 else: key key * 131 (piece ~FACE_UP) return key这样写之后同一颗子翻面前后的局面在“内部模拟缓存”里是同一个键。4.2 吃将判负要放在走子分支里检查现象自对弈日志里出现“红方已经吃掉黑将棋局又继续走了十几步”的诡异记录。原因主循环只在每次走子前检查棋盘上还有没有双方将帅忽略了apply_move在吃子瞬间就应该触发终局。比如红车吃黑将后棋盘上黑 K 确实没了但循环里把这一步当成普通吃子处理。解决每次apply_move之后立刻检查返回值K 被吃掉马上退出循环。playout 里也一样随机模拟走到吃将那一步就得停否则后续动作全是无效统计。taken apply_move(board, move) if taken and (taken TYPE_MASK) TYPE_BIT[K]: # 当前走子方直接获胜立即结束 break4.3 随机补全不能直接用当前 board现象本地自对弈正常一接到真人对战平台引擎胜率暴跌。原因很多揭棋程序写到最后搜索用的棋盘一直是引擎内部的“全知棋盘”所有暗子的真实类型都写在 int 里。对平台对局来说这是一眼看到对手底牌等于作弊视角下还赢不了。解决对平台接口做蒙卡模拟前先维护一个“公开棋盘”只包含你自己翻过的子和你翻出的对方子。每次模拟开始前把未知格子里缺失的子力随机洗牌补全再交给 playout。本地单机调试可以继续用全知棋盘但接口要留一个开关。这个开关也是你判断引擎棋力的试金石全知版和半知版的胜率差距如果太大说明你的搜索过度依赖偷看暗子。4.4 士象规则版本太杂先对齐平台现象同样的引擎换个对弈平台就报一堆非法走法或者出现走法合法但对方平台判定为违规。原因揭棋规则各平台不统一光暗子走法就有“一步格”“任意直线”“必须原地翻开”好几种士象将帅能不能出宫过河更是每个平台自己的配置。解决把规则集中放成一个配置文件每个布尔开关对应一种变体。CFG { loose_advisor: True, # 士可出九宫 loose_elephant: True, # 象可过河 loose_king: True, # 帅/将无九宫限制 hidden_move_once: True, # 暗子只能走一格 }接入平台时先拿平台规则文档逐条过一遍开关没有文档的就按对方已有的对局回放反推规则。别凭感觉默认“揭棋都一样”这是最容易白忙一场的地方。4.5 时间参数应该在真实对局里标定现象本地设了 sims 500开发机上算一手要 3 秒对局平台超时判负反过来设 sims 20速度快了但胜率明显低于直觉。原因自对弈调试时你等得起线上对局等不起。模拟次数和耗时不是线性关系playout 长度、合法动作数都会拉长单次模拟耗时。解决搜索入口先用时间预算而不是固定 sims。def uct_search_time(board, color, budget0.5, c0.8): moves gen_all_moves(board, color) stats {m: [0, 0] for m in moves} start time.time() turns 0 while time.time() - start budget: move select_root_move(stats, turns, c) sim_board board[:] taken apply_move(sim_board, move) winner color if not (taken and (taken TYPE_MASK) TYPE_BIT[K]): winner playout(sim_board, opposite(color)) if winner color: stats[move][0] 1 stats[move][1] 1 turns 1 best max(moves, keylambda m: stats[m][0] / max(1, stats[m][1])) return best, turns先跑一段标杆数据在目标机器上测出 100 次模拟的平均耗时再换算成时间预算。我习惯开局阶段给 0.5 秒残局阶段减到 0.3 秒因为残局合法动作少同样时间能算更多层。5. 验证与调优从自对弈胜率到信息泄漏检查验证一个揭棋引擎有没有棋力最直接的方法是让它和基线策略打循环赛。我常用三个基线纯随机走、贪心只吃子、固定模拟次数 UCT。先跑 30 局看胜率分布而不是单局表现。def run_match(policy_a, policy_b, games30, sims80): wins {A: 0, B: 0, draw: 0} for g in range(games): board make_init_board(seedg) turn RED while True: moves gen_all_moves(board, turn) if not moves: wins[B if turn RED else A] 1 break if turn RED: move policy_a(board, turn, sims) else: move policy_b(board, turn, sims) taken apply_move(board, move) if taken and (taken TYPE_MASK) TYPE_BIT[K]: wins[A if turn RED else B] 1 break turn opposite(turn) return wins如果 UCT 连贪心吃子都打不赢先别急着加大 sims回头查走法生成里暗子吃子是不是漏掉了翻面逻辑。我遇到过引擎一直不敢暗子吃子因为它认为暗子移动后应该还是暗的结果导致搜索永远避开吃子路线。这类问题在自对弈日志里非常显眼吃子数长期为 0 就是信号。第二步做信息泄漏检查把搜索里的全知棋盘换成“公开棋盘 随机补全”版本同参数打 20 局。如果全知版胜率明显高于半知版说明搜索大量依赖偷看暗子这在模拟调试里没毛病但上平台就是另一个故事。我会把两个版本的差距控制在 5% 以内才敢接真人对战。最后我会固定一颗随机种子构造几个手工残局比如红方已翻出一颗车黑方只剩一颗暗将看引擎能不能在 30 步内追杀。揭棋规则下将帅可能在任何位置这个残局比普通象棋的残局更能暴露搜索短视问题。蒙卡搜索在窄局面里经常出现来回绕圈原因是随机 playout 里双方乱走胜率信号被稀释这时可以临时把 playout 里的随机走法改成“优先吃大子”的启发式胜率会立刻改善。但启发式会引入评估偏好改完记得重新跑一遍循环赛。我自己在这类项目上最大的教训是先做日志和基线再调参数别一上来就堆模拟次数。揭棋的搜索黑匣子感比象棋强得多没有基线对比你连“变好了”还是“变坏了”都说不清。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑