最近在好几个接入 AI Agent 的项目里做工具层改造,总会遇到同一个问题:MCP 工具调用为什么会又慢又费 token?很多人第一反应是“模型不行”,但实际排查下来,真正的瓶颈常常藏在连接机制和调用开销里。这篇文章我会把 MCP 工具调用这条链路从头到尾拆一遍,讲清楚连接是怎么建立的、开销到底由哪几个部分组成,再给出一套可以直接落地的优化方案,适合正在做 Agent 应用、需要把业务能力暴露成 MCP 工具的开发者参考。
1. 连接机制:一次 MCP 工具调用从发起到返回的完整链路
1.1 协议流程与 JSON-RPC 的选择:MCP 为什么是这么设计的
MCP(Model Context Protocol)本身解决的是一件事:把外部工具、数据源和 AI 模型之间的交互方式统一成一种开放协议。它不是把每个工具都包一层 REST 接口那么简单,而是一套会话式的请求-响应规范,底层消息走的是 JSON-RPC 2.0。这一点是整个连接机制理解的基础,很多人只看了一遍工具调用示例,没注意到底层通信格式,后面遇到问题就抓瞎。
一次标准的工具调用,在协议层面实际上要经历七个步骤:
- 客户端发送
initialize请求,带上支持的协议版本、客户端信息,并声明自己有哪些能力(比如是否支持采样、是否支持日志级别)。 - 服务端回
initialize响应,包含服务端支持的协议版本、服务端能力列表(是否支持工具、资源、提示词,以及具体语义约定)。 - 客户端发送
initialized通知,表示会话正式可用。 - 客户端调用
tools/list获取工具清单,这一步通常在会话建立后立即做,但不一定每次对话都需要重新拉取。 - 模型在上下文中看到工具定义,产生工具调用意图,输出一个结构化的调用参数。
- 客户端把模型输出的意图转换成
tools/call请求,发送给服务端执行真实逻辑。 - 服务端返回执行结果,结果被放回对话上下文,模型基于结果继续生成最终回答。
这个流程看起来简单,但里面有几个容易忽略的设计点。第一,initialize和initialized是一对“握手 + 通知”的组合,前者属于请求-响应,后者属于通知类消息,通知是不需要回执的。这样做的意义在于让服务端在正式接受业务请求前完成能力协商,避免客户端和服务端版本不一致导致协议错乱。第二,tools/list和tools/call都是标准化的方法名,服务端通过方法名路由,不需要像 REST 那样为每个业务动作单独设计 URL 和资源模型。
为什么 MCP 不直接走 REST 或者 GraphQL?这个问题我在评审内部方案时也反复被问过。REST 的设计哲学是资源导向,每个 URL 代表一个资源,用 HTTP 方法表达操作意图,但工具调用往往是命令式的,比如“查询天气”“创建工单”“执行脚本”,它们天然是动作而不是资源。硬包成 REST 接口,要么得设计一堆POST /tool/execute类型的伪资源,要么就得为每个工具单独约定 URL 规范,最终导致客户端代码全是 if-else 分发。
GraphQL 虽然也能承载这样的调用语义,但对服务端网关的要求高,Schema 的复杂度和嵌套校验对工具这种碎片化场景来说过重。相比之下,JSON-RPC 非常贴合这个场景:它有统一的请求 ID 对应机制,支持多请求并发,支持不需要响应的通知类型,还能用标准错误码表达调用失败。MCP 相当于把“AI 应用需要调用外部工具”这个共性问题,沉淀成了一个带会话语义的 RPC 协议。
1.2 三种传输方式的取舍:stdio、SSE 与 streamable HTTP
MCP 的“连接”还要区分传输层。协议消息是一回事,消息怎么从一个进程到另一个进程是另一回事。目前实践中常用的传输方式有三种,各有各的适用场景,选错了真的会直接影响工具调用的延迟和稳定性。
| 传输方式 | 部署形态 | 连接建立成本 | 推荐场景 | 主要注意点 |
|---|---|---|---|---|
| stdio | 本地子进程,通过标准输入输出传消息 | 中高:包含进程启动、依赖加载 | 本地开发、和 Agent 跑在同一台机器的工具 | 进程生命周期要管理好,子进程退出要自动重启 |
| SSE(Server-Sent Events) | 远程 HTTP 服务,服务端向客户端推流 | 较低:一次性 HTTP 握手 | 早期远程 MCP Server、单客户端长连接 | 连接保活与重连逻辑要自己处理 |
| streamable HTTP | 远程 HTTP,支持请求-响应与流式响应 | 低:每次请求是一次 HTTP 往返 | 跨机器部署、多客户端接入 | 需要正确区分普通响应和 SSE 流式响应 |
在早期 MCP 版本里,SSE 是远程通信的主要选择。SSE 这种形态的特点是客户端发普通 HTTP 请求,服务端通过事件流持续推送消息,天然适合“服务端主动通知客户端”的场景。但它的缺点是连接模型和 JSON-RPC 的请求-响应语义不完全匹配,客户端要自己维护事件流的状态,断线之后重连逻辑稍有不慎就出现消息丢失。
后来协议演进到 streamable HTTP 之后,远程连接的复杂度降低了很多。客户端发一个 POST 请求,服务端要么直接返回完整响应,要么先返回一个 SSE 流,让客户端消费后续的异步结果。这个模型和模型 API 的流式输出习惯非常像,所以现在新项目我基本都优先用 streamable HTTP,SSE 只在兼容老服务端时保留。
stdio 则完全是另一回事。它的连接成本是最特别的一个,因为不存在“网络握手”,但存在“进程启动”这个隐藏成本。本地工具如果是一个 Python 写的服务端,每次启动要从零加载解释器和所有依赖,慢的时候光是 import 就要几百毫秒;Node 写的服务端也类似。所以用 stdio 时,连接复用的价值比远程场景更大,后面优化部分我会专门展开说。选哪一种传输,本质上是根据“工具离客户端有多远”来定,本地用 stdio,远程用 streamable HTTP,别把 stdio 进程跑到远端机器上,也不要为了本地开发就把服务拆成 HTTP 然后跨网络绕一圈。
2. 开销拆解:冷启动时间、网络往返和上下文 Token 三块大头
2.1 时间开销的构成:把“端到端延迟”和“协议延迟”分开看
接入 MCP 后反馈最多的就是“调用好慢”。慢的原因是复合的,不能一股脑归给协议。我习惯把一次工具调用的耗时拆成几个环节,先量出来再谈优化。
| 环节 | 典型耗时范围 | 归属 |
|---|---|---|
| 进程启动 / SDK 初始化 | 200~1000ms | 连接开销 |
| initialize 握手 | 20~100ms | 连接开销 |
| tools/list 拉取工具列表 | 50~200ms(工具特别多时可能到 500ms+) | 连接开销 |
| 模型推理产生调用意图 | 500~2000ms | 模型开销 |
| tools/call 网络往返 | 30~150ms(同区域),100~300ms(跨区域) | 协议开销 |
| 结果回流上下文并继续生成 | 300~1000ms | 模型开销 |
从拆分就能看出来,很多人感知到的“工具调用慢”,有相当一部分其实是模型推理本身的速度,这部分和 MCP 无关。MCP 真正能优化的空间在于三块:连接建立、tools/list 获取、tools/call 的往返。如果这三大块没治理好,在串行调用多个工具的场景下,协议层面的延迟很容易堆到一两秒起步。
这里要说明一个重要的边界:端到端延迟是“模型思考时间 + 协议运输时间 + 工具执行时间 + 结果回注时间”,而协议引入的开销只是其中一个片段。做优化时别盯着模型推理时间,那是模型能力问题,全局的 Agent 编排和模型选型问题,不是协议能解决的。
2.2 Token 开销:一次工具调用的隐性成本比想象中高
时间之外,更隐蔽的是 token 开销。这部分很容易被忽略,因为它在界面上不会直接显示出来,但它直接影响调用成本和上下文窗口的可用空间。我把它分成两块来看。
第一块是工具定义本身产生的上下文占用。模型要能调用工具,就必须在上下文中看到工具的名字、描述、参数结构、必填字段。MCP 的tools/list返回的就是这个定义,很多 Agent 框架会把全部工具定义一次性注入到系统提示词里。工具一多,这个开销是持续存在的,不管本轮对话有没有真的调用某个工具,它都占着上下文。
举例说明。一个常见的天气查询工具,完整定义大概是这样的:
{ "name": "get_weather", "description": "查询指定城市和日期范围内的天气信息,返回温度、湿度、风速、降水概率,用于出行和活动安排", "inputSchema": { "type": "object", "properties": { "city": { "type": "string", "description": "城市中文名,例如北京" }, "date": { "type": "string", "description": "日期,格式为 YYYY-MM-DD,不传则默认今天" } }, "required": ["city"] } }按照常见分词器的粗略估算,这样一个工具定义大概会占掉 150~200 token。听起来不多,但如果你的服务端注册了 30 个工具,每个工具平均 150 token,工具清单总占用就是 4500 token 起步,有的工具如果描述又长、参数又多,整体破万 token 也正常。这意味着每次请求都要为这些定义支付成本,而模型真正用到的可能只有两三个工具。
第二块是工具执行结果回注上下文的开销。MCP 的tools/call返回结果会被完整放回到对话上下文里,模型基于这个结果继续生成。如果服务端返回的是 1000 行数据日志,或者一个几 MB 的原始响应,那么一次调用就能把上下文吃穿大半。这不是 MCP 的问题,而是在使用模式上容易犯错。工具应该返回“模型需要的信息”,而不是“上游接口返回的全量信息”。
2.3 串行调用与并行调用的延迟差距
另一个常被忽视的开销点是调用编排方式。很多 Agent 框架在处理模型输出的多个工具调用时,默认是挨个执行的。模型给出了三个工具的调用参数,Agent 就 while 循环一个接一个发tools/call,每个调用都完整经历一轮网络往返加服务端执行。如果单个往返耗时 150ms,三个串行工具就是 450ms 纯协议开销,还没算服务端执行时间。如果模型输出的是五个并行无关的查询,而你的编排层没有并行分发,延迟就会线性增长。
MCP 连接本身支持并发,一条连接上可以同时发起多个 JSON-RPC 请求,请求 ID 用于区分返回结果。但能不能并发,还取决于服务端实现。有些服务端的工具执行逻辑是同步阻塞的,一个请求没处理完,后面的请求就得排队。所以优化前要先确认服务端是否支持并发,不能只从客户端动手。
3. 优化方案与落地细节:把连接、定义和调用编排一起治理
3.1 连接复用与生命周期管理:单例、预热、自动重连
连接层面的优化是最直接见效的。我在多个项目里的默认做法是:进程内维护一个 MCP 客户端单例,而不是每次调用都新建客户端。这里的核心逻辑是,MCP 客户端不是普通 HTTP Client,它背后可能是一个子进程,也可能是带会话状态的长连接,重新初始化要付出握手成本和进程启动成本。
比较典型的错误写法是把客户端的创建放到工具函数内部,每次用户问一句话就new Client()再initialize()。在 stdio 模式下,这叫“每句话重启一遍 Python 解释器”,耗时可想而知。正确的方向是让客户端和应用生命周期对齐,应用启动时懒加载,第一次真正需要工具调用时才初始化,后续一直复用,直到进程退出或者服务端主动断开。
一个简易的连接池思路长这样:
class McpClientManager: def __init__(self): self._client = None self._lock = asyncio.Lock() async def get_client(self): async with self._lock: if self._client is None: self._client = await self._create_client() elif await self._is_healthy(self._client) is False: await self._client.close() self._client = await self._create_client() return self._client_is_healthy这个检查很重要。长连接进程如果已经被系统杀掉,或者远程服务端重启导致 session 失效,复用旧客户端反而会比重新创建更糟。常见的做法是每个连接记录最近一次心跳时间,超过一定阈值就主动探活,探活失败则销毁重建。探活本身也别做得太频繁,否则高频探活本身也会变成开销。
连接预热也是个小技巧。如果应用可以预判某个 MCP Server 很快会被用到,比如 Agent 启动时已经加载了一个带工具配置的会话,那就在后台提前把客户端初始化好,把进程启动和握手时间从用户请求的关键路径上移除。对于远程服务,预热能省掉一次 100ms 的握手时间;对于 stdio 本地服务,预热的收益是启动进程和 import 依赖的那几百毫秒。
3.2 工具定义瘦身:给工具表做动态裁剪和最小化
定义层面的优化,核心思路就一句话:让上下文里只出现当前任务真正可能用到的工具定义。
先说裁剪。很多 MCP Server 把几十个工具一次性全部暴露给客户端,客户端又全量塞给模型。工具是够用了,但模型需要在几十个定义之间做选择,选择质量会下降,token 开销也会爆炸。比较好的收敛方式是按域拆分,一个服务端只暴露一个领域的工具,比如“订单域服务”“库存域服务”,而不是一个巨大的“ERP 全功能服务”。按域拆分之后,每次会话注入的工具数量会显著下降。
再说工具描述。很多工具描述把字段含义、示例、注意事项全写进去,其实模型推理能力已经很强,工具名和字段名本身就能表意。上面天气工具的例子,描述可以压缩成下面这样:
{ "name": "get_weather", "description": "查询城市天气", "inputSchema": { "type": "object", "properties": { "city": { "type": "string" } }, "required": ["city"] } }这个版本的 token 消耗大概只有完整版的三分之一到四分之一。少写描述会不会影响工具调用准确率?实践经验是,工具名足够语义化、字段名足够清晰时,模型对该工具的意图识别几乎不受影响。真正影响准确率的是工具边界模糊、两个工具的功能高度重叠。这种问题靠写作文式描述解决不了,应该靠重新划分工具边界来解决。
动态注入是另一个更进阶的做法。不是把所有工具都放进系统提示,而是先让模型根据对话内容判断需要哪个工具域,再按需把对应工具定义注入上下文。这相当于给工具列表加了一层“路由”,虽然多了一次模型判断,但换来的是工具定义总量急剧下降。对一些工具定义动辄上万的场景,这个取舍非常划算。
3.3 调用编排优化:并行分发、结果预压缩、日志截断
编排层的优化目标是减少不必要的串行和超大结果回注。
模型输出多个独立工具调用时,客户端应当判断依赖关系。如果工具之间没有输入输出依赖,就并行发出多个tools/call请求,而不该逐个等待。并行度取决于服务端承载能力和网络连接数,使用单条 MCP 连接并发请求时,密切留意服务端的并发处理上限,有些服务端是单线程事件循环,并发请求会被内部排队,这时并行只在网络 RTT 层面有收益,工具执行时间还是串行。实际优化前先做个最小验证:同时发两个空实现工具,看总耗时是否接近单个耗时。如果接近,说明支持并行,可以放心用;如果不接近,就要考虑服务端改造或者接受串行的现实。
结果预压缩也是重头戏。我把这个原则称为“模型只看简报,明细走通道”。工具服务端在执行完业务逻辑后,返回给模型的应该是精简后的摘要和结构化结论,而不是上游全量数据。比如查询订单列表,服务端返回前十条订单编号、金额、状态,同时返回一个“共 500 条,支持翻页”的提示;模型需要更多细节时,再通过另一个带分页参数的工具去查。日志类工具尤其要这样做,动辄几 MB 的日志回注上下文,不只是 token 开销,还会让模型无法从海量噪音里找到重点。
如果工具本身是长耗时任务,比如跑批、构建、大规模检索,可以借助 MCP 的进度通知机制。服务端在处理过程中发送带进度 token 的状态更新,客户端可以选择在界面上显示进度,也可以忽略。这样用户感知上不是死等,也便于排查卡点。
3.4 超时、重试与异常兜底:让工具调用链更稳定
工具调用稳定性是不容忽视的。真实场景里,MCP Server 可能被重启、被限流、或者因为业务代码抛异常未捕获。客户端如果只设置一个固定超时然后直接抛错,模型可能拿不到任何有用信息,Agent 流程就直接断了。
超时设置要分级。连接建立超时和单次工具调用超时不建议用同一个值。连接建立超时通常短一些,比如 5 秒,因为握手失败意味着网络或进程状态有问题,快速失败让上层及时切换。单次调用超时必须结合工具的真实耗时分布来定。我的经验是用“该工具历史 P95 耗时 × 2”作为默认超时,既不给慢操作太紧的预算,也能拦截真正卡死的请求。
重试逻辑必须配合幂等性。工具如果只是查询类操作,重试很安全;如果是写入类操作,重试可能导致数据重复。最简单可靠的约定是:所有 MCP 工具在接口层面都尽量设计成幂等的,或者带一个请求幂等键,服务端自己缓存结果。重试采用指数退避,避免服务端刚恢复就被一波重试请求压垮。
MCP 协议里工具执行本身失败和协议错误是两种语义。业务上失败,比如“订单不存在”“库存不足”,服务端应当返回工具执行结果并带上isError标记,让模型看到这个状态后自行判断下一步,而不是把异常直接抛出导致 JSON-RPC 错误。协议层面的错误,比如参数格式不对、方法不存在,才应该用错误码返回。这个区分很多初用者会搞混,直接导致模型看不到失败原因,只能干愣着说“我遇到了错误”。在客户端接入三方 MCP Server 时,最好是包一层适配器,把各种异常收敛成统一规范,再决定是重试、降级还是让模型根据错误信息重新规划。
4. 可观测性:延迟、Token、错误率怎么量化
4.1 工具调用链路上的三块表
优化不能靠感觉,得先量化。我给 MCP 调用链做可观测性时,会从三块指标入手:连接指标、调用指标、Token 指标。
连接指标包括:连接建立耗时、连接失败率、主动重连次数、当前存活连接数。这些指标能反映连接层的健康状况。如果连接都是长生命周期,理论上建立次数应该很低;如果建立次数偏高,说明连接复用失效或者服务端频繁断连。
调用指标包括:tools/list耗时、tools/call耗时、工具执行成功率、按工具维度的耗时分布、客户端排队等待时间。按工具维度拆是最有价值的,因为不同工具的执行耗时差异极大,比如“查天气”可能几十毫秒,“跑数据分析”可能几十秒,把它们混在一起看平均值没有意义。
Token 指标包括:单次会话工具定义注入量、工具调用结果回注量、工具调用总数、因工具结果过大导致的上下文超限次数。Token 指标不一定要实时,但在测试环境里应该完整记录,用一段时间之后就能发现哪些工具定义太肥、哪些工具结果过度膨胀。
实现的落点是在调用链路的几个关键节点打点:客户端发出tools/call时记录请求 ID 和时间戳,收到响应时记录耗时;在tools/list返回后统计 schema 大小;在结果放回上下文前统计内容长度。配合分布式链路追踪里常见的 traceId 透传,可以把一次 Agent 对话里多个工具调用的耗时关系串起来,看到瓶颈到底落在哪一次调用上。
4.2 日志与进度通知的落地注意事项
MCP 定义了完整的日志能力,服务端可以按级别输出协议层面的日志,客户端可以通过通知机制订阅自己关心的级别。实际部署时,日志级别不建议全程设为 debug,因为 debug 日志在每条消息上都会产生大量内容,低配环境下会拖慢连接。线上环境通常开 info 和 warning 以上,排查问题时临时调到 debug 再恢复。
日志内容要做脱敏和裁剪。工具参数里可能包含用户手机号、地址、内部密钥,这些字段如果原样打进日志,轻则造成敏感信息泄露,重则触发合规问题。我的习惯是在客户端适配层统一做字段过滤,日志里只保留参数名和值类型,不保留真实值。
进度通知比较适合长任务。MCP 服务端可以在_meta.progressToken上附带进度百分比,客户端收到后更新状态。这里有一个容易踩的坑:进度通知不必像业务消息那样保证不丢,它是软状态,丢了用户下次刷新还能看到最终结果。所以不要给进度通知加严格的重试和确认机制,否则反而把简单的事做复杂。
5. 一个典型接入项目的完整优化数据与避坑清单
5.1 优化前后对比:从 3200ms 降到 900ms 是怎么做到的
把上面几套优化组合起来,效果非常可观。我以自己接手的一个内部工具接入项目为例,那个项目把一套业务查询系统通过 MCP 暴露给 Agent 使用,优化前遇到了明显的“慢和贵”问题。
| 指标 | 优化前 | 优化后 | 主要手段 |
|---|---|---|---|
| 平均单次工具调用端到端耗时 | 约 3200ms | 约 900ms | 连接单例复用 + 传输改 streamable HTTP + 调用并行 |
| 工具定义上下文占用量 | 约 4800 token | 约 1200 token | 按域拆分 + schema 裁剪 |
| 连接建立频率 | 每次调用都重新建 | 进程生命周期内一次 | 客户端单例 + 预热 |
| 五连串行调用总耗时 | 约 5200ms | 约 1500ms | 并行分发 + 服务端支持并发 |
| 长列表结果回注量 | 全量返回超 8000 token | 截断摘要约 400 token | 服务端预压缩 + 分页工具 |
优化前最重的一个问题就是客户端在每次对话里都重建连接,当时还跑在 stdio 模式下,进程启动加初始化就能吃掉 600ms 以上。改成长连接并预置进程后,这一块几乎归零。真正带来最大收益的是并行分发,因为那个业务一次回答经常要查询多个相互独立的数据源,以前串行跑,现在并行发,整体延迟一下子就降下来了。
需要提醒的是,这些数值只是相对优化的参考,不代表你的项目也会有同样幅度的提升。是否受益,取决于你的连接模型是不是足够轻、工具定义是不是足够瘦、服务端是不是支持并行。如果这三项本来就是健康的,优化空间可能不会太大。
5.2 四个容易踩进去的坑位
第一个坑:进程半开连接。子进程可能因为崩溃或者资源占用过高被杀掉,但客户端连接对象还保留着。再次调用工具时,消息写进管道没有任何回应,表现为“永远卡住”。解决方案是给 stdio 进程注册退出检测,进程意外退出时立刻销毁客户端对象,下次调用重新拉起。
第二个坑:SSE 断线后没有真正重连。早期用 SSE 传输时,网络抖动可能导致事件流失联。如果客户端只实现了初次连接而没有监听断线事件,之后的所有工具调用都会超时。迁移到 streamable HTTP 之后这个坑少了一些,但老服务端还在用的场景要特别检查。
第三个坑:把业务失败当成协议错误处理。服务端工具发现数据异常直接抛异常,客户端收到 JSON-RPC error 后并不把这个信息交给模型理解,而是当作系统错误中断流程。模型反而不清楚具体发生了什么。统一用isError标记返回业务失败,让模型拿到结果并自行判断,是更符合 Agent 工作方式的做法。
第四个坑:工具服务端返回超大结果。这个问题在数据库查询类工具里尤其常见,一次tools/call返回几万行记录,结果是把上下文撑爆或者引发费用超限。必须在服务端做限制:默认只返回一定行数,并提供分页参数;或者返回聚合结论和样本,全量数据通过下载通道走。
最后一个个人心得:排查工具调用性能问题时,一定要先拆后治。先确认到底是连接建立慢、还是定义注入导致上下文压力大、还是调用编排串行拖慢了整体、还是服务端执行本身慢,然后对症下药。MCP 的价值在于它把工具调用抽象成了统一协议,但如果不懂它背后的连接模型和开销构成,协议本身也会变成新的黑盒。我在做新项目时,接入 MCP 的第一天就会把连接指标和 Token 指标打点打好,后面优化就有了抓手,而不是等用户反馈“太慢”再回头翻代码。