☰
MCP与LangGraph实战:从协议握手机制到多Server编排的Agent集成方案
2026/10/8 12:33:07 网站建设 项目流程

前阵子接了个需求,要把内部订单查询系统和一套运维文档知识库同时接进 Agent,一开始自然想到的是给每个系统单独写 HTTP 封装、自己做鉴权、再手工整理一份 OpenAI Function 描述。磨合到一半我就放弃了:每个系统都得重复一遍"鉴权、文档解析、错误重试、工具注册"的流程,太像早期的接口时代。后面我把这两个系统都改成了 MCP Server,Agent 这边用一个统一客户端拿工具、调工具,配合 LangGraph 做编排,整个集成成本一下子降下来了。

这篇文章就从协议握手的细节讲起,落到 LangGraph 里怎么组织多 Server 调用。内容偏实战,适合已经在用 LLM 做 Agent、但还没系统接触过 MCP 的开发者,也适合那种"见过工具调用、但没搞懂协议层到底发生了什么"的人。

1. 为什么要关心 MCP:从"写胶水代码"到"接标准插头"

先聊一个很现实的问题:Agent 接外部能力,最常见的方式到底是什么?

我见过不少团队的项目,都是这样起步的——用 FastAPI 包一层 HTTP 接口,把查询逻辑暴露出来,然后在 System Prompt 里写清楚"调这个接口需要什么参数",再把函数签名喂给模型。跑一两个服务还行,一旦超过三五个,问题就绷不住了:

  • 每个服务都得自己定义一套"工具描述格式",有的用 JSON Schema,有的直接甩一段自然语言,模型经常理解偏。
  • 鉴权方式五花八门,有 API Key 的,有签名算法的,有 OAUTH 的,Agent 代码里塞了一堆 if-else。
  • 错误处理更是灾难,A 系统超时返回 504,B 系统返回一堆堆栈,C 系统干脆静默失败,LLM 拿到这些结果也判断不出到底要不要重试。

MCP 做的事情,本质上是把这些散落的集成工作收敛成一套标准协议。它不关心你内部是 Python 还是 Node,不关心你数据存在 MySQL 还是 ClickHouse,它只定义了三类原语:

  • Tool:可执行的函数,模型能看见、能调用。
  • Resource:可读的数据源,类似"把某份文档发给模型当上下文"的能力。
  • Prompt:可复用的提示模板,由服务端定义。

一旦两边都遵守这个协议,集成就变成了"插头对插座":系统方写一个 MCP Server 暴露能力,Agent 方用 MCP Client 获取工具列表、发起调用,剩下的鉴权、传输、重试这些破事,被协议和 SDK 挡在下面。

这里有个心态上的转变,我觉得挺重要的。之前我们写 Agent,思考的是"我该怎么为这个 API 写描述、做参数校验、处理返回结果";有了 MCP 之后,思考变成了"我该以什么粒度暴露我的能力、这个工具描述怎么写模型才不会误解"。前者是给机器写适配层,后者是给模型设计接口,完全是两个层级的活。

你可能会问:既然说了这么多好处,那我直接用 FastAPI + function calling 不也能达到类似效果吗?答案是能,但代价是每次都要从零维护一套"半私有协议"。而 MCP 的生态正在快速统一,像 LangGraph、LangChain 这些编排框架已经原生支持 MCP 工具注入,Dify 这类低代码平台也在跟进。基于标题热搜词里的趋势也能看出来,MCP 已经不是概念预热期,而是实打实进入项目落地阶段了。

2. 握手不是黑魔法:MCP 协议握手的请求链路拆解

很多教程上来就教你写 Client、注册工具,但把协议层的握手过程直接跳过了。我觉得这不太好——你调试 MCP Server 时,百分之八十的疑难杂症都出在握手没走通,或者 capabilities 协商跟预期不一致。所以这一节我们把握手拆开看。

2.1 传输层:stdio 与 Streamable HTTP

MCP 的传输层有两种主流形态。

第一种是 stdio,也就是通过标准输入输出跑 JSON-RPC 消息,适合"本地进程"这种场景。你在命令行跑一个 Python 脚本,脚本通过 stdin 接收请求、通过 stdout 写回响应,这就是一个最简单的 stdio MCP Server。

第二种是 HTTP,早期规范是 SSE(Server-Sent Events),后来更新成了 Streamable HTTP,请求和响应都走普通的 HTTP 通道。远程部署、跨机器调用走这个。

无论哪种传输方式,协议消息本身都是 JSON-RPC 2.0。看一个例子就明白了:

{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": { "name": "my-agent", "version": "1.0.0" } } }

2.2 初始化握手:client 与 server 的第一次对话

MCP 的握手比 HTTP 的 TCP 握手多一层用意——它要协商的不是连接,而是"我们互相能做什么、用哪个版本的协议对话"。

完整流程分三步:

  1. 客户端发送initialize请求,带上自己支持的协议版本和 clientInfo。
  2. 服务端返回自身支持的协议版本、服务端能力声明(capabilities)以及 serverInfo。
  3. 客户端发送notifications/initialized通知,握手结束。

服务端返回的响应大概长这样:

{ "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-03-26", "capabilities": { "tools": { "listChanged": true }, "resources": { "subscribe": true } }, "serverInfo": { "name": "order-query-server", "version": "0.3.0" } } }

协议版本协商这块,有一个值得注意的点:如果服务端不支持客户端发的protocolVersion,服务端会返回它自己支持的最新版本,客户端收到后需要判断是否兼容。现实里大部分 SDK 都已经帮你处理了版本降级逻辑,你不需要手工重发请求,但理解这个机制对你排查"两边版本一堆莫名其妙的报错"非常有帮助。

capabilities 更是关键。它声明了这个 Server 到底支持什么——tools支持工具调用,resources支持资源读取,prompts支持模板提示。如果 Server 没声明某个 capability,客户端就不该去调用对应的方法。你们项目里如果出现"工具列表拉到了,但一调用就报 Method Not Found",先回头检查 capabilities 是不是没声明,而不是怀疑协议被破坏。

握手完成之后,正常的 RPC 调用就开始了。Agent 想拿到可用工具,就发一个tools/list:

{ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} }

服务端返回工具数组,每个工具包含名称、描述和输入 Schema。模型就是靠这个描述来决定要不要调用、怎么传参的,所以你会发现 MCP 工具描述的写作质量,直接影响 Agent 的能力上限。工具描述写得稀烂,模型就会在多个相似工具之间犹豫不决。

真正执行工具时发tools/call:

{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "query_order", "arguments": { "order_id": "20250101ABC" } } }

服务端执行完,返回包含内容(content)和是否出错(isError)的结果。这里有个细节:MCP 的结果统一用content数组承载,字段本身还有text、image等类型,所以"返回一张图片给模型看"这类跨模态传递也是天然支持的。

2.3 为什么你平时感觉不到握手的存在

看到这里你可能觉得,MCP 握手流程好繁琐,真要每一步都手写,项目开发效率得打骨折。

没错,所以官方的 Python SDK 和 TypeScript SDK 把这些细节都封装掉了。你用mcp这个 Python 包写客户端时,通常只需要:

from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def main(): params = StdioServerParameters(command="python", args=["server.py"]) async with stdio_client(params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() tools = await session.list_tools()

你看到了,session.initialize()这一行背后就是刚才讲的握手流程。工具函数返回的tools对象里,每个工具都有.name、.description、.inputSchema三个核心字段。站在使用者的角度,协议细节可以全权交给 SDK 处理,但一旦出现问题,你就得靠上面那些协议知识去排查。

一个我在实际项目里测出来的经验:MCP Server 启动阶段如果报错,比如依赖缺失、环境变量没配上,stdio transport 模式下 server 进程一开始就挂了,client 那边表现出的症状不是握手超时,而是"发完 initialize 后完全没有响应"。这种问题先去 server 的 stderr 看日志,比在客户端反复重试有效得多。HTTP transport 模式下,症状又会变成 HTTP 层 404,那就是 Server 的 HTTP 路由压根没暴露出来。

3. 在 LangGraph 里把 MCP 工具"翻译"成 Agent 能用的东西

LangGraph 对 MCP 的支持,并不是说这两个东西谁替代谁——它们本来就在解决不同层面的事。MCP 解决的是"Agent 如何外接工具",LangGraph 解决的是"Agent 如何编排这些工具的执行流程"。把两者拼起来,才算一个完整的 Agent 应用。

3.1 LangGraph 的核心概念与 MCP 对接点

LangGraph 的核心建模方式,总结起来就三个概念:节点(Node)、边(Edge)、状态(State)。

  • State:贯穿整个图执行的共享数据,LLM 的中间输出、工具的执行结果、最终回复都往这里塞。
  • Node:一个执行单元,接受 State 输入、经过逻辑处理、输出新的 State。
  • Edge:节点之间的流转路径,可以带条件,也就是所谓的条件边。

Agent 最常见的一种图结构是:Agent 节点 → 工具节点 → Agent 节点……循环,直到模型认为不需要再调用工具,才输出最终答案。这里面"Agent 节点"负责推理和决策,"工具节点"负责真正执行外部函数。

MCP 工具要接进来,面临一个翻译问题:MCP 协议的工具格式,跟 LangGraph 里的 ToolNode 所期望的格式不是一回事。MCP 返回的工具是"名称 + 描述 + inputSchema"这样的结构,而 LangGraph 里的工具节点通常要求 OpenAI function-calling 风格的完整工具定义。

翻译的核心路径是:把 MCP 返回的每个工具重映射成{type: "function", function: {name, description, parameters}}结构。巧的是,LangChain 官方出的langchain-mcp-adapters库就是专门干这件事的,里面有现成的转换函数,比如convert_to_openai_tools。

不过我也建议你亲自动手写一次转换逻辑,哪怕之后还是用现成库。写一次你就会明白,转换过程不只是字段重命名,还涉及inputSchema里 properties 的必填项、组合 schema 的处理,这些细节一旦要自定义扩展,就得自己动手。

3.2 Agent 节点怎么绑定工具列表

在 LangGraph 里,Agent 节点的典型实现是用一个 LLM 实例,把tools参数传进去:

from langchain_openai import ChatOpenAI llm = ChatOpenAI(model="gpt-4o", temperature=0) # openai_tools 就是从 MCP 工具转换过来的一组工具描述 agent_node = llm.bind_tools(openai_tools)

这一步做完,LLM 在推理时就能看到这些工具,并在需要时输出结构化的 tool_calls。注意,bind_tools不会真的执行工具,它只是把工具描述塞进请求里,让模型"知道有这些工具可用"。

真正执行工具的地方是 ToolNode。你可以这样理解:Agent 节点负责"决定调哪个工具",ToolNode 负责"真的去调"。LangGraph 会把 LLM 输出的 tool_calls 从 State 里读出来,逐个匹配到对应的工具函数上执行。因此,你需要在图里挂一个工具执行节点,并把可分发的工具列表传给它。

多 Server 场景下,这个环节会出现一个常见的架构选择:要么把 A Server 的所有工具和 B Server 的所有工具合并成一个"工具池",统一挂在同一个 ToolNode 下;要么按 Server 拆分多个 ToolNode,再用路由策略决定哪个 Agent 节点去碰哪个工具池。这是下一节要展开的重点,这里先不深入。

3.3 一个最小可跑的集成骨架

我把一个最小实现的结构放在这里,它包含三个元素:MCP 客户端获取工具、把工具转成 OpenAI 格式、在 LangGraph 图中挂一个 ToolNode。

from langchain_mcp_adapters.tools import convert_to_openai_tools from langgraph.prebuilt import ToolNode # tools 来自 MCP session.list_tools() 的结果 openai_tools = convert_to_openai_tools(tools) # 把转换后的工具交给 ToolNode tool_node = ToolNode(openai_tools)

接着就是常规的 LangGraph 构建流程:定义 StateGraph,加入 Agent 节点和 tool_node,用条件边判断"如果 LLM 输出里有 tool_calls 就去 tool_node,否则直接生成最终回复"。

跑通这个最小结构之后,你会发现整套流程跟直接用 OpenAI function calling 没有任何区别——模型的决策逻辑、工具的返回格式、循环次数控制都是一样的。差别只在底层:以前工具定义是手写的,现在是从 MCP Server 动态拉取的;以前工具执行是调自己封装的 Python 函数,现在是经过 MCP 协议通道到达对端服务。

这带来的直接好处是:新增能力时,你不需要改 Agent 代码。Server 端把新工具注册好,客户端重新拉一次tools/list,Agent 立刻就能感知。这种"动态发现工具"的能力,是我认为 MCP 最有杀伤力的特性。

4. 多 Server 调用的真实形态:共享指令流还是路由分发

单个 MCP Server 的集成其实很简单,跟接一个普通工具库差不多。真正考验架构能力的,是多个 Server 同时接入。我这里说的多 Server,指的是"一个 Agent 应用需要同时连接 N 个异构系统",比如数据库查询服务、文档检索服务、网页抓取服务,各由独立的 MCP Server 提供。

4.1 共享池模式:所有工具一股脑塞给一个 Agent

最简单粗暴的做法是:客户端同时连接多个 Server,把每个 Server 的工具列表全部拉下来,合并在一个数组里,再统一传给 LLM。

我开头讲的订单查询 + 运维文档场景,最初就是这种形态。伪代码大概长这样:

class MCPGateway: def __init__(self): self.sessions = [] self.tool_map = {} async def connect(self, server_configs): for name, cmd, args in server_configs: session = await self._create_session(cmd, args) tools = await session.list_tools() for tool in tools.tools: tool_name = f"{name}__{tool.name}" self.tool_map[tool_name] = (session, tool) self.all_tools.append({ "type": "function", "function": { "name": tool_name, "description": tool.description, "parameters": tool.inputSchema } })

合并后的工具数组直接丢给 Agent 节点。模型看到 A 系统的订单查询、B 系统的文档搜索、C 系统的网页内容抓取,自行判断此刻该调哪个。

共享池模式的好处是灵活,模型可以自由组合不同 Server 的工具,完成"查订单 → 查文档 → 汇总生成报告"这种跨系统链路。坏处也很明显,工具一多,prompt 里的工具描述就非常占上下文,而且模型在几十个工具之间做选择时,选错工具的几率会上升。我的经验是:超过 20 个工具时,这种模式就开始变得不可控,你得开始思考路由分发。

4.2 路由分发模式:让专门的 Agent 处理专门的事

路由分发模式的思路是分层。外层一个主 Agent 不持有任何具体工具,只负责理解用户意图、判断该把任务交给哪个子 Agent;内层每个子 Agent 各连接一个 MCP Server,只看到属于自己领域的工具。

在 LangGraph 里,这可以用多 Agent 图实现。主 Agent 输出一个结构化的"路由决定",条件边根据这个决定把流程分发到不同的子图。每个子图内部又包含自己的 Agent 节点和 ToolNode,跟前面讲的最小结构一样。

这种模式的优势是上下文干净:子 Agent 只看到自己领域的工具,不会被无关的工具描述干扰;主 Agent 不需要看任何工具描述,只要做好任务分析。劣势同样存在——路由决策本身有误差,如果主 Agent 判断错了,把本该去文档检索的任务发给了数据库查询子 Agent,后续流程就会走入死胡同,需要额外的纠错机制。另外,子图的引入让整个链路变长,一次普通问答可能要经过主 Agent 和子 Agent 两轮推理,时延会翻倍。

4.3 两种模式的选型建议

我用一个表格总结下我的选择逻辑:

判断维度共享池模式路由分发模式
工具数量适合 20 个以内适合 20 个以上,甚至几百个
任务复杂度链路简单,模型可自主规划链路复杂,需要显式分工
上下文预算宽松,能容纳工具描述紧张,需为子任务保留空间
时延敏感度低,一次推理就行高,主 Agent + 子 Agent 多轮
路由容错没有路由,不担心路由错误要有重路由或兜底设计

我自己在实际项目中,一般会用共享池模式起步,简单直接;当工具数量膨胀、模型开始"乱点工具"时,再拆成路由分发。这个演进顺序比较自然,不必一开始就上复杂架构。

4.4 多 Server 连接的生命周期管理

多 Server 场景还有一个很容易被忽略的工程问题:连接生命周期。

每个 MCP Server 都需要一条独立的连接。stdio 模式下,每条连接就是一个子进程;HTTP 模式下,是一条可复用的 HTTP 通道。如果你在 Agent 每次请求时都重新建连,开销会非常大——子进程启停、握手来回都要时间。所以要做连接池,或者至少在应用层面缓存 Session。

我的做法是做一个 ConnectionManager,维护一份"Server 名称到 Session 的映射",Agent 启动时统一建立连接,之后所有请求复用。连接断了再按需重建,并做好重试和超时。因为 MCP SDK 的 Session 不是线程安全的,如果你做并发请求,进程里要为每个 Server 维护独立的 Session,避免共享同一个 Session 导致消息 ID 冲突。

LangGraph 的并行节点在这个阶段会帮上忙。比如一个任务需要同时调用文档检索和数据库查询,它们互相没有依赖,可以在图上用并行分发的方式同时执行,而不是串行跑。这里要特别提醒一点:MCP 的单个 Session 内部是串行处理请求的,你用同一个 Session 并发调用两个工具,后到的请求会被阻塞。并发场景下要么给每个并发分支建独立连接,要么接受串行的代价、在业务上做取舍。

5. 踩坑实录:多 Server 场景下的三个典型问题

再多的架构理论,不踩坑等于白讲。这里分享三个我实际遇到、而且网上不太容易查到的坑,每个都附上完整排查链路。

5.1 工具重名:模型悄无声息地"点错工具"

第一个坑出现在两个 Server 都定义了query这个工具名的场景。一个查订单,一个搜文档,名字一模一样。合并工具池后,工具列表里出现两条query记录,LLM 调度时随机选中一个,返回的数据完全驴唇不对马嘴,而且不会报任何错误——因为从模型视角看,它调用query是合法的,只是"查错了系统"。

排查链路:先打印 LLM 实际决策出的 tool_calls,确认它选的是哪个工具;再对比 MCP Server 返回的工具列表,发现两个 Server 的工具名发生碰撞;进一步确认,tools/list返回的名字本身没有命名空间隔离,需要客户端自行处理。

解决方式:合并前给工具名加前缀,我用的是{server_name}__{tool_name}这种分隔方式,既保证唯一,又不至于太拗口。同时更新工具描述的文本,把归属信息写进 description 里,帮模型做更精准的判断。这个改动很小,但能省掉后期一大批"调用结果对不上"的排查时间。

5.2 串行阻塞:一个慢工具拖垮整条 Agent 链路

第二个坑,是一次性能测试时暴露的。当时有一个数据库查询 Server,某些查询要跑十几秒才能返回。监控数据发现,在这十几秒里,Agent 完全没法调用其他 Server 的工具,整条链路的吞吐量被单点拖死。

排查链路:先从 Agent 日志看,工具调用记录的时间戳间隔异常;再看 MCP Server 侧,发现 Server 进程全程阻塞在一条请求上;最后翻 MCP 协议文档,确认单个 session 在同一时刻只能处理一个请求——JSON-RPC 的 id 匹配机制天然是串行的。

解决方式:给耗时工具设置超时,超过阈值直接返回"查询超时",让模型决定是否换一种方式;把不同 Server 拆到不同 Session,让 A Server 的慢查询不要阻塞 B Server 的调用;如果同一 Server 内部也有高并发需求,就为它建立多个连接,做一个简单的连接池分配。实际上,多数 Agent 场景不需要刻意追求全并行,"给关键路径建独立连接"就够了。

5.3 权限边界:Agent 调用了不该调的危险工具

第三个坑更具隐蔽性。一个 MCP Server 暴露了"文件写入"类工具,本意是供受控场景使用。一次线上任务里,Agent 依据模型推理,自行调用了这个工具,覆盖了一个本不该动的文件。工具执行成功,数据也回滚了好几个小时。

排查链路:先看 Agent 的完整执行轨迹,确认是模型自主决策还是用户触发;再看 MCP Server 的权限设计,发现工具本身没有任何权限分级;最终确认,MCP 协议层没有原生的"工具级权限控制",谁拿到连接就能调所有工具。

解决方式:我后续做了三层防御。第一层,在 Client 侧做工具过滤,根据工具的 name 或 description 打标,把只读工具和写工具分开;第二层,让 MCP Server 本身对危险工具加确认参数,比如要求额外传一个confirm: true才能执行;第三层,在 Agent 的 System Prompt 里明确规则,把"不得主动调用写操作"写死,并配合工具描述里的 warning 提示。三层叠加后,误操作概率大幅下降。

这套排查链路给我们的启示很直接:协议本身只保证"能通",不保证"安全"。接入外部 Server 时,权限边界要假设对方是不可信的,风险控制必须做在 Client 侧。

6. 按需选择:什么时候真的需要 MCP,什么时候不用硬套

最后泼一盆冷水。MCP 确实好,但它不是银弹。我见过有人把内部两个函数之间的调用也包一层 MCP,纯粹为了"跟上技术潮流",这属于过度设计。

什么时候不建议用 MCP?工具数量少、且只在进程内调用时,直接用 Python 函数就好。模型通过普通 function calling 就能调用,不用引入进程通信和协议层开销。内部系统之间的高频调用,比如毫秒级的热路径查询,MCP 的进程通信成本反而会成为性能瓶颈。

什么时候值得用?跨系统、跨团队、异构技术栈之间的工具互通,以及需要"动态发现工具"的场景。一句话,集成成本高、对方系统你改不动、或你不希望每次加工具都改一遍 Agent 代码,这时候 MCP 的价值才真正体现出来。

项目初期的搭建建议,我一般这么排序:先是单 Server + LangGraph 最小链路跑通,验证模型决策和工具执行闭环;再扩展多 Server,从共享池模式开始;等到工具数量失控或路由错误频发,再考虑拆分路由分发架构。每一步都有明确的触发条件,不要一上来就搭分布式。

最后分享一个很久之后才悟出来的小技巧:给每个 MCP Server 配对客户端时,把鉴权、重试、超时、日志这些横切逻辑统一封装成一个装饰器或基类,而不是在每个连接代码里重复写。因为你永远预测不到项目后期会接入多少 Server,统一的横切封装能让后续每个 Server 都保持相同的代码风格和失败处理模式。这点在你接手别人留下的、连接逻辑千奇百怪的代码时,会尤其感激当时的自己。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询