
1. 从一次多工具调用的踩坑说起前阵子我在做一个内部知识助手的小项目需求很朴素用户问一句话系统自动判断该去查本地文档、查数据库、还是调一个外部接口然后把结果整合成一段回答。一开始我用的是最土的办法——写一堆 if-else把每个工具的调用逻辑硬编码在主流程里。结果工具一多代码就变成了一团乱麻加一个工具要改五六个地方调试的时候根本不知道是哪一步出了问题。后来接触到MCPModel Context Protocol模型上下文协议才意识到这套东西本质上是在解决一个很具体的问题让模型和外部工具之间的对接标准化。你可以把它理解成 USB-C 接口——以前每个设备都有自己的充电口现在统一成一个标准谁都能插。MCP 就是给模型调用工具这件事定了一个统一的插口规范Server 负责暴露能力Client 负责连接和调用中间用一套约定的消息格式沟通。而LangGraph则是另一个维度的东西。它把整个调用流程建模成一张图节点是步骤边是流转关系特别适合处理多工具、有条件分支、需要循环重试的场景。把 MCP 和 LangGraph 放一起就能搭出一个既能标准化接入各种工具、又能灵活编排调用流程的系统。这篇分享我会从最底层的协议握手讲起一直讲到 LangGraph 里怎么同时挂载多个 MCP Server 并做路由。中间会穿插我自己踩过的坑、参数怎么算、代码怎么写。适合已经了解一点大模型应用开发、想把手里的工具调用逻辑理顺的读者。如果你连 MCP 是什么都还没概念也没关系我会先把基础讲透。2. MCP 到底是什么把工具调用抽象成协议2.1 为什么需要 MCP 这层协议在没有 MCP 之前模型要调用一个外部工具通常有两种做法。第一种是把工具的描述直接塞进 prompt让模型输出一段结构化文本然后由外层代码解析执行。这种做法的问题是工具一多prompt 就爆炸而且模型经常输出格式不对的内容解析起来极其痛苦。第二种是每个工具写一套专门的适配代码模型通过 function calling 触发但不同厂商的 function calling 格式还不一样换个模型就得重写。MCP 的思路是把这件事拆成三个角色Host宿主比如你的应用、Client客户端负责和 Server 通信、Server服务端真正提供工具能力。Host 里可以跑多个 Client每个 Client 连一个 Server。Server 对外暴露三类东西Tools可调用的函数、Resources可读取的数据、Prompts预置的提示模板。这么设计的好处是Server 的实现和 Host 完全解耦。你写一个查天气的 MCP Server不管上层用的是哪家的模型、哪个框架只要遵循协议就能接进来。这就是为什么最近能看到各种MCP 接入 XX 工具的讨论——本质上是大家在给各种能力套上这层标准外壳。2.2 协议握手一次连接到底发生了什么很多人用 MCP 是直接拿现成的 SDK握手过程被封装掉了但如果你要排查连接失败的问题就必须知道底层发生了什么。MCP 基于 JSON-RPC 2.0通信可以走标准输入输出stdio也可以走 HTTP 的流式传输。一次完整的握手大致是这样的Client 向 Server 发送initialize请求带上自己支持的协议版本、客户端信息、以及自己的能力声明。Server 返回initialize响应告诉 Client 自己的协议版本、服务端信息、以及支持的能力比如是否支持 tools、resources、prompts。Client 发送notifications/initialized通知表示握手完成可以开始正常通信了。之后 Client 就可以发tools/list拿到工具清单发tools/call执行具体工具。这里有个容易忽略的点协议版本协商。Client 和 Server 各自声明自己支持的版本如果不匹配连接可能直接失败或者退回到某个兼容版本。我遇到过好几次明明 Server 启动了但就是连不上最后发现是版本号对不上。提示调试握手问题时把 Client 和 Server 的日志都开到 debug 级别重点看 initialize 请求和响应里的 protocolVersion 字段是否一致。2.3 Tools、Resources、Prompts 三者的区别刚接触的时候我经常把这三个搞混这里用一句话区分Tools是动作会改变状态或产生副作用比如发邮件、写文件、查数据库。调用需要模型主动决定。Resources是数据只读比如一个文件的内容、一张表的 schema。通常由 Host 决定要不要塞进上下文。Prompts是模板预置好的提示词用户可以主动选择使用。在实际项目里Tools 用得最多Resources 次之Prompts 用得最少。但 Resources 有个很实用的场景把大块静态数据比如产品手册作为 Resource 暴露需要的时候再读避免一次性塞满上下文。3. 手写一个最小 MCP Server 摸清门道3.1 环境准备与依赖选择要真正理解 MCP最好的办法是自己写一个 Server。我用的是 Python 生态里的官方 SDK安装很简单pip install mcp如果你用 TypeScript对应的包是modelcontextprotocol/sdk。选哪个看你的技术栈Python 适合快速验证TypeScript 适合和前端项目集成。写 Server 之前先想清楚一件事这个 Server 要暴露什么能力。我建议第一个练手的 Server 就做一个最简单的计算器暴露一个add工具输入两个数返回和。别小看这个它能让你把整个链路跑通。3.2 用 stdio 方式实现第一个工具stdio 方式是最容易上手的Server 作为一个子进程被 Client 启动通过标准输入输出通信。核心代码大概长这样from mcp.server import Server from mcp.server.stdio import stdio_server from mcp.types import Tool, TextContent app Server(calc-server) app.list_tools() async def list_tools(): return [ Tool( nameadd, description计算两个数字之和, inputSchema{ type: object, properties: { a: {type: number}, b: {type: number} }, required: [a, b] } ) ] app.call_tool() async def call_tool(name, arguments): if name add: result arguments[a] arguments[b] return [TextContent(typetext, textstr(result))] async def main(): async with stdio_server() as (read, write): await app.run(read, write, app.create_initialization_options()) if __name__ __main__: import asyncio asyncio.run(main())这里有几个细节值得说。inputSchema用的是 JSON Schema 格式模型会根据这个 schema 来决定怎么填参数。description写得好不好直接决定模型能不能正确选择这个工具。我见过太多人 description 就写一句加法结果模型根本不知道什么时候该用它。注意工具名建议用下划线命名避免特殊字符。有些 Client 对工具名有格式要求带空格或中文可能直接报错。3.3 参数校验与错误返回的坑上面那段代码有个隐患如果模型传进来的a是字符串而不是数字arguments[a] arguments[b]可能变成字符串拼接或者直接抛异常。生产环境里必须做校验。我的做法是在call_tool里先做类型转换和边界检查出错时返回一个明确的错误信息而不是让异常往上抛try: a float(arguments[a]) b float(arguments[b]) except (KeyError, ValueError) as e: return [TextContent(typetext, textf参数错误: {e})]为什么要返回错误信息而不是抛异常因为抛异常会导致整个调用链断掉而返回错误信息能让模型看到哦我参数传错了然后自己纠正重试。这是 MCP 设计里很聪明的一点——错误也是上下文的一部分。4. LangGraph 编排让多个 Server 协同工作4.1 为什么单靠 MCP 还不够MCP 解决了怎么连工具的问题但没解决什么时候连哪个工具、多个工具的结果怎么串起来的问题。举个实际场景用户问帮我查一下上个月的销售数据然后生成一份简报。这里涉及两个工具——查数据库和生成文档而且有先后依赖关系。如果只靠 MCP你得在 Host 里手写调度逻辑很快就乱了。LangGraph 的价值就在这里。它把流程建模成状态图每个节点做一件事边决定下一步去哪。你可以把调用某个 MCP Server 的工具封装成一个节点然后用边把节点连起来条件边还能实现根据上一步结果决定下一步。4.2 把 MCP 工具包装成 LangGraph 节点核心思路是先用 MCP Client 连上 Server拿到工具列表然后把每个工具转换成一个 LangChain 的 Tool再把这些 Tool 绑定到模型上。LangGraph 的ToolNode可以自动处理工具调用。from langgraph.prebuilt import ToolNode from langchain_mcp_adapters.client import MultiServerMCPClient client MultiServerMCPClient({ calc: { command: python, args: [calc_server.py], transport: stdio }, weather: { url: http://localhost:8000/mcp, transport: streamable_http } }) tools await client.get_tools() tool_node ToolNode(tools)注意这里MultiServerMCPClient可以一次挂多个 Server这是关键。以前要连多个 Server 得自己管理多个 Client 实例现在一个客户端就能搞定工具会自动合并到一个列表里。4.3 多 Server 场景下的工具路由挂载多个 Server 之后最大的问题是工具名冲突和路由选择。假设两个 Server 都有一个叫search的工具直接合并就会覆盖。我的做法是给工具名加前缀比如calc_add、weather_query在 Server 端就定义好避免后期改名。路由选择则交给模型。把所有工具的 schema 一起给模型模型根据用户意图自己选。但工具一多模型的准确率会下降。实测下来工具数量控制在 10 个以内时选择准确率最高超过 20 个就开始频繁选错。如果工具确实很多可以分两层先用一个路由节点判断该用哪一类工具再在那一类里做细选。这就是 LangGraph 条件边的用武之地。def route_by_intent(state): intent state[intent] if intent calc: return calc_node elif intent weather: return weather_node return fallback_node graph.add_conditional_edges(classify, route_by_intent)4.4 状态管理与上下文传递LangGraph 的 State 是整个流程的共享内存。每个节点读取 State、处理后返回更新。多 Server 场景下State 里通常要存用户原始输入、当前意图、已调用的工具及结果、最终回答。我踩过的一个坑是State 里的消息列表无限增长。每轮对话都往里塞跑几十轮之后上下文就爆了。解决办法是加一个裁剪节点只保留最近 N 轮或者对历史消息做摘要。这个在 LangGraph 里可以用trim_messages或者自定义节点实现。提示State 的字段设计要克制只放真正需要跨节点共享的数据。临时变量放在节点内部就好别什么都往 State 里塞。5. 实操全流程从零搭一个多 Server 助手5.1 项目结构与依赖清单我把整个项目分成三块servers/放各个 MCP Servergraph/放 LangGraph 的图定义app.py是入口。依赖清单如下pip install mcp langgraph langchain-mcp-adapters langchain-openai版本上要注意langchain-mcp-adapters更新比较快建议锁一个稳定版本别用 latest否则接口可能变。5.2 启动多个 Server 并验证连接先分别启动两个 Server一个 stdio 的一个 HTTP 的。stdio 的由 Client 自动拉起HTTP 的需要手动跑python servers/weather_server.py --port 8000验证连接是否正常最直接的办法是写个小脚本连上之后打印工具列表tools await client.get_tools() for t in tools: print(t.name, -, t.description)如果这里报错八成是握手阶段的问题。重点检查Server 进程有没有真的起来、端口有没有被占用、协议版本是否匹配。5.3 构建带条件分支的调用图完整的图大概是这样入口节点接收用户输入分类节点判断意图条件边路由到对应的工具节点工具执行完回到汇总节点生成回答。from langgraph.graph import StateGraph, END graph StateGraph(AgentState) graph.add_node(classify, classify_intent) graph.add_node(tools, tool_node) graph.add_node(respond, generate_response) graph.set_entry_point(classify) graph.add_conditional_edges(classify, should_use_tool, { use_tool: tools, direct: respond }) graph.add_edge(tools, respond) graph.add_edge(respond, END)should_use_tool是个判断函数根据分类结果决定走工具还是直接回答。这种结构比纯 if-else 清晰太多加新工具只需要加节点和边。5.4 流式输出与结果落盘实际用的时候用户肯定希望看到流式输出而不是等半天蹦出一整段。LangGraph 支持astream_events可以拿到每个节点的中间结果。我一般会把工具调用的过程也流式推给前端让用户看到正在查询数据库...这样的状态。结果落盘这块我习惯把每次调用的完整 trace 存成 JSON方便事后排查。字段包括时间戳、用户输入、命中的工具、工具参数、工具返回、最终回答、耗时。这个 trace 在调试多 Server 路由问题时特别有用。6. 常见问题与排查技巧实录6.1 连接类问题速查现象可能原因排查方法Server 启动后立即退出缺少if __name__ __main__保护检查入口代码连接超时端口被占用或地址写错netstat查端口确认 URL握手失败协议版本不匹配对比双方 protocolVersion工具列表为空list_tools 未正确注册打印 Server 日志6.2 工具调用类问题最常见的是模型选错工具。原因通常是 description 写得太模糊或者多个工具功能重叠。解决办法是把 description 写具体包含什么时候用和什么时候不用。比如不要写查询数据要写根据用户提供的订单号查询订单详情仅用于订单相关查询。另一个坑是参数类型不匹配。模型有时候会把数字传成字符串或者把数组传成单个值。在 Server 端做一层容错转换能省很多事。6.3 多 Server 并发问题多个 Server 同时被调用时要注意资源竞争。比如两个工具都要写同一个文件就可能冲突。我的做法是给每个 Server 分配独立的资源命名空间或者用锁控制。LangGraph 本身是支持并行的但并行节点之间如果有共享状态要小心竞态。注意stdio 类型的 Server 是单进程的如果并发调用同一个 Server 的多个工具可能会串行执行。对性能有要求的话考虑用 HTTP 传输。7. 我踩过的几个真实坑第一个坑是工具描述里的中文。有些 Client 对非 ASCII 字符处理不好导致工具列表解析失败。后来我统一改成英文描述问题就没了。虽然模型对中文理解没问题但协议层还是保守点好。第二个坑是忘记处理 Server 崩溃。stdio 的 Server 如果挂了Client 不会自动重启后续调用全部失败。加一个健康检查节点定期 ping 一下 Server挂了就重启能省很多半夜被叫起来的事。第三个坑是State 里的消息没做序列化处理。LangGraph 的 checkpointer 需要 State 可序列化如果塞了自定义对象进去持久化就会报错。所有进 State 的东西最好都是基本类型或可 JSON 化的结构。这套 MCP 加 LangGraph 的组合我用了几个月下来最大的感受是前期多花点时间把协议和流程理清楚后期加工具就是复制粘贴的事。以前加一个工具要改半天现在写个 Server、注册一下、加条边十分钟搞定。如果你也在做类似的多工具编排强烈建议从最小可用的 Server 开始跑通了再往上叠别一上来就搞大而全的架构。