从MCP到RAG:Agent的开发之路
在AI时代,大型语言模型(LLM)如ChatGPT已深刻改变我们与机器的交互方式,但"幻觉"(hallucination)问题始终存在:模型面对知识盲区时,常自信地生成错误信息。
为了让AI真正"帮我们做事",而非停留在空谈,两条关键路径应运而生:
- Model Context Protocol(MCP) :专为赋予AI真实行动力而设计的标准化协议。它统一规范LLM与外部工具、资源和提示模板的通信,让AI能轻松调用数据库、浏览器、地图服务等能力,从"会说"迈向"会做"。
- Retrieval-Augmented Generation(RAG) :通过检索可靠外部知识增强提示,确保生成内容有据可依,大幅降低幻觉风险。
MCP提供"双手",RAG赋予"眼睛"。两者结合,打通从思考到落地的完整链路。
本文基于真实代码实践,带你一步步走通这条路径:从手写MCP服务器,到LangChain集成,再到RAG的文档加载与语义分割,最终实现MCP+RAG的闭环智能代理。
第一部分:MCP协议------AI与世界的"桥梁"
想象一下:AI就像一位才华横溢却与世隔绝的学者,它拥有无穷的智慧,却需要可靠的"工具"来触达现实世界------查询数据库、操控浏览器,甚至调用地图API。
但如何让AI与这些工具无缝对接、顺畅协作?
这就是MCP(Model Context Protocol)的独特魅力:它是一个标准化协议,宛如一座桥梁,连接AI(Model)与外部上下文(Context,包括工具、资源和提示模板),让交互变得高效而有序。
MCP的核心概念深度剖析
MCP并非单纯的一个库或框架,而是一个协议规范,类似于HTTP在Web生态中的基石角色。它巧妙地将上下文划分为三大类别,确保AI能全面利用外部资源:
- Tool:这些是可执行的操作,例如查询用户数据或启动浏览器自动化。它们赋予AI实际行动力,让抽象思考转化为具体输出。
- Resource:静态资源,如使用指南或文档,这些作为背景知识注入AI的"记忆",帮助它更好地理解任务背景。
- Prompt Template:预设的提示模板,类似于AI的"思维框架",引导它生成更精确、针对性的响应,避免泛泛而谈。
在实际应用中,MCP的强大在于其扩展性。例如,Zod这样的TypeScript schema验证库可以无缝集成到工具定义中,确保输入数据的类型安全,从而防范运行时错误。
底层逻辑与通信机制
MCP的架构灵感来源于Browser/Server模式(或经典的C/S架构),通过URI(统一资源标识符)来唯一标识每一项资源,就像Web中的URL一样,确保全局无冲突定位。通信则依托标准输入输出流(Stdio),这使得跨进程交互变得高效而可靠,尤其适合本地或远程场景。
如这个手写MCP服务器,它展示了协议的落地方式:
javascript
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
// 数据库模拟
const database = { users: { "001": { ... }, ... } };
const server = new McpServer({ name: 'my-mcp-server', version: '1.0.0' });
server.registerTool('query-user', {
description: '查询数据库中的用户信息...',
inputSchema: { userId: z.string().describe("用户 ID...") }
}, async ({ userId }) => {
// 逻辑处理,返回内容
});
server.registerResource('使用指南', 'docs://guide', {
description: 'MCP Server 使用文档',
mimeType: 'text/plain'
}, async () => {
return { contents: [{ url: 'docs://guide', mimeType: 'text/plain', text: '指南内容...' }] };
});
const transport = new StdioServerTransport();
await server.connect(transport);
在这里,最关键的一步是先通过 new McpServer() 创建服务器实例,它就像一个"工具托管容器"。只有在这个容器存在后,我们才能调用 server.registerTool() 和 server.registerResource() 来向其中添加内容。
registerTool注册了一个名为 query-user 的工具,输入参数使用 Zod schema 进行严格定义,确保 AI 传入的数据格式可靠(比如 userId 必须是字符串),避免运行时错误。- 工具的返回结果统一采用 { content: [...] } 数组格式,支持 text、image、file 等多种类型,为未来的多模态交互留足空间。
registerResource则提供了静态资源(这里是使用指南),AI 可以在调用工具前通过 URI(如 docs://guide)获取背景知识,极大提升工具使用的可理解性和准确性。
这种"先容器、后注册、再连接"的结构,正是 MCP 协议设计的核心哲学:一切工具和资源都必须依附于一个明确的服务器实例,从而实现跨进程、跨语言的标准化调用。
值得一提的是,初学者在实践MCP时,最容易忽略inputSchema的严谨性。如果定义过于宽松,AI可能传入无效参数,导致工具执行失败甚至崩溃。这提醒我们,MCP的核心哲学是"协议优先":先构建坚实的接口规范,再填充实现逻辑。只有这样,AI才能真正"信任"这些工具,发挥出最大潜力。
MCP客户端与LangChain集成------从协议到智能代理的跃进
MCP服务器搭建完成后,真正的魔力在于客户端的调用------它将静态工具转化为动态AI能力的核心枢纽。在下面这段代码中,我使用MultiServerMCPClient来启动并管理服务器实例,这一步宛如为AI注入"外挂"系统,让它从单纯的聊天机器人蜕变为能行动的智能代理。
javascript
const mcpClient = new MultiServerMCPClient({
mcpServers: { "my-mcp-server": { command: "node", args: ["path/to/server.mjs"] } }
});
const tools = await mcpClient.getTools();
const modelWithTools = model.bindTools(tools);
底层逻辑深度剖析
MultiServerMCPClient 到底做了什么?
MultiServerMCPClient 是 LangChain 官方提供的 MCP 适配器中最核心的客户端类,它解决了以下几个关键痛点:
-
多服务器统一管理 你可以一次性配置多个 MCP 服务器(本地 stdio、本地 http、远程 streamable_http 等各种方式),客户端负责启动进程(如果是用 command/args 方式)、建立连接、维护会话。
-
工具自动发现与标准化 调用 await mcpClient.getTools() 时,它会:
- 向每个 MCP 服务器发起协议请求(通常是初始化握手)
- 获取服务器上所有通过 registerTool 注册的工具定义(包括 name、description、inputSchema 等)
- 把这些 MCP 原生工具描述自动转换为 LangChain 兼容的 Tool 对象(包含 invoke 方法)
- 返回一个工具数组,供后续 bindTools 使用
-
默认无状态设计(stateless by default) 每次工具调用时,客户端都会创建一个新的短暂会话(session),执行完就清理。 这适合大多数场景(轻量、无状态工具),但如果你有需要保持上下文的工具(例如文件系统连续操作、浏览器会话),可以显式使用 client.session("server-name") 来创建有状态会话(stateful session),并在 with 块中使用。
-
与 ReAct 循环的完美衔接 一旦工具被 model.bindTools(tools) 绑定到 LLM,模型在思考时就会:
- 看到工具列表和描述
- 根据任务决定是否要调用(输出 tool_calls)
- LangChain 自动执行对应的 tool.invoke(args)(底层走 MCP 协议通信)
- 把结果包装成 ToolMessage 放回消息历史
- 进入下一轮推理(Observe → Reason → Act)
这正是 ReAct(Reason + Act + Observe)范式的经典实现:AI不再一次性给出答案,而是像人类一样"想一想 → 动手查/做 → 看结果 → 再想"。
为什么强调先创建 MultiServerMCPClient?
就像 MCP 服务器必须先 new McpServer() 再 registerTool 一样,客户端侧也必须先有一个"客户端容器"(MultiServerMCPClient 实例),才能去"拉工具、管连接、转格式"。 如果跳过这一步直接硬编码工具,代码会失去扩展性(无法轻松添加第二个、第三个 MCP 服务器),也无法享受协议自动发现的便利。
一句话总结: MultiServerMCPClient 是 MCP 与 LangChain 之间的"翻译官 + 连接枢纽" ,它让分散的、异构的 MCP 工具变成 LangChain Agent 能直接使用的统一工具集。
后续的 getTools() → bindTools() → ReAct 循环,正是建立在这个枢纽之上的流畅体验。
ReAct(Reason-Act-Observe)循环:AI先推理(reason)任务需求,然后主动调用工具执行行动(act),最后基于工具反馈观察并调整(observe)。这种迭代机制让AI处理复杂任务时不再"一锤子买卖",而是像人类一样逐步推进。 完整的主循环代码展示了这一优雅的动态过程:
javascript
async function runAgentWithTools(query, maxIterations = 30) {
const messages = [new HumanMessage(query)];
for (let i = 0; i < maxIterations; i++) {
const response = await modelWithTools.invoke(messages);
messages.push(response);
if (!response.tool_calls || response.tool_calls.length === 0) return response.content;
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);
messages.push(new ToolMessage({ content: toolResult, tool_call_id: toolCall.id }));
}
}
}
}
在这个架构中,AI的决策基于累积的消息历史(包括HumanMessage、AIMessage和ToolMessage)。
每轮迭代,模型评估是否有tool_calls:如果有,继续行动;否则,视作任务完成并输出最终响应。为什么设置maxIterations=30?这是为了防范潜在的无限循环------想象一下,如果工具反馈不清晰,AI可能反复纠缠于某个细节,导致资源耗尽。这提醒我们,任务设计至关重要:避免循环依赖(如工具A依赖B,而B又需A),否则AI容易陷入"观察泥沼"。一个实用优化是引入系统提示(SystemMessage),明确定义结束条件,例如"当所有信息齐备时,直接输出总结,避免额外调用"。
多服务器扩展与实际应用
在代码中,我进一步扩展到多服务器配置,融入了amap-maps(高德地图API)、filesystem(文件系统操作)和puppeteer(浏览器自动化)等强大工具。这让MCP从单一服务器跃升为生态系统。
例如,针对查询"南昌西站附近的3个酒店,拿到图片,浏览器展示",AI会智能拆解:先调用amap-maps查询酒店位置和路线,然后用filesystem保存文档,最后通过puppeteer打开浏览器Tab,每个Tab展示一张酒店图片,并自定义标题为酒店名。
这种扩展凸显MCP"上下文协议"的本质:它将AI从"信息孤岛"转化为"互联生态",支持现代LLM(如GPT-4o)的并行tool_calls,提升执行效率。工具结果处理也很精妙------必须确保返回字符串或{text}对象,否则ToolMessage构建会出错,导致循环中断。但需注意:如果工具反馈模糊(如含歧义数据),AI可能过早终止或反复迭代,影响体验。
这是一个"大任务拆解为多轮小行动"的链式逻辑:从用户查询起步,逐层行动观察,直至收敛。这不仅提高了可控性,还让复杂任务(如地图+浏览器+文件集成)变得流畅自然。
第二部分:RAG------从幻觉到可靠生成的跃迁
想象一下:LLM就像一位博学却健忘的学者,它在训练时掌握了海量知识,但面对新问题或细节时,往往会"即兴发挥",制造出似是而非的幻觉答案。RAG(Retrieval-Augmented Generation,检索增强生成)犹如一剂解药,通过外部知识的注入,让AI从"胡编乱造"转向"有据可依"的可靠输出。它将AI的生成过程拆解为三步曲:检索(Retrieve)相关片段、增强(Augment)提示词、生成(Generate)最终答案。
RAG底层逻辑深度剖析
RAG的核心在于"知识外部化":不再依赖LLM的内置记忆,而是动态检索私有或实时数据,从而减少幻觉并提升准确性。
向量表达是其技术基石------将文本转化为高维向量 (如[0.1, 0.2, ...]),每个维度捕捉独特语义(如"食用性"或"硬度")。
例如,水果向量可能强调[高食用性, 低硬度],苹果[0.9, 0.5]、香蕉[0.9, 0.1]、石头[0.1, 0.9]。查询向量与知识库向量通过余弦相似度(cosine)匹配,实现语义搜索,而非僵硬的关键词比对。这让RAG能捕捉隐含关联,如"水果"自动联想到"苹果"。
为什么RAG如此强大?LLM的知识有截止日期(训练数据有限),RAG注入外部源(如企业知识库或文档),支持实时更新。
在这段代码中,我构建了一个简洁的RAG管道,展示了从知识库构建到答案生成的端到端流程:
javascript
const embeddings = new OpenAIEmbeddings({ ... });
const documents = [new Document({ pageContent: "...", metadata: { chapter: 1, character: "光光", ... } }), ...];
const vectorStore = await MemoryVectorStore.fromDocuments(documents, embeddings);
const retriever = vectorStore.asRetriever({ k: 2 });
const retrievedDocs = await retriever.invoke(question);
const scoreResults = await vectorStore.similaritySearchWithScore(question, 3);
这里,MemoryVectorStore作为内存向量数据库,适合小型数据集测试。它将文档嵌入向量后存储,支持快速检索。asRetriever({ k: 2 })配置返回前2个最相似片段,而similaritySearchWithScore提供[doc, score]对------score是距离值(通常基于L2或余弦),实际相似度可计算为1 - score(范围0-1,越接近1越相关)。这为调试提供了量化依据。
提示词构建是Augment的关键一环,确保检索结果无缝融入LLM输入:
javascript
const context = retrievedDocs.map((doc, i) => `[片段${i+1}]\n${doc.pageContent}`).join('\n\n----\n\n');
const prompt = `
你是一个讲友情故事的老师。
基于以下故事片段回答问题,用温暖生动的语言。
如果故事中没有提及,就说"这个故事里没有提到这个细节"
故事片段:
${context}
问题:
${question}
老师的回答:
`;
const response = await model.invoke(prompt);
这种结构化prompt强化了"事实优先"原则------如果检索无果,AI不会编造,而是诚实回应。这在知识密集任务(如故事解读)中尤为宝贵。metadata如{mood: "活泼"}可进一步过滤检索,提升针对性。
第三部分:RAG进阶------Loader与Splitter,征服大规模知识
基础RAG适合小型手动数据集,但现实世界知识往往庞大而杂乱------网页文章、PDF报告或视频脚本。这时,Loader和Splitter登场,将原始源转化为可检索的语义片段,让RAG从"玩具级"跃升为"生产级"。
Loader:从多元源头高效摄取文档
Loader是RAG的"数据入口",它自动化加载外部内容,避免手动构建Document。在下面的代码中,我使用CheerioWebBaseLoader针对网页优化:
javascript
import { CheerioWebBaseLoader } from '@langchain/community/document_loaders/web/cheerio';
const cheerioLoader = new CheerioWebBaseLoader("https://juejin.cn/post/...", { selector: '.main-area p' });
const documents = await cheerioLoader.load();
底层逻辑:Cheerio模拟后端jQuery,通过CSS选择器(如'.main-area p')精准提取段落文本,过滤噪声(如广告或侧边栏)。
扩展:LangChain社区Loader支持多样源------TXT、PDF、MP3甚至视频(需转录)。这让RAG适用于企业场景,如从内部Wiki加载知识库。网页若依赖JS动态渲染,Cheerio仅抓取静态HTML,可能遗漏内容;此时,可结合MCP的puppeteer工具先渲染页面,再加载。
Splitter:语义切割,确保片段高效可检索
加载的文档往往过长,直接嵌入会稀释向量密度,导致检索不准。Splitter将大块文本递归拆分成小片段,同时保留语义连贯性:
javascript
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 400, chunkOverlap: 50, separators: ['。',',','?','!']
});
const splitDocuments = await textSplitter.splitDocuments(documents);
核心机制:Recursive表示递归分割------从大分隔符(如句号'。')起步,若片段超chunkSize(400字符),则向下递归至小分隔符(如逗号',')。chunkOverlap(50字符重叠)像"语义胶水",防止边界处信息丢失,确保前后片段上下文衔接。
注意,这里的separators是针对中文优化,避免英文默认(如\n)导致不自然切割。这在处理长文章(如Juejin博客)时尤为关键,提升向量表达的语义浓度。
分割后,构建vectorStore和检索如基础RAG相同。 这里分了两种模式:Direct Document(小型、手动,适合故事片段)和Loader+Splitter(大型、自动,适合网页)。这可以理解为"知识库构建流水线":加载(Loader)→分割(Splitter)→嵌入(Embeddings)→存储(VectorStore)→检索(Retriever)。这形成闭环,支持大规模RAG。
在实际应用中,检索后构建prompt类似第二部分,但需注意metadata:Loader文档可能自带(如URL来源),Splitter保留原metadata,便于追踪。
这里有一个易错点:chunkSize太小,片段碎片化,检索召回率低;太大使向量稀疏,相似度计算偏差。优化:测试时用similaritySearchWithScore监控score分布,若平均相似度<0.7,则需要调整Splitter参数。
RAG与MCP的结合潜力无限:AI可先用MCP工具(如浏览器)加载文档,再RAG生成总结,实现"行动+知识"的超级代理。。
结语:AI开发的未来之路
从MCP的手写到RAG的检索,我们看到了AI从"智能"到"可靠"的跃迁。MCP如桥梁,RAG如引擎,二者融合让AI代理无限可能。