1. 为什么我盯上了MCP这个协议
先说结论:MCP(Model Context Protocol)本质上就是给大模型装了一双手。以前我们用GPT-4o写代码、查资料,它只能“说”,不能“做”。你想让它帮你查个航班、订个酒店、调个地图API,得自己写一堆胶水代码,把函数调用(Function Calling)的JSON Schema手搓一遍,再在服务端做路由分发。MCP把这个过程标准化了——模型通过统一的协议描述自己能调用哪些工具,客户端负责执行,结果回传。听起来像插件系统,但比插件轻,比Function Calling规范。
我之所以在3天内能做出一个AI旅游规划产品并上线,核心原因就是MCP把“工具接入”这件事的边际成本压到了极低。以前接一个高德地图API,我得写请求封装、参数校验、错误处理、结果格式化,至少半天。现在只要写一个MCP Server,声明工具名、参数、返回结构,剩下的交给协议层。GPT-4o负责理解用户意图、编排调用顺序,Next.js负责前端渲染和流式输出。整个链路清晰得不像话。
这个产品解决的是什么问题?简单说:用户输入“我想去成都玩3天,预算3000,喜欢美食和人文”,系统自动调用地图API查景点、调用天气API看预报、调用酒店API比价,最后生成一份带时间轴、预算分配、交通建议的完整行程。不是那种模板化的“第一天宽窄巷子、第二天锦里”的垃圾攻略,而是根据实时数据动态生成的。适合谁参考?独立开发者、想快速验证AI Agent产品的小团队、以及所有对MCP协议好奇但还没动手的人。
2. 整体架构设计与技术选型逻辑
2.1 为什么是Next.js而不是纯后端框架
Next.js在这个项目里承担了三个角色:前端页面、API路由、流式输出通道。你可能会问,为什么不用Express或者FastAPI单独做后端?因为AI旅游规划产品的核心体验是“实时生成感”——用户输入需求后,行程要像打字机一样逐字蹦出来,而不是等30秒后一次性刷新。Next.js的App Router配合Vercel AI SDK,天然支持Server-Sent Events(SSE)流式传输。我在app/api/chat/route.ts里用streamText把GPT-4o的输出直接推给前端,前端用useChat钩子接收,整个过程不需要自己维护WebSocket连接。
另一个原因是部署。Vercel对Next.js的支持是亲儿子级别的,git push之后自动构建、自动分配域名、自动HTTPS。我一个人3天做完,根本没时间折腾Nginx配置和SSL证书。Vercel的免费额度对于MVP阶段完全够用,每天几千次请求毫无压力。
2.2 MCP Server的拆分策略
我一共写了三个MCP Server,分别对应地图、天气、酒店三个领域。为什么不写一个大而全的Server?因为MCP的设计哲学是“单一职责”。每个Server独立进程、独立端口、独立生命周期。地图Server挂了不影响天气Server,调试的时候也清晰——我可以在终端里单独跑地图Server,用MCP Inspector测试工具调用,不用启动整个应用。
具体拆分如下:
| Server名称 | 工具数量 | 核心工具 | 数据源 |
|---|---|---|---|
| map-server | 4 | searchPOI, getRoute, getDistance, geocode | 高德地图Web API |
| weather-server | 2 | getForecast, getCurrentWeather | 和风天气API |
| hotel-server | 3 | searchHotels, getHotelDetail, comparePrices | 某OTA平台开放接口 |
每个Server用TypeScript写,基于@modelcontextprotocol/sdk。这个SDK提供了Server类和StdioServerTransport,你只需要定义工具列表和处理函数。我实测下来,一个最简单的MCP Server从零到跑通不超过80行代码。
2.3 GPT-4o在链路中的角色定位
GPT-4o不是用来“生成攻略文本”的,它的核心任务是意图解析和工具编排。用户说“我想去成都吃火锅看熊猫”,GPT-4o要拆解出:目的地=成都,偏好=美食+动物,然后决定先调geocode拿到成都坐标,再调searchPOI搜火锅店和熊猫基地,再调getForecast看那几天天气,最后调searchHotels找春熙路附近的酒店。这个编排逻辑如果自己写if-else,至少几百行,而且脆弱得要命。交给GPT-4o之后,我只需要在System Prompt里写清楚“你有这些工具可用,请按需调用”,剩下的它自己规划。
这里有个关键细节:GPT-4o的Function Calling返回的是工具调用请求,不是最终答案。我的API路由需要拦截这个请求,转发给对应的MCP Server执行,拿到结果后再塞回对话历史,让GPT-4o继续生成。这个循环可能跑2-5轮,直到GPT-4o认为信息足够,输出最终行程。
3. MCP Server的实操实现细节
3.1 从零写一个地图MCP Server
先看依赖。package.json里只需要三个东西:@modelcontextprotocol/sdk、axios、zod。zod用来定义参数类型,SDK会自动把zod schema转成JSON Schema给GPT-4o看。
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import axios from "axios"; const server = new Server({ name: "map-server", version: "1.0.0", }, { capabilities: { tools: {} } });定义工具的时候,inputSchema用zod写,比手搓JSON Schema舒服太多。比如searchPOI:
server.tool( "searchPOI", "根据关键词和城市搜索兴趣点,返回名称、地址、坐标、评分", { keyword: z.string().describe("搜索关键词,如'火锅'、'熊猫基地'"), city: z.string().describe("城市名称,如'成都'"), limit: z.number().optional().default(5).describe("返回结果数量") }, async ({ keyword, city, limit }) => { const response = await axios.get("https://restapi.amap.com/v3/place/text", { params: { keywords: keyword, city: city, offset: limit, key: process.env.AMAP_KEY, extensions: "all" } }); const pois = response.data.pois.map(p => ({ name: p.name, address: p.address, location: p.location, rating: p.biz_ext?.rating || "暂无评分", type: p.type })); return { content: [{ type: "text", text: JSON.stringify(pois, null, 2) }] }; } );注意return的格式:必须是{ content: [{ type: "text", text: "..." }] }。这是MCP协议规定的返回结构,GPT-4o只认这个。我一开始返回了裸对象,结果模型完全无视,排查了半小时才发现是格式问题。
启动Server的代码就三行:
const transport = new StdioServerTransport(); await server.connect(transport); console.error("Map MCP Server running on stdio");用console.error而不是console.log,因为stdio传输模式下,stdout被协议占用,日志必须走stderr,否则会污染通信数据。这个坑我踩过,表现为Server启动后客户端收不到任何响应,查了半天才发现是日志输出到了stdout。
3.2 天气和酒店Server的差异化处理
天气Server比地图简单,因为和风天气的API返回结构很规整。但有个细节:天气预报只能查未来7天,如果用户规划的是10天后的行程,得在工具描述里写清楚限制,让GPT-4o知道“查不到就告诉用户”。我在getForecast的description里加了“仅支持未来7天,超出范围返回空数组”,这样模型不会硬编造数据。
酒店Server最麻烦的是价格比较。不同OTA平台的接口字段名不一样,有的叫price,有的叫amount,有的含税有的不含。我在Server内部做了归一化,统一输出{ name, price, currency, breakfast, cancelPolicy }。这个归一化逻辑放在MCP Server里而不是GPT-4o的Prompt里,因为模型做数值比较不可靠,容易算错。让代码做确定性的事,让模型做模糊判断的事,这条边界要划清楚。
3.3 把MCP Server接入Next.js的API路由
Next.js这边,我用@modelcontextprotocol/sdk的Client类连接MCP Server。因为Server是stdio模式,Client需要启动子进程。在Vercel的Serverless环境里,子进程是可行的,但冷启动会慢1-2秒。我的优化方案是:在API路由的模块顶层初始化Client,利用Vercel的实例复用机制,避免每次请求都重启Server。
import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; let mapClient: Client | null = null; async function getMapClient() { if (mapClient) return mapClient; const transport = new StdioClientTransport({ command: "node", args: ["./mcp-servers/map-server/dist/index.js"] }); mapClient = new Client({ name: "nextjs-client", version: "1.0.0" }, { capabilities: {} }); await mapClient.connect(transport); return mapClient; }然后在streamText的tools参数里,把MCP Server的工具列表动态注入。Vercel AI SDK支持tool函数,我写了一个适配器,把MCP的tool定义转成AI SDK的格式。这样GPT-4o看到的工具列表和MCP Server实际提供的完全一致,不需要手动同步。
4. 前端交互与流式输出的实现
4.1 用useChat钩子实现打字机效果
前端页面极其简单,一个输入框、一个发送按钮、一个消息列表。核心是useChat:
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({ api: "/api/chat", onFinish: (message) => { // 行程生成完毕,可以触发保存或分享 } });useChat自动处理SSE流,每收到一个token就更新messages状态,React重新渲染,用户就看到文字一个个蹦出来。我实测下来,GPT-4o的首token延迟在800ms左右,之后每秒输出30-50个token,体验很流畅。
但有个问题:工具调用的中间状态用户看不到。GPT-4o在调searchPOI的时候,前端是静默的,用户可能以为卡住了。我的解决方案是在API路由里,当检测到工具调用时,往流里插入一个特殊标记,比如[正在搜索成都的火锅店...],前端解析到这个标记就显示一个加载提示。这个标记用data类型的流事件发送,不会污染最终文本。
4.2 行程结果的结构化渲染
GPT-4o最终输出的行程是Markdown格式,但纯Markdown渲染出来很丑。我在前端做了一个简单的解析器,识别## 第一天、- 09:00 宽窄巷子这种模式,转成时间轴组件。每个景点卡片显示名称、地址、建议停留时间、预算。这个解析器不复杂,200行以内,但视觉效果提升巨大。
预算汇总部分,我让GPT-4o在最后输出一个JSON块,前端用JSON.parse提取后渲染成饼图。这里有个技巧:在System Prompt里明确要求“最后输出一个<budget>标签包裹的JSON”,比让模型自由发挥可靠得多。模型有时候会忘记,我加了few-shot示例之后,准确率到95%以上。
4.3 部署上线的最后一步
Vercel部署没什么好说的,vercel --prod一把梭。但环境变量要配好:OPENAI_API_KEY、AMAP_KEY、QWEATHER_KEY、OTA_API_KEY。MCP Server的代码要编译成JS放在mcp-servers/*/dist/目录下,Vercel构建时会一起打包。注意vercel.json里要配置functions的maxDuration,默认10秒不够,我设了60秒,因为GPT-4o多轮工具调用可能跑30秒以上。
域名绑定之后,我第一时间用手机4G网络测了一遍,确认没有跨域问题、没有SSL警告、流式输出正常。从写第一行代码到线上可访问,正好72小时。
5. 踩坑记录与排查技巧
5.1 MCP Server无响应的三种常见原因
第一种:stdout被日志污染。前面提过,console.log会破坏stdio通信。排查方法:把Server单独跑起来,在终端输入一个JSON-RPC请求,看有没有正常返回。如果返回里混了日志文本,就是这个问题。
第二种:工具参数类型不匹配。zod的z.number()如果传了字符串,SDK会直接抛异常,但异常信息可能被吞掉。我的做法是在每个工具处理函数里包一层try-catch,把错误信息通过content返回给模型,而不是让Server崩溃。这样GPT-4o能看到“参数错误:limit应该是数字”,它会自动修正重试。
第三种:Client连接超时。Vercel冷启动时,子进程启动可能超过5秒。我在Client的connect方法里加了重试逻辑,最多重试3次,每次间隔1秒。实测下来,冷启动成功率从70%提升到99%。
5.2 GPT-4o不调用工具的排查思路
有时候模型会“偷懒”,明明有工具可用,它直接编造答案。比如用户问“成都明天天气”,它不调getForecast,直接说“明天晴,25度”。这是幻觉,必须杜绝。
我的解决方案是在System Prompt里加一条硬规则:“任何涉及实时数据的问题,必须先调用工具获取,禁止凭记忆回答。如果工具返回空,如实告知用户。”同时,在API路由里做后置校验:如果GPT-4o的输出里包含“天气”“温度”等关键词,但对话历史里没有对应的工具调用记录,就强制重新生成。这个校验逻辑用正则匹配,简单但有效。
5.3 流式输出中断的修复
用户网络不稳定时,SSE连接可能断开,前端useChat会报错。我加了onError回调,自动重试一次。如果重试还失败,就把已生成的部分内容保留,提示用户“网络波动,点击继续”。这个体验比直接报错好很多。
另一个坑是Vercel的Serverless函数有响应大小限制,如果行程特别长(比如7天详细规划),流式输出可能被截断。我的应对策略是限制单次生成的token数,在streamText里设maxTokens: 2000,超出部分让用户点击“展开更多”再请求一次。这样既控制了成本,又避免了截断。
5.4 成本控制的实操数据
GPT-4o的定价是输入$2.5/百万token,输出$10/百万token。一次完整的旅游规划,平均消耗输入3000 token(含System Prompt和工具定义),输出1500 token,成本约$0.0225,折合人民币一毛六。加上地图和天气API的调用费用(高德免费额度每天5000次,和风免费额度每天1000次),MVP阶段每天100个用户,总成本不到20块钱。这个账算下来,独立开发者完全扛得住。
6. 后续可扩展的方向
这个产品目前只做了“生成行程”,但MCP的潜力远不止于此。我下一步打算加两个Server:一个是“翻译Server”,调用DeepL API,把行程里的景点介绍自动翻译成用户母语;另一个是“分享Server”,生成一个短链接,把行程存到数据库,用户可以把链接发给朋友。这两个Server加起来不超过200行代码,但产品完整度会提升一个档次。
另外,MCP的生态正在快速膨胀。我注意到已经有社区贡献的MCP Server可以接Notion、Slack、GitHub。如果把这些串起来,理论上可以做一个“AI旅行管家”:行程生成后自动创建Notion页面、自动发Slack通知同行伙伴、自动在GitHub上开一个issue记录预算。这种跨工具的编排能力,才是MCP真正可怕的地方。
最后分享一个小技巧:调试MCP Server的时候,用npx @modelcontextprotocol/inspector启动一个Web界面,可以可视化地查看工具列表、手动调用工具、看返回结果。这个工具省了我大量时间,比在终端里手搓JSON-RPC请求舒服多了。