反刍 AI-MUD-MCP 游戏开发日志:用 FastMCP 搭代码框架,TaoToken 统一 Key 接入 DeepSeek
发布时间:2026/9/26 10:14:02来源:尧图网络
1. 为什么我要把 MUD 塞进 MCP一个反刍式 AI 游戏的最小闭环AI-MUD-MCP 这个组合说白了就是让大模型当游戏主持人玩家用自然语言输入指令模型解析后调用工具改变游戏世界状态再把结果用文字叙述回来。它适合两类人一类是想学 MCP 协议但找不到真实项目的开发者另一类是做过文字游戏、想给老玩法接上大模型大脑的独立作者。我这次要交付的不是完整游戏而是一个能跑通的最小可玩循环初始化角色、查看场景、移动、战斗四个动作全部走 MCP 工具调用模型负责把冷冰冰的状态变化翻译成有画面感的文字。上一轮我把世界观和数据结构想清楚了这一轮的重点是代码框架落地。核心思路是用 FastMCP 做骨架把游戏引擎、世界数据、战斗系统拆成独立模块再用 TaoToken 的统一 Key 通道接入 DeepSeek 驱动动态内容生成。为什么不用官方 SDK 直连因为项目里会同时用到对话模型和后续可能的编码辅助模型统一 Key 能省掉多套鉴权和计费对账的麻烦配置只写一份换模型只改一个字段。整个项目目录我按职责切分避免所有逻辑堆在 server.py 里。下面这份骨架你可以直接复制跑起来之后再往里填内容。ruminate-ai-mud-mcp/ ├── server.py # FastMCP 入口注册所有 tool ├── config.toml # TaoToken Key 与模型配置 ├── mud_game/ │ ├── __init__.py │ ├── core/ │ │ ├── game_engine.py # 游戏引擎协调各模块 │ │ ├── game_world.py # 地点、NPC、物品、怪物数据 │ │ └── combat_system.py # 回合制战斗与伤害计算 │ ├── utils/ │ │ └── ai_tool.py # DeepSeek 调用封装 │ └── models.py # Player / Location / NPC / Item / Monster └── requirements.txt这个结构的好处是世界数据改 game_world.py战斗数值改 combat_system.pyAI 提示词改 ai_tool.py互不干扰。MUD 游戏最怕的就是数值和叙事耦合在一起拆开之后调平衡会轻松很多。2. TaoToken 前置统一 Key 与 DeepSeek 通道配置在写任何游戏逻辑之前先把模型通道打通。TaoToken 在这里扮演的是统一入口的角色你只需要一个 Key就能通过兼容 OpenAI 协议的接口调用 DeepSeek。这样做的好处是游戏代码里不需要出现任何厂商专属的鉴权逻辑ai_tool.py 里只认 base_url 和 api_key 两个变量。先去控制台创建一个 API Key然后把它写进 config.toml。我建议不要把 Key 硬编码在 Python 文件里一是容易误提交二是换环境时要改多处。用 TOML 配置文件代码里读一次就行。# config.toml [taotoken] base_url https://taotoken.net/api api_key sk-你的Key写在这里 [model] chat_model deepseek-chat max_tokens 200 temperature 0.7这里 base_url 填的是 TaoToken 的 API 地址不要带任何多余路径。chat_model 先固定用 deepseek-chat等游戏跑通之后你想换模型只改这一行。max_tokens 和 temperature 放在配置里而不是写死在代码里是因为场景描述和战斗叙述对这两个参数的需求不一样后面可以按调用类型覆盖。requirements.txt 里需要这些依赖fastmcp0.4.0 openai1.30.0 tomli2.0.0Python 3.11 以上自带 tomllib如果你用 3.10 或更低版本就装 tomli 并在代码里做兼容导入。我实测下来 3.11 最省事直接 import tomllib 就行。3. 可复制配置FastMCP 骨架与游戏引擎接线现在进入代码部分。先写 models.py把玩家、地点、怪物这些数据结构定下来。用 dataclass 就够了不需要上 PydanticMUD 的数据结构没那么复杂。# mud_game/models.py from dataclasses import dataclass, field from typing import Dict, List, Optional dataclass class Player: player_id: str name: str location: str 青牛镇 health: int 100 max_health: int 100 stats: Dict[str, int] field(default_factorylambda: { strength: 10, agility: 10, mana: 50 }) inventory: List[str] field(default_factorylist) dataclass class Location: name: str description: str exits: Dict[str, str] field(default_factorydict) npcs: List[str] field(default_factorylist) items: List[str] field(default_factorylist) danger: bool False dataclass class Monster: name: str health: int attack: int defense: int appearance: str drops: List[str] field(default_factorylist)接着是 ai_tool.py这是整个项目里唯一和 TaoToken 打交道的地方。所有模型调用都从这里走方便统一加日志、重试和降级。# mud_game/utils/ai_tool.py import tomllib from pathlib import Path from openai import OpenAI _config_path Path(__file__).resolve().parent.parent.parent / config.toml with open(_config_path, rb) as f: _cfg tomllib.load(f) _client OpenAI( base_url_cfg[taotoken][base_url], api_key_cfg[taotoken][api_key], ) class AITool: staticmethod def generate_text(prompt: str, system_prompt: str , max_tokens: int None, temperature: float None) - str: max_tokens max_tokens or _cfg[model][max_tokens] temperature temperature if temperature is not None else _cfg[model][temperature] messages [] if system_prompt: messages.append({role: system, content: system_prompt}) messages.append({role: user, content: prompt}) try: resp _client.chat.completions.create( model_cfg[model][chat_model], messagesmessages, max_tokensmax_tokens, temperaturetemperature, ) return resp.choices[0].message.content.strip() except Exception as e: print(f[AITool] 调用失败: {e}) return 系统灵识受阻请稍后再试注意这里用的是 openai 1.x 的chat.completions.create写法不是旧版的ChatCompletion.create。如果你从旧教程复制代码这一步很容易报AttributeError我踩过这个坑排查了十几分钟才发现是 SDK 版本差异。然后是 game_engine.py它负责把玩家状态、世界数据和 AI 生成串起来。移动逻辑里有个细节移动成功后要检查新地点是否有怪物如果有就返回遭遇提示让玩家决定是否战斗。# mud_game/core/game_engine.py from mud_game.models import Player from mud_game.core.game_world import GameWorld from mud_game.utils.ai_tool import AITool class GameEngine: def __init__(self): self.players: dict[str, Player] {} self.world GameWorld() def init_player(self, player_id: str, name: str) - str: if player_id in self.players: return f角色 {name} 已存在无需重复初始化。 self.players[player_id] Player(player_idplayer_id, namename) return f欢迎来到修仙世界{name}。你当前位于青牛镇。 def look(self, player_id: str) - str: player self.players.get(player_id) if not player: return 你还没有进入游戏请先初始化角色。 loc self.world.locations.get(player.location) if not loc: return 你身处一片未知之地。 desc AITool.generate_text( f请用《凡人修仙传》风格描述{loc.name}这个{loc.name}场景 f包含环境、氛围和可能的活动迹象控制在120字以内。, system_prompt你是修仙世界的场景描述大师语言简洁有画面感。 ) exits 、.join(loc.exits.keys()) if loc.exits else 无 return f{desc}\n\n出口{exits} def move(self, player_id: str, direction: str) - str: player self.players.get(player_id) if not player: return 你还没有进入游戏。 loc self.world.locations.get(player.location) if not loc or direction not in loc.exits: return f无法向{direction}方向移动。 player.location loc.exits[direction] new_loc self.world.locations[player.location] monster self.world.check_encounter(player.location) if monster: return f你来到了{new_loc.name}。\n\n突然一只{monster.name}出现在你面前 return f你来到了{new_loc.name}。game_world.py 里放地点和怪物数据这里只列两个地点做示例你可以按同样格式扩展。注意check_encounter用随机数决定是否触发战斗避免每次移动都打架。# mud_game/core/game_world.py import random from mud_game.models import Location, Monster class GameWorld: def __init__(self): self.locations { 青牛镇: Location( name青牛镇, description, exits{东: 青牛山岭, 西: 迷雾森林, 北: 修仙坊市}, npcs[张铁匠, 李掌柜], items[生锈的铁剑, 初级聚气丹], ), 青牛山岭: Location( name青牛山岭, description, exits{西: 青牛镇}, items[山参, 铁矿], dangerTrue, ), } self.monsters { 山狼: Monster(山狼, 30, 8, 2, 一只灰毛山狼眼中泛着绿光。, [狼皮, 狼骨]), 猛虎: Monster(猛虎, 60, 15, 5, 一头吊睛白额猛虎气势逼人。, [虎骨, 虎皮]), } def check_encounter(self, location_name: str): loc self.locations.get(location_name) if not loc or not loc.danger: return None if random.random() 0.4: return random.choice(list(self.monsters.values())) return None最后是 server.py用 FastMCP 把引擎方法注册成 tool。这里的关键是每个 tool 的 docstring 要写清楚因为 MCP 客户端会把它作为工具描述展示给模型描述质量直接影响模型选工具的准确率。# server.py from fastmcp import FastMCP from mud_game.core.game_engine import GameEngine server FastMCP(name反刍AI-MUD-MCP游戏) engine GameEngine() server.tool() def init_player(player_id: str, name: str) - str: 初始化角色创建新的玩家存档。player_id 用唯一字符串name 是角色名。 return engine.init_player(player_id, name) server.tool() def look(player_id: str) - str: 查看当前所在场景的描述、出口和可交互对象。 return engine.look(player_id) server.tool() def move(player_id: str, direction: str) - str: 向指定方向移动direction 可选东、南、西、北。 return engine.move(player_id, direction) if __name__ __main__: server.run(streamable-http, host0.0.0.0, port25822, path/mcp)启动命令就是python server.py看到监听 25822 端口的日志就说明服务起来了。4. 验证请求一次完整的 MCP 工具调用与成功结果服务起来之后不要急着接客户端先用最直接的方式验证工具能被调用。我推荐用 FastMCP 自带的客户端做一次端到端测试比手写 HTTP 请求省事。# test_client.py import asyncio from fastmcp import Client async def main(): async with Client(http://127.0.0.1:25822/mcp) as client: tools await client.list_tools() print(可用工具:, [t.name for t in tools]) r1 await client.call_tool(init_player, {player_id: p001, name: 韩立}) print(初始化:, r1) r2 await client.call_tool(look, {player_id: p001}) print(查看场景:, r2) r3 await client.call_tool(move, {player_id: p001, direction: 东}) print(移动:, r3) asyncio.run(main())跑通之后你会看到类似这样的输出工具列表里有 init_player、look、move 三个初始化返回欢迎语look 返回一段由 DeepSeek 生成的场景描述带出口信息move 返回新地点描述如果触发遭遇还会带上怪物名。这一步成功意味着 MCP 工具链、TaoToken 通道、DeepSeek 调用三者全部打通。如果你更习惯用模型对话的方式验证也可以直接在支持 MCP 的客户端里连上这个服务然后输入「帮我初始化一个叫韩立的角色然后看看周围有什么」。模型会自动选择 init_player 和 look 两个工具你观察工具调用日志就能确认链路正常。5. 本篇常见错排查从 Key 报错到工具不触发第一个高频问题是 401 鉴权失败。表现是 AITool 打印「调用失败: Error code: 401」。原因通常是 config.toml 里的 api_key 没填、填错或者 base_url 多写了/v1之类的后缀。TaoToken 的 API 地址就是https://taotoken.net/api不要自己拼路径。改完配置记得重启 server.py因为配置是在模块导入时读取的。第二个问题是工具注册了但模型不调用。表现是你跟模型说「看看周围」它却直接编了一段文字回复没有触发 look 工具。这通常是 docstring 写得太模糊。MCP 客户端把 docstring 作为工具描述传给模型如果描述里没有明确「查看当前场景」这个语义模型可能觉得不需要调工具。解决办法是把每个 tool 的 docstring 写成一句完整的动作说明包含参数含义。第三个问题是移动后场景描述重复或为空。检查 game_world.py 里 Location 的 description 字段我上面留空了因为描述由 AI 动态生成。如果你发现 look 返回空字符串大概率是 AITool 调用异常被吞掉了去看控制台有没有[AITool] 调用失败的日志。另外 temperature 设太高会导致同一场景每次描述差异过大场景描述建议用 0.6 到 0.7战斗叙述可以用 0.85。第四个问题是端口冲突。25822 被占用时 server.run 会直接抛异常。换一个端口就行但记得客户端连接地址也要同步改。我一般用 25822 是因为它不常见不容易和别的服务撞。第五个问题是 Python 版本导致的 tomllib 导入失败。如果你在 3.10 环境跑import tomllib会报 ModuleNotFoundError。两个选择升级到 3.11或者装 tomli 然后改成import tomli as tomllib。后者需要把 open 的 mode 从rb保持二进制读取tomli 和 tomllib 的 load 接口一致。6. 下一步把最小循环扩成可玩世界现在这个骨架已经能跑通「初始化 → 查看 → 移动 → 遭遇」的最小循环但战斗系统还没接进来。combat_system.py 我留了空文件下一轮会把回合制战斗、伤害计算和 AI 战斗叙述补上。如果你现在就想继续可以先把 fight 工具注册进 server.py然后在 game_engine 里加一个 fight 方法调用 combat_system 的静态方法处理回合逻辑。关于模型通道目前所有调用都走 deepseek-chat。等你需要让模型辅助写游戏内容、生成 NPC 对话树或者批量产出物品描述时可以在 config.toml 里加一个 coding_model 字段指向更适合长文本生成的模型ai_tool.py 里按调用类型选择模型名即可。统一 Key 的好处在这里就体现出来了换模型不用换 Key也不用改鉴权代码。如果你在接入过程中遇到工具调用不触发、Key 鉴权报错或者 MCP 连接超时优先去 TaoToken 的 API Keys 页面确认 Key 状态和额度再对照接入文档检查 base_url 和请求格式。模型对话入口适合快速验证 DeepSeek 是否正常响应而长期跑游戏服务、需要稳定调用和额度管理的话Coding Plan 会更省心。
网站建设高端定制企业官网