调用远程MCP,手搓一个能查酒店、自动打开浏览器的 Agent

我本来想写个能自己查周边酒店、自动生成网页还能帮我打开浏览器的小 Agent,结果第一步接高德 MCP 就给我干沉默了 ------ 控制台啥有用信息都没吐,只甩给我一句网页解析失败。

说实话,之前看别人聊 Agent、MCP,总觉得是很玄乎的新概念,好像要很复杂的架构才能做。直到自己一行一行把代码敲完,看着它一轮一轮调用工具、写文件、开页面,跑完整整 13 轮终于把三个酒店页面摆在我面前的时候,才突然反应过来:这玩意儿本质上,就是给大模型装了双手啊。

先说说我想做个什么东西

需求说起来很简单:找北京南站附近最近的 3 个酒店,拿到酒店图片,自动生成 HTML 页面存到本地,再用 Chrome 打开,每个酒店占一个标签页,页面标题改成酒店名字。

人来做这件事,无非就是打开地图搜、点详情、存图片、写 HTML、开浏览器,几分钟的事儿。但让 AI 自己完整做完,就没那么直观了 ------ 它既不会打开网页,也不会写文件,连北京南站在哪都得现查。

最开始我想的是,要不我自己把步骤拆好,一步步调 API 拼起来?后来一想,那我写 Agent 干嘛,直接写脚本不就完了。Agent 的核心乐趣不就在于,你只说目标,它自己拆步骤、自己找工具、自己干完吗。

于是我就搭了个 LangChain 的架子,接了四个 MCP 服务:高德地图用来查酒店,文件系统用来写 HTML,Chrome DevTools 用来控浏览器,再加一个本地自定义的备用。

第一个大坑:我以为 MCP 就是个普通 URL 接口

配置MultiServerMCPClient的时候,我想当然地照着本地服务的写法,把高德 MCP 的链接直接填进去了,长这样:

javascript 复制代码
'amap-mcp-server': {
    command: 'node',
    "url": "https://mcp.amap.com/mcp?key=xxx"
}

一运行,直接报错:网页解析失败,可能是不支持的网页类型。

当时我懵了,第一反应是 key 错了,去高德开放平台重新申请了好几个 key,挨个试,全不行。又以为是网络问题,开了代理、换了节点,还是一样。折腾了快一个小时,我才突然反应过来 ------ 不对啊,本地 MCP 是用 node 命令启动一个子进程,远程 HTTP 的 MCP 怎么可能也用 command 字段?

后来翻文档才搞明白,MCP 服务分两种运行方式:一种是本地子进程模式,用command + args启动;另一种是远程 SSE/HTTP 模式,得配置transport,不能把 url 随便塞进去当命令参数。

踩坑提醒远程 HTTP 类型的 MCP 服务,不能直接套本地子进程的配置格式。不同的 MCP 客户端对远程服务的配置方式不一样,别像我一样想当然乱写。

跑起来那一刻,看着它一步步干活居然有点治愈

改完配置再跑,控制台终于开始正常输出了。我盯着屏幕看它一轮一轮往下走,那种感觉特别奇妙,就像站在实习生背后看他干活,笨手笨脚但步骤都对。

第 1 轮,它先调用地理编码工具,定位北京南站的坐标;第 2 轮,用坐标调周边搜索,拉出附近的酒店列表;第 3 轮更聪明,一下子并行调用了三个酒店详情查询,同时拿地址、评分和图片;第 4 到 6 轮,连续三次调用写文件,把三个酒店的 HTML 挨个存到本地;第 7 轮查了一遍允许访问的目录,第 8 轮列了下目录文件,确认自己写进去了;第 9 到 11 轮,连续开三个浏览器新标签页,分别加载三个本地 HTML;第 12 轮切到对应页面,第 13 轮截了个图确认效果。

整整 13 轮,全程我没碰一下键盘,就只在最开始说了一句需求。它自己规划步骤、自己选工具、自己处理结果、自己往下推进,直到把事情做完。

第二个大坑:工具返回值格式根本不统一

其实上面这个顺利的版本,是我踩了第二个坑之后才跑通的。

最开始我写工具结果处理的时候,想当然认为所有 MCP 工具返回的都是字符串,直接塞进去就行。结果跑起来之后,AI 要么在原地打转重复调用工具,要么直接开始瞎编结果,轮次跑满了也没个正经输出。

我打了个断点才发现,不同 MCP 服务的返回格式根本不一样。有的直接返回字符串文本,有的返回一个对象,内容包在text字段里,还有的返回数组。文件系统那个工具返回的就是对象格式,我没做处理,AI 拿到的就是空内容,自然不知道下一步该干嘛。

后来我加了一层兼容判断,才算稳住:

javascript 复制代码
    for(const toolCall of response.tool_calls){
        const foundTool = tools.find(t => t.name === toolCall.name);
        if(foundTool){
            const toolResult = await foundTool.invoke(toolCall.args);
            let contentStr;
            // 别问我为什么知道要写这么多判断,踩了三回坑
            if(typeof toolResult === 'string') {
                contentStr = toolResult;
            } else if (toolResult && toolResult.text) {
                contentStr = toolResult.text;
            }
            messages.push(new ToolMessage({
                tool_call_id: toolCall.id,
                content: contentStr,
            }));
        }
    }

说真的,这部分是目前 MCP 最闹心的地方。说是统一协议,但实际返回格式各家有各家的写法。不做兼容的话,AI 拿到空结果就会开始胡来,要么死循环调用工具,要么直接给你编造数据,坑得很。

完整可跑的代码,给你们贴出来

踩完这两个坑,整个脚本基本就能顺下来了。我把完整的运行代码贴在这,你们把环境变量里的 key、本地路径换成自己的,直接就能跑。

整个代码结构其实很直白:先初始化大模型,再初始化 MCP 客户端拉取所有可用工具,把工具绑定给模型,最后写一个循环函数 ------ 一轮一轮让 AI 思考、判断要不要调用工具、执行工具并回填结果,直到它给出最终回答或者跑满最大轮次。

javascript 复制代码
import 'dotenv/config';
import { MultiServerMCPClient } from '@langchain/mcp-adapters';
import { ChatOpenAI } from '@langchain/openai';
import chalk from 'chalk';
import {
    HumanMessage,
    SystemMessage,
    ToolMessage
} from '@langchain/core/messages';

// 初始化大模型,这里用 DeepSeek,兼容 OpenAI 接口格式
const model = new ChatOpenAI({
    modelName: process.env.DEEPSEEK_API_MODEL,
    apiKey: process.env.DEEPSEEK_API_KEY,
    temperature: 0, // 做确定性任务就拉到 0,别让它自由发挥
    configuration: {
        baseURL: process.env.DEEPSEEK_API_BASE_URL,
    },
})

// 初始化 MCP 客户端,一次性接入多个工具服务
const mcpClient = new MultiServerMCPClient({
    mcpServers: {
        // 高德地图 MCP,远程服务配置别写错,我在这栽过跟头
        'amap-mcp-server': {
            command: 'node',
            "url": "https://mcp.amap.com/mcp?key=xxx"
        },
        // 本地自定义 MCP 服务,想自己封装工具就用这个
        'my-mcp-server': {
            command: 'node',
            args: ['D:/workspace/xjl_ai/ai/agent_in_action/mcp-demo/src/my-mcp-server.mjs'],
        },
        // 文件系统 MCP,用来读写本地文件
        'filesystem': {
            command: 'npx',
            args: [
                '-y',
                '@modelcontextprotocol/server-filesystem',
                // 只开放指定目录,别把整个盘都给出去,安全第一
                'D:/workspace/xjl_ai/ai/agent_in_action/remote-mcp'
            ]
        },
        // Chrome DevTools MCP,用来控制浏览器打开页面、截图
        'chrome-devtools': {
            command: 'npx',
            args: [
                '-y',
                'chrome-devtools-mcp',
                '--port',
                '9222'
            ]
        }
    }
});

// 从 MCP 客户端拿到所有可用工具,绑定给大模型
const tools = await mcpClient.getTools();
console.log(tools);
const modelWithTools = model.bindTools(tools);

// 核心 Agent 循环函数
async function runAgentWithTools(query, maxIterations = 30) {
    const messages = [
        new HumanMessage(query)
    ];
    // 最多跑 30 轮,防止 AI 死循环把资源吃光
    for(let i=0; i< maxIterations; i++){
        console.log(chalk.bgGreen(`正在等待第${i+1}轮 AI 思考...`));
        const response = await modelWithTools.invoke(messages);
        messages.push(response);

        // 没有工具调用了,说明 AI 认为任务完成,直接返回最终结果
        if(!response.tool_calls || response.tool_calls.length === 0){
            console.log(chalk.bgGreen(`\n AI 最终回复: \n ${response.content}`));
            return response.content;
        }

        console.log(chalk.bgBlue(`工具调用:${response.tool_calls.map(tc => tc.name).join(', ')}`));

        // 执行每一个工具调用,把结果塞回消息历史里
        for(const toolCall of response.tool_calls){
            const foundTool = tools.find(t => t.name === toolCall.name);
            if(foundTool){
                const toolResult = await foundTool.invoke(toolCall.args);
                let contentStr;
                // 兼容不同 MCP 的返回格式,纯字符串和带 text 字段的对象都处理了
                if(typeof toolResult === 'string') {
                    contentStr = toolResult;
                } else if (toolResult && toolResult.text) {
                    contentStr = toolResult.text;
                }
                messages.push(new ToolMessage({
                    tool_call_id: toolCall.id,
                    content: contentStr,
                }));
            }
        }
    }
    // 跑满最大轮次还没结束,直接返回最后一条消息兜底
    return messages[messages.length - 1].content;
}

// 执行具体任务:找北京南站附近3个酒店,生成HTML并打开浏览器展示
await runAgentWithTools('北京南站附近的酒店,最近的 3 个酒店,拿到酒店图片,打开浏览器,展示每个酒店的图片,每个 tab 一个 url 展示,并且在把那个页面标题改为酒店名。将生成的 HTML 文件保存在当前目录下,文件名使用 hotel1.html、hotel2.html、hotel3.html');
await mcpClient.close();

运行前别忘了这几件事

  1. 先装依赖:@langchain/mcp-adapters@langchain/openaichalkdotenv 这几个包别漏装
  2. Chrome 要先开启远程调试端口:chrome --remote-debugging-port=9222,不然浏览器控制那步会失败
  3. 高德的 key 换成你自己的,别直接用我这个

最终效果出来的时候,还真有点小震撼

等所有轮次跑完,浏览器自动弹出三个标签页的时候,我说实话还是有点小激动的。

每个页面都整整齐齐:酒店照片在上面,地址、评分、类型在下面,页面标题也老老实实改成了酒店名字。对应的 HTML 文件也安安稳稳躺在本地目录里,文件名就是我要求的 hotel1、hotel2、hotel3。

你别觉得这件事简单。从定位地点、搜索酒店、拉取详情,到生成 HTML、写入磁盘、打开浏览器,全程没有任何人工干预。没有预设步骤,没有硬编码逻辑,所有的决策都是 AI 在每一轮思考里自己做的。

这就是 Agent 最有魅力的地方 ------ 它不是给你一个文字答案,它是真的帮你把事情做完。

聊点干货:Agent 到底是怎么工作的

折腾完这一遭,我算是把 Agent 那层窗户纸捅破了。之前看各种概念总觉得云里雾里,真自己手搓一个出来,发现核心逻辑简单到离谱。

大模型本身是 "无手无脚" 的,它只能输出文字。你问它北京南站附近有什么酒店,它要么凭训练数据瞎编,要么告诉你 "你可以去地图上搜"。它自己是做不了任何实事的。

Agent 做的事情,就是在大模型外面套了一层循环:

  1. 把当前的对话上下文丢给大模型
  2. 大模型判断:是直接回答,还是需要调用工具
  3. 如果要调用工具,程序就帮它执行,拿到返回结果
  4. 把工具结果塞回对话历史,再丢给大模型
  5. 重复上面的步骤,直到大模型说 "事儿办完了"

就这么简单。所谓的 Memory 记忆、RAG 知识库、MCP 工具,全都是往这个循环里添东西。

  • Memory 就是每次循环都把历史对话带上,让它记得之前干了啥
  • RAG 就是在提问的时候,把知识库查出来的内容一起塞进去
  • MCP 就是给所有工具定了个统一格式,不用每个工具都单独写适配代码

说白了,Agent = 一个会思考的大脑 + 一堆能用的工具 + 一个循环调度的壳子。大脑负责想,工具负责干,壳子负责传话。

顺便说个性能小细节:工具可以并行调用

不知道你注意到没有,第三轮的时候,它一下子调用了三个maps_search_detail,不是一个一个来的。

这就是大模型的并行工具调用能力。同一个步骤里如果有多个互不依赖的工具,它会一次性全列出来,不用等上一个跑完再调下一个。

我现在代码里是用 for 循环挨个执行的,其实完全可以改成Promise.all并行处理,三个工具同时跑,速度能快不少。尤其是那种一次调用五六个工具的场景,优化完体感提升特别明显。

说到 Promise 就多提两句,很多刚接触异步的朋友容易搞混。简单理解就是:

  • Promise 就是一件 "正在干、还没干完" 的事,有等待、成功、失败三种状态,变了就不能改了
  • Promise.all就是好几件事同时干,等所有事都成功了才一起返回结果,有一个失败就整体失败
  • await 就是等着这件事干完,再往下走代码

写 Agent 的时候,异步处理写得好不好,直接影响整个智能体的响应速度。

最后说几句掏心窝的

折腾完这一圈,我最大的感受有三个。

第一,别把 Agent 想得太高大上。它不是什么强人工智能,就是 "大模型 + 工具调用循环"。你自己手写个几十行的 while 循环,也能做个最简单的 Agent。核心门槛不在框架,在于你能不能把工具接好、把场景想清楚。

第二,MCP 是个好东西,但生态还没那么成熟。统一工具协议这个思路特别棒,理论上所有符合标准的工具都能即插即用。但实际用起来,返回格式、参数校验、远程连接,到处都是细节坑,得慢慢踩。

第三,Agent 的能力边界,其实全看工具。你给它接地图,它就能查地点;给它接文件系统,它就能读写文件;给它接浏览器,它就能操作网页;给它接数据库,它就能查数据。工具越多,它能干的事就越多。想象力比技术本身重要。

当然也别神话这玩意儿。简单任务它能给你干得漂漂亮亮,步骤一多、逻辑一复杂,很容易在某一步跑偏,然后就开始死循环。所以代码里的maxIterations一定要设,别让它无限跑下去把资源吃光。

反正我这次折腾完,算是彻底搞懂 Agent 到底是怎么回事了。之前看各种文章讲概念,总觉得隔了一层,真的自己手搓一个出来,踩一遍坑,啥都通透了。

如果你也在搭自己的小 Agent,踩了什么奇奇怪怪的坑,或者有更优雅的写法,欢迎在评论区留个言。我也想看看大家都是怎么玩的。

相关推荐
启雀AI1 小时前
生物医疗行业如何建设合规、安全、可复用的知识库?
人工智能·安全·软件构建·知识图谱·知识库
x-cmd1 小时前
Mac 涨价后,本地 AI 还能千元入门吗?
linux·人工智能·macos·ai·agent·amd·本地ai入门
To_OC1 小时前
跑通第一个 MCP Server 后,我终于搞懂它到底解决了什么问题
人工智能·agent·mcp
楷哥爱开发1 小时前
如何使用 Claude Fable 5 进行网页抓取?2026最新实战教程
大数据·网络·人工智能
YMWM_1 小时前
lerobot中use_relative_actions=True需要重新计算meta/stats.json等信息
人工智能·深度学习·lerobot
触底反弹1 小时前
🔥 DeepSeek 560 万美金干翻 OpenAI?一文讲透「蒸馏」的来龙去脉
人工智能
私人珍藏库2 小时前
[Android] 会计快题库 -财会职称考试刷题学习
android·人工智能·学习·app·软件·多功能
Sirius Wu2 小时前
OpenClaw Skill:Matplotlib 可视化技能 + 沙箱双层隔离完整详解
服务器·网络·人工智能·安全·ai·架构·aigc
闻道且行之2 小时前
TurboOCR:基于PP-OCRv6的极速Windows离线OCR工具,深度解析3.4GB依赖背后的技术架构
c++·人工智能·python·qt·机器学习·ocr