把 AI 接进 3D 场景,方法其实五花八门:有人靠插件脚本,有人把场景导出成图片让 AI 看,也有人让 AI 只生成代码再由人来跑。绕了一圈我最终选择了 MCP server——用一个标准协议把 AI 的意图翻译成 3D 场景里的真实操作。这篇文章是我的完整实现记录:从协议选择、工具设计、代码落地到联调排错,全程基于我自己的一个轻量 3D 场景管理系统。如果你是搞三维工具链、数字孪生、或单纯想在个人项目里让 AI 帮忙改场景,可以直接照抄这套思路。
1. 为什么要给 3D 世界装一个 MCP server
1.1 想让 AI 直接动手改场景
做三维内容的人应该都有这种感觉:AI 聊天再流畅,到了真正动手的时候还是隔着一层。你让它“在场景里放一张桌子,桌上放一个杯子”,它确实能给你写出一大段描述,甚至生成一段伪代码,但场景文件本身不会因为你这段对话就发生变化。
我最早尝试的方案是让 AI 输出 Python 脚本,然后把脚本喂给三维软件的脚本面板去执行。听起来可行,落地却很难受。模型不熟悉你内部的数据结构,经常生成一个只存在于它想象中的 API;脚本一旦报错,它看不到完整日志,只能靠人回贴报错信息,来回几次效率很低。更致命的是,这种方式完全没有“边界”概念,AI 生成的脚本理论上可以碰文件系统、碰系统命令,你根本不敢把完整权限交给它。
后来我把思路换成了“工具调用”:AI 不直接写代码,而是调用一组我已经定义好的函数,每个函数都有清晰的入参和返回值。函数内部怎么做,对 AI 来说是一个黑盒,它只需要按 JSON Schema 提交参数。这样做的好处是职责清晰,权限可控,参数还能校验。
1.2 MCP 解决了什么
如果只是自己定义一组函数,其实没必要用 MCP 这种协议。麻烦在于,AI 客户端不知道你这组函数长什么样,函数数量一多,你还需要自己处理模型选工具、传参、错误恢复这些环节。
MCP(Model Context Protocol)做的就是把“工具发现、参数校验、请求响应、错误协议”这些通用机制标准化。AI 客户端启动时会主动向 MCP server 要一份工具清单,包含工具名称、描述、参数 schema;模型看到这份清单后,按需决定调用哪个工具。你不需要为每个 AI 客户端单独写适配层,只要暴露一个 MCP server,支持 MCP 的客户端都能直接接入。
我实际体验下来,这相当于给 3D 场景装了一个“标准插座”。场景内核负责三维数据,MCP server 负责翻译 AI 的意图,两边通过 JSON-RPC 通信,互不干扰。后续想换场景引擎,或者换 AI 客户端,都只需要改一小块代码。
2. 整体方案设计:协议、传输与工具面
2.1 先搞清楚 MCP 到底在传输什么
动手写代码之前,我花了一晚上把 MCP 的协议语义理清楚。虽然官方 SDK 封装了大多数细节,但如果不理解底层,遇到问题会非常被动。
MCP 里最有用的概念是 tools。一个 tool 就是一个可以被大模型调用的函数,它有明确的 name、description、inputSchema。AI 客户端负责根据对话上下文决定“该不该调”,MCP server 负责“能不能调、怎么调”。
通信格式是 JSON-RPC 2.0。完整流程大概是:
- 客户端发起 initialize,做协议版本协商。
- 客户端发 tools/list,server 返回所有工具定义。
- 客户端根据工具定义构造参数,发 tools/call。
- server 执行函数,返回文本内容或结构化错误。
这里有个容易忽略的细节:工具返回给模型的内容,不是专门给用户看的界面,而是模型“下一轮决策”的输入。模型会根据返回值判断操作是否成功,再决定要不要继续调用别的工具。因此,返回值必须是干净、结构化、机器可读的,别把无关日志掺进去。
2.2 stdio 还是 SSE:传输层怎么选
MCP server 支持的传输方式有很多,我实际用下来主要是 stdio 和 SSE 两种。stdio 适合本地工具场景,AI 客户端直接以子进程方式拉起 server,进程生命周期由客户端管理,配置简单,启动快。SSE 适合 server 跑在另一台机器、或同时服务多个客户端的情况,通信走 HTTP 长连接,能做远程调用。
| 对比项 | stdio | SSE |
|---|---|---|
| 部署位置 | 本机进程 | 远程服务 |
| 启动方式 | 客户端拉起 | 独立启动 |
| 多客户端 | 一般一对一 | 可多客户端 |
| 鉴权 | 不适用 | 需要处理 |
| 典型场景 | 桌面 AI 客户端接入 | 网页端、团队协作 |
我的个人项目选择 stdio 起步,理由很简单:业务逻辑在本地,不需要跨网络传输,调试也方便。后来为了给一个网页端预览工具用,才加了一条 SSE 启动入口。两个入口共用同一套工具函数,只是最后的 mcp.run 参数不一样。
2.3 3D 场景内核约定
MCP 是胶水层,真正干活的是 3D 场景内核。我没用现成的重型引擎,而是写了一个轻量的场景图。每个物体是一个节点,节点拥有名字、类型、坐标、缩放、材质、包围盒信息。
为了让模型不“胡猜”,我在设计阶段就固定了几个约定:
- 坐标系:右手系,Y 轴向上,X 向右,Z 向外。
- 单位:所有位置和尺寸都使用米。
- 颜色:统一用 6 位十六进制,比如
#ff0000。 - 命名:场景物体名最终会归一化为小写蛇形命名。
这些约定不只是给自己看的,每条都要写进工具 description。模型没有默认单位感,如果你不告诉它“单位是米”,它可能给你传一个“1”表示一像素,也可能传一个“2”表示两英尺,完全不可控。
3. 工具集拆解:AI 最需要哪些 3D 操作
3.1 最小可用工具集
我没有一开始就把所有功能都暴露给 AI,而是先定义了六个最小可用工具。工具不在多,在于覆盖完整“编辑闭环”:能创建、能修改、能查询、能删除、能导出。
| 工具名 | 作用 | 关键参数 |
|---|---|---|
| add_primitive | 添加基础体 | name, primitive, x/y/z, size, color |
| transform_object | 平移物体 | name, dx, dy, dz |
| set_material | 修改颜色/粗糙度 | name, color, roughness |
| query_scene | 查询场景结构 | pattern |
| delete_object | 删除物体 | name |
| export_scene | 导出场景文件 | path |
这里面 query_scene 是我最看重的。AI 做决策前需要“看到”当前场景状态:如果场景里已经有一张桌子,你再让它放杯子,它必须先查出桌子顶面的坐标,而不是凭空生成一个杯子位置。大部分失败都源于模型对场景状态一无所知,所以查询工具是智力的来源。
3.2 参数描述是模型的行为指南
大模型不像传统程序那样严格按函数签名调用,它会把函数名、描述、参数说明当成“使用说明书”。说明书写得越清楚,调用越准确。我写完初版之后,最大的改进不是代码,而是把每个参数的描述重新打磨了一遍。
以 transform_object 为例,description 我写成“将指定物体按相对偏移移动,单位米;dx 表示向右为正、dy 表示向上为正、dz 表示向外为正;如果你想让物体往左,请传负的 dx”。这样一句话,比代码里的类型标注有用得多。模型看到后会自己换算正负方向,不再出现“想往左却传了正 dx”这种低级错误。
颜色参数也要写得死。模型习惯用“红色”这种自然语言,但你让它在 JSON 里传一个color: "红",后端解析很容易炸。我的做法是在 add_primitive 的 color 参数描述里写明“颜色必须是 6 位十六进制,例如 #ff0000 表示红色”,并在 server 端做解析兜底,把常见的英文颜色名也转成十六进制。
3.3 返回值要面向模型而不是面向人
工具返回值要同时满足两拨读者:模型要拿它做下一步推理,用户要能听懂。我的方案是统一返回 JSON 字符串,结构固定为{"ok": true/false, "message": "...", "data": {...}}。
比如 add_primitive 成功之后返回:
{ "ok": true, "message": "已创建立方体 red_cube", "data": { "id": "node_1024", "name": "red_cube", "position": [0.0, 1.0, 0.0], "size": 1.0 } }模型拿到ok: true就知道操作成功,拿到name就知道后续要引用哪个物体。如果返回错误,我会尽可能给出原因,比如“物体 table_top 不存在,当前场景中的名字为 table_1, table_top_marker”,模型看到具体名字后,下一轮调用就能自我纠正。
4. 动手实现:从空目录到可调用
4.1 环境准备与项目结构
我用的是 Python 3.10 + 官方 MCP SDK。项目本身很简单,不引入重型三维库,只用一个内存场景图。
python -m venv .venv source .venv/bin/activate pip install "mcp[cli]" pydantic目录结构大概是:
3d-mcp-server/ ├── server.py ├── scene.py └── config.jsonserver.py 负责 MCP 工具层,scene.py 负责 3D 场景数据结构,config.json 记录场景自动保存路径。这样拆分的好处是,工具层和数据层不互相依赖,你后续完全可以只替换 scene.py,把后端换成游戏引擎或商业三维软件的内核。
4.2 用官方 SDK 写 server
官方 SDK 自带一个 FastMCP 封装类,写起来像微服务框架一样简单。核心代码如下:
import json from mcp.server.fastmcp import FastMCP from scene import SceneGraph scene = SceneGraph() mcp = FastMCP("3d-world-server") @mcp.tool() def add_primitive( name: str = "cube", primitive: str = "cube", x: float = 0.0, y: float = 0.0, z: float = 0.0, size: float = 1.0, color: str = "#cccccc", ) -> str: """在场景中新增一个基础体。primitive 支持 cube、sphere、cylinder;单位为米;颜色为 6 位十六进制。""" node = scene.add_primitive(name, primitive, (x, y, z), size, color) return json.dumps({"ok": True, "message": f"已创建 {primitive} {node.name}", "data": node.to_dict()}) @mcp.tool() def transform_object(name: str, dx: float = 0.0, dy: float = 0.0, dz: float = 0.0) -> str: """将指定物体按相对偏移移动,单位米;dx 向右为正,dy 向上为正,dz 向外为正。""" if not scene.exists(name): return json.dumps({"ok": False, "message": f"物体 {name} 不存在"}) node = scene.move(name, dx, dy, dz) return json.dumps({"ok": True, "message": f"{name} 已移动", "data": node.to_dict()}) @mcp.tool() def query_scene(pattern: str = "*") -> str: """查询场景中所有物体及坐标、包围盒、颜色。pattern 支持通配符匹配物体名。""" nodes = scene.query(pattern) return json.dumps({"ok": True, "count": len(nodes), "data": [n.to_dict() for n in nodes]}) @mcp.tool() def set_material(name: str, color: str = "", roughness: float = 0.5) -> str: """修改物体的颜色和粗糙度。color 应为 6 位十六进制。""" if not scene.exists(name): return json.dumps({"ok": False, "message": f"物体 {name} 不存在"}) node = scene.set_material(name, color, roughness) return json.dumps({"ok": True, "message": f"{name} 材质已更新", "data": node.to_dict()}) @mcp.tool() def delete_object(name: str) -> str: """删除场景中的指定物体。""" if not scene.exists(name): return json.dumps({"ok": False, "message": f"物体 {name} 不存在"}) scene.delete(name) return json.dumps({"ok": True, "message": f"{name} 已删除"}) @mcp.tool() def export_scene(path: str = "scene.json") -> str: """把当前场景导出为 JSON 文件。""" count = scene.export(path) return json.dumps({"ok": True, "message": f"已导出 {count} 个物体到 {path}"}) if __name__ == "__main__": mcp.run(transport="stdio")这段代码最需要注意的地方是每个函数的 docstring。FastMCP 会自动把函数的签名和 docstring 转成 JSON Schema 发给客户端。你其实是在用“注释”给模型写说明书。
4.3 场景内核的几行关键逻辑
scene.py 是实现细节,但有几个点值得展开。我用了最简单有效的场景图结构:一张以物体名为 key 的字典,外加一个自增 ID 用来保持唯一性。
import re import json class SceneNode: def __init__(self, name, primitive, position, size, color): self.name = name self.primitive = primitive self.position = list(position) self.size = size self.color = color self.roughness = 0.5 self.parent = None self.children = [] def to_dict(self): return { "name": self.name, "primitive": self.primitive, "position": self.position, "size": self.size, "color": self.color, "roughness": self.roughness, } class SceneGraph: def __init__(self): self.nodes = {} self.counter = 0 def _normalize_name(self, name): name = name.strip().lower() name = re.sub(r"[\s\-]+", "_", name) while name in self.nodes: self.counter += 1 name = f"{name}_{self.counter}" return name def add_primitive(self, name, primitive, position, size, color): node_name = self._normalize_name(name) node = SceneNode(node_name, primitive, position, size, color) self.nodes[node_name] = node return node def exists(self, name): return name in self.nodes def move(self, name, dx, dy, dz): node = self.nodes[name] node.position[0] += dx node.position[1] += dy node.position[2] += dz return node def set_material(self, name, color, roughness): node = self.nodes[name] if color: node.color = color node.roughness = roughness return node def delete(self, name): del self.nodes[name] def query(self, pattern): matcher = pattern.replace("*", ".*") return [node for name, node in self.nodes.items() if re.match(matcher, name)] def export(self, path): payload = [node.to_dict() for node in self.nodes.values()] with open(path, "w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False, indent=2) return len(payload)这个内核虽然简单,但足够模拟 AI 操作三维物体的完整闭环。真正接入渲染引擎时,只需要把 add_primitive、move 这些方法内部替换成相应引擎的命令。
4.4 注册进 AI 客户端并做一次冒烟测试
支持 MCP 的 AI 客户端,一般都会提供 MCP server 配置入口。配置本质上是告诉客户端“用哪个命令起这个 server”。我的 config.json 是这样写的:
{ "mcpServers": { "3d-world": { "command": "python", "args": ["server.py"], "env": { "SCENE_FILE": "workspace/scene.json" } } } }配置完成后重启客户端,它应该能自动拉起 server,并通过 tools/list 拿到六个工具。冒烟测试我就问一句话:“在场景中心放一个红色立方体。”如果配置正常,客户端会调用 add_primitive,参数大概是name: "red_cube", primitive: "cube", x: 0, y: 0, z: 0, size: 1, color: "#ff0000"。server 返回成功后,再问一句“现在场景里有什么”,它应该会调用 query_scene,并把立方体信息念给你听。
走到这一步,整个链路就通了。后面的工作基本都是在完善细节。
5. 一次完整的自然语言改场景复盘
5.1 模型如何拆解任务
我拿一个稍微复杂的例子复盘:用户说“在桌子上放一个杯子,桌子在场景中叫 table_1,桌面高度大概是 z=1.2 米”。
这句描述其实包含多个隐性步骤。模型不能直接把杯子硬编码到某个坐标,它需要先明确“杯子应该放在桌子顶面之上”。我预期的理想调用顺序是:
- 调用 query_scene,搜索 table_1,拿到它的位置和包围盒。
- 根据包围盒算出一个桌面中心坐标,比如 (0.5, 1.2, 0.3)。
- 调用 add_primitive,创建容器或圆柱体,位置设在桌面中心上方一点。
- 调用 query_scene 验证场景状态,再向用户汇报。
如果我的工具没把包围盒信息返回给模型,模型就只能瞎猜“杯子的位置”。这也是为什么我在查询工具里特意返回 position 和 size,而不是简单说一句“场景中有几个物体”。
5.2 返回结果如何影响下一步
模型不是一次推理完成所有动作的,它需要不断从结果中获取新信息。这里最有意思的是:返回错误也能成为下一步行动的依据。
有一次我让 AI“把场景里所有红色物体改成蓝色”。它先调用 query_scene,发现没有任何物体带 color 属性——因为我初版返回里忘了加颜色字段。于是模型停顿了一下,然后直接回答“当前场景中没有可识别的红色物体”。我事后检查,确实应该把材质字段提前放到查询结果里。像这种“查询字段不全”的问题,传统接口联调时靠人眼发现,在 MCP 场景里却会直接表现为 AI 能力缺失。
调整后同样是这个问题,模型就能先查出color: "#ff0000",然后逐个调用 set_material。整个流程非常接近一个真人助手的工作方式:先看场景,再做决定,再验证结果。
5.3 失败回滚与现场恢复
AI 连续调用工具时,最怕的是“做了三步,第四步失败”,场景停在一个半成品状态。比如建好了桌子,又建好了杯子,结果给杯子贴材质时传了一个不存在的名字。
我在 server 里加了一层简单的操作日志,每次成功调用都会记录一条原始操作。这样如果用户不满意,我还能提供一个undo_last工具来撤销最近一次成功的修改。当然,这个工具原本不在最小工具集里,后来用着用着你就发现,AI 操作场景这件事本身需要容错机制。
更稳妥的做法是让场景图支持快照。每次调用前把整个场景序列化到内存里,某一步失败就回滚。对小型个人项目来说,快照开销可以忽略不计,但能避免很多“AI 把场景搞乱”的尴尬瞬间。
6. 踩坑实录与问题速查
6.1 高频问题表和根因分析
我把自己踩过的坑按频率排了个序,做成一张速查表。如果你照着做,遇到类似问题可以直接对照。
| 问题现象 | 根因 | 解决方案 |
|---|---|---|
| 客户端连不上 server | 启动命令或 Python 环境不对 | 检查 config.json 的 command 是否指向正确解释器 |
| 工具列表为空 | 装饰器作用域不对,或代码有语法错误 | 先手动运行 server.py 看是否报错 |
| 模型总是不调某个工具 | description 太模糊,模型不知道适用场景 | 重写 description,明确“什么时候该用” |
| 参数传入“红色”而非“#ff0000” | 参数描述没写格式要求 | 在字段描述里强制格式,并在后端做兼容转换 |
| 物体名带空格,后续找不到 | 模型按自然语言命名 | 入参做 normalize,并在返回值里暴露最终名字 |
| 连续操作后场景状态混乱 | 缺少查询和回滚机制 | 提供 query_scene 和 undo_last |
最让我意外的是“模型不调工具”这一类。有时候不是代码错了,而是模型“觉得”题目太简单不需要调用工具。比如你问“现在场景里有什么”,如果之前的对话里已经出现过场景描述,模型可能会凭记忆回答,而不是老老实实去查。解决办法是在 system prompt 或工具 description 里明确要求“任何关于场景状态的问题都必须先调用 query_scene”。
6.2 三个我一直在用的排错技巧
第一个技巧是看原始 JSON-RPC 帧。MCP 客户端有时会把模型内部过程吞掉,只给你最终回答。我在调试阶段给 server 加了一个环境变量开关MCP_DEBUG=1,打开后会打印收到的每个 tools/call 请求和返回值。一眼就能看出模型到底传了什么参数、哪个字段多了一个空格。
第二个技巧是给工具参数加范围约束。pydantic 支持数值校验,但 FastMCP 的 schema 生成对 Field 的支持非常丰富。比如 size 可以约束在 0.01 到 100 之间,roughness 约束在 0 到 1 之间。超过范围直接返回参数错误,避免修改到一半才发现数值离谱。
第三个技巧是做“干跑模式”。我在 transform_object 里加了一个 dry_run 参数。dry_run=True 时不实际修改场景,只计算并返回“修改后会变成什么状态”。模型先干跑一遍,确认结果对了,再真正执行。这对测试阶段特别有用,能显著减少 AI 误操作的次数。
7. 安全与权限:AI 会不会把场景改废
7.1 最小权限与只读查询
让 AI 操作三维场景,风险往往不在“改坏一个物体”,而在“AI 拥有多少系统能力”。MCP server 本质上是一个执行者:模型每调用一个 tool,你的代码就在真实环境里跑一段逻辑。我坚持的最小权限原则是:只暴露场景编辑相关函数,绝不暴露文件系统、命令执行、网络请求这类通用能力。
如果你确实需要让 AI 导出场景并压缩成 zip,不要直接给它一个“执行 shell 命令”的工具,而是写一个专门的export_and_archive函数,内部固定调用压缩逻辑。宁可多写几个专用工具,也不要给一个万能执行入口。
7.2 入参校验与场景一致性
AI 传参数很少“完全守规矩”,校验必须落在后端。我的场景内核里每次操作都会查物体是否存在;transform_object 会检查位移量是否是有限数字,避免 NaN;add_primitive 会检查 size 是否为正数;颜色字符串会做正则校验,不符合就转成灰度默认值。
还有一致性问题。如果你的场景还要被别的进程读写,AI 修改的时候加一把线程锁是必须的。我在 SceneGraph 的操作方法里加了threading.Lock,保证 MCP server 在 SSE 模式下多个请求并发时,不会出现两个工具同时改同一物体的情况。
7.3 审计与快照机制
最后是最容易被人忽略的审计。MCP 的调用链很长,用户可能连问了五次才得到一个满意结果,中间 AI 可能误删了三次物体。没有记录,你根本说不清“现在这个场景是怎么变成这样的”。
我的做法是每次成功调用都往一个audit.log里写一行:时间、工具名、参数 JSON、返回状态。这个日志不会给模型看,只给开发者排查用。配合场景快照,我可以在任何一轮调用之后恢复现场。个人项目这么做多少有点“过度”,但如果你要做团队工具,审计能力早晚要补。
8. 扩展思路:还能往哪里走
8.1 从单机场景到数字孪生
我的 MCP server 目前跑在本地,操作的是一个内存场景图。同样的逻辑可以平滑迁移到数字孪生场景:场景内核换成实时业务数据,物体坐标对应真实设备位置,查询工具对应设备状态上报。到那时候,AI 的一句“把三号设备的状态调整为运行”就会映射成一次真实的控制操作,而不是虚拟物体的位置变更。
当然,权限管理要更严格。数字孪生里的写操作影响现实设备,至少应该增加“确认机制”或“二次审批工具”,让 AI 不能直接完成破坏性动作。
8.2 让场景查询支持语义检索
我最近在折腾的另一件事是给 query_scene 增加语义检索能力。现在查询只能按物体名匹配通配符,AI 问“这里有靠窗的物体吗”时,它要先知道窗的定义和物体的位置关系,才能算出来。如果场景里已经有一面墙的坐标,我完全可以在查询工具里内置几何规则:返回所有位于某个平面附近、或处于某个包围盒内的物体。
这是一个很自然的进化方向。MCP 工具不一定要保持“最小功能”,当你信任场景数据模型之后,可以把更聪明的空间计算包进工具内部,让 AI 的表达更接近自然语言,而不是被迫说出“pos_x 在 3 到 5 之间”这种反直觉的话。
最后再分享一个小习惯:我不管改了什么逻辑,第一件事永远是跑一遍“冒烟对话”,让 AI 从空场景建一个物体、查询一次、移动一次、删掉一次。这套回归流程不到一分钟,但能拦住百分之八十的接口破坏。做 MCP server 这种事,边界清晰比功能丰富重要得多,把查询做扎实了,后面的路自然就顺了。