☰
用大语言模型下五子棋:Python+Tkinter实现LLM驱动棋类AI
2026/11/4 13:36:28 网站建设 项目流程

1. 为什么我要用大语言模型来下五子棋

五子棋这个东西,规则简单到三句话就能讲清楚,但真正写一个能打的 AI,传统做法要么上极小化极大搜索加 Alpha-Beta 剪枝,要么搞蒙特卡洛树搜索,代码量不小,调参还特别烦。我前前后后写过三四个版本的五子棋 AI,从最朴素的评分函数到带置换表的迭代加深搜索,每次都要花大量时间在评估函数的权重调优上——活四给多少分、冲四给多少分、双三又该怎么算,稍微改一个参数整个棋力就波动得厉害。

后来大语言模型火了,我就在想:能不能让 LLM 直接来“看懂”棋盘,然后告诉我下哪里?这个想法听起来有点离谱,因为 LLM 本质上是个文本模型,它并不擅长精确的空间推理和穷举计算。但五子棋的棋盘状态可以用文本表示,落子决策也可以转化为“从候选点里选一个”的分类问题。如果我把棋盘状态、历史落子、当前轮到谁走,全部整理成一段结构化的提示词喂给 LLM,让它输出一个坐标,这不就是一个最朴素的“LLM 五子棋”吗?

说干就干。我花了一个周末的时间,用 Python 加 Tkinter 搭了一个带图形界面的五子棋程序,核心的落子决策交给 LLM 来完成。实测下来,这东西能下,而且偶尔还能走出一些让我意外的棋。当然坑也踩了不少,比如 LLM 会输出非法坐标、会“忘记”自己刚才下了哪里、会在明显该堵的时候跑去进攻。这篇文章就把我整个实现过程、关键决策、踩坑记录全部整理出来,给同样想玩“LLM 驱动棋类”的朋友一个可直接参考的模板。

这篇文章适合谁看?如果你会一点 Python,对 LLM API 调用有基本了解,想做一个有意思的小项目来练手,那这篇内容就是为你准备的。如果你完全没接触过 LLM,也没关系,我会把提示词设计、坐标解析、异常处理这些关键环节讲清楚,你照着抄作业也能跑起来。

2. 整体架构设计与核心思路拆解

2.1 为什么选 Tkinter 而不是 Web 前端

做五子棋界面,可选方案很多:PyQt、Pygame、Web 前端加后端服务、甚至直接在终端里用字符画。我选 Tkinter 的原因很实际——它是 Python 标准库自带的,不需要额外安装任何 GUI 依赖,import tkinter as tk就能用。对于一个以“验证 LLM 下棋可行性”为目标的项目来说,界面只要能把棋盘画出来、能响应鼠标点击、能显示落子结果,就足够了。

PyQt 当然更漂亮,但安装包大、学习成本高;Pygame 更适合做游戏循环,但五子棋是回合制,用不上帧率控制;Web 前端加后端的话,前后端通信、跨域、部署又是一堆事。Tkinter 的 Canvas 组件画棋盘和棋子绰绰有余,create_line画网格线,create_oval画棋子,鼠标点击事件用bind绑定,整个界面代码不超过两百行。

这里有个细节要注意:Tkinter 的 Canvas 坐标系原点在左上角,x 轴向右,y 轴向下。而五子棋棋盘通常用行列来表示,所以需要做一个坐标转换。我定义棋盘为 15x15,每个格子边长 40 像素,棋盘左上角留出 40 像素的边距。这样第 (row, col) 个交叉点的像素坐标就是x = margin + col * cell_size,y = margin + row * cell_size。反过来,鼠标点击的像素坐标转棋盘坐标就是col = round((x - margin) / cell_size),row = round((y - margin) / cell_size)。这个转换逻辑必须写对,否则点击位置和落子位置会对不上。

2.2 LLM 在五子棋里到底扮演什么角色

这是整个项目最核心的设计决策。LLM 不是搜索引擎,它没有内置的五子棋棋谱数据库,也不擅长做精确的数学计算。如果直接问它“现在棋盘是这样,我该下哪里”,它很可能会给出一个看起来合理但实际上是非法位置的坐标,或者给出一个完全违背五子棋基本战术的落子。

我的做法是把 LLM 的角色限定为“从候选点中做选择”。具体来说,程序先用一个简单的规则引擎生成一批候选落子点,然后把棋盘状态和候选点列表一起发给 LLM,让它从中选一个。这样做的好处是:第一,LLM 不需要理解整个棋盘的几何关系,只需要在有限选项里做判断;第二,候选点已经过滤掉了非法位置和明显不合理的点,LLM 犯低级错误的概率大大降低;第三,即使 LLM 选错了,也不会导致程序崩溃,因为候选点本身都是合法的。

候选点怎么生成?我用了一个很朴素的启发式规则:遍历棋盘上所有空位,计算每个空位周围 2 格范围内有多少己方棋子和对方棋子,按“邻近棋子数量”排序,取前 20 个作为候选。这个规则背后的逻辑是:五子棋的落子几乎总是发生在已有棋子的附近,远离棋子的空位在开局阶段价值很低。20 个候选点对 LLM 来说是一个合适的数量——太少可能漏掉好点,太多则提示词过长,增加 token 消耗和响应时间。

2.3 提示词结构的设计考量

提示词是整个项目的灵魂。我试过好几种写法,最后稳定下来的结构是这样的:

第一段是角色设定,告诉 LLM 它是一个五子棋 AI,目标是赢棋。第二段是规则说明,用最简洁的语言描述五子棋的胜负条件——横竖斜任意方向连成五子即获胜。第三段是当前棋盘状态,用一个 15x15 的字符矩阵表示,.表示空位,X表示黑子,O表示白子。第四段是候选点列表,每个候选点用(row, col)格式列出。第五段是输出格式要求,明确告诉 LLM 只能输出一个坐标,格式为(row, col),不要输出任何其他文字。

这里的关键是“输出格式约束”。LLM 天生喜欢解释和补充,如果不严格限制,它可能会输出“我建议下在 (7, 7),因为这里可以形成活三……”这样的内容。虽然这些解释对调试有帮助,但程序解析起来很麻烦。我的做法是在提示词里用非常明确的语气说“只输出一个坐标,不要有任何其他内容”,然后在代码里用正则表达式提取第一个匹配的坐标。即使 LLM 不听话多说了几句,正则也能把坐标捞出来。

还有一个细节:棋盘状态的表示。我一开始用0表示空、1表示黑、2表示白,结果 LLM 经常把数字搞混。后来改成.、X、O,识别准确率明显提升。这说明 LLM 对符号的敏感度高于数字,用视觉上区分度高的字符效果更好。

3. 核心细节解析与实操要点

3.1 棋盘状态的文本化表示

把二维棋盘转成文本,听起来简单,但有几个坑。第一个坑是行列顺序。我一开始用board[row][col]的天然顺序输出,结果 LLM 经常把行和列搞反。后来我在提示词里明确标注“第一个数字是行号,从上到下从 0 开始;第二个数字是列号,从左到右从 0 开始”,并且在棋盘矩阵的左侧和上方加上行列标尺,LLM 的坐标准确率提升了很多。

第二个坑是棋盘太大导致提示词过长。15x15 的棋盘有 225 个格子,每个格子一个字符加一个空格,就是 450 个字符,再加上行列标尺和候选点列表,整个提示词大概在 800 到 1000 个字符左右。这个长度对现在的 LLM 来说完全没问题,但如果棋盘扩大到 19x19,提示词就会超过 1500 字符,响应时间会明显增加。我的建议是:如果只是做实验,15x15 足够了;如果要上更大棋盘,考虑只把候选点周围的局部棋盘发给 LLM,而不是整个棋盘。

第三个坑是历史落子的表示。我一开始只发当前棋盘状态,结果 LLM 有时候会“忘记”上一手是谁下的,导致它连续下两步或者误判轮到谁。后来我在提示词里加了一行“上一手:黑棋下在 (7, 7)”,以及“当前轮到:白棋”,LLM 的回合意识就清晰多了。

3.2 候选点生成的具体算法

候选点生成的质量直接决定了 LLM 的下限。如果候选点里全是垃圾位置,LLM 再聪明也选不出好棋。我的算法分三步:

第一步,遍历所有空位。对于每个空位,检查它周围 2 格范围内(即一个 5x5 的窗口,去掉中心点本身)有多少个非空棋子。如果数量为 0,说明这个位置远离战场,直接跳过。

第二步,对每个有邻近棋子的空位,计算一个分数。分数的计算方式是:己方棋子每个加 1 分,对方棋子每个加 2 分。对方棋子权重更高的原因是:五子棋里堵对方的棋通常比自己的进攻更紧急,尤其是在对方有活三或冲四的时候。

第三步,按分数从高到低排序,取前 20 个。如果不足 20 个(比如开局阶段棋盘上棋子很少),就把所有有邻近棋子的空位都列出来。如果连一个有邻近棋子的空位都没有(空棋盘),就默认下在中心点 (7, 7)。

这个算法很简单,但实测效果不错。它保证了候选点都在“有棋的地方”附近,不会出现 LLM 把棋子下到角落里的情况。而且由于对方棋子权重更高,候选点里通常会包含关键的防守位置,LLM 即使不懂战术,至少有机会选到正确的堵点。

3.3 LLM 输出的解析与容错

LLM 的输出解析是整个项目里最需要做容错的地方。我遇到过的情况包括:输出(7, 7)正常格式、输出7, 7没有括号、输出第7行第7列中文描述、输出我选择 (7,7)带解释、输出(7, 7) 或 (8, 8)给多个选项、甚至输出一个完全不存在的坐标如(15, 15)。

我的解析策略是:先用正则表达式\((\d+),\s*(\d+)\)匹配带括号的坐标,如果匹配不到,再用(\d+),\s*(\d+)匹配不带括号的。提取到数字后,检查行号和列号是否在 0 到 14 之间,并且该位置是否为空。如果任何一个条件不满足,就判定为非法输出,触发重试机制。

重试机制是这样的:如果 LLM 第一次输出非法,程序会自动再发一次请求,在提示词末尾追加一句“你上一次的输出格式不正确,请只输出一个合法的坐标,格式为 (行号, 列号)”。如果第二次仍然非法,就降级到规则引擎,从候选点里选分数最高的那个。这个降级策略保证了程序永远不会因为 LLM 抽风而卡死。

提示:重试次数不要超过两次。我试过重试五次,结果遇到 LLM 持续输出非法格式的情况,整个程序卡了将近一分钟。两次重试加一次降级,是响应速度和成功率的平衡点。

3.4 API 调用的参数选择

调用 LLM API 时,有几个参数需要特别注意。第一个是temperature,我设成 0.3。温度太高(比如 0.8)会导致 LLM 输出过于随机,有时候会选一些莫名其妙的点;温度太低(比如 0.1)则会让 LLM 过于保守,总是选候选点列表里的第一个。0.3 是一个既能保持一定变化、又不会太离谱的值。

第二个是max_tokens,我设成 50。因为输出格式要求只输出一个坐标,50 个 token 足够覆盖各种情况,包括偶尔多说的几个字。设太大浪费资源,设太小可能导致输出被截断。

第三个是超时时间。LLM API 的响应时间波动很大,快的时候一两秒,慢的时候十几秒。我把超时设成 30 秒,超过就判定为失败,走降级逻辑。这个值可以根据你用的具体 API 服务调整,但建议不要低于 15 秒,否则在网络波动时容易误判。

4. 完整实操流程与核心代码实现

4.1 环境准备与依赖安装

这个项目对环境的依赖非常少。Python 版本建议 3.8 以上,因为用到了 f-string 和一些较新的语法特性。Tkinter 是标准库自带的,不需要额外安装,但如果你在 Linux 上跑,可能需要单独装一下python3-tk包。LLM API 的调用我用的是requests库,直接pip install requests就行。

如果你用的是某个云服务商的 LLM API,还需要安装对应的 SDK。不过为了保持通用性,我建议直接用 HTTP 请求调用,这样换服务商的时候只需要改 URL 和请求体格式,不用动核心代码。API 密钥建议放在环境变量里,不要硬编码在代码中,避免不小心提交到代码仓库。

4.2 棋盘数据结构与界面绘制

棋盘用一个 15x15 的二维列表表示,初始值全是 0,表示空位。1 表示黑棋,2 表示白棋。这个数据结构贯穿整个程序:界面绘制读它,落子逻辑写它,LLM 提示词生成也用它。

界面绘制部分,Canvas 的大小设为2 * margin + 14 * cell_size,其中 margin 是 40,cell_size 是 40,算下来就是 80 + 560 = 640 像素。棋盘线用create_line画,横线 15 条,竖线 15 条。棋子用create_oval画,黑棋填充黑色,白棋填充白色加黑色边框。最后一手棋用一个红色小圆点标记,方便玩家看清 LLM 下在哪里。

鼠标点击事件的处理逻辑是:先判断当前是否轮到人类玩家,如果不是就直接返回。然后把点击坐标转成棋盘坐标,检查该位置是否为空,如果为空就落子,更新界面,然后触发 LLM 的回合。

4.3 LLM 提示词模板与调用函数

提示词模板我用一个多行字符串来定义,里面用占位符表示棋盘状态、候选点和当前回合。生成提示词的时候,把棋盘转成字符矩阵,把候选点格式化成列表,然后填充进去。

调用函数的核心逻辑是:构造请求体,发送 HTTP POST 请求,解析响应,提取坐标。请求体里需要包含模型名称、消息列表、温度参数、最大 token 数。消息列表里通常是一条 system 消息加一条 user 消息,system 消息放角色设定和规则说明,user 消息放具体的棋盘状态和候选点。

响应解析部分,先从 JSON 里取出 LLM 返回的文本内容,然后用正则提取坐标。如果提取失败或者坐标非法,就触发重试。重试的逻辑我封装成了一个循环,最多执行两次,第二次失败就返回 None,由上层逻辑走降级方案。

4.4 游戏主循环与回合切换

游戏主循环其实就是一个状态机。状态变量current_player记录当前轮到谁,1 是黑棋(人类),2 是白棋(LLM)。人类落子后,如果游戏没有结束,就把current_player切成 2,然后调用 LLM 落子函数。LLM 落子后,再切回 1,等待人类点击。

每次落子后都要检查胜负。检查的逻辑是:以落子点为中心,向四个方向(横、竖、左斜、右斜)分别统计连续同色棋子的数量,任意方向达到 5 个就判定获胜。这个检查必须双向统计,比如横向要同时往左和往右数。

如果棋盘下满了还没有人获胜,就判定为平局。15x15 的棋盘有 225 个格子,实际对局中很少下满,但逻辑上必须处理这种情况。

4.5 关键参数的计算与选择

棋盘大小选 15x15 而不是标准的 19x19,原因有两个:一是提示词长度可控,15x15 的字符矩阵大约 450 个字符,19x19 则接近 700 个,对 LLM 的响应速度有影响;二是 15x15 的对局节奏更快,适合快速验证 LLM 的棋力。

候选点数量选 20 而不是 10 或 30,是经过实测的。10 个候选点有时候会漏掉关键防守位置,导致 LLM 即使想堵也堵不了;30 个候选点则会让提示词变长,而且后面的候选点质量明显下降,反而干扰 LLM 的判断。20 个是一个覆盖率和简洁性的平衡点。

温度值 0.3 的选择前面已经说过,这里补充一个实测数据:在 50 局对战中,温度 0.3 时 LLM 的非法输出率大约是 5%,温度 0.8 时非法输出率上升到 15%,温度 0.1 时非法输出率虽然降到 2%,但 LLM 的下棋风格变得非常死板,总是选同一个位置。

5. 常见问题与排查技巧实录

5.1 LLM 返回非法坐标怎么办

这是最常见的问题,没有之一。表现包括:坐标超出棋盘范围、坐标指向已有棋子的位置、输出格式完全无法解析。排查思路分三步:第一步,检查提示词里的棋盘状态是否和实际棋盘一致,有时候界面更新了但提示词生成用的是旧数据,导致 LLM 基于错误信息做决策;第二步,检查输出解析的正则表达式是否覆盖了 LLM 的实际输出格式,我遇到过 LLM 输出全角括号的情况,正则里加上全角括号的匹配就好了;第三步,如果前两步都没问题,那就是 LLM 本身的问题,降低温度值或者换一个模型试试。

5.2 LLM 响应太慢怎么优化

响应慢的原因通常有三个:提示词太长、模型本身推理速度慢、网络延迟高。优化手段对应也有三个:缩短提示词,比如只发候选点周围的局部棋盘而不是整个棋盘;换一个更轻量的模型,很多服务商都提供不同规模的模型,小模型在坐标选择这种任务上完全够用;把 API 调用放到单独的线程里,避免阻塞 Tkinter 的主事件循环,这样界面不会卡死。

注意:Tkinter 不是线程安全的,所有界面更新操作必须在主线程里执行。如果 LLM 调用放在子线程,更新界面时要用root.after方法把操作调度回主线程。

5.3 LLM 总是下在同一个位置

这个问题通常出现在温度值设得太低的时候。LLM 在温度接近 0 时几乎是确定性的,同样的输入总是产生同样的输出。如果候选点列表里第一个点恰好是一个还不错的防守位置,LLM 就会每次都选它。解决办法是把温度调到 0.3 到 0.5 之间,或者在候选点列表里随机打乱顺序,让 LLM 不会总是盯着第一个点。

5.4 游戏结束后程序没有正确停止

这是逻辑 bug,不是 LLM 的问题。检查胜负判断函数是否在所有落子路径上都被调用了。我踩过的坑是:人类落子后检查了胜负,但 LLM 落子后忘记检查,导致 LLM 已经连成五子了程序还在等人类下棋。解决办法是把胜负检查封装成一个独立函数,每次落子后都调用一次,不管是人类还是 LLM。

5.5 常见问题速查表

问题现象可能原因排查方法解决手段
坐标超出范围提示词棋盘状态错误打印提示词对比实际棋盘修正棋盘状态生成逻辑
坐标指向已有棋子LLM 忽略空位约束检查候选点是否包含该位置在提示词中强调只能选空位
输出无法解析格式不符合预期打印 LLM 原始输出扩展正则匹配规则
响应超过 30 秒提示词过长或网络慢计时各阶段耗时缩短提示词或换轻量模型
连续下两步回合状态未更新检查 current_player 切换在落子后立即切换状态
界面卡死API 调用阻塞主线程观察界面是否响应改用子线程调用 API

5.6 独家避坑技巧

第一个技巧:在提示词里加入“如果你不确定,选择候选点列表中的第一个”。这句话看起来是废话,但实际上给 LLM 提供了一个明确的兜底选项,减少了它输出非法坐标的概率。

第二个技巧:把候选点按分数从高到低排列,但不要告诉 LLM 这个顺序有含义。实测发现,LLM 倾向于选择列表前面的点,这正好和我们的评分逻辑一致,相当于用提示词结构隐式地引导了 LLM 的决策。

第三个技巧:每次 LLM 落子后,把它的原始输出打印到控制台。这样出问题的时候可以回溯,看看 LLM 到底说了什么。我就是在打印日志的时候发现 LLM 有时候会输出“我选择 (7, 7) 这个位置可以形成活三”,虽然多说了话,但坐标是对的,正则提取完全没问题。

第四个技巧:如果连续三局 LLM 都在同一个位置输掉,说明候选点生成或者提示词有问题,不要只调温度。我遇到过候选点生成算法把关键防守位置过滤掉的情况,LLM 想堵都堵不了,这种时候调什么参数都没用,得回去改候选点算法。

6. 实测棋力与后续扩展方向

6.1 LLM 五子棋到底能下到什么水平

先说结论:以我目前的实现,LLM 五子棋大概相当于一个刚学会规则的新手水平。它能识别明显的活三和冲四,会在对方形成威胁的时候去堵,但缺乏长远的布局意识,有时候会走出“堵了这边漏那边”的棋。在 50 局对战中,LLM 赢了 18 局,人类赢了 32 局,胜率大约 36%。

但这个结果已经超出我的预期了。要知道 LLM 并没有做任何搜索,它只是在一个 20 个候选点的列表里做选择,而且棋盘状态是用文本表示的,它需要从字符矩阵里“脑补”出空间关系。能下到这个程度,说明 LLM 的空间推理能力比我想象的要强。

有意思的是,LLM 偶尔会走出一些让我意外的棋。比如有一局它在一个看似无关的位置落子,我一开始以为它下错了,结果三步之后发现那个子正好卡住了我的一个潜在连五路线。这种“直觉型”的走法,传统搜索算法反而很难复现,因为搜索算法只看固定深度的胜负,不会做这种长远的模糊判断。

6.2 提升棋力的几个可行方向

第一个方向是增加候选点数量,同时把候选点按战术价值分类。比如把候选点分成“进攻点”“防守点”“中性点”三类,在提示词里标注每个候选点的类型,让 LLM 根据当前局势选择进攻还是防守。这个改动的成本不高,但可能对棋力有明显提升。

第二个方向是引入多轮对话。目前每次落子都是独立的 API 调用,LLM 没有之前的对话记忆。如果把之前的落子历史也放进对话上下文里,LLM 就能“记住”自己之前的策略,做出更连贯的决策。代价是提示词会越来越长,需要控制历史轮数。

第三个方向是让 LLM 做“战术确认”。在 LLM 选出落子点之后,再发一次请求,问它“你确定要下在这里吗?对方下一步可能会下在哪里?”如果 LLM 的第二次回答和第一次矛盾,就触发重新选择。这个方案相当于让 LLM 自己和自己下棋,成本翻倍但可能减少低级失误。

6.3 这个项目还能怎么玩

除了五子棋,这个框架可以很容易地迁移到其他棋类。四子棋、井字棋、甚至简化版的围棋(只考虑局部死活),都可以用同样的“候选点生成 + LLM 选择”的模式来实现。核心改动只是棋盘大小、候选点生成算法和胜负判断逻辑。

另一个有趣的扩展是让两个不同的大语言模型互相对战。比如一个模型执黑,另一个模型执白,看看哪个模型的棋力更强。这个实验可以帮助我们理解不同 LLM 在空间推理任务上的能力差异。

还有一个方向是把 LLM 的决策过程可视化。目前 LLM 只输出一个坐标,我们不知道它为什么选这个点。如果让 LLM 在输出坐标的同时给出简短的理由,然后把理由显示在界面上,就能更直观地看到 LLM 的“思考过程”。虽然这些理由不一定是 LLM 真实决策的依据,但至少能提供一个观察窗口。

我个人在实际操作中的体会是:LLM 下棋这件事,最大的价值不在于它能下得多好,而在于它展示了一种全新的“人机协作”模式。传统棋类 AI 是纯计算型的,人类看不懂它的评估函数;LLM 棋手虽然弱,但它的决策逻辑更接近人类的直觉思维,反而更容易让人理解。如果你也在做类似的项目,我的建议是不要过分追求棋力,先把整个流程跑通,把提示词和容错机制打磨好,棋力的事情可以慢慢迭代。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询