这段时间一直在给团队搭 Agent 服务,最大的感触是:模型能力本身已经卷到头了,真正卡脖子的反而是“模型怎么调用外部工具”这最后一公里。早期做工具集成,每个框架有每个框架的脾气——LangChain 有自己的 tool 装饰器,OpenAI 有 function calling 协议,自己写服务还要再包一层 HTTP 接口。一通折腾下来,维护成本比写业务逻辑还高。后来 MCP(Model Context Protocol,模型上下文协议)逐步成熟,我果断把整套工具层切到 MCP 上,再用 LangGraph 做 Agent 编排,总算把这团乱麻理清了。
这篇分享我不打算讲大而全的 MCP 百科,而是挑最实战的三条线讲:协议握手到底在做什么、LangGraph 怎么把 MCP Server 编排成可调用的 Agent 工具节点、以及多个 Server 同时接入时的资源调度与隔离问题。内容基于我最近几个项目的实际经验,代码和配置我都贴了出来,你可以直接照着复现。适合正在搞 AI Agent、尤其是快被“工具接入”折磨疯掉的开发者、算法工程师和平台架构师。
1. 为什么说 MCP 是 AI 工具调用的“高铁基建”
1.1 工具调用的混乱年代:每个模型都要自己发明轮子
聊 MCP 之前,先回忆一下没有它的日子。假设你想让大模型读一个本地文件,再做一次 SQL 查询,最后把结果发到某个协作系统。最朴素的做法是给模型开一个 tool,用框架的装饰器写上,然后自己处理参数校验、错误返回。问题在于:你写的工具基于某个特定框架的函数调用规范,换一个框架就要重写一遍。模型侧同样混乱——GPT 走 function calling,Claude 走 tools API,参数格式、返回结构、错误约定各不相同。更麻烦的是,如果工具本身是一个独立服务,你还要自己定义 REST 接口、鉴权方式、调用协议。每接一个新工具,就是一次新的“轮子发明”。
我当时一个项目接了六个外部能力:文件系统、数据库查询、代码搜索、HTTP 请求、数学计算、消息通知。按照传统方式,要做六套接口封装,还要适配五六套不同框架的调用格式。这事不是不能干,是太琐碎,琐碎到任何一个环节出错都很难排查。更关键的是,这种混乱有乘法效应:每加一个新模型厂商的接入,维护成本不是加一,而是乘二。MCP 出现之前,市场上不是没有工具调用标准,而是没有一套同时被模型厂商、工具开发者、应用开发者三方共同认可的轻量标准。
1.2 MCP 的核心抽象:工具、资源、提示词模板
MCP 定义了三类能力抽象,对应 AI 应用的三类需求:
- Tool(工具):可执行的动作,比如“读取文件”“运行 SQL”“发消息”。模型在执行循环中自主决定何时调用。
- Resource(资源):可读取的数据源,比如日志文件、数据库表结构、代码库文件,由用户或流程显式暴露给模型。
- Prompt(提示词模板):可复用的提示词工程模板,比如“代码审查”“Bug 修复步骤”,由用户主动触发,指导模型按特定方式工作。
对应到 MCP Server 实现上,这三类能力通过统一的 JSON-RPC 2.0 端点暴露。Server 可以只实现 Tool,也可以同时实现 Resource 和 Prompt,完全取决于场景。这套抽象让我这种下游使用者感受很直接:不用再为每个工具单独写适配层,只要对方暴露了 MCP 端点,用同一套客户端 API 就能全部收编。我接入内部几个老系统时,就是包一层薄薄的 MCP Server 适配层,把原有 REST API 包装后暴露给 Agent,效果立竿见影。
1.3 协议层面的三个关键设计决策
MCP 能在众多方案中胜出,我认为有三个设计决策是决定性的:
- 基于 JSON-RPC 2.0,而不是自造私有协议。JSON-RPC 2.0 是成熟的远程调用协议,请求、响应、通知、错误处理语义完整。省掉了协议设计成本,开发者几乎没有学习门槛。协议层的东西,越成熟越稳妥。
- 双向通信能力。传统 REST 是“客户端请求、服务端响应”的单向模型,但 AI 应用里常有“服务器主动通知客户端”的需求,比如文件系统变化了需要让模型重新读取。MCP 早期借助 SSE 实现,2025 年演进后的 Streamable HTTP 也保留双向通道。
- 标准化的能力协商。客户端和服务器在握手阶段就明确声明自己支持什么、不支持什么,避免运行到一半才发现双方能力不匹配。
这三个决策让 MCP 成为一套既简单又完整的“AI 应用与外部世界通信”的统一管道。你可以把它理解成 USB-C——不需要关心另一端是什么设备,插上就能用,前提是大家都遵守同一个物理接口标准。
2. 协议握手全流程拆解:从 initialize 到能力协商
2.1 传输层选型:stdio 与 Streamable HTTP
先聊连接方式。MCP 目前主流有两种传输模式:
- stdio:客户端把 MCP Server 作为子进程拉起,通过标准输入输出传 JSON-RPC 消息。适合本地工具服务,最大的好处是“零网络暴露”。缺点是进程必须由客户端管理,不能跨机器。
- Streamable HTTP:Server 是一个 HTTP 端点,客户端通过 HTTP POST 发送 JSON-RPC 消息。支持远程部署,多个客户端可以连同一个 Server,适合微服务架构。
类比一下:stdio 像你电脑上直接插的内置硬盘,Streamable HTTP 像 NAS 硬盘,一个本地一个远程,各有用处。实际选型时,本地文件操作、代码分析这类场景优先 stdio;团队共享的数据查询服务、需要在多台机器上复用的能力,直接走 HTTP。
2.2 initialize 请求:客户端鸣锣开道
无论走哪种传输,会话的第一条消息永远是initialize请求,这是协议里的硬性规定。它的作用类似双方见面先递名片、亮底牌,在正式对话前摸清对方的身份和能力边界。
一个典型的initialize请求长这样:
{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-03-26", "capabilities": { "roots": { "listChanged": true }, "sampling": {} }, "clientInfo": { "name": "my-target-agent", "version": "1.2.0" } } }关键字段逐个说:
protocolVersion:客户端声明的 MCP 协议版本。MCP 协议版本是带日期的,比如2025-03-26。双方协商时选择都支持的最高共同版本。capabilities:客户端的能力声明。roots表示客户端会告诉服务端“目录根在哪”,sampling表示客户端允许服务端发起模型采样请求——可以理解为服务端反向请模型帮忙。多数场景只需声明roots,把根目录暴露给文件类 Server 就够了。clientInfo:客户端身份信息,方便服务端做日志和兼容性判断。
2.3 能力协商:双方亮底牌
服务端收到initialize后,返回一个initialize响应,同样带协议版本、能力声明和服务端信息:
{ "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-03-26", "capabilities": { "tools": { "listChanged": true }, "resources": { "subscribe": true }, "prompts": {} }, "serverInfo": { "name": "filesystem-server", "version": "0.1.0" } } }这里有几个关键点:
- 服务端声明自己实现了
tools、resources、prompts能力子集,客户端看到这些声明才知道后续可以调哪些方法。 - 能力声明可以带参数,比如
tools.listChanged表示“工具列表变化时会主动通知客户端”。这样客户端不用反复轮询工具列表,而是等通知来了再刷新。LangGraph 接入时,这个特性可以减少不必要的刷新开销。 - 如果双方协议版本不兼容,服务端可能返回错误,或者选择兼容的旧版本。实际中最常见的情况反而是一些 Server 实现不规范,返回的版本号旧但实际能力很新,这会在后续调用时暴露问题。
握手完成后,客户端必须再发一条notifications/initialized通知,告诉服务端“我已确认初始化结果,可进入正常业务阶段”。注意是通知(notification),没有响应,JSON-RPC 2.0 里这类消息不需要id字段。服务端处理时一定要区分有id的请求和无id的通知,否则很容易把自己堵死。
2.4 握手后的会话维护:请求-响应、通知、错误处理
握手结束后,双方主要交互三类消息:
- 请求/响应:如
tools/list获取工具列表、tools/call执行工具。每条请求有id,响应必须带对应id。 - 单向通知:如
notifications/resources/list_changed,服务端主动通知资源变了。通知没有id,不需要响应。 - 错误响应:工具调用失败时,返回
error对象,含code和message。MCP 遵循 JSON-RPC 2.0 错误码约定,标准错误码在-32700到-32099区间。
实际排查问题时,我最常撞见的是错误码不标准:有的 Server 返回 HTTP 500,但 JSON-RPC 的error.code却是 0。排查时一定要把 HTTP 状态码和 JSON-RPC error code 分开看,两套体系不能混在一起。还有一点值得注意:协议要求服务端对每个请求都必须响应,哪怕是业务上不确定怎么处理,也要返回一个规范错误体。有些 Server 实现图省事直接断开连接,这会让客户端侧很难区分“服务端崩溃”和“网络抖动”。
3. LangGraph 集成 MCP:把 Server 变成 Agent 的可调用节点
3.1 LangGraph 为什么适合做 Agent 编排
LangGraph 定位不是另一个 Agent 框架,而是更底层的“Agent 流程编排框架”。核心抽象是 Graph:节点(Node)是你执行的一段逻辑,边(Edge)定义节点跳转关系,还支持条件边(Conditional Edge)实现分支控制。这种显式的图结构,比一套隐式的“循环调用工具”给开发者的控制力高出不止一个量级。
拿我最近的项目举例:一个 Agent 要完成“查资料→分析→汇总→推送”整条链路,中间某一步失败需要走重试分支,某类任务需要绕过某一步。在 LangGraph 里,我直接画成一张有向图,每个节点是独立函数,调试时可以单独跑某个节点。相比之下,纯 LangChain 的 Agent 更像一个黑盒循环——模型自己决定调用什么工具,开发者控制力有限。
3.2 两种接入方式:直接加载 tools 与包装成节点
LangGraph 接入 MCP 的工具,实际有两条路:
方式一:把 MCP 工具直接加载成 LangGraph 的 tools 列表
代码量最小,用load_mcp_tools把 MCP Server 的工具转换成 LangChain Tool 对象,然后交给ToolNode或create_react_agent。
from langchain_mcp_adapters.client import load_mcp_tools from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client server_params = StdioServerParameters( command="npx", args=["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], ) async with stdio_client(server_params) as (read, write): async with ClientSession(read, write) as session: tools = await load_mcp_tools(session) # tools 已转成 LangChain Tool 列表 for t in tools: print(t.name, t.description)方式二:把 MCP 连接封装成 LangGraph 节点
如果 MCP Server 承载的不是“可被模型随意调用的工具”,而是一个完整子流程,或者 Server 有状态、需要维护会话上下文,更好的做法是把整个ClientSession包成一个独立节点。流程先进入该节点,节点内部完成会话建立、工具调用、断连清理。这样非工具型能力(比如从 Server 拉取模型采样结果)也能编排进图。
我两种方式都用:简单的无状态工具走方式一,复杂的、有状态的交互走方式二。
3.3 一个最小可运行的 LangGraph + MCP 示例
下面给一个能直接跑通的例子。我用数学计算 MCP Server 和文件系统 Server,把两个都接进 LangGraph 的create_react_agent。
import asyncio from langchain_mcp_adapters.client import MultiServerMCPClient from langchain_openai import ChatOpenAI from langgraph.prebuilt import create_react_agent async def main(): model = ChatOpenAI(model="gpt-4o", temperature=0) async with MultiServerMCPClient( { "math": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-everything"], "transport": "stdio", }, "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], "transport": "stdio", }, } ) as client: tools = await client.get_tools() agent = create_react_agent(model, tools) result = await agent.ainvoke({ "messages": [ ("user", "先看看 /tmp 下面有哪些文件,然后算一下1234*9876的结果") ] }) for message in result["messages"]: print(message.pretty_print()) if __name__ == "__main__": asyncio.run(main())这段代码里,MultiServerMCPClient把两个 Server 都管理起来,get_tools()返回合并后的工具列表。create_react_agent会构建标准的 ReAct 循环——模型决定调用哪个工具、工具执行、结果再回到模型,直到任务结束。MultiServerMCPClient退出上下文时会自动关闭所有连接,资源回收干净。大流量生产环境还是建议自己控制连接池。
MCP 工具流式输出内容到文件这类的需求,在这个框架下也能解决。模型调用工具时,工具执行结果会经过ToolNode流回主消息循环,如果你要边生成边落盘,可以自己在工具函数里实现增量写入,或者用 LangGraph 的流式输出接口监听工具调用结果。
3.4 ToolNode 在编排里的角色
不用create_react_agent而是手动搭 Graph 的话,几乎一定会用到ToolNode。ToolNode本质是“批量执行工具”的节点:从 Agent 消息状态里取出待执行工具的请求,逐个调用,把结果塞回消息流,再返还给模型继续推理。
from langgraph.prebuilt import ToolNode from langgraph.graph import StateGraph, MessagesState, START, END from langgraph.prebuilt import tools_condition graph = StateGraph(MessagesState) graph.add_node("agent", model_with_tools) # 模型节点,带工具绑定 graph.add_node("tools", ToolNode(tools)) # 工具执行节点 graph.add_edge(START, "agent") graph.add_conditional_edges("agent", tools_condition) # 模型决定是否继续调用工具 graph.add_edge("tools", "agent") app = graph.compile()这个结构的可读性非常好:agent节点是大脑,tools节点是手脚,tools_condition做判断,流程在“大脑→手脚→大脑”之间循环,直到模型认为任务完成。调试时盯住节点的输入输出,就能看到模型每一步在想什么、工具返回了什么。这也是 LangGraph 相比纯 LangChain 最让我喜欢的地方:过程完全显式可见。
4. 多 Server 调用实战:连接管理、安全保障与性能收口
4.1 多 Server 的典型架构
真实的 Agent 不会只接一个 Server。我最近的项目里,一个生产 Agent 同时挂了代码分析 Server、数据库查询 Server、内部 API 网关 Server 和文件操作 Server。每个 Server 能力不同,有本地有远程,有带状态会话的。多 Server 架构下的核心问题就两个:连接怎么管、能力怎么隔离。
需要澄清一个术语层级:MCP 的 Client 和 Session 是两层概念。一个 Client 可以发起多个 Session,每个 Session 对应一个 Server 连接。MultiServerMCPClient这个适配器做的事情,就是帮你在一个 Client 下管理多个 Session,拉平合并工具列表。
4.2 连接管理与工具聚合
async with MultiServerMCPClient( { "database": { "url": "http://localhost:8020/mcp", "transport": "streamable-http", }, "code_search": { "command": "docker", "args": ["run", "--rm", "-i", "mcp/code-search"], "transport": "stdio", }, } ) as client: tools = await client.get_tools()这看起来简单,但背后有细节。get_tools()返回的列表里,工具名直接沿用 Server 里的原始名字。如果两个 Server 都有叫search的工具,模型就会困惑到底调哪个。解决方案是给工具名加前缀,或者在上层按 Session 把工具分组,在 Prompt 里明确每个分组的用途。项目初期用语义化前缀最快,比如db_query、cs_search,模型也容易理解。当工具数量超过 30 个、调用冲突明显增多时,再考虑 Graph 级隔离:不同领域的工具放进不同子图,通过路由节点分发。一上来就把架构设计复杂,往往死在第一步。
4.3 连接生命周期、超时与重连策略
多 Server 长期运行,最怕的不是协议不兼容,而是连接静默断开。stdio 连接受子进程生命周期影响,HTTP 连接受网关和负载均衡器影响,都可能在无人感知的情况下断开。
我的实践建议:
- 启动时一次性建立全部连接,失败快速失败(fail fast),不带病运行。
- 周期性发送
ping通知做健康检查,MCP 的ping请求没有业务副作用,适合探测连接活性。间隔我习惯 30 秒一次。 - 捕获异常后,对特定 Server 做指数退避重连,重试上限 3 次。超时阈值我习惯是空闲超时 15 秒、请求超时 30 秒,具体按业务调整。
async def with_reconnect(session_factory, max_retries=3): for attempt in range(max_retries): try: async with session_factory() as session: yield session break except Exception as e: print(f"连接异常,第{attempt + 1}次重试: {e}") await asyncio.sleep(2 ** attempt) # 指数退避重连策略不用实现得很完美,但它的存在本身,就能避免很多生产环境“莫名其妙不可用”的问题。我再强调一点:多 Server 场景下,单个 Server 的故障不应该拖垮整体流程。给每个 Server 的调用设置独立超时,用asyncio.wait_for包一层,超时就跳过该 Server 的工具,走降级路径。
4.4 安全边界:权限最小化与敏感操作隔离
MCP 协议本身不定义鉴权规范,安全边界要靠应用层自己守住。我在实际项目中遵循三条原则:
- Roots 最小化。客户端通过
roots能力向 Server 声明根目录,这相当于给工具划定“可操作范围”。文件操作类 Server,我只暴露项目目录,绝不暴露/或用户主目录。如果 Server 实现里没有正确遵守 roots 边界,工具理论上确实存在越权读取的隐患,所以选型时优先选实现规范的官方 Server。 - Tool 级权限控制。不是所有工具都应该对所有 Agent 开放。内部 API 网关 Server 上,有的工具只允许特定角色调用。我是在 LangGraph 的节点层做拦截,在调度工具前检查请求上下文里的角色信息,不符合就返回拒绝。把权限判断放在业务逻辑层之外,更清晰也更容易审计。
- 敏感信息脱敏。数据库查询类 Server 返回的数据可能包含敏感字段,在工具返回给模型之前,先过一次脱敏过滤器。常见的做法是规则替换:手机号、身份证、密钥等正则匹配后打码。这步不能省,模型一旦把敏感数据带进上下文,后续日志和会话记录都会留下隐患。
另外,sampling能力是双向的:Server 可以反向请求客户端调用模型。如果客户端声明了sampling能力,等同于允许 Server 发起模型调用,这可能带来不可控的模型调用开销和信息泄漏风险。我的建议是默认不开启sampling,除非你非常清楚 Server 的采样动机。权限边界这种事,宁可严格一点,也别等出了问题再补。
5. 生产环境踩坑记录与性能优化
5.1 我踩过的三个坑
坑一:stdio 子进程没有清理干净
第一次上线时,服务器内存持续上涨。排查后才确认,每个请求都拉起了一个新的 stdio 子进程,请求结束后进程也没回收。后来才知道,ClientSession退出时,必须确保stdio_client的上下文正确退出,子进程需要明确 terminate。用asyncio的上下文管理器还不够,要在容器级别兜底,确保npx这类进程不会变成孤儿进程。这也是我后来倾向把 stdio 型 Server 部署成常驻进程、再用 HTTP 方式接入的原因——进程生命周期由专门的守护系统管理,不会失控。
坑二:工具列表缓存导致模型看不到新工具
MCP Server 支持动态工具列表。最初我图省事,启动时拉一次tools/list就缓存到内存,结果 Server 端新加了工具,模型一直调不到。后来改成监听notifications/tools/list_changed通知,收到就刷新缓存。这也是前面强调能力协商里listChanged字段价值的原因——不要忽略这个字段,动态工具列表是远端能力的常态,不是特例。
坑三:HTTP 传输的鉴权和限流
Streamable HTTP 的 Server 部署在公网或跨网络环境,必须做鉴权。MCP 协议本身不定义鉴权规范,实践中普遍是在 HTTP 层用 Bearer Token。我在内网环境踩过一个坑:网关限流没有给 MCP 接口单独放行,Agent 高峰期的调用全部 429,整条流程陷入重试地狱。建议在网关层给 MCP 接口单独立规则,同时客户端侧做好退避兜底。
5.2 日志与可观测性
协议层的可观测性要点,是把握手细节、请求-响应映射、错误码都记录下来。我习惯在客户端侧打结构化日志,每个 MCP 请求都带一个request_id,响应回来时用同一个request_id关联。
关键日志字段:
session_id:会话标识request_id:请求标识method:调用方法名server_name:目标 Serverlatency_ms:耗时error_code:JSON-RPC 错误码
有了这些字段,排查问题就能按session_id或request_id把整条链路串起来。生产环境里,我建议至少把握手阶段和工具调用的失败日志独立采样,别跟普通业务日志混在一起。握手是排查很多诡异问题的第一现场——协议版本不兼容、能力声明缺失、通知顺序错误,都会在握手日志里留下痕迹。
5.3 性能优化:连接池、并发与超时控制
MCP 本身没有连接池概念,多 Server 场景下连接池完全靠应用层自己管。我的经验是:
- stdio 连接:一个 Server 一个长连接,别每请求都起进程。进程启动的耗时差异可能是毫秒级到秒级,这个差距在 Agent 多轮工具调用里会被放大。
- HTTP 连接:用
httpx的 AsyncClient 连接池,配置连接复用上限。MCP 适配器默认可能没有这个配置,需要自己额外管理。 - 并发调用:多个独立工具之间,可以用
asyncio.gather并发执行,但要给每个 Server 的并发设置上限,防止一个慢 Server 拖垮整个流程。
我给一个优化案例:原先一个 Agent 任务里顺序调用 5 个 MCP 工具,总耗时约 8 秒。分析后发现其中 3 个互相独立,改成asyncio.gather并发后单次任务降到 3 秒左右。优化前提是工具之间确实无依赖,不要为了并发而并发——模型状态依赖一旦错乱,结果可能是灾难级的。
5.4 多 Server 的并发可靠性设计
多 Server 并发还有一个容易忽略的维度:失败隔离。一个 Server 超时,不能阻塞其他 Server 的工具调用。我把这层逻辑拆成三步:第一步是超时隔离,每个工具调用独立挂在asyncio.wait_for下;第二步是降级策略,核心 Server 失败时走备用 Server 或直接降级返回,保证 Agent 主链路不断;第三步是限流,不只在网关层限,客户端侧也要防止模型在一个循环里疯狂调用某个重工具。LangGraph 的tools_condition天然有这个约束能力——你可以在条件边里加一个调用频次判断,超过阈值就跳出工具循环,让模型先总结已有信息。
这些设计都是被线上事故逼出来的。第一次遇到一个 Server 卡住导致整个 Agent 流程挂起时,我才意识到:多 Server 的复杂度,不在于连多少个,而在于当一个 Server 出问题时,系统还能不能优雅地继续。协议握手只是入门,真正的考验是生产环境下的资源治理。
最后说一点实际体会。MCP 这套协议,真正解决的不是“能不能调工具”的问题,而是“工具生态能不能标准化”的问题。它像极了当年 USB 取代五花八门充电口的过程——起初大家觉得无所谓,但当越来越多的 Server 和 Client 遵守同一套协议时,复用的价值才真正爆发出来。而我个人最大的收获是:把注意力从“怎么接工具”转移到“怎么编排工具”之后,Agent 的工程质量反而上了一个台阶。如果你正准备把零散的 AI 工具能力收拢成统一入口,我的建议很直接:先用 stdio 加本地 Server 把链路跑通,再逐步拆分成多 Server 架构,过程中重点盯握手、工具命名、连接生命周期这三个点。成熟,往往是从一个简单但能跑通的原型开始的。