MCP 多服务器编排与上下文路由

当一个客户端需要同时接入数据库、搜索、代码库等多个 MCP 服务器时,编排就成了核心工程。本文详解多服务器聚合架构、工具路由与命名空间隔离、上下文窗口分配、并行调用与结果合并、故障隔离与降级策略,并对比主流的编排器模式。

1. 为什么需要多服务器

真实世界的 Agent 不可能只挂一个工具源。数据库查询、代码搜索、文档检索、外部 API、企业系统各有一套独立的生命周期与安全边界,把它们塞进同一个服务器会带来三个问题:

  • 维护爆炸:几十个工具挤在一个仓库,每次发布都要全量回归。
  • 安全耦合:只读的搜索工具和能写库的工具共享同一权限模型,违背最小权限。
  • 上下文污染:工具列表越长,模型每次决策要「看」的 schema 越多,token 消耗与选择错误率同步上升。

一句话:多服务器不是炫技,而是把「不同信任等级的能力」物理隔离成不同进程,让每个服务器的权限、发布、监控各自独立。

1.1 一个典型的多服务器拓扑

┌──────────────────────────────┐
│  Agent / Client              │
│  ┌────────────────────────┐  │
│  │   Orchestrator         │  │
│  │   - 路由表             │  │
│  │   - 命名空间           │  │
│  │   - 上下文预算         │  │
│  └───────┬────────┬──────┘  │
└──────────┼────────┼─────────┘
           │        │
     ┌─────┴──┐  ┌─┴──────────┐
     │  db-srv│  │  search-srv │
     │ 只读SQL │  │ 向量检索    │
     └────────┘  └────────────┘

2. 多服务器聚合架构模式

客户端接入多个服务器的方式有三种,工程取舍各不相同。

模式实现方式优点缺点
平铺直连客户端逐个 connect,手工命名简单直观命名冲突、无统一治理
代理聚合一个网关 Server 内部转发给多个下游 Server对外单端点、统一鉴权多一跳延迟、网关易成瓶颈
编排器(Orchestrator)独立服务持有路由表,按需调度可路由可降级、可观测实现复杂度最高

2.1 代理聚合的实现骨架

代理 Server 本质上是一个「路由器」:它把 tools/call 请求按工具名转发到下游,并把结果原样返回:

// 代理聚合服务器核心
import { Server } from "@modelcontextprotocol/sdk/server/index.js";

type Downstream = { name: string; client: any; tools: string[] };

const downstreams: Downstream[] = [
  { name: "db", client: dbClient, tools: ["query_sql", "list_tables"] },
  { name: "search", client: searchClient, tools: ["search_docs", "get_chunk"] },
];

// 工具名 -> 下游服务器 的索引
const toolRoute = new Map<string, Downstream>();
for (const ds of downstreams) {
  for (const tool of ds.tools) toolRoute.set(tool, ds);
}

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;
  const route = toolRoute.get(name);
  if (!route) {
    return {
      content: [{ type: "text", text: `无路由: ${name}` }],
      isError: true,
    };
  }
  // 转发给正确的下游
  const result = await route.client.callTool({ name, arguments: args });
  return result;
});

3. 工具路由与命名空间隔离

当两个服务器恰好都提供 search 工具时,平铺直连就会互相覆盖。命名空间隔离是标准解法:把工具名改写为 serverName_toolName。

3.1 命名空间映射

function namespacedName(dsName: string, toolName: string): string {
  return `${dsName}__${toolName}`; // 如 search__search_docs
}

// 发布给模型时使用命名空间名
const visibleTools = downstreams.flatMap((ds) =>
  ds.tools.map((t) => ({
    name: namespacedName(ds.name, t),
    description: `[${ds.name}] ${t}`,
    inputSchema: ds.getToolSchema(t),
  }))
);

// 收到调用时还原为原始工具名
function resolve(visibleName: string): { ds: Downstream; tool: string } {
  const [dsName, ...rest] = visibleName.split("__");
  const ds = downstreams.find((d) => d.name === dsName)!;
  return { ds, tool: rest.join("__") };
}

一句话:命名空间就像文件系统的目录——search__search_docs 一眼能看出它属于哪个服务器,冲突自然消解。

3.2 统一错误语义

下游返回的错误也要带上来源,否则模型会误以为是自己的问题:

if (result.isError) {
  return {
    content: [{ type: "text", text: `[${ds.name}] ${result.content[0]?.text ?? "未知错误"}` }],
    isError: true,
  };
}

3.3 语义路由:不只是前缀

命名空间解决「名字撞车」,但解决不了「两个服务器都能做类似的事」——比如 db__search_orders 与 search__search_docs 都含「search」。更强的做法是语义路由:先由路由层(或模型)按描述相似度挑出候选,再让模型在候选中决策,而不是把几十个工具全塞给它。

# 语义路由:把用户任务映射到最匹配的服务器
def semantic_route(task: str, servers: dict) -> list:
    """按任务与服务器描述的相关性打分,返回 Top-K 服务器"""
    task_vec = embed(task)
    scored = []
    for name, desc in servers.items():
        sim = cosine(task_vec, embed(desc["summary"]))
        scored.append((sim, name))
    scored.sort(reverse=True)
    # 只把 Top-K 服务器的工具暴露给模型
    return [name for _, name in scored[:k]]

一句话:命名空间防「冲突」,语义路由防「噪音」——前者保证不会选错,后者保证不需要从几十个里选。

4. 上下文窗口分配

多服务器聚合后,工具的 schema 总量 会迅速膨胀。一个工具的 JSON Schema 平均 300-500 token,20 个工具就可能吃掉 8000 token——这还没算真正的调用结果。因此上下文分配是编排器的核心职责。

4.1 三层预算模型

┌───────────────────────────────────────┐
│ 上下文总预算(如 32k)                 │
├───────────────────────────────────────┤
│ 1. 系统提示 + 对话历史   (固定 60%)  │
│ 2. 工具 schema 可见区     (动态 25%) │
│ 3. 工具结果/资源          (流动 15%) │
└───────────────────────────────────────┘
  • 可见区:只有当前任务可能用到的工具才被注入模型,其余保持「不可见」。
  • 流动区:工具结果按重要性/新旧排序,超预算时截断或降采样。

4.2 按任务动态注入工具

def select_tools_for_task(task: str, registry, budget_tokens: int) -> list:
    """根据任务语义挑出命中工具,并控制在 token 预算内"""
    candidates = registry.match(task)   # 基于描述相似度召回
    candidates.sort(key=lambda t: t.priority, reverse=True)

    selected = []
    used = 0
    for tool in candidates:
        schema_cost = estimate_tokens(tool.input_schema)
        if used + schema_cost > budget_tokens:
            break
        selected.append(tool)
        used += schema_cost
    return selected

一句话:上下文分配的本质是「把有限的窗口花在当下最可能的工具上」,而不是把所有工具永远摆在模型眼前。

5. 并行调用与结果合并

多服务器协作的经典场景是「问数据库 + 问搜索 + 问文档」同时进行。顺序执行会把延迟串行累加,并行调用能把 P95 降到单次最慢服务的水平。

5.1 并发编排

// 并发调用多个服务器,返回结构化结果
async function fanOut(calls: { name: string; args: any }[]) {
  const results = await Promise.allSettled(
    calls.map(async (c) => {
      const route = toolRoute.get(c.name)!;
      return { tool: c.name, result: await route.client.callTool(c) };
    })
  );
  return results;
}

// 消费端合并
const merged = await fanOut([
  { name: "db__query_sql", args: { sql: "SELECT count(*) FROM orders" } },
  { name: "search__search_docs", args: { query: "订单量" } },
]);
for (const item of merged) {
  if (item.status === "fulfilled") {
    console.log(item.value.tool, item.value.result.content[0].text);
  } else {
    console.warn(item.reason);
  }
}

5.2 结果合并策略

  • 并列结构:把各路结果包装成 tool_xxx_result,让模型逐条消化。
  • 冲突消解:同一事实多个来源不一致时,按可信度排序并在结果里标注来源。
  • 体积控制:合并前先按 token 预算裁剪每条结果,避免「合并完反而超窗」。

6. 故障隔离与降级

多服务器的另一大价值是故障隔离:搜索挂了不影响数据库查询。编排器要做的不是「不失败」,而是「失败得可控」。

6.1 熔断与降级

class CircuitBreaker {
  private failures = 0;
  private openedAt = 0;
  constructor(
    private threshold = 5,
    private cooldownMs = 30_000
  ) {}

  async call<T>(fn: () => Promise<T>, fallback: T): Promise<T> {
    if (this.isOpen()) return fallback;   // 熔断期内直接降级
    try {
      const result = await fn();
      this.failures = 0;
      return result;
    } catch (err) {
      this.failures++;
      if (this.failures >= this.threshold) this.openedAt = Date.now();
      return fallback;
    }
  }

  private isOpen() {
    if (!this.openedAt) return false;
    return Date.now() - this.openedAt < this.cooldownMs;
  }
}

6.2 降级路径设计

故障服务降级动作对模型提示
db 服务器返回「数据库暂不可用」禁止编造查询结果
search 服务器退回本地缓存索引标注「来自缓存」
全部下游仅保留对话能力明示当前离线

一句话:降级的目标不是「假装没坏」,而是「告诉模型真相并给出可用路径」——编造是比失败更贵的事故。

6.3 健康检查与连接池

多服务器意味着连接数量上升,每条连接都要被管理。编排器应当维护一个连接池,定期健康检查,把坏连接提前摘除,而不是等调用时才撞上。

// 带健康检查的连接池
class McpConnectionPool {
  private pools = new Map<string, PoolEntry>();

  async healthCheckAll(): Promise<void> {
    for (const [name, entry] of this.pools) {
      try {
        await entry.client.ping();   // 或发一个最小请求
        entry.degraded = false;
      } catch {
        entry.degraded = true;       // 标记降级,路由时跳过
        console.warn(`服务器 ${name} 健康检查失败`);
      }
    }
  }

  async call(name: string, tool: string, args: any) {
    const entry = this.pools.get(name);
    if (!entry || entry.degraded) {
      throw new Error(`服务器 ${name} 不可用`);
    }
    // 简单的轮询:每次调用取一条可用连接
    const conn = entry.next();
    return conn.callTool({ name: tool, arguments: args });
  }
}

// 每 30 秒跑一轮健康检查
setInterval(() => pool.healthCheckAll(), 30_000);
运维动作触发条件编排器行为
摘除连续 N 次健康检查失败路由表移除该服务器
恢复健康检查通过自动重新加入路由
限流单服务器 QPS 过高降低其被选中的概率

7. 编排器模式对比

选哪种编排模式,取决于团队规模、服务器数量与延迟敏感度:

模式服务器数量治理诉求延迟适用团队
平铺直连≤3低最低原型/个人项目
代理聚合3-10中+1 跳中型产品
编排器>10高+1~2 跳大型平台,需要路由/降级/审计

演进路径:绝大多数团队应从「平铺直连」起步,当出现命名冲突或统一审计诉求时,再升级到代理聚合;只有出现跨服务的依赖编排(如「先查用户再查订单」)时,才值得引入完整的编排器。

8. 总结

多服务器编排解决的是「能力太多、信任不同、窗口有限」这三重矛盾:

问题解法收益
工具命名冲突命名空间隔离 server__tool零冲突、可溯源
上下文膨胀按任务动态注入 schematoken 成本下降
延迟叠加并行 fan-out + 结果合并P95 显著降低
单点故障熔断 + 降级路径局部故障可控
治理缺失代理聚合/编排器统一鉴权与审计

掌握了多服务器编排,下一个问题自然是:这些工具如何被 Agent 框架真正调用起来——这就要说到 MCP 与 ReAct、Function Calling 的集成了。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「AI工程」更多文章

  1. MCP 资源模板与订阅:URI 模板、ListChanged 通知与上下文注入
  2. MCP 的 OAuth 鉴权与会话:动态注册、PKCE 与令牌轮换
  3. MCP 服务器测试框架:in-memory 传输、协议断言与端到端测试