大模型驱动地图实战:前端直连与完整 MCP 架构对比
对比了大模型驱动地图的两种方案:前端直连模型与完整 MCP 架构。详细解析了 Host、MCP Server 及浏览器的角色分工,通过 Vue、Cesium、Node.js 和 WebSocket 搭建最小化 Demo,演示了从用户输入到地图动作执行的完整链路。重点阐述了为何完整 MCP 更适合扩展与复用,并提供了新手避坑指南与能力扩展建议。
对比了大模型驱动地图的两种方案:前端直连模型与完整 MCP 架构。详细解析了 Host、MCP Server 及浏览器的角色分工,通过 Vue、Cesium、Node.js 和 WebSocket 搭建最小化 Demo,演示了从用户输入到地图动作执行的完整链路。重点阐述了为何完整 MCP 更适合扩展与复用,并提供了新手避坑指南与能力扩展建议。
很多人第一次做'大模型驱动地图'的时候,都会有一个非常自然的想法:
我在网页上放一个聊天框,用户输入'飞到上海',然后把这句话发给模型;模型决定调用工具;前端收到工具调用后,直接控制地图飞过去。
这个想法并不算错,而且它确实能跑。
但问题在于:这不一定是完整的 MCP 架构。
如果你后面想把地图能力做成:
那你很快就会发现:
'前端直接请求模型并本地执行工具',和 '真正完整的 MCP 链路',其实是两套不同的设计思路。
这篇教程,就是要把这两件事讲清楚。
我们会先讲原理,再讲架构,最后用一个 Vue + Cesium + Node + MCP 的最小 demo 把整个链路跑起来。
这是最容易想到、也最容易快速跑通的一种方式。
它的基本流程是:
flyToShanghaitool_callsflyToShanghai它的时序图长这样:
用户 -> 前端页面 -> 大模型 API -> 前端页面(收到 tool_calls) -> 前端本地工具执行 -> Cesium 地图 -> 前端页面 -> 大模型 API -> 前端页面 -> 用户
这个方案的优点非常明显:
但它也有一个根本特点:
前端同时承担了'聊天客户端'和'工具执行器'两种角色。
这就是后面很多结构混乱的起点。
完整 MCP 不是前端自己决定怎么调用工具,而是由一个 Host 来负责:
在这个架构里,浏览器不再负责'模型调度',而只负责'地图动作执行'。
完整链路如下:
用户 -> Host -> 模型 -> Host -> Node MCP Server -> 浏览器 -> Cesium -> 浏览器 -> Node MCP Server -> Host -> 模型 -> Host -> 用户
这条链路里最关键的一句是:
模型不直接调用 Node。Host 才是真正替模型调用工具的人。
这是理解 MCP 的核心。
很多人会觉得:
'前端直连模型'和'完整 MCP'不都是模型调用工具吗?
表面上看都像'工具调用',但真正的区别在于:
| 维度 | 前端直连模型 | 完整 MCP |
|---|---|---|
| 谁和模型对话 | 前端页面 | Host |
| 谁拿到 tool_calls | 前端页面 | Host |
| 谁真正调工具 | 前端页面 | Host |
| Node 在哪里 | 可以没有 | 是 MCP Server |
| 浏览器扮演什么 | 聊天端 + 执行端 | 纯执行端 |
| 是否符合完整 MCP 分层 | 不一定 | 是 |
最通俗的说法是:
因为它们在表面上太像了。
无论是哪种方案,你都会看到类似这些词:
于是就很容易误以为:
'我已经把 tools 发给模型了,模型也返回要调用的工具了,那我就是在做 MCP。'
实际上不一定。
真正的问题不是'有没有工具',而是:
谁在承担工具调度这件事。
如果是前端自己在做调度,那你更接近'前端直连模型 + 本地执行工具'。
如果是 Host 在调度,Node 暴露 MCP 工具,浏览器只执行动作,那才是完整 MCP。
在地图场景里,我们可以把系统拆成四个角色。
负责说自然语言,比如:
用户只关心结果,不关心内部链路。
Host 是这套系统的大脑调度层。
它负责:
你可以把 Host 理解成:
'懂模型、懂工具、懂对话流程'的中控台。
MCP Server 负责把系统能力暴露成标准工具。
比如:
flyToShanghaiflyToLocationpingBrowseraddPointdrawPolyline它不直接负责和用户聊天,也不负责渲染地图。
它只负责:
'我这里有这些工具,你要调哪个,我帮你转成真实动作。'
浏览器里的地图页面不再和模型直接对话,而是只做一件事:
接收动作命令,并在地图里执行。
比如收到:
{"method":"flyTo","params":{"longitude":121.4737,"latitude":31.2304,"height":8000}}
浏览器就调用 Cesium 相机飞行。
所以浏览器的角色是:
地图动作执行器。
下面这张图,是整篇教程最重要的一张图。
┌──────┐ ┌───────────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────┐ ┌────────────┐ ┌─────────┐ │ 用户 │ │ MCP Host │ │ 模型 │ │ Node MCP │ │ WebSocket │ │ 浏览器前端 │ │ Cesium │ │ │ │ │ │ (LLM) │ │ Server │ │ 通道 │ │ + Bridge │ │ Scene │ └─┬────┘ └──────┬────────┘ └────┬─────┘ └──────┬───────┘ └────┬─────┘ └─────┬──────┘ └────┬────┘ │ '飞到上海' │ │ │ │ │ │ │──────────────────>│ │ │ │ │ │ │ │ 把用户消息发给模型 │ │ │ │ │ │ │──────────────────>│ │ │ │ │ │ │ │ 判断需要用工具 │ │ │ │ │ │<──────────────────│ tool_call: │ │ │ │ │ │ │ flyToShanghai │ │ │ │ │ │ 调 Node MCP 工具 │ │ │ │ │ │ │────────────────────────────────────>│ │ │ │ │ │ │ │ sendToBrowser() │ │ │ │ │ │ │─────────────────>│ │ │ │ │ │ │ │ JSON-RPC 消息 │ │ │ │ │ │ │───────────────>│ │ │ │ │ │ │ │ bridge.execute│ │ │ │ │ │ │──────────────>│ │ │ │ │ │ │ 相机飞到上海 │ │ │ │ │<─────────────────│ 执行结果 │ │ │ │<────────────────────────────────────│ tool result │ │ │ │ │ 再把结果发给模型 │ │ │ │ │ │ │──────────────────>│ │ │ │ │ │ │<──────────────────│ 返回自然语言回复 │ │ │ │ │<──────────────────│ │ │ │ │ │
这张图里最值得你盯住的两个点是:
模型只是返回:
我建议调用
flyToShanghai
真正去执行 callTool() 的,是 Host。
浏览器只负责:
它不负责选择工具,也不负责和模型多轮推理。
地图类应用和普通文本工具类应用有一个很大的区别:
地图动作通常必须在浏览器或图形环境里执行。
比如:
这些事情并不适合让 Node 自己去做。
Node 很适合做:
浏览器很适合做:
所以最自然的分工就是:
这比'前端自己包办一切'要更清晰,也更容易扩展。
我们用一套很小的 demo 来实现完整链路。
技术角色如下:
负责:
MiniBridge.execute()负责把通用动作转成 Cesium API。
比如:
flyTo -> viewer.camera.flyTo(...)index.ts负责:
flyToShanghai / pingBrowser最初可以是终端版 host.ts,后面再升级成页面版 host-web.ts。
它负责:
在真正开始编码之前,我们先把职责拆成三层。
执行层在浏览器里。
它只负责:
工具层在 Node 里。
它只负责:
智能调度层在 Host 里。
它只负责:
把职责拆干净以后,你会发现整个系统其实并不复杂。
复杂感大多来自于:
一开始把前端、模型、工具、地图执行全写在一个文件里。
下面开始进入真正的搭建过程。
这一层你可以理解成一个'地图机器人'。
它不思考,只执行。
一个非常典型的桥接器长这样:
class MiniBridge {
constructor(viewer) {
this.viewer = viewer;
}
async execute(cmd) {
const action = cmd?.action;
const params = cmd?.params || {};
switch (action) {
case 'flyTo':
await this.flyTo(params);
return { success: true };
default:
return { success: false, error: `Unsupported action: ${action}` };
}
}
flyTo(params) {
const { longitude, latitude, height = 8000 } = params;
return new Promise((resolve) => {
this.viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(longitude, latitude, height),
complete: resolve,
});
});
}
}
这个桥的意义在于:
flyTo'这一步很关键,因为它让地图执行能力变成了一个可复用的动作接口。
这一步是整个架构的中间层。
Node 做两件事:
例如:
server.tool('flyToShanghai', 'Fly camera to Shanghai in connected browser Cesium scene.', {
height: z.number().default(8000),
duration: z.number().default(2),
}, async ({ height = 8000, duration = 2 }) => {
const result = await sendToBrowser('flyTo', {
longitude: 121.4737,
latitude: 31.2304,
height,
duration,
heading: 0,
pitch: -45,
});
return {
content: [{ type: 'text', text: JSON.stringify(result ?? { success: true }) }],
};
});
这里看起来像是在'飞到上海',但其实 Node 没有自己控制地图。
它做的只是:
flyTo 命令给浏览器核心逻辑是这样的:
function sendToBrowser(method, params, timeoutMs = 15000) {
return new Promise((resolve, reject) => {
const ws = getBrowser(DEFAULT_SESSION);
if (!ws || ws.readyState !== WebSocket.OPEN) {
reject(new Error('No browser connected.'));
return;
}
const id = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
pendingRequests.set(id, { resolve, reject, timer });
ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }));
});
}
它的本质是:
把 MCP 工具调用转成浏览器可执行的动作消息。
这是完整 MCP 里最容易被忽略,但又最关键的一层。
Host 的工作流程是:
tool_calls这个过程可以抽象成这样:
const tools = await client.listTools();
const first = await callChatCompletions(messages, openAITools);
const toolCalls = first?.choices?.[0]?.message?.tool_calls || [];
for (const tc of toolCalls) {
const result = await client.callTool({
name: tc.function.name,
arguments: JSON.parse(tc.function.arguments || '{}'),
});
secondMessages.push({
role: 'tool',
tool_call_id: tc.id,
content: toolResultToText(result),
});
}
const second = await callChatCompletions(secondMessages, openAITools);
很多新手在这里第一次真正理解到:
Host 是连接'模型世界'和'工具世界'的那个人。
一开始,为了最小验证链路,你可以用终端版 Host:
这是最容易验证完整链路的方式。
但最终用户肯定希望在网页输入,而不是在终端输入。
这时,正确思路不是把'前端直连模型'改回来,而是:
给 Host 再包一层 HTTP 接口,让页面把聊天请求发给 Host。
于是链路就变成:
页面输入 -> Host Web API -> 模型 -> Host Web API -> Node MCP Server -> 浏览器执行地图动作 -> Node MCP Server -> Host Web API -> 模型 -> 页面
这一步很重要,因为它保证了:
也就是说,页面输入 和 完整 MCP 是可以同时成立的。
到了这里,很多人会问:
'那页面聊天到底有几种实现方式?'
答案是两种。
页面 -> 模型 -> 页面收到 tool_calls -> 页面本地执行工具 -> 页面再请求模型
这种方式好处是简单,但页面承担了太多职责。
页面 -> Host Web API -> 模型 -> Host Web API -> MCP Server -> 浏览器地图执行 -> Host Web API -> 模型 -> 页面
这种方式更符合完整 MCP,因为页面没有直接参与工具调度。
如果页面自己在处理 tool_calls,那通常不是完整 MCP。
如果页面只是把用户输入交给 Host,然后等待回复,那更接近完整 MCP。
当用户在页面输入'飞到上海'时,可以从三个层面来看这件事。
模型在理解:
Host 和 MCP Server 在处理:
浏览器和 Cesium 在处理:
你把这三层分开看,就会觉得整个系统非常清晰。
在真实学习过程中,很多人不会一下子就被'架构图'讲明白,反而会在写 demo、跑代码、看日志的时候不断冒出一些非常具体的问题。 这些问题看起来零散,但其实正好能帮助你把完整 MCP 理解得更扎实。
下面把几个最典型的问题串起来讲。
这是最常见的困惑。
很多人第一次看到 index.ts 里的 McpServer,会本能地以为:
模型是不是直接连到了这个 Node 服务?
其实不是。
更准确的链路是:
用户 -> Host -> 模型 -> Host -> Node MCP Server -> 浏览器 -> Cesium
也就是说:
所以真正连模型和 Node 的,不是模型自己,而是 Host。
host-web.ts 里没有把工具传给 StdioClientTransport,那工具是怎么来的?'这也是一个特别典型的误解。
很多新手看到这段代码时,会以为:
const transport = new StdioClientTransport({ command: getNpxCommand(), args: ['tsx', 'index.ts'], })
是不是已经把工具传进去了?
其实没有。
这里的意思只是:
tsx index.ts 启动一个 MCP Server 进程args 不是工具内容本身,它只是告诉 transport:去启动哪一个 Server。
真正的工具读取发生在后面:
await client.connect(transport);
const toolsRes = await client.listTools();
toolsCache = toolsRes.tools || [];
openAITools = toolsCache.map(mcpToolToOpenAITool);
你可以把这几步理解成:
args:找到餐厅地址connect:走进餐厅listTools:向餐厅要菜单openAITools:把菜单整理成模型能看懂的格式所以工具并不是被'静态读取源码'拿到的,而是 index.ts 运行起来以后,通过 server.tool(...) 向外暴露,再由 Host 用 listTools() 动态读取出来的。
这个问题问得非常好,因为它正好点出了'哪里像、哪里不一样'。
答案是:
在模型接口这一层,确实很像;在系统架构这一层,本质上不一样。
两种方案后面都会做这件事:
messages + tools -> model -> tool_calls
所以从模型的视角看,它们很像。
但系统架构上差别很大:
tool_callslistTools() 动态发现工具tool_callscallTool() 调 MCP Server所以更准确地说:
完整 MCP 不是'不把工具发给模型',而是把'工具定义权'和'工具执行调度权'从前端抽离了出来。
这是从'验证链路'过渡到'真正产品形态'的关键一步。
一开始用终端输入,是为了验证最小闭环:
终端输入 -> Host -> 模型 -> Host -> MCP Server -> 浏览器 -> Cesium
当这条线通了以后,你已经证明:
这时候再把'终端输入'换成'页面输入',本质上不是重新发明一套逻辑,而是把 Host 的入口从命令行换成 HTTP API:
页面输入 -> host-web.ts -> 模型 -> host-web.ts -> MCP Server -> 浏览器 -> Cesium
所以页面输入版的核心不是'让前端重新直连模型',而是:
页面把用户输入交给 Host,由 Host 继续完成完整 MCP 链路。
这个问题非常重要,因为很多 demo 表面上看都叫'工具调用',但架构上完全不是一回事。
最实用的判断标准只有一句:
看工具定义写在哪里,工具调度由谁负责。
如果是这样:
getTools()runLocalTool()那本质上还是前端直连。
如果是这样:
index.ts 里 server.tool(...) 定义工具listTools() 发现工具callTool() 调用工具那才是完整 MCP。
你可以把这个判断标准记成一句话:
前端直连看的是'页面会不会调工具',完整 MCP 看的是'工具是不是独立存在于 MCP Server 里'。
因为在学习初期,大家最先看到的都是这些显眼的词:
这些词在两套架构里都出现,所以很容易产生一种错觉:
'我都在用 tools 了,那我就是在做 MCP。'
真正需要区分的,其实不是关键词,而是角色分工:
一旦你把角色看清楚,之前那些零散的疑问就会突然全部串起来。
你会发现,上面这些问题并不是无关的'零碎提问',而是学习过程中非常自然的一条理解升级路径:
不是的。
tool calling 是模型返回工具调用决策。 MCP 是一整套 client-server 协议和角色分层。
短期能跑,长期一定会乱。
因为你会发现:
模型不会自己去连 Node。
真正调用 Node MCP 工具的是 Host。
如果浏览器没有连上,Node 收到工具调用也没法执行地图动作。
比如 9100 已经被占用,再起一个同样监听 9100 的进程,就会报端口占用错误。
当'飞到上海'跑通以后,这套架构就已经具有扩展性了。
你可以继续加这些工具:
flyToLocation({ longitude, latitude, height })setCameraView(...)lookAt(...)addPoint(...)addPolyline(...)addPolygon(...)showLayer(...)hideLayer(...)toggleLayer(...)getCurrentCamera()getSelectedEntity()pingBrowser()这时候你会真正体会到完整 MCP 架构的价值:
你扩展的是'工具能力',而不是把业务越写越塞进页面里。
如果你只记住一句话,请记这句:
完整 MCP 不是'模型自己调地图',而是'Host 根据模型决策去调工具,再让浏览器执行地图动作'。
如果你再多记三句,那就是:
最后,把两套方案再浓缩成最简版本:
页面自己又当聊天端,又当工具执行端
Host 负责调度,Node 负责工具,浏览器负责执行
这就是这篇教程想真正教会你的东西。
从'让地图飞到上海'这件小事出发,你其实已经接触到了一个很有代表性的工程问题:
当大模型开始接管工具调用时,系统应该怎么分层,才不会越做越乱?
地图场景给了我们一个很典型的答案:
这样搭起来的系统,才真正适合继续长大。
如果你已经把'飞到上海'跑通了,那么下一步,不是继续把代码堆在页面里,而是:
把更多地图能力,整理成一套干净的 MCP 工具体系。
这时,你就真正迈进了'通过大模型聊天驱动地图系统'的工程化阶段。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
生成新的随机RSA私钥和公钥pem证书。 在线工具,RSA密钥对生成器在线工具,online
基于 Mermaid.js 实时预览流程图、时序图等图表,支持源码编辑与即时渲染。 在线工具,Mermaid 预览与可视化编辑在线工具,online
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online