1. 从一次多工具调用的踩坑说起上周在做一个智能代码审查助手的项目需求很明确让大模型能够自动读取本地代码仓库、查询数据库中的历史缺陷记录、再调用静态分析工具做扫描最后把结果汇总成一份报告。听起来是个典型的 Agent 工作流但真正动手的时候才发现光是让模型知道有哪些工具可用、每个工具怎么调这件事就足够让人头疼。传统做法是把每个工具都包装成一个函数写死在 prompt 里模型输出 JSON 再去解析执行。这套方案在工具数量少于五个的时候还能凑合一旦工具膨胀到十几个prompt 会变得又长又乱模型经常把参数名记混更别提跨工具的数据传递了。我试过用 LangChain 的 Tool 抽象来管理确实好了一些但每个工具的实现方式还是各写各的没有一个统一的协议来约束工具应该长什么样、怎么被发现、怎么被调用。直到我把目光转向MCPModel Context Protocol配合LangGraph做多 Server 编排整个链路的清晰度才上了一个台阶。这篇分享就把我从协议握手到多 Server 调用的完整实践过程拆开讲包括协议层到底在做什么、LangGraph 怎么和 MCP Server 对接、多个 Server 之间怎么协调以及我在这个过程中踩过的那些坑。如果你正在做 Agent 工具集成、或者想让自己的 REST 接口快速变成模型可调用的工具这篇内容应该能帮你少走不少弯路。2. MCP 协议到底解决了什么问题2.1 从每个工具写一套到统一接口规范在没有 MCP 之前我给模型接工具的方式基本是这样的定义一个 Python 函数写好 docstring然后用 LangChain 的tool装饰器包一下最后塞进 Agent 的 tools 列表里。这套流程本身没问题问题出在复用上。假设我写了一个查询 SQL Server 数据库表结构的工具同事想在他的项目里用他得把我的代码复制过去改改依赖再重新适配他的 Agent 框架。如果他用的是另一套框架那基本等于重写。MCP 的思路是把工具这件事标准化。它定义了一套基于 JSON-RPC 2.0 的通信协议规定了 Server 端要暴露哪些能力Resources、Tools、PromptsClient 端怎么发现这些能力、怎么调用、怎么处理返回结果。你可以把它理解成工具界的 USB-C 接口——只要你的工具实现了 MCP Server任何支持 MCP 的 Client 都能直接接上去用不用关心底层是 Python 还是 Node.js不用关心是本地进程还是远程服务。这个类比不是随便说的。USB-C 的核心价值在于物理形态和电气协议的标准化让不同厂商的设备能互插。MCP 的核心价值在于工具描述和调用方式的标准化让不同框架、不同语言的 Agent 能互通。我实测下来一个用 Python 写的 MCP Server可以被 Node.js 写的 Client 直接调用中间不需要任何胶水代码这种互操作性在以前是很难想象的。2.2 协议握手的三个核心阶段MCP 的连接建立过程官方叫法是初始化握手我习惯把它拆成三个阶段来理解这样排查问题的时候定位更准。第一阶段是能力协商。Client 连上 Server 之后双方会交换各自支持的能力集。Client 会告诉 Server我支持 roots、sampling 这些特性Server 会告诉 Client我提供 tools、resources、prompts 这些能力。这个阶段的关键在于版本匹配——如果 Client 用的是 2024-11-05 版本的协议Server 只支持更老的版本握手就会失败。我在早期调试的时候遇到过这个问题报错信息很隐晦后来抓包才发现是协议版本对不上。第二阶段是能力发现。协商完成后Client 会调用tools/list方法Server 返回一个工具列表每个工具包含名称、描述、输入参数的 JSON Schema。这一步是整个 MCP 最有价值的地方——模型不需要预先知道有哪些工具它可以在运行时动态发现。这意味着你可以给 Agent 挂载几十个 Server每个 Server 提供若干工具模型根据当前任务自动选择合适的工具来调用。第三阶段是调用执行。当模型决定调用某个工具时Client 发送tools/call请求带上工具名和参数Server 执行后返回结果。结果可以是文本、图片、结构化数据甚至是另一个工具的调用建议。这里有个细节值得注意MCP 的调用是有状态的同一个连接内的多次调用可以共享上下文这对于需要多步操作的工具比如先打开文件再读取内容非常关键。2.3 为什么选 MCP 而不是自己造轮子你可能会问这套东西我自己用 HTTP JSON 也能实现为什么要用 MCP我的回答是自己造轮子的成本不在实现而在维护和生态。我早期确实自己写过一套工具调用协议用 FastAPI 暴露接口Agent 通过 HTTP 请求调用。刚开始挺爽但很快问题就来了工具的描述格式要自己定参数校验要自己写错误处理要自己设计流式输出要自己实现。更麻烦的是每接一个新工具都要重复这套流程。等到工具数量上到二十个光是维护这些接口的一致性就够喝一壶的。MCP 把这些脏活累活都标准化了。参数校验有 JSON Schema 兜底错误处理有标准的状态码流式输出有 SSE 传输机制工具发现是协议内置的。我只需要关注工具本身的业务逻辑协议层的事情交给 SDK 处理。而且因为 MCP 是开放协议社区里已经有大量现成的 Server 可以直接用——文件系统、数据库、浏览器自动化、代码分析基本覆盖了常见的工具需求。3. LangGraph 与 MCP 的对接设计3.1 为什么是 LangGraph 而不是普通 AgentLangGraph 和普通 LangChain Agent 的核心区别在于状态管理和流程控制。普通 Agent 是一个循环模型思考、调用工具、观察结果、再思考直到任务完成。这个模式在单工具场景下够用但一旦涉及多工具协作、条件分支、人工介入就会变得难以控制。LangGraph 把 Agent 的执行过程建模成一张状态图每个节点是一个操作调用模型、执行工具、做判断边是状态转移条件。这种建模方式的好处是流程可视化、状态可持久化、分支可控制。我在做多 Server 调用的时候需要根据前一个 Server 的返回结果决定下一个调用哪个 Server这种逻辑用 LangGraph 的 conditional edge 表达非常自然用普通 Agent 就得在 prompt 里写一堆 if-else 的描述模型还不一定听话。另一个关键点是状态共享。LangGraph 的 State 是一个贯穿整个执行过程的字典所有节点都能读写。这意味着 MCP Server 返回的结果可以直接存进 State后续节点按需取用不需要在 prompt 里来回传递。我实测下来这种方式比把结果塞进对话历史要干净得多尤其是当返回结果是结构化数据的时候。3.2 多 Server 的接入架构我的项目里同时接了三个 MCP Server一个文件系统 Server 负责读取代码一个 SQL Server 数据库 Server 负责查询缺陷记录一个静态分析 Server 负责代码扫描。这三个 Server 的接入方式不太一样正好覆盖了常见的几种场景。文件系统 Server 是本地进程通过 stdio 传输。Client 启动的时候会 spawn 一个子进程通过标准输入输出通信。这种方式的好处是简单、无需网络配置适合本地工具。缺点是 Server 的生命周期和 Client 绑定Client 挂了 Server 也跟着挂。数据库 Server 是远程服务通过 SSEServer-Sent Events传输。Server 部署在一台内网机器上Client 通过 HTTP 连接。这种方式适合需要共享的工具多个 Client 可以连同一个 Server。但要注意 SSE 是单向的Client 到 Server 的请求还是走 HTTP POSTServer 到 Client 的推送走 SSE。静态分析 Server 是混合模式启动时通过 stdio 拉起但内部会调用远程的分析服务。这种模式适合工具本身是本地进程、但依赖远程资源的场景。在 LangGraph 里我把这三个 Server 的 Client 都封装成了独立的节点。每个节点负责一件事调用对应的 MCP Client执行工具把结果写进 State。节点之间通过 conditional edge 连接根据 State 里的标志位决定下一步走哪个节点。3.3 工具发现与动态路由多 Server 场景下最棘手的问题是工具名冲突和路由决策。假设文件系统 Server 有一个read_file工具数据库 Server 也有一个read_file工具读取数据库中的文件记录模型怎么知道该调哪个我的解决方案是在工具名前加 Server 前缀比如fs.read_file和db.read_file。这个前缀在工具发现阶段由 Client 自动加上模型看到的工具列表里就是带前缀的名字。调用的时候Client 根据前缀把请求路由到对应的 Server。这个方案实现简单但要求前缀命名有规范不能出现歧义。路由决策这块我没有让模型直接选工具而是用 LangGraph 的图结构来约束。具体做法是先让模型输出一个任务类型比如代码审查、缺陷查询、静态扫描然后根据任务类型走对应的边进入对应的 Server 节点。节点内部再让模型从该 Server 的工具列表里选具体工具。这种两级决策的方式比让模型从几十个工具里直接选要稳定得多。4. 实操从零搭建一个多 Server 调用链路4.1 环境准备与依赖安装先把基础环境搭起来。我用的是 Python 3.11LangGraph 和 MCP 的 Python SDK 都对这个版本支持良好。依赖清单如下pip install langgraph langchain-core mcp pip install langchain-openai # 如果用 OpenAI 兼容的模型 pip install mcp-server-filesystem # 文件系统 Server如果你要用数据库 Server还需要装对应的驱动。我用的是 SQL Server所以装了pyodbc和pymssql。这里有个坑要注意pyodbc在 Windows 上需要先装 ODBC DriverLinux 上要装unixodbc-dev否则 import 的时候会报错。MCP 的 Python SDK 提供了ClientSession和StdioServerParameters两个核心类。前者管理会话后者描述怎么启动 Server 进程。SSE 模式的 Client 用sse_client函数返回一个异步上下文管理器。4.2 单个 MCP Server 的连接与工具发现先写一个最简单的例子连接文件系统 Server 并列出可用工具import asyncio from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def main(): server_params StdioServerParameters( commandnpx, args[-y, modelcontextprotocol/server-filesystem, /path/to/your/code], ) async with stdio_client(server_params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() tools await session.list_tools() for tool in tools.tools: print(f工具名: {tool.name}) print(f描述: {tool.description}) print(f参数: {tool.inputSchema}) print(---) asyncio.run(main())这段代码做了三件事启动 Server 进程、建立会话、发现工具。initialize()就是前面说的握手阶段它会完成能力协商。list_tools()返回的是工具列表每个工具的inputSchema是一个标准的 JSON Schema描述了参数的类型、是否必填、默认值等信息。我实测下来这个握手过程大概需要 200-500 毫秒取决于 Server 的启动速度。文件系统 Server 用 npx 启动第一次会慢一些因为要下载包。后续启动会快很多。4.3 把 MCP 工具接入 LangGraph 节点接下来把 MCP 的调用封装成 LangGraph 的节点。核心思路是每个节点持有一个 MCP Client 的引用节点执行时调用对应的工具把结果写进 State。from typing import TypedDict, Annotated from langgraph.graph import StateGraph, END import operator class AgentState(TypedDict): task: str code_content: str db_records: list scan_result: str messages: Annotated[list, operator.add] async def read_code_node(state: AgentState, session: ClientSession): result await session.call_tool( read_file, {path: state[task]} ) return {code_content: result.content[0].text} async def query_db_node(state: AgentState, session: ClientSession): result await session.call_tool( execute_query, {sql: fSELECT * FROM defects WHERE file_path LIKE %{state[task]}%} ) return {db_records: result.content} async def scan_node(state: AgentState, session: ClientSession): result await session.call_tool( scan_code, {code: state[code_content]} ) return {scan_result: result.content[0].text}这里有个细节要注意call_tool返回的content是一个列表因为 MCP 支持返回多种类型的内容文本、图片、资源引用。我一般取第一个元素的text字段但如果你的工具返回的是结构化数据可能需要解析 JSON。节点的绑定方式我用的是闭包把 session 作为参数传进去。这样做的原因是 MCP 的 session 是有状态的不能跨连接复用。如果你用类来组织代码可以把 session 存在实例属性里。4.4 多 Server 的并行与串行编排三个节点写好后用 StateGraph 把它们串起来。我的流程是先读代码然后并行做数据库查询和静态扫描最后汇总结果。from langgraph.graph import StateGraph, END def build_graph(fs_session, db_session, scan_session): graph StateGraph(AgentState) graph.add_node(read_code, lambda s: read_code_node(s, fs_session)) graph.add_node(query_db, lambda s: query_db_node(s, db_session)) graph.add_node(scan, lambda s: scan_node(s, scan_session)) graph.add_node(summarize, summarize_node) graph.set_entry_point(read_code) graph.add_edge(read_code, query_db) graph.add_edge(read_code, scan) graph.add_edge(query_db, summarize) graph.add_edge(scan, summarize) graph.add_edge(summarize, END) return graph.compile()LangGraph 会自动处理并行分支的同步——summarize节点会等query_db和scan都完成后才执行。这个特性在多 Server 场景下非常有用因为不同 Server 的响应速度差异很大并行调用能显著缩短总耗时。我实测下来串行调用三个 Server 大概需要 3-5 秒并行之后降到 1.5-2 秒。4.5 流式输出的处理MCP 支持流式返回这对于长时间运行的工具比如代码扫描很重要。Client 端通过call_tool的progress_callback参数接收进度更新async def progress_handler(progress: float, total: float, message: str): print(f进度: {progress}/{total} - {message}) result await session.call_tool( scan_code, {code: code}, progress_callbackprogress_handler )这里有个坑不是所有 Server 都实现了进度上报如果 Server 端没有调用report_progresscallback 永远不会触发。我在调试的时候一度以为是 Client 的问题后来看了 Server 源码才发现是 Server 没实现。所以用第三方 Server 的时候最好先确认它是否支持流式进度。5. 常见问题与排查技巧实录5.1 握手失败的几种典型情况握手失败是我遇到最多的问题表现形式各不相同但根因就那么几个。我整理了一张速查表现象可能原因排查方法连接超时Server 进程启动失败手动执行启动命令看是否有报错协议版本不匹配Client 和 Server 版本差异过大检查双方 SDK 版本升级到兼容版本能力协商失败Server 不支持 Client 请求的能力查看 Server 文档确认支持的能力集认证失败远程 Server 需要 token检查环境变量或配置文件中的凭证我踩过最坑的一次是 Server 进程启动失败但报错信息被 stdio 吞掉了Client 只看到超时。后来我在启动命令里加了日志重定向才看到是 Python 依赖缺失。所以调试 stdio 模式的 Server 时一定要把 stderr 重定向到文件否则你永远不知道 Server 那边发生了什么。5.2 工具调用返回空结果的排查有时候工具调用成功了但返回的 content 是空的。这种情况通常有三个原因一是工具本身执行成功但没有输出二是返回的内容类型不是文本比如图片三是参数传递有问题导致工具走了空分支。我的排查顺序是先在 Server 端加日志确认工具确实被调用了然后打印完整的result对象看content列表里到底有什么最后检查参数特别是那些有默认值的参数确认传进去的值符合预期。有个细节值得注意MCP 的call_tool返回的isError字段表示工具执行是否出错但它和 HTTP 的状态码不是一回事。有些 Server 在工具出错时仍然返回 200只是把isError设为 true。所以判断调用是否成功不能只看有没有异常还要检查isError。5.3 多 Server 场景下的资源竞争同时连多个 Server 的时候资源竞争是个容易被忽视的问题。我遇到过一次文件系统 Server 和静态分析 Server 同时读取同一个文件导致文件锁冲突两个调用都失败了。解决方案是在 LangGraph 层面做资源隔离。具体做法是给每个 Server 分配独立的临时目录或者用信号量控制对共享资源的访问。我用的是后者在 State 里加一个file_locks字典节点执行前先检查锁用完释放。另一个资源问题是连接数限制。有些远程 Server 对并发连接数有限制超过就会拒绝新连接。我的做法是在 Client 端加一个连接池复用已有的 session而不是每次调用都新建连接。MCP 的 session 本身是支持多次调用的只要不关闭就能一直用。5.4 超时与重试策略MCP 的调用默认没有超时如果 Server 卡住了Client 会一直等。这在生产环境是不可接受的。我的做法是在 Client 端包一层asyncio.wait_fortry: result await asyncio.wait_for( session.call_tool(scan_code, {code: code}), timeout30.0 ) except asyncio.TimeoutError: # 记录日志走降级逻辑 result fallback_scan(code)重试策略要谨慎。对于幂等的工具比如查询、读取重试是安全的对于有副作用的工具比如写文件、发请求重试可能导致重复操作。我的经验是给每个工具打一个idempotent标签只有标记为幂等的工具才自动重试其他的走人工确认。6. 几个提升开发效率的实操心得6.1 用 MCP Inspector 做协议调试MCP 官方提供了一个叫 Inspector 的调试工具可以可视化地查看握手过程、工具列表、调用请求和响应。我在开发 Server 的时候基本离不开它比看日志直观多了。启动方式很简单npx modelcontextprotocol/inspector它会启动一个本地 Web 界面你可以在里面配置 Server 连接、手动调用工具、查看原始 JSON-RPC 消息。我特别喜欢它的消息历史功能能看到每一次请求和响应的完整内容排查参数问题时特别有用。6.2 把 REST 接口快速包装成 MCP Server项目里有很多现成的 REST 接口重新用 MCP SDK 写一遍太浪费。我的做法是写一个通用的包装层读取 OpenAPI 规范自动生成 MCP Server。核心逻辑是把每个 REST 端点映射成一个 MCP 工具参数从 OpenAPI 的 schema 转换过来调用时发 HTTP 请求。这个包装层大概两百行代码但省下了大量重复劳动。我实测下来一个中等规模的 REST 服务二十个端点包装成 MCP Server 只需要改改配置十分钟就能跑起来。需要注意的是认证信息的传递REST 接口的 token 不能硬编码在 Server 里要通过环境变量或者 MCP 的 roots 机制传入。6.3 工具描述的写法直接影响模型调用准确率这一点是我踩了很多坑才意识到的。MCP 工具的description字段不只是给人看的模型在选择工具时会参考它。描述写得好模型调用准确率能提升一大截。我的经验是描述里要包含什么时候用和什么时候不用。比如一个search_code工具描述不能只写搜索代码而要写在代码库中搜索匹配的代码片段适用于查找特定函数或变量的定义不适用于查找文件路径用 list_files。这样模型在决策时就有了明确的边界。参数描述同样重要。每个参数的description要说明格式和约束比如文件路径相对于项目根目录不要以斜杠开头。我见过太多因为参数格式不对导致调用失败的案例加上这些说明后失败率明显下降。6.4 用 LangGraph 的 checkpointer 做状态持久化LangGraph 的 checkpointer 功能在多 Server 场景下特别有用。它可以把 State 持久化到数据库这样即使 Client 崩溃了重启后也能从上次的状态继续执行。我用的是 SQLite checkpointer配置很简单from langgraph.checkpoint.sqlite import SqliteSaver memory SqliteSaver.from_conn_string(checkpoints.db) graph builder.compile(checkpointermemory)调用的时候传一个thread_id同一个 thread 的多次调用会共享状态。这个功能在做长流程任务时是刚需比如代码审查这种可能跑几分钟的任务中途断了不用从头再来。6.5 监控与日志的埋点位置多 Server 链路的可观测性很重要但埋点位置有讲究。我的做法是在三个地方加日志Client 发起调用前记录工具名和参数Server 返回后记录耗时和结果大小LangGraph 节点切换时记录当前 State 的关键字段。这样出问题的时候我能快速定位是哪个环节卡住了。比如如果 Client 日志有但 Server 日志没有说明请求没到 Server如果 Server 日志有但返回很慢说明是工具本身的问题如果节点切换异常说明是图结构的问题。日志格式我用的是结构化 JSON方便后续用 ELK 或者 Loki 做聚合分析。字段包括timestamp、server_name、tool_name、duration_ms、status、error_message。这套埋点方案跑下来排查问题的平均时间从半小时降到了五分钟以内。7. 关于多 Server 编排的一点个人体会这套 MCP LangGraph 的方案我在两个项目里落地过整体感受是协议标准化带来的收益远大于学习成本。刚开始接触 MCP 的时候光是理解握手流程和传输机制就花了两天但一旦跑通后面接新工具的速度是指数级提升的。以前接一个工具要半天现在半小时就能搞定因为所有样板代码都被协议层吸收了。LangGraph 在多 Server 场景下的价值也很明显。它的状态图模型让复杂的调用逻辑变得可读、可调试、可持久化。我特别喜欢它的条件边和并行分支这两个特性在处理根据结果决定下一步和同时调用多个工具时特别顺手。如果非要给正在考虑这套方案的人一个建议我会说先从单个 Server 跑通再逐步加 Server。我见过有人一上来就接五个 Server结果握手阶段就卡住了排查起来非常痛苦。单 Server 跑通后多 Server 的复杂度主要在于路由和状态管理这两块 LangGraph 都有现成的方案照着文档配就行。另外工具描述和参数 schema 的打磨值得花时间。这部分投入的回报是直接的——模型调用准确率提升人工干预减少整个链路的稳定性上一个台阶。我现在每接一个新工具都会先写描述和 schema再写实现这个顺序不能反。