1. 从一次多工具调用的踩坑说起
去年年底我接手了一个内部工具链整合的活儿,需求说起来很简单:让一个对话式助手能同时调用公司内部的代码仓库查询、数据库表结构读取、以及一个自研的部署状态检查接口。三个能力,三个不同的后端服务,每个服务都有自己的鉴权和参数格式。我一开始的想法很朴素——写三个函数,在对话流程里根据意图判断该调哪个,然后手动拼参数、发请求、解析返回。结果第一版跑通花了不到两天,但接下来两周我几乎都在填坑:参数格式对不上、超时没有统一处理、某个服务改了字段名导致整条链路静默失败、上下文在多次调用之间丢失。
后来我把这套东西推倒重来,换成了基于 MCP 协议的统一接入方式,再用 LangGraph 做多 Server 的编排。整个链路从“三个散装函数”变成了“一套标准协议 + 一张状态图”,维护成本直接降了一个数量级。这篇文章就把这套方案从头到尾拆一遍——MCP 到底是什么、协议握手阶段发生了什么、怎么把多个 MCP Server 接进 LangGraph、以及我在实操中踩过的那些坑。
如果你正在做 AI 应用的工具调用层,或者手头有一堆零散的内部接口想要统一暴露给模型,这套思路应该能帮你少走不少弯路。哪怕你之前只听说过 MCP 这个词、完全没动过手,跟着下面的步骤也能跑起来。
2. MCP 到底是什么:把工具调用变成一门“通用语言”
2.1 为什么需要 MCP 这层协议
在没有 MCP 之前,模型要调用外部能力,通常有两种做法。第一种是把工具描述直接塞进提示词,让模型输出一段结构化文本,再由应用层解析执行——这就是早期的 function calling 思路。第二种是每个工具单独写适配代码,模型输出意图,应用层用 if-else 分发。这两种做法在小规模场景下都能用,但一旦工具数量上去、或者多个应用要复用同一批工具,问题就来了:每个应用都要重新实现一遍工具的接入逻辑,工具提供方也要为每个消费方适配不同的调用约定。
MCP(Model Context Protocol)要解决的就是这个“N 对 N”的适配爆炸问题。它定义了一套标准的客户端-服务端交互协议,工具提供方只需要实现一个 MCP Server,任何支持 MCP 的客户端都能直接接入。你可以把它理解成工具调用领域的 USB-C 接口——以前每个设备一个专用口,现在统一成一个标准口,插上就能用。
这里有个关键点容易被忽略:MCP 不只是“工具调用协议”,它实际上定义了三类能力——Tools(可执行的操作)、Resources(可读取的数据)、Prompts(预置的提示模板)。很多教程只讲 Tools,但 Resources 和 Prompts 在实际项目里同样重要。比如你要让模型读取一份配置文件,用 Resource 就比包装成一个 Tool 更自然,因为它是只读的数据暴露,不涉及副作用。
2.2 MCP 的核心概念拆解
要理解 MCP,先得把几个角色分清楚。Host是最终面向用户的应用,比如一个桌面客户端或者一个 Web 服务;Client是 Host 内部负责与 Server 通信的组件,通常一个 Client 对应一个 Server 连接;Server则是能力提供方,暴露 Tools、Resources、Prompts。这三者的关系是:Host 管理多个 Client,每个 Client 与一个 Server 建立连接。
通信层面,MCP 目前主流有两种传输方式:stdio和HTTP with SSE(以及较新的 Streamable HTTP)。stdio 适合本地进程,Server 作为子进程启动,通过标准输入输出通信,配置简单、延迟低,适合开发调试和本地工具。HTTP 方式适合远程 Server,多个客户端可以共享同一个 Server 实例,适合团队内部部署。
消息格式上,MCP 采用JSON-RPC 2.0。这意味着每个请求和响应都是标准的 JSON-RPC 结构,包含jsonrpc、id、method、params等字段。用 JSON-RPC 的好处是协议成熟、有现成的错误码规范、请求响应能通过id精确配对,天然支持并发。
2.3 MCP 与 LangGraph 的分工边界
很多人会混淆 MCP 和 LangGraph 的职责。简单说,MCP 负责“能力怎么暴露和调用”,LangGraph 负责“什么时候调用、调用完怎么走下一步”。MCP 是工具层的标准,LangGraph 是编排层的框架。两者是互补关系,不是替代关系。
举个具体例子:你有三个 MCP Server,分别提供数据库查询、代码搜索、部署检查。MCP 保证你调用这三个 Server 的方式是一致的——都是tools/call,参数都是 JSON。但“先查数据库、根据结果决定要不要搜代码、再根据代码结果决定要不要检查部署”这个决策流程,是 LangGraph 的活儿。LangGraph 用状态图的方式把这个流程显式建模出来,每个节点可以是一个 MCP 调用,边则定义了流转条件。
这种分工的好处是:工具层的变化(比如某个 Server 换了实现)不影响编排逻辑,编排逻辑的调整也不影响工具层。两层各自独立演进,维护起来清爽很多。
3. 协议握手:一次 MCP 连接到底发生了什么
3.1 握手阶段的完整消息流
MCP 连接建立不是“连上就能用”,中间有一系列握手消息。以 stdio 传输为例,Client 启动 Server 子进程后,第一件事是发送initialize请求。这个请求里包含客户端支持的协议版本、客户端能力声明(比如是否支持 roots、sampling)、以及客户端信息。Server 收到后返回自己的协议版本、能力声明和 Server 信息。
这里有个细节值得注意:协议版本协商。Client 和 Server 各自声明自己支持的版本,如果版本不兼容,连接会失败。我在实操中遇到过因为客户端库版本比 Server 新一个大版本导致握手失败的情况,报错信息还比较隐晦,排查了半天。所以建议在项目里把 MCP 相关依赖的版本锁定,避免自动升级带来的意外。
握手完成后,Client 通常会发送notifications/initialized通知,表示初始化完成。之后就可以正常调用tools/list、tools/call、resources/list等方法了。整个握手过程如果一切顺利,在本地 stdio 场景下通常几十毫秒就能完成,但如果是远程 HTTP 连接,网络往返会让这个过程明显变长。
3.2 能力声明与动态发现
握手阶段的能力声明决定了后续能调用哪些方法。比如 Server 如果没声明tools能力,Client 就不应该去调tools/list。这个设计的好处是 Client 可以在握手后就知道 Server 支持什么,不需要盲目试探。
能力发现是动态的——tools/list返回的是当前 Server 实际暴露的工具列表,包含每个工具的名称、描述、输入参数的 JSON Schema。这意味着你可以在不重启 Client 的情况下,让 Server 动态增减工具(只要重新拉一次列表)。我在一个项目里就利用这个特性做了热插拔:运维同学在 Server 端加了一个新工具,Client 端定时刷新工具列表,模型下一次对话就能用上新工具,完全不用改客户端代码。
不过这里有个坑:工具描述的质量直接决定模型选得准不准。我见过很多 Server 把工具描述写得极其简略,比如就一句“查询数据”,模型根本不知道这个工具查的是什么数据、参数该怎么填。好的工具描述应该包含:这个工具做什么、什么场景下用、每个参数的含义和格式、返回值的结构。这部分投入的回报率极高,值得花时间打磨。
3.3 握手失败的常见原因与排查
握手失败在实际项目里并不少见,我整理了几类高频原因。第一类是传输层问题:stdio 场景下 Server 进程启动失败(比如命令路径写错、依赖没装),HTTP 场景下端口不通或证书问题。第二类是协议版本不匹配,前面提过。第三类是能力声明冲突,比如 Client 要求某个 Server 必须支持 Resources,但 Server 没声明这个能力。
排查的时候,我的习惯是先看 Server 进程的 stderr 输出——stdio 场景下 Server 的日志通常打到 stderr,很多启动错误会直接显示在那里。如果是 HTTP 场景,先用 curl 手动发一个initialize请求,确认 Server 本身是活的,再排查 Client 侧的配置。这个“先隔离再定位”的思路能省很多时间。
提示:调试 MCP 连接时,把 Client 和 Server 的日志级别都调到 debug,能看到完整的 JSON-RPC 消息流。很多问题看一眼原始消息就明白了,比猜快得多。
4. LangGraph 多 Server 调用的架构设计
4.1 为什么用状态图而不是链式调用
传统的链式调用(比如 LangChain 的 SequentialChain)是线性的:A 完了走 B,B 完了走 C。但多 Server 调用的真实场景往往不是线性的——你可能需要根据第一个 Server 的返回结果,决定要不要调第二个 Server,或者并行调两个 Server 再汇总。这种带条件分支和并行汇聚的流程,用链式表达会非常别扭,而状态图天然适合。
LangGraph 的核心抽象是StateGraph:你定义一个状态结构(通常是个字典或 TypedDict),然后添加节点(每个节点是一个函数,接收状态、返回状态更新)和边(定义节点之间的流转)。条件边可以根据状态内容动态决定下一个节点。这个模型和 MCP 多 Server 调用的需求高度契合——每个 MCP 调用是一个节点,调用结果写入状态,后续节点根据状态决定怎么走。
我用下来的体会是,状态图最大的价值不是“能画出来”,而是把隐式的控制流显式化了。以前散落在代码各处的 if-else 判断,现在都变成了图上的边和条件函数,一眼就能看出整个流程长什么样。新人接手的时候,看图比读代码快得多。
4.2 多 Server 的连接管理策略
接多个 MCP Server 时,连接管理是个需要认真设计的地方。最朴素的做法是每次调用都新建连接、用完关闭,但这样开销很大,尤其是 stdio 场景下每次都要启动子进程。更好的做法是维护一个长连接池,在应用启动时把所有 Server 连接建立好,调用时从池里取。
但长连接也有代价:Server 进程如果崩溃了,连接就断了,需要有重连机制。我的做法是给每个连接加一个健康检查,定期发一个轻量的ping(或者tools/list),发现异常就重建连接。同时给连接加超时,避免某个 Server 卡死拖垮整个流程。
另一个策略问题是连接是全局共享还是按会话隔离。如果 Server 是无状态的(大多数工具类 Server 都是),全局共享没问题,还能省资源。但如果 Server 有会话状态(比如维护了一个对话上下文),那就得按会话隔离,否则不同用户的请求会串。这个要根据具体 Server 的行为来定,不能一刀切。
4.3 状态结构的设计要点
状态结构设计得好不好,直接决定了图的可维护性。我的经验是,状态里应该包含三类信息:输入信息(用户请求、初始参数)、中间结果(各个 Server 的返回)、控制信息(当前步骤、重试次数、错误标记)。
中间结果的存放有个技巧:不要把所有 Server 的返回都平铺在状态顶层,而是按 Server 分组,比如results: { db: {...}, code: {...}, deploy: {...} }。这样状态结构清晰,节点读取自己关心的部分也方便。另外,MCP 的返回通常是结构化的内容块数组,存进状态前最好做一层归一化,把内容块转成更易处理的格式,避免下游节点反复解析。
控制信息里,重试计数和错误标记是两个必须的字段。MCP 调用可能因为网络抖动或 Server 临时故障失败,有重试计数就能实现有限次重试。错误标记则让条件边能根据“是否出错”决定走正常流程还是走降级流程。这两个字段看起来简单,但少了它们,图的健壮性会差很多。
5. 从零搭建:多 Server 调用的完整实操
5.1 环境准备与依赖安装
先把环境搭起来。Python 版本建议 3.10 以上,因为 LangGraph 和一些 MCP 客户端库用到了较新的类型语法。核心依赖有三个:langgraph(编排框架)、langchain-mcp-adapters(把 MCP 工具转成 LangChain 工具,方便在 LangGraph 里用)、以及mcp(MCP 的 Python SDK)。
pip install langgraph langchain-mcp-adapters mcp如果你要用 HTTP 传输的 Server,还需要httpx。另外建议装python-dotenv管理配置,把 Server 的路径、端口、鉴权信息放到.env里,不要硬编码在代码里。
环境变量大概长这样:
DB_SERVER_CMD=python DB_SERVER_ARGS=-m,mcp_servers.db_server CODE_SERVER_URL=http://localhost:8081/mcp DEPLOY_SERVER_URL=http://localhost:8082/mcp把命令和参数分开配置,是因为 stdio 场景下启动命令和参数需要分别传给客户端库,合并成一个字符串反而不好处理。
5.2 单个 MCP Server 的接入
先接一个 Server 跑通,再扩展到多个。以 stdio 传输为例,用langchain-mcp-adapters的MultiServerMCPClient来管理连接:
from langchain_mcp_adapters.client import MultiServerMCPClient client = MultiServerMCPClient({ "db": { "command": "python", "args": ["-m", "mcp_servers.db_server"], "transport": "stdio", } }) tools = await client.get_tools()get_tools()返回的是 LangChain 工具对象列表,每个工具对应 Server 暴露的一个 Tool。这一步背后其实做了好几件事:启动 Server 子进程、完成协议握手、调用tools/list、把每个工具的描述和参数 Schema 转成 LangChain 的工具格式。你拿到的tools可以直接绑定到模型上,也可以在图节点里手动调用。
这里有个实操细节:get_tools()是异步的,必须在 async 上下文里调用。如果你在同步代码里用,需要用asyncio.run()包一层,但要注意别在已经有事件循环的环境里再调asyncio.run(),会报错。我的做法是整个应用统一用 async,避免同步异步混用带来的麻烦。
5.3 多 Server 并行接入与工具命名冲突处理
接多个 Server 就是把配置字典扩展一下:
client = MultiServerMCPClient({ "db": { "command": "python", "args": ["-m", "mcp_servers.db_server"], "transport": "stdio", }, "code": { "url": "http://localhost:8081/mcp", "transport": "streamable_http", }, "deploy": { "url": "http://localhost:8082/mcp", "transport": "streamable_http", } })多 Server 场景下最容易踩的坑是工具名冲突。如果两个 Server 都暴露了一个叫query的工具,get_tools()返回的列表里就会有两个同名工具,模型调用时无法区分。解决办法有两个:一是在 Server 端给工具名加前缀(比如db_query、code_query),二是在 Client 端做一层重命名。我倾向于在 Server 端加前缀,因为这样工具名本身就自带了来源信息,模型看到名字就知道该用哪个。
另一个坑是工具数量过多导致模型选择困难。我接过一个项目,三个 Server 加起来暴露了四十多个工具,模型经常选错。后来做了工具分组,按场景把工具分成几组,每次只把相关组的工具暴露给模型,准确率明显提升。这个思路在 LangGraph 里很好实现——不同的图节点绑定不同的工具子集。
5.4 用 LangGraph 编排多 Server 调用流程
现在进入核心部分:用 LangGraph 把多个 MCP 调用串起来。先定义状态:
from typing import TypedDict, Annotated from langgraph.graph import StateGraph, END import operator class AgentState(TypedDict): query: str db_result: dict code_result: dict deploy_result: dict error: str retry_count: int然后定义节点。每个节点负责调用一个 MCP 工具,把结果写进状态:
async def call_db(state: AgentState): try: result = await db_tool.ainvoke({"sql": state["query"]}) return {"db_result": result, "error": ""} except Exception as e: return {"error": str(e), "retry_count": state.get("retry_count", 0) + 1} async def call_code(state: AgentState): result = await code_tool.ainvoke({"keyword": state["db_result"]["table"]}) return {"code_result": result} async def call_deploy(state: AgentState): result = await deploy_tool.ainvoke({"service": state["code_result"]["service"]}) return {"deploy_result": result}接着定义条件边,根据状态决定流转:
def after_db(state: AgentState): if state["error"] and state["retry_count"] < 3: return "retry_db" if state["error"]: return "fallback" return "call_code" graph = StateGraph(AgentState) graph.add_node("call_db", call_db) graph.add_node("call_code", call_code) graph.add_node("call_deploy", call_deploy) graph.add_node("fallback", lambda s: {"deploy_result": {"status": "skipped"}}) graph.set_entry_point("call_db") graph.add_conditional_edges("call_db", after_db, { "retry_db": "call_db", "call_code": "call_code", "fallback": "fallback", }) graph.add_edge("call_code", "call_deploy") graph.add_edge("call_deploy", END) graph.add_edge("fallback", END) app = graph.compile()这段代码的关键在于after_db这个条件函数——它把“出错重试”“出错降级”“正常继续”三种情况显式表达出来了。以前这些逻辑散在 try-except 里,现在集中在图定义里,改起来一目了然。
5.5 参数传递与结果归一化
多 Server 调用里,参数传递是个容易被低估的难点。第一个 Server 的返回往往不是第二个 Server 直接能用的格式,中间需要做转换。比如数据库查询返回的是行记录列表,代码搜索需要的是表名或字段名,这中间就得有个提取逻辑。
我的做法是在节点内部做转换,而不是单独加一个转换节点。原因是转换逻辑通常和具体的 Server 对强相关,放在调用节点里更内聚。但转换逻辑要写得防御性强一些——第一个 Server 的返回结构可能因为版本变化而改变,转换代码要能处理字段缺失的情况,不能直接result["table"]这样硬取。
结果归一化方面,MCP 的返回是内容块数组,每个块有type字段(text、image、resource 等)。我通常写一个辅助函数,把内容块数组转成纯文本或结构化字典,下游节点统一用归一化后的格式。这样即使某个 Server 返回了特殊类型的内容块,也不会让下游节点崩溃。
6. 常见问题与排查技巧实录
6.1 连接类问题速查
连接类问题占了我在 MCP 实操中遇到问题的一大半。下面这张表是我整理的速查表,覆盖了最常见的几种情况:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 握手超时 | Server 进程未启动或端口不通 | 检查命令路径、端口占用、防火墙 |
| 协议版本不匹配 | 客户端与服务端版本差异过大 | 锁定依赖版本,查看握手日志 |
| 工具列表为空 | Server 未声明 tools 能力 | 检查 Server 能力声明配置 |
| 连接频繁断开 | 长连接无健康检查 | 加 ping 机制和重连逻辑 |
| HTTP 401/403 | 鉴权信息缺失或过期 | 检查 token 配置和有效期 |
排查连接问题时,我有个习惯:先用最简客户端手动连一次。比如用官方 SDK 写个十行的脚本,只做握手和tools/list,确认 Server 本身没问题。这一步能排除掉大部分“到底是 Server 问题还是 Client 问题”的纠结。
6.2 调用超时与重试策略
MCP 调用超时是另一个高频问题。默认超时时间往往偏短,尤其是涉及数据库查询或远程服务的工具,几秒钟根本不够。我的做法是给每个工具单独配置超时,而不是用全局默认值。查询类工具给 30 秒,写入类工具给 60 秒,轻量的状态检查给 5 秒。
重试策略上,不是所有错误都值得重试。网络抖动、临时性服务不可用,这些重试有意义;参数错误、鉴权失败,重试多少次都一样。所以重试逻辑里要区分错误类型,只对可重试的错误做重试。LangGraph 的条件边正好能做这件事——在条件函数里判断错误类型,决定是重试还是走降级。
注意:重试一定要有次数上限,并且最好加退避(比如每次重试间隔翻倍)。我见过没加上限的重试逻辑,Server 挂了之后客户端疯狂重试,把日志刷爆还把资源耗尽了。
6.3 工具选择错误的优化
模型选错工具是多 Server 场景下的顽疾。除了前面说的工具分组,还有几个优化手段。第一是优化工具描述,把使用场景写清楚,比如“当需要查询用户订单时使用此工具”比“查询订单”要好得多。第二是减少同时暴露的工具数量,人面对太多选项会犹豫,模型也一样。第三是在系统提示里给出选择指引,比如“优先使用 db 系列工具查询数据,只有在 db 工具无法满足时才用 code 工具”。
我实测下来,这三招组合使用,工具选择准确率能从六七成提升到九成以上。其中投入产出比最高的是优化工具描述,几乎不花什么成本,效果立竿见影。
6.4 状态污染与并发问题
LangGraph 的状态是每个执行实例独立的,正常情况下不会有并发问题。但如果你在节点里用了全局变量或者共享的可变对象,就可能出现状态污染。我踩过一次坑:在一个节点里用了一个模块级的缓存字典,结果两个并发请求互相覆盖了缓存内容,导致返回了错误的结果。后来改成把缓存挂在状态里,或者用请求级别的上下文,问题就解决了。
另一个并发相关的点是 MCP 连接池。如果多个图执行实例共享同一个连接池,要确保连接池本身是线程安全或协程安全的。大多数客户端库在这方面做得不错,但自己实现连接池的话要特别注意。
7. 几个让我少走弯路的实操心得
第一个心得是先把单个 Server 跑通再扩展。我一开始贪快,直接配了三个 Server 一起调,结果握手阶段就出问题,根本不知道是哪个 Server 的锅。后来改成先接一个、跑通、再加第二个、再跑通,每步都确认无误,整体调试时间反而更短。
第二个心得是给每个 MCP 调用都加日志。日志里记录调用的工具名、参数、耗时、返回状态。这些日志在排查问题时是救命稻草。我现在的习惯是,MCP 调用的日志单独打一个 logger,和业务日志分开,方便过滤。
第三个心得是工具描述值得反复打磨。我有个工具的描述改了五版,每版都根据模型选错的案例调整措辞,最后准确率稳定在九成五以上。这个过程有点像给新人写操作手册,写得越清楚,对方越不容易出错。
第四个心得是别把编排逻辑写得太复杂。LangGraph 能表达很复杂的流程,但复杂不等于好。我见过把十几个节点串成一张大图的方案,维护起来极其痛苦。我的原则是:能用线性流程解决的,就别加条件分支;能在一个节点里做完的,就别拆成三个节点。图的复杂度应该和业务复杂度匹配,而不是为了用而用。
最后分享一个配置管理的小技巧:把每个 MCP Server 的配置写成一个独立的配置文件(比如 YAML),启动时统一加载。这样新增或修改 Server 不用改代码,改配置就行。我在一个项目里用这个方式,运维同学自己就能加 Server,完全不需要开发介入,省了很多沟通成本。