计时记录 / 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
| 次序 | initialize |
tools/list |
间隔 | 工具数 |
|---|---|---|---|---|
| 1 | 8,461 ms | 8,464 ms | 3 ms | 15 |
| 2 | 7,853 ms | 7,860 ms | 7 ms | 15 |
| 3 | 8,304 ms | 8,309 ms | 5 ms | 15 |
| 4 | 7,630 ms | 7,633 ms | 3 ms | 15 |
| 5 | 7,787 ms | 7,790 ms | 3 ms | 15 |
五次都回答了 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:19 | 5 | 7,630 – 8,461 ms | 7,853 ms | 831 ms |
| B | 2026-09-27 21:37 | 3 | 6,399 – 6,671 ms | 6,429 ms | 272 ms |
| C | 2026-09-27 21:40 | 5 | 6,391 – 6,688 ms | 6,654 ms | 297 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) | 5 | 6,991 – 7,763 ms | 7,209 ms | 772 ms |
| E | 削减之前,同一条命令 | 5 | 6,948 – 8,914 ms | 7,252 ms | 1,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 工作本身。由此有两条结论:
- 如果客户端的连接预算比 import 时间还短,那么精简工具清单、延迟发现、让服务端「按需」加载工具 都救不了它——慢的部分根本还没轮到服务端做事。
- 一个 7-8 秒应答的服务端,在 30 秒预算里是合格的,在 5 秒预算里就是失败的。同一个产物因此 在一个客户端里「正常」、在下一个客户端里被标成「failed」。OpenCode 文档写的 MCP 启动默认超时是 30 秒;促使我们做这套测量的那份外部报告里,冷安装落在 33-34 秒,正好越界被判失败。
这里看不出「首次启动专属开销」。五次里第一次反而最慢,所以在这台机器上七秒是每次都要付的, 不是缓存一次就消失的成本。这一点值得明说,因为针对这个症状最常见的建议恰恰是「把缓存预热一下」。
补充一条同类读数:服务端向 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
这套测量工具做了什么、以及刻意不做什么:
-
启动子进程、发送
initialize,并在第一条应答解析完成后立刻发送tools/list,这样两条应答之差量的是服务端自己的工作,不是我们的写入延迟。 -
子进程环境里只有
PATH和NODE_ENV=production; 如果请求注入的环境变量名里出现KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH(按名字分段匹配,所以AUTHOR这类良性名字不会被误杀),工具直接拒绝启动。「没有任何 provider Key 进到进程里」这句话是由工具强制成立的,不是由本页断言的。 -
通知、日志行、半截 JSON 都不会被当成应答:只有能解析成带整数
id的行才算数。 假的「已应答」是唯一能掩盖真实连接超时的读数。 - 每一条退出路径都会 SIGKILL 子进程,因此一次测量不会留下残余进程。
这一页不能证明什么
- 一台机器、一个 Node 版本。这里是 v25.8.1,而我们的 CI 钉在 22;我们还单独见过一个预编译 原生依赖在新主版本上拿不到二进制文件。所以不要把 6.4-8.9 秒读成跨平台常数。
- 不与其他服务端比较。我们量的是自己的。任何人都可以用同一条命令量自己的并得到自己的五个数字; 本页不给任何服务端排名。
- 不声称我们的启动时间可以接受。七到八秒才出第一个字节,正是这件事存在的理由:我们宁愿把这个数字 写进公开的 issue(#168), 也不愿让新用户在客户端界面里把它当成一个「failed」徽章第一次遇到。