Skip to content

Latest commit

 

History

History
321 lines (240 loc) · 7.84 KB

File metadata and controls

321 lines (240 loc) · 7.84 KB

MCP服务器顺序启动优化

问题描述

即使实现了MCP异步加载,用户仍然发现:

  • 在MCP服务器启动过程中无法输入文字
  • 只有在服务器启动的间隙才能输入
  • 感觉像是有阻塞

具体现象:

启动MCP1... (输入被阻塞)
MCP1启动完成
  ↓ (短暂可以输入)
启动MCP2... (输入被阻塞)
MCP2启动完成
  ↓ (短暂可以输入)
启动MCP3... (输入被阻塞)

根本原因分析

Node.js单线程特性

Node.js是单线程事件循环模型:

  • 所有JavaScript代码在主线程执行
  • 虽然有异步I/O,但子进程创建等操作仍会占用主线程
  • 当主线程被占用时,无法处理用户输入

MCP服务器启动的阻塞点

每个MCP服务器启动时会执行这些同步操作

  1. 创建子进程 (spawn):

    • 虽然是异步API,但进程创建本身是同步的
    • 需要系统调用、内存分配等
  2. 设置stdio管道

    • 创建stdin、stdout、stderr管道
    • 配置管道选项(如'pipe'模式)
    • 这些操作占用事件循环
  3. 进程初始化

    • 设置环境变量
    • 设置工作目录
    • 加载进程可执行文件

并行启动的问题

之前的实现使用 Promise.all 并行启动:

const discoveryPromises = Object.entries(mcpServers).map(
  ([name, config]) => connectAndDiscover(name, config, ...)
);
await Promise.all(discoveryPromises);

问题

  • ❌ 虽然是"并行",但所有进程几乎同时创建
  • ❌ 多个子进程同时创建会集中占用事件循环
  • ❌ 在所有进程创建完成前,事件循环无法处理输入
  • ❌ 用户体验:长时间连续阻塞

解决方案:顺序启动

核心思想

不要同时启动所有MCP服务器,而是一个接一个启动,每次启动后让出事件循环

实现方式

export async function discoverMcpTools(
  mcpServers: Record<string, MCPServerConfig>,
  mcpServerCommand: string | undefined,
  toolRegistry: ToolRegistry,
  promptRegistry: PromptRegistry,
  debugMode: boolean,
): Promise<void> {
  mcpDiscoveryState = MCPDiscoveryState.IN_PROGRESS;
  try {
    mcpServers = populateMcpServerCommand(mcpServers, mcpServerCommand);

    // ✅ 顺序启动MCP服务器
    for (const [mcpServerName, mcpServerConfig] of Object.entries(mcpServers)) {
      // 在启动每个服务器前,让出事件循环给UI渲染和用户输入
      await new Promise(resolve => setImmediate(resolve));

      // 启动当前服务器
      await connectAndDiscover(
        mcpServerName,
        mcpServerConfig,
        toolRegistry,
        promptRegistry,
        debugMode,
      );
    }
  } finally {
    mcpDiscoveryState = MCPDiscoveryState.COMPLETED;
  }
}

关键技术点

1. setImmediate

await new Promise(resolve => setImmediate(resolve));

作用

  • 将后续代码推迟到下一个事件循环tick执行
  • 让出当前tick给其他任务(如UI渲染、用户输入)
  • 确保事件循环不会被长时间占用

2. 顺序执行 for...of

for (const [name, config] of Object.entries(mcpServers)) {
  await connectAndDiscover(...);  // 等待当前完成再启动下一个
}

效果

  • 一次只启动一个MCP服务器
  • 每次启动完成后才开始下一个
  • 避免多个进程同时创建

效果对比

并行启动(优化前)

时间线:
0ms    启动MCP1、MCP2、MCP3(同时创建3个进程)
       ↓ ❌ 事件循环被占用200-500ms
500ms  3个进程创建完成
       ↓ ✅ 事件循环恢复,可以处理输入

用户体验:
- 500ms内完全无法输入
- 然后突然恢复响应

顺序启动(优化后)

时间线:
0ms    让出事件循环
       ↓ ✅ 处理用户输入
10ms   启动MCP1
       ↓ ❌ 事件循环被占用100-200ms
200ms  MCP1完成,让出事件循环
       ↓ ✅ 处理用户输入
220ms  启动MCP2
       ↓ ❌ 事件循环被占用100-200ms
420ms  MCP2完成,让出事件循环
       ↓ ✅ 处理用户输入
440ms  启动MCP3
       ↓ ❌ 事件循环被占用100-200ms
640ms  MCP3完成

用户体验:
- 每次只阻塞100-200ms
- 阻塞间隙可以输入
- 整体感觉更流畅

性能指标

指标 并行启动 顺序启动
总启动时间 ~500ms ~600-700ms
单次阻塞时长 500ms 100-200ms
可输入间隙 每个服务器之间
用户体验 ❌ 长时间卡死 ✅ 短暂停顿,可接受

为什么顺序反而更好?

并行的误区

虽然并行看起来"更快",但:

  • 感知性能更差:长时间连续阻塞让用户觉得"卡死"
  • 无法使用:在阻塞期间完全无法输入
  • 糟糕体验:用户可能以为程序崩溃了

顺序的优势

虽然总时间略长,但:

  • 感知性能更好:短暂停顿,用户能感受到进度
  • 基本可用:在间隙可以输入,UI保持响应
  • 更好体验:用户知道程序在运行,只是在加载

人机交互原则

根据用户体验研究:

  • 100ms以下:感觉即时
  • 100-300ms:感觉稍慢,但可接受
  • 300-1000ms:感觉明显延迟,但仍在等待
  • 1000ms以上:感觉卡死,可能放弃

顺序启动每次阻塞100-200ms,属于"可接受"范围。 并行启动一次阻塞500ms,接近"放弃"阈值。

进一步优化空间

1. 动态调整间隔

根据服务器数量调整间隔:

const delayBetweenServers = Math.min(100, 500 / Object.keys(mcpServers).length);
await new Promise(resolve => setTimeout(resolve, delayBetweenServers));

2. 优先级启动

先启动常用的服务器:

const sorted = Object.entries(mcpServers).sort((a, b) =>
  (a[1].priority || 0) - (b[1].priority || 0)
);

3. 后台Worker线程

使用Worker线程启动子进程(复杂度高):

const { Worker } = require('worker_threads');
const worker = new Worker('./mcp-launcher.js');

4. 批量启动

小批量并行(如每次启动2个):

for (let i = 0; i < servers.length; i += 2) {
  await Promise.all([
    connectAndDiscover(servers[i]),
    connectAndDiscover(servers[i+1])
  ]);
  await new Promise(resolve => setImmediate(resolve));
}

测试验证

测试场景

  1. 3个MCP服务器

    • ✅ 验证每个服务器启动间隙可以输入
    • ✅ 验证总启动时间略有增加但可接受
    • ✅ 验证UI保持响应
  2. 1个MCP服务器

    • ✅ 验证单个服务器启动不受影响
    • ✅ 验证仍然可以输入
  3. 5个以上MCP服务器

    • ✅ 验证顺序启动不会太慢
    • ✅ 验证UI仍然保持基本响应

性能测量

console.time('Total MCP Discovery');
for (const [name, config] of Object.entries(mcpServers)) {
  console.time(`MCP ${name}`);
  await connectAndDiscover(...);
  console.timeEnd(`MCP ${name}`);
}
console.timeEnd('Total MCP Discovery');

注意事项

启动时间略有增加

  • 并行: ~500ms
  • 顺序: ~600-700ms
  • 增加: 100-200ms

这是可接受的权衡,用户体验提升远大于这点时间差。

服务器依赖

如果MCP服务器之间有依赖关系,顺序启动反而更安全。

错误处理

单个服务器失败不影响后续服务器:

for (const [name, config] of Object.entries(mcpServers)) {
  try {
    await connectAndDiscover(...);
  } catch (error) {
    console.error(`Failed to start ${name}:`, error);
    // 继续启动下一个
  }
}

修改文件

  • packages/core/src/tools/mcp-client.ts

修改日期

2025-01-10

相关文档