name="twitter:description" content="工具枚举只要 3-7 毫秒,进程启动要七秒。附一条可复现的测量命令。" />

计时记录 / 2026 年 9 月

MCP 客户端为什么在服务端应答之前就放弃

stdio 形态的 MCP 服务端必须先成为一个跑起来的进程,才能说第一句话;而客户端的连接预算是从 它 spawn 进程那一刻开始计时的,不是从服务端加载完成那一刻。这两件事一旦相遇,故障看起来就像 服务端坏了:客户端报超时,服务端还没得到回答的机会,两边日志里都没有「我还在 import」这句话。 我们把自己的服务端对着这个预算量了一遍,让数字留在记录上,而不是留在猜测里。

数字

2026-09-27 在一台 Windows 10 x64 机器上,用 Node.js v25.8.1,从仓库克隆里针对已发布镜像 同样会执行的入口文件(packages/mcp-server/src/index.js)测得:

node tools/mcp-startup-profile.mjs --repeat 5
从 spawn 到收到应答字节的毫秒数
次序 initialize tools/list 间隔 工具数
18,461 ms8,464 ms3 ms15
27,853 ms7,860 ms7 ms15
38,304 ms8,309 ms5 ms15
47,630 ms7,633 ms3 ms15
57,787 ms7,790 ms3 ms15

五次都回答了 initialize,落在 7,630 ms 到 8,461 ms 之间 (中位数 7,853 ms,极差 831 ms)。每一次完整的 15 个工具清单都在其后 3 到 7 毫秒内到达。服务端自报为 unified-ai-system, 协议版本 2025-06-18,五次都向 stderr 写出同样的 69 字节。

同日晚间又跑了第二批与第三批,命令完全相同,读数更低。 下面是 node tools/mcp-startup-profile.mjs --repeat N --json 对同一个入口、同一套精简环境 (PATH 与 NODE_ENV=production)的原样读数:

批次 时刻(UTC) 次数 initialize 区间 中位数 极差
A(上表)2026-09-27 约 17:1957,630 – 8,461 ms7,853 ms831 ms
B2026-09-27 21:3736,399 – 6,671 ms6,429 ms272 ms
C2026-09-27 21:4056,391 – 6,688 ms6,654 ms297 ms

B 与 C 彼此吻合,且整体低于 A 的最小值:差出约 1.2–1.8 秒,约等于任一批次内部极差的四倍。 所以这是批次之间的差异,不是一批内部的噪声——而真实的差异恰恰是最容易被悄悄写成「变快了」的那种东西。 本页不声称启动变快了。这一段最初发布时,有两种解释凭这些数字还分不开:A 采样时的机器负载(C 也是在仍有九个 node.exe 进程存活的机器上测的,并不是「终于找到一台空闲机器」), 或者是同一天落进本仓库的 ESM 循环依赖削减——它缩短了首字节之前必须解析完的导入链,理论上确实可能值一两秒。

涉及代码变动的那一支现在跑了,它解释不了这段差距。削减那次提交动过的 35 个路径里, 有 26 个在本工作树里按路径逐项还原成了它的父提交——就地还原,没有复制也没有 junction,因为在本机挪动 pnpm 树会造出假超时——然后两种状态各连续冷启动五次,同一套精简环境(2026-09-28 约 00:25 UTC,两趟紧挨着跑):

对照臂 装的是哪份代码 次数 initialize 区间 中位数 极差
D含循环削减(HEAD)56,991 – 7,763 ms7,209 ms772 ms
E削减之前,同一条命令56,948 – 8,914 ms7,252 ms1,966 ms

削减之前的那一趟慢了 43 毫秒,而不是快了 1.5 秒——方向与那个解释相反,所以我们放弃它。 三条边界要写在同一口气里:两趟是先后连着跑的,没有交错;第三趟(还原回 HEAD 再复测 D)在开始测量之前 就被一个 pathspec 错误打断,所以这是一对读数,不是原计划里可重复的 A/B/A;而 E 自己的内部极差 1,966 毫秒,比它要去解释的那段差距还大——每趟五次冷启动,即使真有 1.5 秒的差也分辨不出来。 所以这里读作代码解释未获支持,而不是效果已被证明不存在。

这一对真正改变的是本页公布的数字:削减前的代码在一台比 A 批次更空闲的机器上仍读到 7,252 毫秒, 高于 C 批次的 6,654——也就是说不动任何代码,本机的冷启动本身就会漂上一秒多。因此区间是变宽而不是收窄: 上面两张表合计 23 次计时冷启动,initialize 落在 6.4–8.9 秒之间, 而这 23 次每一次返回的都仍是同样的 15 个工具名。在别人机器上读这段的人,应当把更宽的那个区间当成结论,而不是把更好看的那个。

还有一个实验把这件事从「慢」改成了「脆」,也是上面那个数字不该只被当成延迟抱怨的原因。 用同样的三次采样跑法,同时让 8 个 CPU 燃烧器满载: 3 次里有 0 次在 45,000 毫秒内应答——不是更慢,是根本没有应答。 杀掉燃烧器后立刻再测:3 次全部在 6,618-6,669 毫秒应答。 负载高的机器不是让这个握手慢 20%,而是越过某个点后让它干脆不发生,并且负载一撤就立刻恢复。 三次采样定不出那个点在哪。

这才是真正值得抱怨的形状,因为最可能跑这个服务端的机器,正是一台已经在编译、建索引、 或同时挂着别的 MCP 服务端的笔记本。它同时也是为什么「批次间差 1.2-1.8 秒」这件事不需要 代码变动的解释也能成立:负载能把这个指标移动数十秒,一秒量级的差距完全在它射程之内。 削减前提交的对比已经跑过了(上面第 D、E 两趟),结果并不偏向代码解释,所以本页保留负载这一支; 本页依旧不声称启动变快了。

预算究竟花在哪里

间隔就是重点。如果枚举工具是昂贵的那一步,第二个数字会动。它不动:无论多少次, 清单都在第一条应答之后几毫秒内到达。也就是说慢的部分发生在第一个应答字节之前, 对 stdio 服务端而言就是进程启动加上模块图,而不是 MCP 工作本身。由此有两条结论:

这里看不出「首次启动专属开销」。五次里第一次反而最慢,所以在这台机器上七秒是每次都要付的, 不是缓存一次就消失的成本。这一点值得明说,因为针对这个症状最常见的建议恰恰是「把缓存预热一下」。

补充一条同类读数:服务端向 stderr 写出的唯一一行(69 字节的 「ready on stdio; real providers disabled」)出现在 7,160 ms, 比第一个 stdout 字节(同一次运行 7,194 ms)只早 34 毫秒。也就是说盯着 stderr 的人, 前七秒看到的是完全沉默——「还在加载模块」就是这样被读成「卡死了」的。

方法,以及怎么拿它量你自己的服务端

# 我们的服务端,跑五次
node tools/mcp-startup-profile.mjs --repeat 5

# 你机器上任意一个 stdio MCP 服务端
node tools/mcp-startup-profile.mjs -- npx -y some-mcp-server@1.2.3

# 机器可读输出
node tools/mcp-startup-profile.mjs --repeat 3 --json

这套测量工具做了什么、以及刻意不做什么:

这一页不能证明什么