我本来想写个能自己查周边酒店、自动生成网页还能帮我打开浏览器的小 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();
运行前别忘了这几件事
- 先装依赖:
@langchain/mcp-adapters、@langchain/openai、chalk、dotenv这几个包别漏装 - Chrome 要先开启远程调试端口:
chrome --remote-debugging-port=9222,不然浏览器控制那步会失败 - 高德的 key 换成你自己的,别直接用我这个
最终效果出来的时候,还真有点小震撼
等所有轮次跑完,浏览器自动弹出三个标签页的时候,我说实话还是有点小激动的。
每个页面都整整齐齐:酒店照片在上面,地址、评分、类型在下面,页面标题也老老实实改成了酒店名字。对应的 HTML 文件也安安稳稳躺在本地目录里,文件名就是我要求的 hotel1、hotel2、hotel3。

你别觉得这件事简单。从定位地点、搜索酒店、拉取详情,到生成 HTML、写入磁盘、打开浏览器,全程没有任何人工干预。没有预设步骤,没有硬编码逻辑,所有的决策都是 AI 在每一轮思考里自己做的。
这就是 Agent 最有魅力的地方 ------ 它不是给你一个文字答案,它是真的帮你把事情做完。
聊点干货:Agent 到底是怎么工作的
折腾完这一遭,我算是把 Agent 那层窗户纸捅破了。之前看各种概念总觉得云里雾里,真自己手搓一个出来,发现核心逻辑简单到离谱。
大模型本身是 "无手无脚" 的,它只能输出文字。你问它北京南站附近有什么酒店,它要么凭训练数据瞎编,要么告诉你 "你可以去地图上搜"。它自己是做不了任何实事的。
Agent 做的事情,就是在大模型外面套了一层循环:
- 把当前的对话上下文丢给大模型
- 大模型判断:是直接回答,还是需要调用工具
- 如果要调用工具,程序就帮它执行,拿到返回结果
- 把工具结果塞回对话历史,再丢给大模型
- 重复上面的步骤,直到大模型说 "事儿办完了"

就这么简单。所谓的 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,踩了什么奇奇怪怪的坑,或者有更优雅的写法,欢迎在评论区留个言。我也想看看大家都是怎么玩的。