ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

离线AI棋手:用Ollama和Stockfish打造人格化国际象棋对弈系统

离线AI棋手:用Ollama和Stockfish打造人格化国际象棋对弈系统 在完全离线的电脑上运行一个叫 Abby Steele 的 AI 角色它既能自然聊天又能真的在棋盘上和你对弈这件事的难点不在于写了多少代码而在于把两个完全不同的系统拼在一起本地大语言模型Local LLM负责语言人格国际象棋引擎负责棋力计算。如果只依赖 LLM模型会对非法棋步一本正经地给出解释如果只依赖引擎对局又会变成冷冰冰的 UCI 文本交互。下面用一个最小可运行项目把 Ollama、Stockfish 和 Python 组合成一个完整可用的“Abby Steele”并说明每一步的目的、配置和验证方法。这条技术线的核心判断是在离线环境下AI 人格对话和大模型可以本地运行但“象棋引擎”不能交给大模型去推理。象棋引擎是经过专门设计的搜索程序能稳定给出合法且可解释的走法LLM 的价值在于把棋盘上的变化转化成符合角色性格的语言。所以最终架构是“引擎算棋LLM说话”两者通过标准输入输出和 HTTP API 衔接。1. 先拆解需求Abby Steele 到底要解决什么1.1 离线 AI 人格的能力边界Abby Steele 本质上是一个离线 AI 角色它的语言能力来自本地大语言模型。常见的本地模型运行方式包括 Ollama、llama.cpp、LM Studio 等它们都能在无网络的环境下加载模型并提供 Chat API 或本地 HTTP 服务。离线 AI 人格必须解决三个问题对话由谁生成本地 LLM 根据系统提示词和上下文生成回复。知识边界在哪里模型不能访问实时网络因此所有知识都来自训练数据和本地记忆。交互如何触发通过命令行、WebSocket 或桌面应用把用户输入传给模型。在国际象棋场景下单纯有对话能力还远远不够。如果用户输入“e2e4”给 LLM模型可能返回一段文学性描述也可能返回“e4”这种简写但无法保证这个走法在棋盘上合法。因此人格系统必须依赖专用棋力模块。1.2 为什么必须引入本地象棋引擎国际象棋引擎的核心能力是搜索和评估。引擎会从当前局面展开博弈树使用alpha-beta剪枝、局面评估函数、开局库、残局表等技术寻找最佳走法。Stockfish 是目前最常用的开源引擎之一它通过标准输入输出接收命令并返回计算结果。不要把 LLM 当作象棋引擎。原因如下LLM 生成 token 时并不具备“每一步都合法”的保证。即使模型见过棋谱也可能在复杂局面下输出非法目标格。引擎输出的 bestmove 可以直接被棋盘库解析而 LLM 输出需要额外校验。引擎计算速度稳定LLM 的响应延迟从几百毫秒到几秒不等不适合实时走棋。因此正确切入点是Stockfish 计算最佳走法Abby Steele 的人格由 LLM 负责最终输出给用户的是“引擎走法 人格化解释”。1.3 核心交互流程一个完整对局回合包含以下步骤玩家输入一个 UCI 格式走法例如e2e4。程序使用python-chess校验走法并更新棋盘。如果对局未结束程序把当前走法历史发送给 Stockfish。Stockfish 搜索一段时间后返回bestmove。程序执行引擎走法并把棋谱、玩家走法、引擎走法一起发送给本地 LLM。LLM 以 Abby Steele 的角色生成 2 到 4 句回复。控制台输出 Abby 的回复和引擎走法等待下一轮输入。这个流程把“棋力”和“人格”彻底解耦每一步都可以单独测试和替换。比如想换一个角色只需要替换提示词想换一个引擎只需要保持 UCI 协议不变。2. 技术选型与整体架构2.1 本地 LLM 的两种接入方式本地 LLM 的接入方式决定了代码复杂度。此处推荐两种常见方式接入方式适用场景优点注意事项Ollama 原生 API原型开发、桌面端、单机运行安装简单模型管理方便HTTP 端口固定需要预先拉取模型离线部署前要准备模型文件llama.cpp server对内存和 CPU 占用敏感的场景可精细控制量化、上下文长度、线程数编译和参数调优成本更高如果你的环境已安装 Ollama使用http://127.0.0.1:11434/api/chat是最快的方式。下面的示例以 Ollama 为主但代码接口同样可以适配 llama.cpp server。2.2 象棋引擎的 UCI 协议UCIUniversal Chess Interface是国际象棋引擎和 GUI 程序之间常用的通信协议。它通过标准输入输出完成命令交换核心命令如下命令作用uci启动引擎握手引擎返回自己的名称和选项position startpos moves e2e4 e7e5设置初始局面并应用走法历史go movetime 800指定搜索时间单位为毫秒bestmove e2e4搜索结束后引擎返回最佳走法quit关闭引擎进程只要引擎输出符合 UCI 协议程序就可以统一处理。Stockfish 是一个可靠实现我们在这里假设它安装在本机实际项目应通过配置文件指定路径。2.3 系统组件与数据流整体组件分为四层用户交互层CLI 命令行输入。编排层主循环调用引擎和模型。棋力层Stockfish 进程。语言层Ollama 本地模型。数据流向如下用户输入棋步 - python-chess 校验并更新棋盘 - 发送 position 命令给 Stockfish - Stockfish 返回 bestmove - 程序执行最佳走法 - 发送棋谱和走法给本地 LLM - LLM 返回角色回复 - 输出 Abby 的话和走法每一层都可以独立替换。例如后续把 CLI 换成 FastAPI WebSocket主循环仍然复用同一套逻辑。3. 环境准备与依赖安装3.1 准备本地模型运行环境这里以 Ollama 作为示例因为它的安装和模型管理足够简单。安装完成后先确认服务状态ollama serve另开一个终端拉取模型。为了离线场景建议提前把模型下载到本机。量化和尺寸选择要根据机器内存来定7B 量化模型通常需要 4GB 到 8GB 内存具体取决于模型版本。ollama pull qwen2.5:7b拉取完成后测试本地模型能否正常对话ollama run qwen2.5:7b 简单介绍一下自己看到模型输出后再尝试直接用 HTTP API 访问curl http://127.0.0.1:11434/api/chat \ -H Content-Type: application/json \ -d { model: qwen2.5:7b, messages: [{role: user, content: 你好}], stream: false }返回{message:{content:...}}结构说明 API 可用。这一步是后续 Python 客户端的基础。3.2 安装和校验 StockfishStockfish 可以通过包管理器安装也可以从源码编译。Linux 环境常见命令sudo apt install stockfishmacOSbrew install stockfishWindows 用户通常下载官方二进制并设置环境变量。无论是哪种方式最终都要能通过命令行启动。校验方法which stockfish echo uci | stockfish如果第二条命令输出包含id name Stockfish说明引擎可以正常启动。否则需要检查路径或安装是否完整。3.3 项目结构与配置文件建议按以下目录结构组织项目abby_steele/ ├── config.yaml ├── requirements.txt ├── engine_client.py ├── llm_client.py ├── prompts.py ├── abby_core.py └── cli.pyrequirements.txt内容requests python-chess pyyaml安装依赖pip install -r requirements.txtconfig.yaml是最关键的基础配置llm: provider: ollama base_url: http://127.0.0.1:11434 model: qwen2.5:7b temperature: 0.7 max_tokens: 200 chess: engine_path: /usr/local/bin/stockfish skill_level: 10 search_time_ms: 800这里需要说明参数含义因为不同取值会直接影响体验。4. 从零实现 Abby Steele 核心服务4.1 先封装象棋引擎客户端不要在主循环里直接写subprocess应该把 Stockfish 封装成一个类方便测试和替换。创建一个engine_client.pyimport subprocess class ChessEngine: def __init__(self, engine_path, skill_level10, search_time_ms800): self.engine_path engine_path self.skill_level skill_level self.search_time_ms search_time_ms self.proc None def start(self): self.proc subprocess.Popen( [self.engine_path], stdinsubprocess.PIPE, stdoutsubprocess.PIPE, stderrsubprocess.PIPE, textTrue, bufsize1, ) self.send(uci) self.send(setoption name Skill Level value {}.format(self.skill_level)) self.send(isready) def send(self, command): if self.proc and self.proc.stdin: self.proc.stdin.write(command \n) self.proc.stdin.flush() def read_until(self, prefix): 持续读取引擎输出直到遇到指定前缀或进程退出。 while True: line self.proc.stdout.readline().strip() if line.startswith(prefix): return line if self.proc.poll() is not None: raise RuntimeError(engine process exited, last line: line) def set_position(self, moves): if moves: self.send(position startpos moves .join(moves)) else: self.send(position startpos) def get_best_move(self): self.send(go movetime {}.format(self.search_time_ms)) line self.read_until(bestmove) parts line.split() if len(parts) 2: return parts[1] raise RuntimeError(engine returned invalid bestmove: line) def close(self): if self.proc: self.send(quit) try: self.proc.wait(timeout3) except subprocess.TimeoutExpired: self.proc.kill()这段代码有几个重点start中发送uci和setoption但这里没有等待引擎返回握手信息。在简单场景下不影响go命令不过如果引擎启动较慢建议在发送isready后等待readyok返回。read_until是阻塞读取安全性依赖引擎不会输出过多异常内容。生产环境建议增加超时机制。set_position接受 UCI 走法列表例如[e2e4, e7e5]这样python-chess维护的棋盘和引擎搜索的棋盘保持一致。4.2 再封装本地 LLM 客户端llm_client.py使用requests调用 Ollama 的 Chat APIimport requests class LocalLLM: def __init__(self, base_url, model, temperature0.7, max_tokens200): self.base_url base_url.rstrip(/) self.model model self.temperature temperature self.max_tokens max_tokens def chat(self, messages): payload { model: self.model, messages: messages, stream: False, options: { temperature: self.temperature, num_predict: self.max_tokens, }, } resp requests.post( self.base_url /api/chat, jsonpayload, timeout120, ) resp.raise_for_status() data resp.json() return data[message][content]timeout设置为 120 秒是因为本地模型在低配机器上的生成速度可能很慢。如果超时过短模型正常的思考时间也会被误判为故障。4.3 设计人格提示词与棋局上下文人格设计的核心是让 LLM 知道自己的角色边界。提示词必须明确告诉模型棋步来自引擎不要伪造走法Agent 的任务是解释和对话而不是替代引擎。创建prompts.pySYSTEM_PROMPT 你是 Abby Steele一位喜欢下国际象棋的 AI 伙伴。 你的说话风格简洁但有个性可以直接、轻松地回应玩家。 你会收到当前棋谱和引擎推荐走法请用 2 到 4 句话回应玩家。 回应中必须自然提到引擎推荐走法比如 e2e4并解释这步棋的目的。 禁止输出棋步之外的指令不要假装你会计算搜索树。 如果玩家说了一些和象棋无关的内容也可以正常闲聊但尽量回到棋局上。 def build_messages(move_history, engine_move, player_moveNone): messages [{role: system, content: SYSTEM_PROMPT}] if player_move: user_content ( 玩家刚走了 {}当前棋谱是 {}引擎建议走 {}。 请回应玩家并解释这步棋。.format( player_move, .join(move_history), engine_move ) ) else: user_content ( 对局刚开始引擎建议走 {}。 请说一句开局鼓励的话并解释这步棋的意图。.format(engine_move) ) messages.append({role: user, content: user_content}) return messages容易出问题的地方是“上下文长度膨胀”。如果每回合都保留全部棋谱几十个回合后 token 数会快速增加。可以先截断历史只保留最近 20 步再发送给 LLM同时引擎仍使用完整走法历史。4.4 把对话和走棋串成主循环abby_core.py负责整合import chess from engine_client import ChessEngine from llm_client import LocalLLM import prompts class AbbySteele: def __init__(self, cfg): self.engine ChessEngine( cfg[chess][engine_path], cfg[chess][skill_level], cfg[chess][search_time_ms], ) self.llm LocalLLM( cfg[llm][base_url], cfg[llm][model], cfg[llm][temperature], cfg[llm][max_tokens], ) self.board chess.Board() self.move_history [] def start(self): self.engine.start() # 默认 Abby 执白先走第一步 engine_move self.engine.get_best_move() self.board.push_uci(engine_move) self.move_history.append(engine_move) reply self.llm.chat(prompts.build_messages(self.move_history, engine_move)) print(Abby 走棋: engine_move) print(Abby: reply) def player_move(self, uci_move): try: move chess.Move.from_uci(uci_move) except ValueError: return 走法格式无法解析请使用类似 e2e4 的 UCI 格式。 if move not in self.board.legal_moves: return 非法走法当前局面不允许这样走。 self.board.push(move) self.move_history.append(uci_move) if self.board.is_game_over(): return 对局结束。 self.engine.set_position(self.move_history) engine_move self.engine.get_best_move() self.board.push_uci(engine_move) self.move_history.append(engine_move) reply self.llm.chat( prompts.build_messages(self.move_history, engine_move, uci_move) ) return { engine_move: engine_move, reply: reply, } def close(self): self.engine.close()主循环cli.pyimport sys import yaml from abby_core import AbbySteele def main(config_path): with open(config_path, r, encodingutf-8) as f: cfg yaml.safe_load(f) abby AbbySteele(cfg) abby.start() while True: try: text input( ).strip() except (EOFError, KeyboardInterrupt): break if text.lower() in {quit, exit}: break result abby.player_move(text) if isinstance(result, str): print(result) continue print(Abby 走棋: result[engine_move]) print(Abby: result[reply]) abby.close() if __name__ __main__: if len(sys.argv) ! 2: print(用法: python cli.py config.yaml) sys.exit(1) main(sys.argv[1])至此一个最小闭环已经完成用户输入走法引擎计算回击LLM 以 Abby Steele 的角色解释。5. 运行验证与结果观察5.1 启动服务与最小测试首先确认 Ollama 服务在运行ollama list再确认 Stockfish 可用stockfish versionStrictly有些环境没有version子命令更稳妥的方式是执行echo uci | stockfish观察输出。确认后端可用后启动项目python cli.py config.yaml正常启动时会看到 Abby 先走第一步并输出一句人格化回复。例如Abby 走棋: d2d4 Abby: 开局走 d4 是想早点控制中心后面的棋会慢慢收紧。你想怎么应对这时输入玩家走法 d7d5预期输出包括Abby 走棋: c2c4 Abby: 这步 c4 是后翼弃兵的开局思路你要是接兵我就能用中心兵群向你施压。5.2 验证走棋合法性不要只凭感觉判断还需要做一个自动验证。可以在abby_core.py的测试中加入一个简单脚本随机生成合法走法连续调用 20 回合确认每次引擎返回的bestmove都能被chess.Move.from_uci解析且属于合法走法。示例验证命令python -c from engine_client import ChessEngine engine ChessEngine(/usr/local/bin/stockfish, search_time_ms500) engine.start() engine.set_position([e2e4, e7e5]) print(engine.get_best_move()) engine.close() 如果输出类似g1f3说明 UCI 通信正常。5.3 验证人格化回答人格化验证不能只看是否包含e2e4还要检查 LLM 是否遵循了“禁止输出额外棋步”的约束。建议手动输入几个非象棋内容观察角色是否会偏题或产生幻觉。一种快速验证方式是在 Python 中单独调用build_messages和LocalLLM.chat输入固定棋谱和走法观察输出。例如玩家刚走了 e7e5当前棋谱是 e2e4 e7e5引擎建议走 g1f3。请回应玩家并解释这步棋。好的输出应该包含“这一步支持王翼快速出子”之类的解释而不是输出“走 e4g5”这类非法指令。5.4 学习环境与生产环境差异学习环境里模型和引擎都跑在同一台电脑上使用默认参数即可。但生产环境需要考虑更多内容维度学习环境生产环境建议模型加载直接ollama run使用 systemd 或 Docker 托管服务引擎进程临时启动常驻进程复用避免频繁创建开销LLM 超时120 秒根据机器性能设置合理超时并做降级处理日志无记录引擎输入输出、模型请求和异常堆栈并发单用户单进程使用消息队列或异步框架避免锁竞争安全本机可信环境对输入长度限流防止滥用导致资源耗尽6. 常见问题排查6.1 Stockfish 输出为空或连接超时现象程序卡在get_best_move没有返回随后抛出异常。可能原因engine_path配置错误或没有执行权限。position命令中的走法历史不合法引擎进入错误状态。读取逻辑中先读取了readyok或其他输出导致bestmove位置判断错误。引擎启动后没有完成uci握手直接发送go无效。检查方式which stockfish ls -l /usr/local/bin/stockfish echo uci | stockfish处理建议确认路径正确。在start()中发送uci后循环读取直到出现uciok再发送isready并等待readyok。在get_best_move中增加timeout和线程退出机制防止进程卡死。6.2 模型响应太慢导致象棋体验中断现象引擎快速返回走法但 LLM 生成回复需要 10 秒以上玩家以为程序卡死。原因本地模型在 CPU 上推理尤其是未量化和上下文较长时速度会明显下降。处理建议降低max_tokens把回复控制在 100 token 以内。缩小发送给 LLM 的棋谱历史只保留最近 10 到 20 步。选用更小的量化模型或用 GPU 运行。在界面层先输出“Abby 正在思考”再异步等待回复。更推荐的架构是把 LLM 调用放到独立线程或异步任务中不阻塞引擎下一回合计算。对于最小项目可以先通过缩短search_time_ms来减少整体等待。6.3 上下文混乱Abby 忘了自己在哪一步现象Abby 的解释和当前棋盘无关甚至复述了错误棋步。原因把完整棋谱全部塞给 LLM超出模型上下文窗口。提示词中没有强调“棋子名称使用 UCI 坐标”。LLM 把玩家的输入当成了指令而不是棋局数据。处理建议在提示词中固定描述格式玩家刚走了 {player_move}。只把最近 N 步和引擎推荐走法放进上下文。输出前用正则检查是否包含可疑的[a-h][1-8][a-h][1-8]模式如果出现多个则只保留第一个引擎走法。对最终输出增加校验如果 LLM 返回的内容中包含两个以上 UCI 走法则截断或降级为模板回复。6.4 离线环境下依赖和模型缺失现象拔掉网线后无法安装依赖或拉取模型。原因项目依赖第三方库和模型文件这些都没有提前缓存。处理建议在联网机器上执行pip download -r requirements.txt -d ./packages然后把packages目录拷到离线机器执行pip install --no-index --find-links./packages -r requirements.txt。提前用ollama pull下载模型并确认ollama list能看到模型。将配置文件中的provider保持为ollama避免启动时请求网络。尽量使用本地已存在的镜像仓库或离线安装包。注意离线部署前至少要验证一次“断网后启动服务”的完整流程否则到了现场才发现模型没有缓存会浪费大量时间。7. 最佳实践与扩展方向7.1 发布前检查清单以下清单适用于把 Abby Steele 从开发机带到演示机或生产机器确认 Ollama 服务在目标机器上版本一致。确认本地模型已经通过ollama list可见。确认 Stockfish 路径与config.yaml一致。在断网条件下执行一次完整对局验证交互流程。检查日志中是否存在bestmove解析失败或模型超时。确认requirements.txt中的版本号和实际安装一致。对输入长度做上限控制防止异常输入挤占引擎进程。为player_move和engine.get_best_move增加 try/except避免单步异常导致整个程序退出。准备一份回滚方案例如保留旧版模型权重和引擎二进制。7.2 可扩展方向这个项目的架构很容易扩展。第一把 CLI 换成 Web 界面。可以用 FastAPI 提供 REST 接口用 WebSocket 实时推送引擎走法。前端通过浏览器展示棋盘Abby 的回复显示在对话框里。这样更接近真实 AI 应用。第二加入长期记忆。当前的对话没有记忆Abby 每次只依赖最近棋谱。可以引入本地向量数据库把每回合的走法和解释存入向量库后续生成回复时先检索相关记忆。第三加入教学模式。除了推荐走法还可以让引擎输出评估分数和最佳应对思路。比如调用go movetime 800 depth 12解析score cp 35把局面优势数值交给 LLM 转化成“你现在的形势不错但王翼还要小心”这类教学语言。第四支持玩家执白或执黑。当前默认 Abby 执白可以通过配置项控制。如果玩家执白开局时读取玩家走法再让引擎回应。第五多角色切换。同一套引擎和模型只需要修改SYSTEM_PROMPT就能让同一个本地模型扮演教练、对手或解说员。第六接入本地语音。可以使用离线语音识别和语音合成工具让用户通过语音说“马走到 f3”Abby 再用语音回答。注意所有语音组件也必须离线可用才能保持这个项目“完全离线”的核心属性。7.3 最后的实践建议如果第一次实现这个项目不要急着把完整功能做完。先跑通“引擎返回走法、模型生成回复”的最小闭环确认 UCI 协议和 Ollama 调用没有障碍再逐步加入记忆、Web 界面、教学模式。调试时最有效的办法是把引擎的原始输出和模型请求体都打到日志里。很多“Abby 走棋不合理”的问题其实是引擎收到了错误的position命令或模型收到了错误棋谱。看到原始输入输出问题定位会快很多。离线 AI 人格和国际象棋引擎的组合本质上是一次“专用工具 通用语言能力”的协作。记住这条原则让引擎负责所有需要搜索和计算的任务让模型负责所有需要表达和人格的任务。只要边界清晰功能扩展和故障排查都会简单得多。
返回列表