它如何思考

暗棋以自己独特的方式隐藏信息:每枚棋子起初都背面朝下,翻开一枚,就会从剩下的棋子里随机翻出一枚。因此和国际象棋不同,引擎的搜索树里既有普通着法,也有随机事件。MistyBanqi 把翻子当作一个概率节点,对这枚棋子可能翻出的各种身份取加权平均;其余部分则像经典国际象棋引擎那样搜索:向前推演双方可能走的着法,再把最佳一路的价值回传上来。

它做不到的是凭感觉判断局面。这棵搜索树的每个叶子节点,都由一套手写的评估来打分:基于一张修正过的子力价值表的子力(炮靠隔子吃,是盘面上最危险的棋子)、每枚棋子控制多少格、将帅有多暴露,以及另外一些项。引擎的水平完全取决于这些项的好坏——下面要讲的弱点正源于此。

大多数时候,它会赢

MistyBanqi 能赢过大多数人,而它取胜的方式正是经典引擎的方式:往前算上好几步的吃子。逐步回放下面这盘它把对手清光的棋。暗棋的局势随翻子起伏,这盘里它开局甚至一度子力落后,随后一步步扳了回来,直到对手没有棋子可走。每枚棋子第一次被翻开时,会翻出它所发到的身份。

这种一子一子算下去的计算,是它棋力强的那一半。盲点是另一半:当胜利不再需要吃子、只需要耐心时,会发生什么。

它是怎么做出来的

我做了 MistyBanqi,一个用 Rust 写的传统暗棋(中国暗棋)引擎。它使用 alpha-beta 加 Star1 expectiminimax、手工编写的评估函数,对局版本中不含神经网络。

这不是在宣称它最强。强力的暗棋程序 CLAP_CDC 和 DarkKnight 都是闭源的,而且更强。这是一份构建报告,讲的是在我愿意花的预算内能做出的最强开源引擎。真正有用的结论不是某个神奇的评估项。几乎所有进展都来自修正我衡量棋力的方式。

它的评估函数起初来自george0828Zhang 的开源 CDC 引擎,之后我把那个引擎留作固定对手,用来衡量每一项改动。现在 MistyBanqi 已经相当称职,与这个基准大致势均力敌,并且开源。

不要拿自己当尺子

我的第一次升级是常规的搜索机件:置换表和重复局面检测。我用成对的自我对弈,拿它对上自己引擎的早期版本来衡量。它得分 60.9%,约 +77 Elo,各项统计都说这个提升是真的。

然后我把同一项改动拿去对上参考引擎。那 +77 Elo 消失了;结果是打平。一个拿自己调参的机器人,优化的是打败自己的盲点,而自我对弈的数字衡量的是我的引擎心中「难缠对手」的样子,也就是我的引擎本身。于是我把那个引擎定为标尺,后续每一项改动都拿它做大规模成对比试,每方几百局,散布在 Modal 的多个容器上,让每轮跑完不到一小时。暗棋和棋很多,几个百分点的优势需要这么多局才看得出来;我一开始用的 20 局对抗赛纯粹是噪声。

调评估函数

这把诚实的标尺首先找出的,是我一直带着的一个 bug,在子力价值表里。下面是修正后的版本:

棋子取值
将30
炮16(原为 12)
士14
象11
车9(原为 14)
马7
卒4

炮和车弄反了。炮靠隔一个炮架吃子,这让它成为盘面上战术威胁最大的棋子,尽管它在吃子等级里排位很低;车只是普通的直线滑行棋子。仅这一处修正本身就值几个百分点,占了评估收益的大半。自我对弈永远不可能向我揭示这一点,因为我自己引擎的每个版本都带着同一张错误的表;只有外部对手才能把它暴露出来。

更有意思的调参是:一个棋子的价值取决于盘面上还剩什么,而不是常数。参考引擎对将/帅已经做了一个小规模版本,将/帅是唯一会被兵吃掉的棋子:随着对方的兵被吃掉,它的价值上升,因为唯一威胁它的东西正在消失。我把这个思路推广到每个棋子。每个棋子都有一组能吃它的对方棋子,随着这些棋子被换掉,它的价值就朝「无人能碰」上升。这个项叫自适应压制,是评估上收益最大的单项,整套改动对参考引擎达到 +16.6%,负局减少了三分之二。即使我故意把它和旧的、错误的价值表搭配,也仍有 +8.7%,这说明它抓住的是暗棋的真实结构,而不是只对一个对手拟合出来的数字。

整个项只有十来行。对盘面上每个棋子,数一数还存活(在盘上或在袋中)且能吃掉它的对方棋子数量,再按反比缩放一个加分:

// dom_val: a piece's value grows as the enemy pieces that can capture it
// disappear, toward "immortal" when its dominators are gone.
const DOM_K: f64 = 0.5;
for i in 0..NSQ {
    let c = self.sq[i];
    if !is_piece(c) { continue; }
    let role = code_role(c);
    let enemy = (1 - code_color(c)) as usize;
    // living enemy pieces that can capture this role
    // (role 5 = cannon, which screen-captures anything, so it always counts)
    let mut dominators = 0;
    for d in 0..7 {
        if d == 5 || can_capture(d, role) { dominators += alive[enemy][d]; }
    }
    let bonus = values[role] * DOM_K / (1.0 + dominators as f64);
    total += if code_color(c) == persp { bonus } else { -bonus };
}

当对方的兵全被换掉后的将/帅,或任何能吃它的棋子都已离开盘面的棋子,会趋向于无人能碰,而这正是固定数值表无法承载的直觉。(完整函数)

棋力不在于搜得更多

静态评估项到了瓶颈,于是我转向搜索深度。两件事拦住了我。

第一是翻棋。在一个翻棋节点上,引擎必须对这枚暗子可能翻出的每种棋子取加权平均,于是一次翻棋就散开成十几个加权结果,而可翻的暗子又有很多。这种机会分支让搜索树爆炸;暗棋里没法像国际象棋那样直白地搜得很深。(Star1 expectiminimax 会剪掉一些机会分支,有帮助,但并不能让问题消失。)

第二点更重要:即使给更多节点,结果也不再变化。每着超过几十万节点之后,搜得更深并没有让它更能打赢参考引擎。瓶颈不是深度,而是评估,也就是搜索到底部时给局面打分的那个函数。于是我不再试图靠搜索堆出棋力,而开始看棋。胜率只告诉你在输,它从不告诉你该去看哪一着。

它会把赢定的棋下成和棋

同样这个引擎,下面处在一个它已经完全赢定的局面。它以十子对两子领先,已经没有子可吃,唯一要做的就是把胜势走到底。结果它却下成了和棋。

这盘棋下于 2026 年 10 月之前,当时任何三次重复局面都判和。按网站现行的长捉规则,第 64 着是禁着,所以这块棋盘停在第 63 着。

评估里没有任何一项会因为「把优势转化为胜利」而比「单纯守住子力」给更高的分,于是一个遥遥领先的局面和一个真正已经赢下的局面,得分几乎一样。既然没有哪一项促使它取得进展,它从不主动推进,棋局便滑进三次重复局面,按暗棋规则判和。

引擎也看不见重复局面本身。在生产环境里,它只拿到当前局面,没有历史,所以它对自己正走进的重复局面一无所知。

修正的做法是把真实对局的着法历史接进搜索,于是引擎能看到重复局面将至,领先时避开它,只在落后时去寻求它(一个小的蔑视值设定)。这值约 +53 Elo。另有一道防护处理最难看的情形:引擎先丢掉一个子被吃,然后还是接受和棋,这比直接接受和棋严格更糟。那一处看起来像蔑视值的 bug,但在蔑视值为零时也能复现;真正的原因是评估把这次亏子的吃子算得比和棋值高出一丝。

这对你是有好处的。如果你子力落后,不代表就输了:MistyBanqi 仍可能来回挪子,走进重复和棋。这种事多到可以量:引擎自我对弈的 200 盘里,每六盘就有一盘和棋,其中一半曾经走到赢定的局面。

它也可能丢掉自己的将帅

另一个相关的盲点跟将有关。只有卒、对方的将或炮的跳吃拿得下它,所以被围住的将一次只怕一种子,而引擎很慢才会替被逼到角落的将腾出空间。它有时候会把一颗子调到棋盘另一头,同时一颗孤零零的敌方卒走过来把它困死。跟上面那个和棋是同一个缺口:这套评估函数对于好几步之外慢慢成形的安静威胁,没有什么感觉。

看一个对方的兵沿 a 路把 Misty 的将追下去。MistyBanqi 执黑。它的将在 b1,之后追猎它的那个红兵在 b4。

MistyBanqi(黑方)对 Mistboard 上的一位棋手,2026 年 6 月 18 日,第 44 至 58 着。

Misty(此处为黑方)把将挪进了 a1 角。一个红兵沿这一路推上来,将就被围死了:它旁边的格子仍是暗子,暗子上不能走,而将又不能吃兵。最后它完全无着可走,被冻在原地,28 着之后被吃。

救法就是棋手说的「开气孔」:在追猎者到来之前翻开旁边的一枚暗子,给将/帅开出退路。我加了一个将/帅安全项,正好诱导出这种走法,把 Misty 丢掉自己将/帅的频率从 35.5% 降到 26%。看看衡量这一点需要什么:对上参考引擎时胜率几乎没动,因为它不追猎将/帅,而这些棋 Misty 本来多半也赢。基准看不到这项修正,所以我直接衡量灾难本身,而不是相信分数。(我还发现自己在拿引擎自己的评估去问一次救援是否奏效。一个对危险视而不见的评估,无法为自己的盲区打分,所以我手动核对了。)

这就是手工编写能达到的诚实边界。威胁是一次缓慢、安静、跨越多着的行军,任何静态项都看不见,直到为时已晚,而这恰恰是学习型评估要解决的长视野判断。

为什么会有这些问题,以及下一步

这些是手写评估的局限:它只能给有人想到要编码进去的东西打分,而「把胜势转化为胜利」和「缓慢围猎将帅」恰恰是那种很难写成规则的长程判断。最强的暗棋程序采用的解法,是一套从对局胜负中学习得到的评估,让引擎能自己判断这些。这也是 MistyBanqi 终将迈出的下一步。在学习版于测试中越过现有引擎这道门槛之前,你面对的仍是手写版:强,且对自己会在哪里出问题保持坦诚。

最后一次衡量:这段攀升值不值得付费?

便宜的收益已经用光,而两个病症都指向同一个解法:一个理解局面而不是数子的评估。那就是学习型价值网络,也就是 CLAP_CDC 用来在这个项目上赢下计算机奥林匹克的 AlphaZero 配方。

于是我搭好了流水线(棋盘编码、一个小型 ResNet、考虑随机性的 MCTS、带门限的自我对弈以防坏的一代污染下一代),然后在为完整训练付费之前,又做了一次衡量:一次低成本的本地风险排除,看看价值网络是否真能打败手工调参的引擎。

它没能做到。我在笔记本的 GPU 上跑了这次风险排除,三次运行中网络对上 alpha-beta 引擎的胜率都封顶在 35% 左右,从未达到算得上改进的 55%。把搜索预算翻倍几乎没有变化,所以这不是对局太少的问题;最可能的原因是机会节点产生的价值目标噪声太大,以及网络太小。这些都可以修,但测试这些修正所需的算力远超一台跑几小时就降频的笔记本,而云 GPU 在这里也不是即插即用:对局逻辑是单线程 Python,所以快速 GPU 只能饿着等它,除非那条路径用 Rust 重写并放在一个批量推理服务后面。这是一两周的工作量,之后还要花几百到一两千美元来跑。就我手上的证据看,这是一次赌博,而不是稳妥的一步,所以我还没下注;更便宜的本地实验排在前面。

所以 MistyBanqi 是一个称职的 alpha-beta 引擎,不是最先进水平的宣称,而这是衡量之后的选择,不是走进了死路。我还没有对 CLAP_CDC、DarkKnight 或其他顶级 CDC 程序做过可复现的正面较量;学习型网络是下一段攀升,等哪天风险排除说它值得付费。

贯穿这里每一节的模式是:整个项目中最便宜、最高杠杆的决定,是选择衡量什么。自我对弈得分、胜率、丢将/帅的比率、风险排除门限,每一次挑一件能看见我真正在意的东西的量具,作用都大于对引擎本身的任何改动。

misty-banqi 是引擎本体,MIT 许可,alpha-beta + Star1 搜索以及上文的评估项。chinese-dark-chess-hw 是我的评估函数起初取自、并用作固定基准的开源引擎。引擎系列更多内容:斗兽棋引擎,棋盘更大的完全信息表亲。

来下一盘

MistyBanqi 已在 Mistboard 上线。来按你选择的强度挑战它,或阅读它的源代码。

对战 MistyBanqiGitHub 上的 misty-banqi暗棋规则