先聊一个比较常见的场景:本地跑了一个开源大模型,对话体验不错,但只要一聊到“精确计算”或者“规则推演”,它就很容易一本正经地胡说八道。比如你问它国际象棋怎么走,它能说出“e4 e5 不错”,但继续追问几步,就会出现非法走法,甚至棋子在棋盘上“瞬移”。
之所以会这样,是因为大语言模型的本质是“根据上下文预测下一个 token”,它擅长生成自然语言,却不擅长做确定性的规则推演。而像国际象棋这种非常吃规则、深度和计算的场景,真正靠谱的解法,是把两套东西拼在一起:
- 本地 LLM:负责角色人格、对话表达、情绪和点评;
- 国际象棋引擎(Stockfish):负责真正的棋力计算。
本文要动手实现的 Abby Steele,就是这样一个离线 AI 人格:她住在你自己的电脑里,不需要联网,不调用云端 API,能陪你聊天,也能跟你下一盘正经的国际象棋。
你可以把她理解成一个小型 Agent 实战项目:LLM 负责“大脑与嘴巴”,Stockfish 负责“肌肉记忆与精确计算”。本文会从原理讲到完整代码,最后给出可运行的 Python 工程。无论你是刚接触本地 LLM 的新手,还是想练手 Agent 架构的开发者,都可以照着搭一遍。
1. 背景与核心概念
1.1 什么是离线 AI 人格
离线 AI 人格,简单说,就是运行在你自己电脑上的数字角色。它由本地 LLM 驱动,具备稳定的性格设定、说话风格和交互模式。和云端助手最大的区别是:
- 数据不出本机,隐私边界清楚;
- 不按 Token 付费,推理成本可以忽略;
- 不依赖外网 API,断网也能用。
但“有性格”只是第一层。如果这个角色只能聊天,那它更像一个本地版聊天机器人。真正有意思的是,把“对话能力”和“专业工具”结合起来,让这个角色既能说话,也能做事。
Abby Steele 的定位就是一个“会下棋的离线人格”。她能像朋友一样和你聊天,也能切换成棋手状态,认真和你下一盘棋。这个设计非常适合作为 Agent 开发的入门案例,因为它足够小,却覆盖了“LLM + 工具调用”的核心路径。
1.2 为什么需要国际象棋引擎,而不是让 LLM 直接下棋
先看一个关键问题:国际象棋的走法,为什么不能完全交给 LLM?
第一个原因是正确性。国际象棋有严格的移动规则,比如“马走日”“王车易位有条件”“吃过路兵只能立即执行”。LLM 能背出这些规则,但在长对局中很难稳定推理。早期用 LLM 直接下棋的实验里,最常见的失败就是“吃到不存在的子”和“走到非法格子”。
第二个原因是计算深度。局部战术、杀棋、长线弃子,都需要大量搜索。Stockfish 这类引擎使用 alpha-beta 剪枝、局面评估函数、开局库和残局库,复杂度远超 LLM 直接生成。
第三个原因是表达与计算的解耦。让 LLM 计算棋步,是一种资源错配。更好的分工是:
- Stockfish 负责找最优走法;
- LLM 负责把这个走法翻译成 Abby 说的话。
举个例子,用户走了一步漂亮的弃子,Stockfish 能算出“这步有补偿”,但不会说人话。LLM 可以把局面翻译成:“你这一手有点意思,我要是贪吃,接下来会被你抽车。”这就是两者结合的价值。
1.3 技术组成概览
整个项目可以拆成三层:
用户输入 ↓ Abby 主循环(Python) ├── 本地 LLM:人格对话、下棋意图判断、局面点评 └── 国际象棋引擎:计算并返回最佳走法 ↓ 控制台输出:Abby 的话 + 棋盘可视化在本文的代码实现中:
- 本地 LLM 使用 Ollama 作为推理服务,HTTP 接口方式调用;
- 国际象棋引擎使用 Stockfish;
- 棋盘状态、走法合法性校验、FEN 与 SAN 转换,使用
python-chess库; - 主程序是一个命令行交互工具,用户输入文字,Abby 回复文字。
接下来按照这个架构,从环境准备开始一步步搭建。
2. 环境准备与项目架构
2.1 硬件与运行环境
本地 LLM 对硬件有一定要求,但门槛不高。一个 3B 到 7B 参数的量化模型,在普通笔记本上就可以运行:
- 内存 8GB:可以运行 3B 左右的小模型;
- 内存 16GB:可以运行 7B 到 14B 的量化模型;
- NVIDIA 显卡 8GB 以上显存:推理速度会明显提升,但不是必须。
本文示例是一个“最小可运行方案”,优先保证流程能跑通。硬件条件更好的话,可以换更大的模型来提升对话质量。
2.2 安装本地 LLM 推理服务
本文使用 Ollama 作为本地 LLM 推理服务,原因是安装简单、API 接口清晰。如果你更熟悉 llama.cpp 或 llamafile,基本原理相同,只需要替换掉 HTTP 调用层。
建议按照 Ollama 官方文档的安装方式安装,不要随意使用第三方脚本。安装完成后,打开终端验证:
ollama --version然后拉取一个适合入门的小模型。以下命令以llama3.2:3b为例,具体模型名以你本地实际可用的模型为准:
ollama pull llama3.2:3b拉取模型需要联网,模型下载到本地后,后续对话推理都发生在你的电脑上。之后启动服务:
ollama serve执行后 Ollama 会监听本机的11434端口。这里需要重点说明一下“离线”的边界:
- 首次下载模型文件:需要联网;
- 模型加载、推理、对话:完全在本机完成;
- 如果处于无外网环境,可以提前在联网机器上下载模型,再拷贝到本机 Ollama 的模型目录。
2.3 安装 Stockfish
Stockfish 是目前最常用的开源国际象棋引擎,遵循 UCI(Universal Chess Interface)协议。安装方式因系统不同而不同:
# macOS brew install stockfish # Debian / Ubuntu sudo apt install stockfish在 Windows 上,可以从 Stockfish 官网下载对应平台的二进制文件,然后把可执行文件所在目录加入 PATH。
安装完成后,在终端手动验证一下引擎是否可用:
stockfish进入引擎交互界面后,输入uci并回车,如果看到包含uciok的输出,说明安装成功:
id name Stockfish ... uciok然后输入quit退出。
2.4 项目目录结构
创建一个项目目录,这里命名为abby-steele:
abby-steele/ ├── requirements.txt ├── config.py ├── llm_client.py ├── chess_engine.py ├── abby.py └── main.py其中requirements.txt内容如下:
requests python-chess安装依赖:
pip install -r requirements.txtpython-chess负责棋盘状态管理、走法解析与合法性校验,Stockfish 负责计算走法,两者是互补关系。
3. 核心原理拆解
3.1 本地 LLM 的对话接口
Ollama 提供/api/chat接口,请求结构与 OpenAI 兼容。最小请求如下:
curl http://localhost:11434/api/chat -d '{ "model": "llama3.2:3b", "messages": [ {"role": "user", "content": "你好"} ], "stream": false }'Python 中可以直接用requests封装。关键参数有两个:
stream:是否流式返回。控制台演示时用false即可,追求交互体验时可以用true。options.temperature:控制随机性。人格对话建议设置为 0.7 左右;棋局点评时建议降到 0.3 以下,让话术更稳定。
在真实项目中,你需要维护一个messages列表,它是所有对话历史的载体。系统提示词放在第一项,之后是新消息。
3.2 国际象棋引擎与 UCI 协议
UCI 协议是脚本、GUI 与引擎之间的通信标准。它的核心是文本命令和文本回显。手动交互时最常用的命令:
uci:请求引擎返回能力信息,并以uciok结束;isready:检测引擎是否就绪;position startpos:初始化棋盘到初始局面;go movetime 500:让引擎思考 500 毫秒,并输出bestmove。
一个典型的过程是:
position startpos go movetime 500 info depth 15 score cp 32 ... bestmove e2e4如果自己写底层通信,需要处理大量info行,并且要异步读取标准输出,否则主线程会卡住。下面是一个用于理解原理的最小片段:
# uci_demo.py —— 仅用于演示底层通信原理 import subprocess import threading import queue def start_engine(path="stockfish"): p = subprocess.Popen( [path], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, bufsize=1, ) out_q = queue.Queue() def read(): for line in p.stdout: out_q.put(line.strip()) threading.Thread(target=read, daemon=True).start() return p, out_q def send(p, cmd): p.stdin.write(cmd + "\n") p.stdin.flush()为什么要用queue和后台线程?因为引擎的输出是持续不断的,如果直接readline(),输出多的时候会阻塞主流程。后台线程把输出放进队列,主线程可以按需获取。这是底层的标准做法。
在实际项目中,我们不必自己写完整的 UCI 客户端,python-chess已经封装了这些细节。理解底层协议仍然很重要,因为排查问题时,你会需要知道“bestmove 在哪里”“info 行是什么意思”。
3.3 聊天与下棋的状态切换
Abby 的工作流需要区分两种模式:
- 聊天模式:用户输入普通文本,交给 LLM,LLM 以 Abby 人格回复。
- 下棋模式:用户输入一个符合 SAN(Standard Algebraic Notation)的走法,比如
e5或Nf6,程序先校验走法,再让 LLM 生成点评,最后让 Stockfish 计算应手。
在项目中,切换时机可以这样设计:
- 默认处于聊天模式;
- 当用户消息包含“下棋”“国际象棋”“对弈”等关键词时,进入下棋模式;
- 当用户说“不下了”“结束棋局”时,退出下棋模式,回到聊天。
关键词判断虽然简单,但可控性最好。你也可以让 LLM 做意图分类,只是那样会增加一次本地推理,并且返回不稳定。先跑通关键词方案,后续再升级成 LLM 判断,是更稳妥的思路。
4. 完整实战案例:搭建 Abby Steele
4.1 编写配置文件
配置文件统一管理路径和参数,避免把硬编码散落在各个模块里。
# 文件路径:config.py import os OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434") LLM_MODEL = os.getenv("LLM_MODEL", "llama3.2:3b") STOCKFISH_PATH = os.getenv("STOCKFISH_PATH", "stockfish") ENGINE_SKILL_LEVEL = int(os.getenv("ENGINE_SKILL_LEVEL", "10"))说明:
- 如果你没有拉取
llama3.2:3b,可以把LLM_MODEL换成你本地已有的模型; STOCKFISH_PATH默认是系统命令stockfish,如果你的 Stockfish 不在 PATH 中,就填完整路径;ENGINE_SKILL_LEVEL是 Stockfish 的等级参数,范围是 0 到 20,值越低越弱,适合陪练。
4.2 编写 LLM 客户端
LLMClient负责所有本地 LLM 的调用。这里封装一层,后续主程序不需要关心 HTTP 细节。
# 文件路径:llm_client.py import requests from config import OLLAMA_URL, LLM_MODEL class LLMClient: def __init__(self, base_url=OLLAMA_URL, model=LLM_MODEL): self.base_url = base_url.rstrip("/") self.model = model self.session = requests.Session() def chat(self, messages, temperature=0.7, max_tokens=512): payload = { "model": self.model, "messages": messages, "stream": False, "options": { "temperature": temperature, "num_predict": max_tokens, }, } resp = self.session.post( f"{self.base_url}/api/chat", json=payload, timeout=180, ) resp.raise_for_status() data = resp.json() return data["message"]["content"].strip()这里需要注意:
timeout=180是因为本地模型在小内存机器上生成速度可能较慢,超时放宽一些;data["message"]["content"]是 Ollama 非流式响应里的文本字段。
4.3 编写国际象棋引擎封装
ChessEngine把 Stockfish 和棋盘状态绑定在一起。它负责:
- 重置棋盘;
- 校验并执行用户走法;
- 调用 Stockfish 计算应手;
- 返回 FEN 和棋盘字符串用于展示。
# 文件路径:chess_engine.py import chess import chess.engine from config import STOCKFISH_PATH, ENGINE_SKILL_LEVEL class ChessEngine: def __init__(self, engine_path=STOCKFISH_PATH, skill_level=ENGINE_SKILL_LEVEL): self.engine = chess.engine.SimpleEngine.popen_uci(engine_path) self.engine.configure({"Skill Level": skill_level}) self.board = chess.Board() def reset(self): self.board = chess.Board() return self.fen() def fen(self): return self.board.fen() def board_to_text(self): return str(self.board) def apply_user_move(self, move_text): try: move = self.board.parse_san(move_text) except ValueError: return False, "这个走法我看不明白,请使用国际象棋代数记谱法,例如 e5 或 Nf6。" if move not in self.board.legal_moves: return False, "这个走法不符合规则,换个思路试试。" self.board.push(move) return True, self.board.san(move) def apply_engine_move(self, movetime=1.0): result = self.engine.play(self.board, chess.engine.Limit(time=movetime)) uci_move = self.board.uci(result.move) self.board.push(result.move) return uci_move, self.fen() def is_game_over(self): return self.board.is_game_over() def result(self): return self.board.result() def close(self): self.engine.quit()说明几个关键点:
board.parse_san会把e5、Nf6这种人类记谱解析成Move对象;board.legal_moves是当前局面的合法走法集合,用它做最终校验;engine.play(board, chess.engine.Limit(time=1.0))让 Stockfish 思考 1 秒并返回最优走法;result.move是引擎建议的走法,board.uci(move)将其转成 UCI 字符串,便于展示。
这里movetime=1.0是单步思考时间,在本机性能不足时可以降到0.5,体验更流畅,棋力会弱一些。
4.4 编写人格提示词
Abby 的人格定义放在独立的模块中。系统提示词决定了 AI 人格的说话方式。
# 文件路径:abby.py SYSTEM_PROMPT = """你是 Abby Steele,一个运行在用户电脑上的离线 AI 人格。 你的性格特点:冷静、聪明、说话简洁、带一点幽默感。 你非常喜欢国际象棋,经常用棋局比喻生活。 当用户和你下棋时,你会像一位棋手一样评价局面和走法。 你从不说"作为AI模型",永远用 Abby 的身份说话。 每轮回答尽量控制在 80 字以内。""" CHESS_INTENT_KEYWORDS = [ "下棋", "走棋", "国际象棋", "棋盘", "对弈", "棋", "chess", ] def build_system_messages(): return [{"role": "system", "content": SYSTEM_PROMPT}] def contains_chess_intent(text: str) -> bool: lower_text = text.lower() return any(word in lower_text for word in CHESS_INTENT_KEYWORDS)注意,contains_chess_intent只是最简单的意图判断。实际交互中,用户可能会说“来一盘”“杀一局”这样的口语,所以后续可以考虑用 LLM 做意图分类。
4.5 编写主循环
主循环是整个项目的核心。它承担以下职责:
- 维护对话历史;
- 判断当前处于聊天模式还是棋局模式;
- 在棋局模式中更新棋盘、请求 LLM 生成点评、请求 Stockfish 计算走法;
- 控制对话历史长度,避免无限膨胀。
# 文件路径:main.py from abby import build_system_messages, contains_chess_intent from chess_engine import ChessEngine from llm_client import LLMClient def trim_messages(messages, max_history=12): """保留系统提示词和最近的 max_history 条消息。""" if len(messages) <= max_history + 1: return messages return messages[:1] + messages[-max_history:] def main(): llm = LLMClient() engine = ChessEngine() messages = build_system_messages() is_chess_mode = False print("Abby Steele 已离线启动,输入 quit 退出。") while True: try: user_input = input("\nYou> ").strip() except (KeyboardInterrupt, EOFError): print("\n再见,祝棋运亨通。") break if user_input.lower() in ("quit", "exit"): break if not is_chess_mode and contains_chess_intent(user_input): is_chess_mode = True engine.reset() messages.append({"role": "user", "content": "我想和你下一盘国际象棋。"}) reply = llm.chat(messages, temperature=0.7) messages.append({"role": "assistant", "content": reply}) print("\nAbby> " + reply) # Abby 执白,先走一步 move, _ = engine.apply_engine_move(movetime=1.0) context = f"当前棋盘 FEN:{engine.fen()}。你执白先走,你走了 {move}。" messages.append({"role": "user", "content": context}) reply = llm.chat(messages, temperature=0.3) messages.append({"role": "assistant", "content": reply}) print(f"\nAbby 落子:{move}") print("Abby> " + reply) print("\n" + engine.board_to_text()) continue if is_chess_mode: if user_input.lower() in ("不下了", "结束棋局", "退出棋局"): is_chess_mode = False messages.append({"role": "user", "content": "我们结束下棋,回到聊天吧。"}) reply = llm.chat(messages, temperature=0.7) messages.append({"role": "assistant", "content": reply}) print("\nAbby> " + reply) continue ok, info = engine.apply_user_move(user_input) if not ok: print("Abby> " + info) continue messages.append({"role": "user", "content": f"我走:{info}"}) if engine.is_game_over(): result = engine.result() messages.append({"role": "user", "content": f"棋局结束,结果是:{result}"}) reply = llm.chat(messages, temperature=0.7) messages.append({"role": "assistant", "content": reply}) print("\nAbby> " + reply) is_chess_mode = False continue # 先让 LLM 点评用户走法 context = ( f"当前棋盘 FEN:{engine.fen()}。" f"用户刚走出:{info}。请结合局面用 Abby 的语气简短点评。" ) messages.append({"role": "user", "content": context}) reply = llm.chat(messages, temperature=0.3) messages.append({"role": "assistant", "content": reply}) print("\nAbby> " + reply) # 再让 Stockfish 计算应手 move, _ = engine.apply_engine_move(movetime=1.0) context = f"当前棋盘 FEN:{engine.fen()}。你走了 {move}。" messages.append({"role": "user", "content": context}) reply = llm.chat(messages, temperature=0.3) messages.append({"role": "assistant", "content": reply}) print(f"\nAbby 落子:{move}") print("Abby> " + reply) if engine.is_game_over(): result = engine.result() messages.append({"role": "user", "content": f"棋局结束,结果是:{result}"}) reply = llm.chat(messages, temperature=0.7) messages.append({"role": "assistant", "content": reply}) print("\nAbby> " + reply) is_chess_mode = False else: print("\n" + engine.board_to_text()) messages = trim_messages(messages) continue # 普通聊天模式 messages.append({"role": "user", "content": user_input}) reply = llm.chat(messages, temperature=0.7) messages.append({"role": "assistant", "content": reply}) messages = trim_messages(messages) print("\nAbby> " + reply) engine.close() if __name__ == "__main__": main()这段代码有几点需要解释:
- 在下棋模式下,LLM 和 Stockfish 是分开调用的。LLM 的上下文里带上了 FEN 字符串,这让 Abby 至少能感知当前棋盘的位置,不至于说出和局面完全无关的话。
trim_messages只保留系统提示词和最近 12 条消息。本地模型的上下文窗口有限,如果不限制历史,长会话会越聊越慢,甚至超出窗口。- 用户在棋局模式下输入的不是自然语言,而是 SAN 走法,比如
e5、Nf6。程序会先校验走法,不合法的输入会被 Abby 的性格话术挡回去。
4.6 运行与验证
在项目目录下运行:
python main.py预期交互如下:
Abby Steele 已离线启动,输入 quit 退出。 You> 你好 Abby> 你好,我是 Abby。今天想下一盘棋,还是随便聊聊? You> 我想下棋 Abby> 好,我执白,先走一步。e4。 Abby 落子:e2e4 Abby> 我还是喜欢王前兵开局,感觉局势清楚。 r n b q k b n r p p p p p p p p . . . . . . . . . . . . . . . . . . . . P . . . . . . . . . . . P P P P . P P P R N B Q K B N R You> e5 Abby> 西西里方向?挺有想法的,我看看怎么回应你。 Abby 落子:g1f3 Abby> 我先出马,保持中心压力。注意,由于本地模型和 Stockfish 的随机性,输出文本不会完全一致,这是正常的。你需要关注的是:流程是否顺畅、走法是否合法、Abby 的话是否贴合局面。
5. 常见问题与排查思路
在实际搭建过程中,比较常见的异常如下:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
ollama: command not found | Ollama 未安装或未加入 PATH | 重新安装,确认 PATH 配置 |
连接localhost:11434被拒 | ollama serve没有启动 | 先启动服务,再运行程序 |
| 模型名称报错 | LLM_MODEL写了一个不存在的模型 | 执行ollama list查看本地模型名 |
python-chess报错 | 依赖未安装 | 执行pip install python-chess |
| Stockfish 启动失败 | 路径错误或不是 UCI 引擎 | 在终端输入stockfish,手动验证uci |
| 用户走法解析失败 | 输入了非 SAN 文本,比如马f6 | 提示用户输入Nf6这种代数记谱 |
| 每轮响应很慢 | 模型过大或没有 GPU | 换更小的量化模型,降低movetime |
| 对话越聊越慢 | messages历史太长 | 用trim_messages截断历史 |
下面展开说明几个常见问题。
5.1 Ollama 服务无法访问
如果你运行程序时看到ConnectionError或Connection refused,优先排查 Ollama 是否在运行:
curl http://localhost:11434/api/tags如果返回 JSON 列表,说明服务正常;如果连接失败,先启动ollama serve,再重新运行程序。
5.2 Stockfish 路径配置错误
在 Windows 上最常见。如果你没有把 Stockfish 加入 PATH,popen_uci("stockfish")会抛出FileNotFoundError。解决方式是在config.py里设置完整路径:
STOCKFISH_PATH = os.getenv("STOCKFISH_PATH", "D:/tools/stockfish/stockfish-windows-x86-64.exe")也可以在启动程序时通过环境变量覆盖:
export STOCKFISH_PATH=/your/path/to/stockfish python main.py5.3 模型回答质量不稳定
如果你发现 Abby 的棋局点评和棋盘完全对不上,先