第30届智能体大赛季前攻略(一)--OmegaFantasy和ShowCry代码对比分析
本文对比第26届智能体大赛冠军OmegaFantasy与八强ShowCry的代码实现差异。在SnakeGo游戏中,OmegaFantasy的估值函数采用连续梯度评分处理道具,相比ShowCry的二值判定更精细;其距离计算、风险评估等设计也更完善。搜索策略方面,OmegaFantasy结合力场引导与MCTS,比ShowCry的纯MCTS更高效,节点存储和LocalMCTS设计更优。其他差异包括大蛇保
第30届智能体大赛季前攻略(一)–OmegaFantasy和ShowCry代码对比分析
目录
- 前言
- 游戏规则简介
- 引子 - 游戏关键点
- 估值函数分析
- 4.1. ShowCry的估值设计思路
- 4.2. OmegaFantasy与ShowCry的估值对比
- MCTS搜索引擎实现分析
- 5.1. 节点存储策略
- 5.2. MCTS主循环
- 5.3. LocalMCTS设计
- 其它差异分析
- 6.1. 大蛇保护策略
- 6.2. sigmoid胜率转换机制
- 6.3. 时间管理策略
- 6.4. 固化方案选择
- 总结
1. 前言
听说今年的清华大学第30届智能体大赛要引入LLM之类的有趣东西,比赛将会非常好玩。而历届比赛官方做的一些新手入门活动,仅仅是对一些完全没有参与过类似比赛的萌新有作用,即使是请来往届的冠军选手讲解我听着也是没有什么干货。
那么对于已经入门过比赛又想进阶的同学,感觉能获取的信息又非常少,而我最近也是在休息期,想要review以前的代码、参赛经历、想法、和分享一些新发现的东西。于是我打算开始编写这么一个《智能体大赛季前攻略》,计划包括这么几篇:
-
第一篇:第26届SnakeGo回顾,分析OmegaFantasy(第1)和ShowCry(前8)源码对比分析。这篇你可以跟随我的第一视角完完全全了解在当时你如果想要获得一个前8的成绩,你需要具备什么样的思路和设计。同时对比分析和第1名源码的区别,也可以了解到8强和冠军选手差距究竟在哪。
-
第二篇:第28届Generals回顾,同样是对OmegaFantasy(第1)和ShowCry(第6)源码对比分析。这一篇将更侧重于游戏内容的变化导致的策略调整。
-
第三篇:第29届RollMan回顾,ShowCry(前4)部分源码分析。在本届比赛中着重想要介绍的点在于,游戏内容和往届有一个较大的变化和应对策略,以及我的技术路线、实现方式和参赛目的的变化论述。
-
第四篇:katac4源码分析,游戏内容和LLM强化学习技术前瞻,LLM技术,LLM红警AI框架。
同时我也会用大模型来辅助对代码进行对比分析。那么话不多说开始第一篇,跟我一起回到2022年的那个春天。
2. 游戏规则简介
第26届SnakeGo的游戏介绍页面:https://www.saiblo.net/game/10
SnakeGo的规则描述:https://docs.saiblo.net/games/snake-go/snake-go.html
OmegaFantasy Github: https://github.com/omegafantasy/SnakeGo-AI

以防有些观众不愿意点进链接看冗长的文档,我还是简要介绍下规则:
-
这是一个先后手轮流行动,每条蛇每回合必须选择移动或使用道具。
-
吃到图上随机生成的豆子会增加蛇的长度,增加的方式是下一回合头行动时尾巴不行动,这样总体长度就增加1,直到长度银行buffer值为0。
-
如果蛇头碰到自己的身体,则会把蛇"围"起来的地方固化为你的"墙体",墙体会参与最后计分。
-
如果蛇碰到(step into)对方的蛇身体或者任何墙体,则这条蛇会立即死亡。
-
射线道具是蛇头朝向前面所有墙体被破坏,但不会杀死蛇。
-
最终游戏胜负以场上存活蛇长度+墙体数量,分数高者获胜。
3. 引子 - 游戏关键点
我们暂时不考虑学习型AI,如果你打定主意要写一个规则型AI,那么分析好游戏关键点就至关重要了。就像你打一场游戏,一个团战在你的脑海里应该要提前有画面,那么你打这个游戏的水平也不会差。更不必说对于对抗智能体的开发,你需要花大量时间在开发上,对智能体的设计基本就决定了最终AI水平的上限。
那么对于SnakeGo,我们首先知道了这是一个序贯博弈游戏,那么只要你算力时限无穷,在游戏第一步你就可以算出胜负。首先可以确定这一定是基于棋牌游戏的搜索引擎去做的,那么工程上的难点,我们先不讨论,这部分我想放在第三篇去论述。我们且只论算法设计的思路。
首先这个游戏大体的战术是什么,博弈的局部对抗是否激烈。即使是博弈游戏,他也有长博弈和短博弈之分:
- 短博弈:奖励即时,动作对了立即就有反馈(如MOBA游戏中的补刀、压制、击杀)
- 长博弈:奖励不及时,需要很久才能感受到效果(如出装选择、游走时机)
如果这是一个对抗激烈的游戏,它就会出现大量的短博弈。那么首先游戏中会出现的第一件事,就是中间会生成一些加蛇长度的道具,如何根据他们的分布来决定你的蛇去哪?我们首先就假设两边的蛇都是贪心的,就去最近的那个道具,那就会出现一个问题:
- 如果敌方的也是最近的且比我近呢?
- 如果另一个道具的长度更大但更远?
这就是我想展示的一个参赛的思路,既然你需要写一个规则型AI,在这个过程中,你的思考就应该一直集中在,“如果如何我就如何,然后这样对吗?”,并尝试举出反例。上面论述中在我最简单的假设下,一瞬间就能举出三个反例,然后迅速开启思维推理模式,不断地解决推理,再用代码实现你的想法这样AI就会不断地跟着你的思维进化。
但也会出现一个问题,由于你的问题太多,甚至会提出一些大胆的无法解决的问题,例如"蛇越长越好吗?"胜负靠蛇的长度来判定,但你有没有想过蛇可能并非越长越好?特别是游戏初期我好像记得,长度为2的小蛇可以原地掉头,你很快就能发现,长度越长的蛇越不灵活,越容易被小蛇杀死。我记得参赛当初花了两天时间在脑子里演算这个过程,而你需要你的大蛇感知这个危险,你需要用一个函数来总结他。这些就是你在参赛时会不断地去经历的事。
很显然这些问题和解法一时半会也无法列举清楚,那么我们不仿直接来看ShowCry源码中最后给出的答案?仅针对第一个问题,如果一开始生成了一系列的道具,蛇如何在这些道具中做出选择?
4. 估值函数分析
4.1. ShowCry的估值设计思路
以下是我给大模型(Claude 4.5 Sonnet)分析代码后它给出的总结,这块内容中斜体为大模型原话:
一、ShowCry并未直接给出具体的道具作为目标,而是MCTS+启发式评估来进行动作决策。
二、使用道具归属判定来解决前述的核心问题"一个道具应该争取还是放弃"。 我当时是把每个道具判定为属于某一方,这样蛇的动作就更全局,如果它往某一方向走,虽然抢到某一近处道具但使得其它地方某一高分道具丢失那么。但这个设计其实也有一些问题,例如蛇只是把道具围起来而不吃掉,这个问题好像困扰了我很久,因为整个比赛过程中我还有很多参数要调整,这个问题会时不时的因为我的参数调整干扰了评分敏感度不断地又重现骚扰我。
三、ShowCry计算了一个地图锁定时间LockMap,这个Map在每一格上标注了蛇尾会穿过这个格子的时间,这相当于你不需要完全的模拟游戏,如果你走到这里的步数>蛇尾穿过的时间,就能快速判定你可以穿过这个格子(否则会撞到别人的身体),这相当于一个搜索优化吧,在中期大家8条蛇挤做一堆时,这个处理能加速不少。
四、反向SPFA搜索,从道具反推谁能拿到,不是计算"每条蛇到每个道具的距离",而是从道具出发反向搜索,直接找到最先能到达的蛇! 这在大模型看来好像挺不可思议,不过我感觉挺正常吧,这样你不用对于每个道具都做8条蛇的寻路,速度快8倍。
五、先手优势调整(Adj机制)
if (camp==env.cur_side){
if (i_snake>=env.cur_snake&&i_snake<env.act_snake_nums[camp]){
adj=-0.5; // 当前回合即将行动的蛇
} else {
adj= 0.0; // 己方已行动的蛇
}
} else {
adj = -0.25; // 对方的蛇
}
这并称不上一个机制,这是个细节问题,是处理在搜索过程中,如果你和敌方距离一样,那下一步谁先动这个道具就归谁,但问题是你搜索到这时你不能继续搜索了,你必须对局面做出一个评价,所以就必须对先后手有一个响应。
六、考虑蛇尾分裂可能性。当蛇数量<4时,还会把蛇尾也作为锚点!因为可以分裂,蛇尾也能去抢道具。注意这里adj+1,因为分裂需要消耗一个回合。 同样是由于这本身就是搜索的评估函数,不能带搜索的内容,这对应着一种战术,我蛇头有一个道具,蛇尾有一个道具,即使蛇尾的道具有敌方蛇过来抢夺,我可以分裂之后,蛇尾正好能吃到那个道具。
4.2. OmegaFantasy与ShowCry的估值对比
那么既然话题到这了,我们顺势让大模型分析一下这部分,并对比OmegaFantasy在这部分处理上有哪些比我更强的地方:
4.2.1. 道具归属判定策略
(1)ShowCry:二值归属判定
// 163-188行:ShowCry-SnakeGo-v2_final/brain.h
if (hit_camp==0){
belong_score+=item_value*discount;
} else if (hit_camp==1){
belong_score-=item_value*discount;
}
(2)OmegaFantasy:连续梯度评分
// 1263-1269行:OmegaFantasy-SnakeGo-AI-main/main.cpp
if (mindis[0] + 1 < mindis[1]) {
val += item_val / (float)(max(mindis[0], 2.0));
} else if (mindis[0] > mindis[1] + 1) {
val -= item_val / (float)(max(mindis[1], 2.0));
} else {
val += item_val / (float)(max(mindis[0], 2.0))
- item_val / (float)(max(mindis[1], 2.0));
}
精妙之处:
- 有争议的道具(距离差≤1):双方都计分,按距离加权
- 无争议的道具(距离差>1):只给近的一方加分,但除以距离作衰减
这一点,我追问了大模型,为什么OmegaFantasy在距离选择上不考虑先后手?因为对于我来说我更希望模型能准确地拿下某个道具,这样效果更好。大模型进行了进一步搜索,发现Omega代码中居然有注释掉的考虑了先后手的部分:
// char round_bias[2][4]; // ⚠️ 被注释掉了!
char mindis[2];
for (char i = 0; i < 2; i++) {
for (char j = 0; j < nownode.snakecount[i]; j++) {
heads[i][j] = nownode.player_snakes[i][j].front();
id[i][j] = nownode.player_snakes[i][j].id;
// if (i <= nownode.nowaction && j < nownode.nownumber) {
// round_bias[i][j] = 0;
// } else {
// round_bias[i][j] = -1;
// }
}
}
LLM认为,Omega放弃了先后手说明先后手效果并不好,并进一步解释"冠军选手模糊的哲学"。但是这个先后手还是用的0和-1,这个数我知道,这样计算先后手是不对的,而我是用的-0.5和-0.25,我觉得这里是我的设计更胜一筹,因为我确实解决了道具归属的问题,而不是模糊化处理它。不过如果也妥协一点,即使有归属也在归属附近做一些80%或90%这样的模糊化处理是否更好?因为确实,距离并不是决定道具的全部。
4.2.2. 力场引导 vs 纯MCTS
(1)ShowCry没有Rollout策略
// cmcts.h - 没有找到明确的rollout引导策略
// 似乎主要依赖MCTS自然探索
(2)OmegaFantasy的力场引导
// 1667-1730行:OmegaFantasy-SnakeGo-AI-main/main.cpp
short fieldx, fieldy;
fieldx = fieldy = 0;
MyPos nowpos = tmpnode.player_snakes[tmpnode.nowaction][tmpnode.nownumber].front();
for (char i = 0; i < tmpnode.itemcount; i++) {
MyItem &c = tmpnode.itemlist[i];
dx = c.x - nowpos.x;
dy = c.y - nowpos.y;
dis = abs(dx) + abs(dy);
if (dis <= c.time + 16 - tmpnode.round && dis <= 6) {
value = c.type == 0 ? c.param : 3;
fieldx += value * 100 * dx / (dis * dis); // 关键!
fieldy += value * 100 * dy / (dis * dis);
}
}
// 根据fieldx, fieldy计算各方向的概率权重
首先来说,大模型可能误解了rollout的意思,rollout是指在某个局面下把棋局进行到最后。这里说的应该是给Policy进行概率引导,引导它以更大概率做出你想要的动作。起初我也想这么干,因为AlphaGo中Policy就是同时输出Prob和Val,但是我到最后更倾向于纯Val引导,Prob只是用于做一些概率屏蔽,比如某个地方你就不可能做某种动作,你就把它的概率置为0。而AlphaGo这种设计是为了Prob形成策略训练梯度,是A2C的训练框架。如果是MCTS还没有人研究过Prob和Val配合能显著比纯Val要好。
让我们继续看看,fieldx/fieldy对于动作的影响:
// 1699-1715行
short max_value = abs(fieldx) + abs(fieldy); // 归一化基准
if (max_value == 0) {
// 没有道具引力,随机选择
choose_action = valid_actions[rand() % action_count];
} else {
short action_value[4];
short total = 0;
for (char i = 0; i < action_count; i++) {
if (valid_actions[i] == 1) { // 向右
action_value[i] = fieldx + max_value;
} else if (valid_actions[i] == 2) { // 向上
action_value[i] = fieldy + max_value;
} else if (valid_actions[i] == 3) { // 向左
action_value[i] = -fieldx + max_value;
} else { // 向下
action_value[i] = -fieldy + max_value;
}
total += action_value[i];
}
}
我理解到的就是由于正好action是对应四个方向,那fieldx/y的正负正好覆盖了四个方向的概率。最后是选择动作的时候用线性的归一化,这部分也是常规操作。
而我当时之所以没有往这个方向设计就是我认为走向道具并不是我的目标,而是同时要把对方的蛇"挤走",但这是不是自然而出的呢?因为如果你实现了"挤走"那么这个会反映到敌方的寻路距离上。而Omega有没有响应这一点,接下来正好就看一下,Omega对于距离的处理。
4.2.3. 距离计算的精细度
(1)ShowCry:Lock Map精确计算
// 289-291行:ShowCry-SnakeGo-v2_final/brain.h
for (int i_body=0;i_body<len_body;i_body++){
lock_map[body[i_body].x][body[i_body].y]=len_body-i_body+snake.length_bank;
}
- 精确计算蛇身消散时间
- 反向SPFA搜索时考虑穿过
- 问题:计算复杂度高,每个道具都要SPFA
(2)OmegaFantasy:distance_actual简化
inline char distance_actual(Node &nownode, const MyPos &pos1, const MyPos &item) {
char dis = abs(pos1.x - item.x) + abs(pos1.y - item.y);
if (nownode.map[item.x][item.y] == -1 || nownode.map[item.x][item.y] == -2) {
return dis + 2;
} else {
return dis;
}
if (dis > 4 || dis <= 1) {
return dis;
}
char nowx = pos1.x;
char nowy = pos1.y;
char x_bias, y_bias;
while (nowx != item.x || nowy != item.y) {
x_bias = item.x - nowx;
y_bias = item.y - nowy;
if (abs(x_bias) + abs(y_bias) == 1) {
return dis;
}
if (abs(x_bias) < abs(y_bias)) {
if (y_bias < 0) {
if (nownode.map[nowx][nowy - 1] == CHAR_MIN) {
nowy--;
} else if (x_bias < 0 && nownode.map[nowx - 1][nowy] == CHAR_MIN) {
nowx--;
} else if (x_bias > 0 && nownode.map[nowx + 1][nowy] == CHAR_MIN) {
nowx++;
} else {
return dis + 2;
}
} else {
// ... 类似逻辑
}
} else {
// ... 类似逻辑
}
}
return dis;
}
可以看到这是一个贪心的寻路,撞到任何东西它都会直接把dis+2输出,复杂度为O(1)。这就很难评,这就感觉你看到明明一个不对的东西,但他拿了冠军你又无话可说哈哈哈。不过我觉得即使不用精准的寻路,但我的蛇尾消失时间,这个东西加上去好像也不影响复杂度吧。先继续往下看。
4.2.4. 未来道具的处理
(1)ShowCry:时间折扣
// 314-317行:ShowCry-SnakeGo-v2_final/brain.h
if (item.time>env.cur_round){
discount=pow(0.995,item.time-env.cur_round);
}
(2)OmegaFantasy:道具时间窗口 + 蛇身特判
// 1272-1329行:OmegaFantasy-SnakeGo-AI-main/main.cpp
for (short i = 0; i < future_item_rounds_count[nownode.round]; i++) {
auto &c = gen_itemlist[future_item_rounds[nownode.round][i]];
if (c.time - nownode.round > 20) { // 只看20回合内
break;
}
if (nownode.map[c.x][c.y] >= 0) { // 道具在蛇身上!
// 计算蛇身消散时间,判断是否"必吃到"
if (left > c.time - nownode.round) {
val += (j == 0) ? item_val : -item_val; // 必吃到,全分
} else {
val += (j == 0) ? item_val/3.0 : -item_val/3.0; // 可能吃到,1/3分
}
}
}
精妙设计:
- 只看20回合内:更远的道具意义不大,节省计算
- 蛇身特判:道具会落在未来的蛇身上,提前给分!
- 必得 vs 可能:根据消散时间判断确定性
这解决了你提到的问题6"考虑蛇尾分裂可能性",但Omega做得更深入——不仅考虑分裂,还考虑道具会落在未来蛇身的哪个位置。
这块确实如此,这个可能是我和Omega的一大差距。我当时可能忽略了道具落到蛇身上会直接算作蛇吃掉了这个道具的处理。实际上我在决赛前测试另一名选手Viridian时胜率更低,我印象中他的AI更倾向于更长的蛇,后续道具经常就落在他蛇身上,我只能通过小蛇杀他的长蛇取胜但即使杀完后期似乎也打不过。
4.2.5. 蛇数量和位置的权重
(1)ShowCry:战区惩罚
// 328-353行:ShowCry-SnakeGo-v2_final/brain.h
if (len_snakes<4){
warzone_punish-=7.5*(4-len_snakes); // 鼓励分裂到4条
}
center_dis = fabs(double(head.x-7.5))+fabs(double(head.y-7.5));
warzone_punish-=center_dis; // 鼓励向中心移动
(2)OmegaFantasy:蛇数量阶梯惩罚
// 1459-1472行:OmegaFantasy-SnakeGo-AI-main/main.cpp
if (nownode.snakecount[0] == 1) {
val -= 3;
} else if (nownode.snakecount[0] == 2) {
val -= 1;
} else if (nownode.snakecount[0] == 0) {
val -= (max_round - nownode.round) / 6.0f; // 动态惩罚
}
亮点:
- 1条蛇:-3(重惩)
- 2条蛇:-1(轻惩)
- 0条蛇:动态惩罚,剩余回合越多惩罚越大
- 没有鼓励4条蛇!可能认为3条是最优
这可能是风格化的问题,因为我的蛇特别喜好围杀对面的蛇,所以在搜索过程中也就很怕被对面的蛇围杀,所以我会更选择站在中间,这样能把对手的蛇往边上赶。但回想起来这也许是个问题,因为后面的道具生成在边上的倾向也不小。另外其实杀蛇的收益并不一定是正的,因为你使用了两条蛇和若干时间,那么如果像omega这样倾向于保留三条蛇,那么被杀的蛇也可以最后再分裂,保留尾巴的那一半长度,那么其实我的收益就只有一半。
4.2.6. 边界风险评估
这是Omega独有的设计!
// 1417-1429行:OmegaFantasy-SnakeGo-AI-main/main.cpp
inline float border_value_judge(Node &nownode, MySnake &nowsnake, short len) {
char border = if_border(nownode, nowsnake.front(), nowsnake.getdir(), nowsnake.id);
if (border == 0) {
return 0;
} else if (border == 1) {
return len / 6.0; // 一侧有墙
} else {
return len / 4.0; // 两侧有墙(夹道)
}
}
设计思想:
- 检测蛇头方向延伸3格,两侧是否有墙/蛇身
- 如果在"夹道"中,按蛇长度给惩罚
- 蛇越长,在夹道里越危险(容易被堵死)
这是ShowCry没有的!可能解释了为什么ShowCry在复杂局面下容易被Omega压制。
为什么我没有这种设计,这是我需要反思的点。以我当时的思路,我可能很难给AI添加上这种评估函数。因为我没有一个能把我堵死的对手,我能从对局中观察到有这种情况,但已经很晚并临近决赛了,我当时都还在解决如何不在很大劣势的情况下跟对手打进后期的问题。这个设计应该是后期的设计。
而在MCTS编写的过程中,对局面函数的一个小改动,可能会引起之前很多本来对的局面受到影响。所以我整个参赛过程中大量的时间用于测试和debug,这是看最后代码看不出来的点。好了,以上就是对OmegaFantasy和ShowCry的局面评估和动作引导部分的对比分析。
5. MCTS搜索引擎实现分析
那么还有一个重要的点就是MCTS搜索引擎的设计,因为赛后有一次跟Omega请教过他能搜索的局面数量,在1秒的时限下,我印象中我能搜索的局面数量大约为3-5万个,搜索深度大约为8-20个蛇步,平均10左右。蛇步表示一条蛇动一步,这就意味着8条蛇,来回也就动了1回合其实AI还是很短视的。并且还有个重大问题我没有解决,如果蛇分裂了,那么"分裂"这个动作下的那颗搜索树会极端变深,这就带来了一个问题,这个分裂的搜索树下的评估相对模糊,就更接近"平均值",那么你的AI的评估风格就直接影响了你的分裂倾向,如果你的AI偏"悲观",那么他就会极其倾向分裂,如果乐观就倾向于一条蛇干到底。这就搞得我设计AI评估函数时还需要时刻关注评估函数的"平衡性"。我不知道Omega是如何解决这个问题的,Anyway我后来问Omega它搜索的局面数,它说有几十万,那么也许引擎的编写上也存在问题?
不过分析之前我想提一下后来我在优化评估函数时探索出一个小技巧,就是你可以把几十种比较不同的局面存下来,要求AI从某一个局面搜索并做出你想要的动作,并把树节点的搜索信息存储打印,我一般是打印三层左右。
关于搜索引擎的设计还有一个不得不提的前提,我们知道像围棋这样的游戏它的Val值从结束生成为-1/1,表示胜负,每到Val往上一层搜索节点Backward时,就需要乘以-1,因为当前局面的执棋方变化了。而蛇棋的这个乘以-1是要管理的,你并不知道是2层以后换边还是4层以后换边,所以在搜索过程中也需要进行管理。另外还有一些其它需要注意的问题,比如每次都从root节点对局面进行模拟,还是把模拟后的局面存到每个节点上,直觉是后者因为你在下次走树时不需要再模拟局面。然而实践上,大部分情况下,应该跟着走树的过程去重新模拟棋步,因为存局面的时间相比起模拟棋步对几个变量的改动多太多了。如果是非常简单的游戏,甚至每次模拟都不会开一个新的变量去copy根节点上的局面,而是直接你怎么改变的变量就原路退回去。但在saiblo的比赛中,一般游戏都还算复杂,完全反着开发游戏引擎功能工作量应该不少。
还是先让大模型看看两位的搜索引擎有什么区别吧:
5.1. 节点存储策略
这是根本性架构差异。
(1)ShowCry:轻量级节点 + 动态模拟
// ShowCry-SnakeGo-v2_final/cmcts.h 20-68行
class Node{
public:
int father=-1;
int ind_in_father=-1;
vector<int> son_actions;
vector<int> sons;
vector<double> son_qs;
int nv, cur_side;
double q, u, p;
// ...没有存储游戏状态!
};
(2)OmegaFantasy:重量级节点 + 状态存储
// OmegaFantasy-SnakeGo-AI-main/main.cpp 150-167行
struct Node {
MySnake player_snakes[2][4];
MyItem itemlist[17];
char map[16][16]; // 存储完整地图状态!
char itemcount;
char snakecount[2];
// ...完整的游戏状态
float count, win;
int p, lc, rc;
// ...
};
这点我就不敢苟同了,因为我曾经测试过基本存储状态的搜索方式要好于动态存储的情况是很苛刻的,基本上都是动态存储效率更高。不过我确实也没有注意一些数据结构的问题,例如使用vector这种效率低的东西。
另外omega的节点看起来并不太像mcts的节点,我重新来review一下整个树展开的策略。先来说Node存储的问题吧,Omega似乎不仅采用了静态存Node的方式,还把游戏的模拟存进了Node里。emmm…这个我还是不推荐这么干,他这么搞虽说好处是避开了很多抽象惩罚,但是debug会比较困难。
首先来说一点前提,就是如果你需要在比赛中使用搜索,那么对整个游戏逻辑的复现是必不可少的,而且必须是用C++实现。智能体大赛对时限的要求非常高,就我个人经验而言,千万不要用python去实现这些游戏逻辑,运行会慢非常多,cpp能搜索几万个局面的时间,python能搜索1000个就不错了。当然有些选手曾经也提出过质疑例如对python进行cython化之类的,就我个人而言我还是更相信纯cpp的效率。
那么话说回来,刚才聊到我们需要在比赛中复现游戏逻辑进行搜索,那么这个复现我建议是和搜索分离开来,专门抽象一个Game、Env或Board类来管理游戏的进行,因为这样你可以方便进行debug和测试,你可以接一些可视化,手动的去玩这些游戏来检查游戏运行的bug。因为如果你是在搜索中几十万个点之后,报出一个游戏逻辑的错,那你如何去复现呢?难道要重新跑一次搜索吗,如果搜索中再带上一点随机,你可能这辈子都复现不出来那个错误。
好了先forget about这个架构问题,重新来看MCTS本身的实现。对比两人的代码来看,虽然Omega对MCTS的实现也不算忠实有一定的改进,但我的实现显然更抽象一点,因为当时我实现MCTS的效果并不好,所以我对它的反传函数进行了一些我认为好的"改进",主要就是正常子节点传递到父节点的Q值是被平均的,而这个Q值会沿着你的搜索路径稳定的传播。而我当时误认为是递归的传播,Q值到父节点平均后,再把父节点的Q值往回传播,这个显然是有点问题的,但我当时没有意识到。我以为是MCTS本身的问题,所以我就采取了一种更激进的策略,子节点往回传时,不参与平均,而是直接与历史父节点的MaxQ对比,如果大于MaxQ就更新。乍一听好像有点道理,但实际上我后续的开发因为这个遇到了很多问题,包括我刚才提到的悲观失衡问题,导致我的一直在value设计上非常走钢丝,需要保持局面的balance,否则搜索就会马上崩掉。
所以整个MCTS框架我就不打算放我的代码了没有什么参考意义,我们来看Omega是如何优化MCTS搜索的。
5.2. MCTS主循环
// 2043-2216行:MCTS()函数
void MCTS() {
node[root].origin = value_judge(node[root]); // 根节点估值
while (nodecount < MAX_NODE_COUNT && time_judge()) {
// ====== 核心三步 ======
// 1. Select:找到要扩展的叶子节点
now = utcbest(root);
// 2. Expand:扩展该节点的子节点
if (!node[now].end) {
get_current_actions(node[now]); // 获取合法动作
// 有选择地创建子节点
if (a1 == 0) { newnode(now, 1); } // 动作1
if (a2 == 0) { newnode(now, 2); } // 动作2
// ...
if (可以分裂) { newnode(now, 6); }
}
// 3. Simulate + Backprop:模拟并回传
randomplay(now, max_length, true, root); // 第一次模拟
randomplay(now, max_length, false, root); // 第二次模拟
}
}
就是比较标准的MCTS逻辑,这里一个比较显眼的细节就是在循环末尾进行了两次randomplay,这个randomplay其实就是我前面提到的rollout。传统的MCTS每到一个节点要获取当前局面估值都需要随机rollout到对局结束,以获取胜负值-1/1,然后再向前序节点反传。而这里有两个问题,这个游戏最多有500步,这个模拟可能挺耗时的,而且纯随机的模拟并没有什么意义。
所以这就是Omega设计两次模拟想要解决的问题。首先randomplay中有max_length,限制了模拟步数,在中间步还是使用与局面评估相同的value_judge。而两次模拟的true和false,也就是"collision",在我的理解就是打或者不打,两种风格。
在collision=true的分支中,似乎实现了敌方蛇杀我和我方蛇杀敌两种情况择一的action。collision=false那就是双方的蛇以抢道具优先。
注意不管哪个分支,这个randomplay就是一种风格,而且是双方同一风格玩到最后的情况。
那么我们自然而然就想到是不是有杀到一半不杀的情况,那确实是,但是需要注意这部分并不是由rollout来解决的问题,而是由前序的树扩展来解决的。并且这个randomplay的false分支还是random的,也就是说在道具争夺上有一定的random性。总之确实是非常有趣的处理方法。
在AlphaGo技术中,有高于或低于5%直接结束对局的剪枝,我记得我的版本好像也有分数(两边蛇的长度差)相对于baseline损失超过30则不可接受的情况。那么Omega这里有没有类似的设计呢?好像是有的:
if (check_preend && sim_count % 500 == 0 && sim_count >= 1000) {
float val1 = node[node[root].lc].win / node[node[root].lc].count;
float val2 = node[node[root].rc].win / node[node[root].rc].count;
if (abs(val1 - val2) > 0.2) { // 胜率差>20%
break; // 提前终止!
}
}
不过这块是出现在MCTS最后的地方是整个MCTS提前终止,我指的是在rollout过程中,对randomplay提前终止。我意思是rollout因为行为比较单一,要么是激烈对抗要么是随机取道具,更容易出现局面突然崩的情况,所以在这块做这个优化的意义更大。
OK再次回到MCTS的主逻辑上,我们还注意到每次到叶子结点,这个MCTS需要对叶子结点进行所有的六个动作扩展,生成新的局面,这块跟AlphaGo的MCTS就有一些区别了。AlphaGo到叶子结点是只评价不扩展,在叶子节点处用神经网络生成prob和val,创建子节点就可以进行估值的backpropagate了,虽然基本上没区别,另一种还是对神经网络的交互更友好一些。大部分当你需要用神经网络对局面评估时,你就适应这种新的架构。而不是传统的静态存储架构。
5.3. LocalMCTS设计
很显然参与搜索的个体数量,对搜索深度的影响是指数级的。Omega在对单个蛇进行搜索的时候会将无关的蛇排除,这个也是当时我想实现但没有实现的点。首先来看删除某条蛇的判定条件:
// 2009-2023行:扫描逻辑
MyPos centerpos = lroot.player_snakes[lroot.nowaction][lroot.nownumber].front();
char centerx = centerpos.x;
char centery = centerpos.y;
// 距离<=5
for (char i = -5; i <= 5; i++) {
for (char j = -5; j <= 5; j++) {
if (centerx + i < 0 || centerx + i > 15 ||
centery + j < 0 || centery + j > 15) {
continue;
}
// ====== 关键:检查这个格子上是否有蛇 ======
if (lroot.map[centerx + i][centery + j] >= 0) {
// 这个格子有蛇!找到它是哪条蛇
MyPos index = lroot.snakeindex(lroot.map[centerx + i][centery + j]);
keep[index.x][index.y] = true; // 标记:保留这条蛇
}
}
}
这个是删除"当前行动"的蛇周围11x11范围的蛇,也就是manh_dis不超过10,那么很明显Omega的搜索轮数应该是不超过10的,不过10轮已经很多了,这意味着80个蛇步。注意这边是蛇身体的任意部分都不在11x11里,但实际上也有问题,如果这条蛇在远处即将被击杀,那么这个蛇的身体也即将消失,这里是不是可以稍微有优化的可能。
另外所谓"删除"远处的蛇,也是将蛇的身体标记为墙,而不是把蛇消除。那么整个智能体做了一个整体搜索,以及只考虑近处蛇的精搜。我们来看一下整个大逻辑。
// 2533-2704行:make_your_decision的决策流程
Operation make_your_decision(...) {
// ====== 第一阶段:全局MCTS ======
adjust_ratio = min(0.6, max(0.4, ...)); // 分配60%时间
MCTS(); // 粗略搜索,考虑所有蛇
// 选择最佳动作
for (int i = node[root].lc; i <= node[root].rc; i++) {
action_values[node[i].useaction] += node[i].win / node[i].count;
}
// 找到maxval_action...
// ====== 第二阶段:根据动作类型决定是否LocalMCTS ======
if ((action <= 4 && actions[action - 1] == 0) || action > 4) {
// 正常移动或分裂/射线
adjust_ratio = 1 - adjust_ratio; // 剩余40%时间
LOCAL_MCTS(); // 局部精细搜索!
// 重新评估所有动作(结合两次搜索)
for (int i = node[local_root].lc; i <= node[local_root].rc; i++) {
action_values[node[i].useaction] += node[i].win / node[i].count;
}
// 选择新的最佳动作
for (char i = 1; i <= 6; i++) {
if (action_values[i] > maxval) {
maxval = action_values[i];
action = i;
}
}
} else {
// 固化或自杀 → 太危险,再次全局MCTS
MCTS();
// 重新评估...
}
}
也是直接将全局MCTS和LocalMCTS的搜索以6:4的权重混合。
这种配比我们来仔细的琢磨一下,这说明什么呢,说明全局MCTS大部分情况下是对的,Omega更看重的是GlobalMCTS,但LocalMCTS意味着什么,意味着对于局部战斗的极致模拟,毕竟我这一次MCTS只决定我当前蛇的行动,那么如果我的某条蛇在当前局部对抗中某个动作创造了超过6:4的得分优势,那么这个则会覆盖掉全局action。
这显然解决了我当时也碰到了的,调全局价值和局部价值冲突,如果我加重某个全局价值的参数权重,有时候会让自己的蛇在局部战斗中变弱。而且两条不相关的蛇本应该独立搜索,这个确实是我有想法但没实现的地方。但当时我想搞的更完美一些,我想的是把local search融入到MCTS中,就是某些节点搜着搜着就转换成独立的local mcts,但是显然又会触发我刚才提到的问题,因为独立的部分搜索的深看的准,更悲观,反而会被无理地抛弃。
其实我觉得更好的方式可能是local mcts作为一个课题去训练(类似于围棋训练了一个死活题模型),而AI在MCTS时,把规则型概率和这个死活题网络输出的局部概率做融合。当然这也会带来一系列问题,如果你在没有死活题的情况下做死活题就没有意义了,不过这些都是可以去优化扩展的地方。
而且我认为让几条蛇一起search只做一次MCTS是更合理的选择,而不是像Omega那样拆成4次MCTS。
So,以上我们从估值函数和MCTS引擎架构两个方面对选手代码做了对比分析和拆解。那么仍有其它方面可以做一些对比。
6. 其它差异分析
6.1. 大蛇保护策略
(1)Omega的"气"(Qi)机制
inline char get_qi(Node &nownode, const MyPos pos) {
char ret = 0;
if (pos.x > 0 && nownode.map[pos.x - 1][pos.y] == CHAR_MIN) {
ret++;
}
if (pos.x < 15 && nownode.map[pos.x + 1][pos.y] == CHAR_MIN) {
ret++;
}
if (pos.y > 0 && nownode.map[pos.x][pos.y - 1] == CHAR_MIN) {
ret++;
}
if (pos.y < 15 && nownode.map[pos.x][pos.y + 1] == CHAR_MIN) {
ret++;
}
return ret;
}
应用场景:
一、根据气值评估蛇被围杀的风险:
- qi=0:蛇已经被困死,直接扣除蛇长度
- qi=1:极度危险,按 length/1.5/dist 惩罚
- qi=2:较危险,按 length/2.0/dist 惩罚
- qi≥3:相对安全,按 length/2.5/dist 惩罚
二、围杀策略:在collision模式下优先追杀qi≤2的敌方蛇,且qi=1时权重翻倍
三、逃生策略:当被3条以上敌方蛇包围时,选择移动到气最大的方向
(2)ShowCry的danger_score机制 - 长蛇保护
if (lock_map[newx][newy]>0&&need_search[snake_ind]&&(!search_end[snake_ind])&&lock_map[newx][newy]>dis1){
//check body return
bool self_body=false;
auto & body = env.snakes[exp_side][exp_snake].body;
int hit_len=0;
for (auto &p:body){
hit_len+=1;
if (p.x==newx&&p.y==newy){
self_body=true;
break;
}
}
if (self_body){
int tmp_return = dis1+hit_len;
if (tmp_return>max_return[snake_ind]){
max_return[snake_ind] = tmp_return;
}
}
}
核心思想:
一、只对长度≥10的蛇进行危险评估
二、计算蛇能否"回到自己身体"形成固化的最大路径长度(max_return)
三、两种损失计算:
- loss0:被完全围杀损失整条蛇
- loss1:通过分裂保留一半长度
四、取较小的损失作为惩罚
双方都有一定的长蛇保护的办法,但Omega还有一个更出众的策略。
(3)Omega的自杀式保护策略(should_suicide)
for (int i = 0; i < tmpnode.snakecount[tmpnode.nowaction]; i++) {
if (i != tmpnode.nownumber) {
if (get_qi(tmpnode, tmpnode.player_snakes[tmpnode.nowaction][i].front()) == 0) {
could_split = false;
if (nowlength * 2 < tmpnode.player_snakes[tmpnode.nowaction][i].length() / 2 +
tmpnode.player_snakes[tmpnode.nowaction][i].length_bank) {
should_suicide = true;
break;
}
}
}
}
ShowCry只是让蛇分裂一半避免一半的损失,而Omega则是用一条自杀小蛇去保护更长的蛇分裂。不过这其实还是考验双方其它的设计,因为Omega天生就是更倾向于用3条蛇,所以它分裂出四条蛇本身就已经是被逼了,这个策略更像是很少出现的,3条蛇某一条被击杀然后分裂的情况下,又出现了另一次击杀此时不得不找一条蛇腾位置的情况,这实际上出现在后期。
而ShowCry则是更倾向于四条蛇在玩。所以如果ShowCry能通过四条蛇完成比三条蛇更多的事,那AI水平就不一定一样。这就关联上了最初我们讨论的点,ShowCry的脑子里就一直有侵略性更强的玩法,多产小蛇去攻击对方的大蛇,而Omega则是更倾向于保护好自己现有的三条蛇。
但话又说回来既然ShowCry这么爱玩四条蛇,那我其实比Omega更需要小蛇自杀保护,而我却没有写这一点,这显然也是我水平更差的一大原因。
6.2. sigmoid胜率转换机制
Omega使用sigmoid将评分转换为0-1胜率:
float val = value_judge(tmpnode);
float diff = val - node[root_index].origin;
float expo = -diff / 3;
float winval = 1 / (1 + exp(expo)); // sigmoid
while (node_index != -1) {
node[node_index].count += 1;
node[node_index].win += winval;
node_index = node[node_index].p;
}
这主要是一个归一化处理,按理说值是不是(0,1)不是绝对要求的,只不过搞到0到1可以和概率匹配,通过调整CPUCT来平衡概率和Val和概率的取舍。而如果没有prob的MCTS则是否归一化好像没有影响?
这里我还是得锐评一下,我实战中更喜欢把评价值改成-1到1。这点大模型对Omega给了差评,因为零和博弈如果你用0到1那平局是0.5这个在数值表意上感觉有点问题。另外AlphaGo论文中对值的输出也是tanh,-1到1。
6.3. 时间管理策略
float total_length = sqrt(snake_to_operate.length() + snake_to_operate.length_bank);
float now_length = sqrt(snake_to_operate.length() + snake_to_operate.length_bank);
const std::vector<Snake> &my_snakes = ctx._current_player == 0 ? ctx._snake_list_0 : ctx._snake_list_1;
for (char i = 0; i < my_snakes.size(); i++) {
if (snake_to_operate.id == my_snakes[i].id) {
snake_index = i;
break;
}
}
for (char i = snake_index + 1; i < my_snakes.size(); i++) {
total_length += sqrt(my_snakes[i].length() + my_snakes[i].length_bank);
}
// 时间分配
used_time_ratio = (clock() - total_t) / (CLOCKS_PER_SEC * TIME);
snakes_left = 0;
float ratio = 1.1 * (1.0 - used_time_ratio) * (float)now_length / total_length;
if (used_time_ratio >= 1.0f) {
ratio = 0;
} else if (ratio > 1.0 - used_time_ratio) {
ratio = 1.0 - used_time_ratio;
}
特点:
- 使用蛇长度的平方根作为权重(而非线性)
- 考虑已用时间比例动态调整
- 为后续蛇预留时间
6.4. 固化方案选择
Omega实现了"选择最优固化方案",但ShowCry没有实现。
Omega的固化选择:
for (char k = nowsnake.tail - 1; k >= nowsnake.head; k--) {
for (char i = 0; i < targets_count; i++) {
if (nowsnake.pos_list[k] == targets[i]) {
findflag = true;
newnode(now, targets_actions[i]);
break;
}
}
if (findflag) {
break;
}
}
从蛇尾向蛇头遍历,选择第一个能形成固化的位置(即最靠近蛇尾的),这样固化面积最大。
不过这块我也很好奇,因为只是选择位置的话那条蛇不会把身体撑开成一个方形再固化,这个当时我没太在意,我觉得在搜索中因为固化好自然分数就高,好像不需要引导。
7. 总结
通过对第26届SnakeGo竞赛中冠军OmegaFantasy和前8强ShowCry的源码深度对比分析,我们可以清晰地看到冠军与强者之间的差距所在:
7.1. 核心技术差异总结
(1)估值函数设计
- OmegaFantasy:采用连续梯度评分,对有争议的道具进行模糊化处理,更符合博弈的不确定性本质。同时引入边界风险评估、"气"机制等精细化的危险感知系统。
- ShowCry:使用二值归属判定,虽然逻辑清晰但过于绝对,容易在复杂局面下被利用。缺少边界风险评估是一个重要短板。
(2)搜索架构创新
- OmegaFantasy:创新性地引入Global MCTS + Local MCTS的混合架构(6:4权重分配),既保证全局视野又能在局部战斗中做出精准决策。通过力场引导优化rollout策略,双模拟(collision/non-collision)覆盖不同战术风格。
- ShowCry:采用纯MCTS架构,虽然实现了LockMap等优化,但在局部与全局价值冲突时缺乏有效平衡机制。搜索引擎的backpropagation策略存在设计缺陷(MaxQ更新而非平均),导致评估函数设计空间受限。
(3)策略风格差异
- OmegaFantasy:偏向稳健型,倾向保持3条蛇,用"气"机制和自杀保护策略保护长蛇,优先考虑未来道具落在蛇身上的情况。
- ShowCry:偏向进攻型,倾向分裂至4条蛇,喜好围杀对手,站位更偏向中心以控制局面。但这种风格需要更精细的保护机制支撑,而ShowCry在小蛇自杀保护等方面存在缺失。
7.2. 工程实现的启示
(1)架构设计的重要性
虽然OmegaFantasy使用了"重量级节点+状态存储"的方案,理论上效率不如ShowCry的"轻量级节点+动态模拟",但通过合理的整体设计和优化,依然实现了更高的搜索效率(几十万局面 vs 3-5万局面)。这说明整体架构优化比局部优化更重要。
(2)Debug和测试策略
规则型AI的开发中,大量时间消耗在参数调优和bug修复上。建议:
- 将游戏逻辑与搜索引擎分离,便于独立测试
- 建立标准局面测试集,记录搜索树信息(前3层)便于分析
- 避免在搜索框架上做过于激进的"改进"(如ShowCry的MaxQ更新),除非有充分验证
(3)迭代开发的陷阱
ShowCry在开发过程中遇到的典型问题:
- 评估函数的小改动会影响大量已有局面
- 蛇围住道具不吃的bug反复出现
- 悲观/乐观失衡导致分裂策略异常
这些都说明需要更稳定的基础框架,而不是在不稳定的基础上不断打补丁。
7.3. 一些补充
(1)trade-off
有一个我和大模型都难以言表的的点,就是局面评估这块你不能无限的追求合理性,例如你可能道具归属什么的更精准,但你用了O(N^2)的寻路,这导致你每一次评估都更耗时,而MCTS本身搜着搜着就有一定寻路的功能了。所以合理性和时间的取舍非常难表述和评论。但这肯定是每个选手需要关注的点。 而神经网络是一种合理性Max同时耗时Max的,如何利用好它也是一个课题。
我们畅想一下如果我前几步使用神经网络评估,确保搜索走到正确的树上,同时我仍然确保几十万的节点搜索量保证深度,是不是能最大限度发挥其特长?
(2)学习型AI的潜力
从规则型AI的复杂度来看,手工设计的极限已经接近。未来比赛引入LLM等学习型技术后,可能的方向包括:
- 用神经网络学习局部战斗的"死活题"模式
- 将规则型概率与学习型概率融合
- 用强化学习自动发现评估函数的最优权重
(3)混合架构的优势
OmegaFantasy的Global+Local MCTS混合架构证明了"分层决策"的有效性。在更复杂的游戏(如第28届Generals、第29届RollMan)中,这种思想可能更加重要。
(4)硬编码参数
两位选手都使用了大量硬编码参数(如距离阈值、权重系数等),看起来好像很有优化空间实则不然,很多参数往往不是局部你觉得有更合理的参数设置和调优就行了,往往牵一发动全身,我是不太建议用机器学习的模型对一些参数进行调优,跟着感觉来设置往往更高效一些。
致谢
感谢OmegaFantasy开源他的代码,让我们有机会深入学习冠军级AI的设计思路。
下期预告: 第28届Generals回顾——当游戏规则改变时,AI策略如何适应?
更多推荐



所有评论(0)