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 | 零冲突、可溯源 |
| 上下文膨胀 | 按任务动态注入 schema | token 成本下降 |
| 延迟叠加 | 并行 fan-out + 结果合并 | P95 显著降低 |
| 单点故障 | 熔断 + 降级路径 | 局部故障可控 |
| 治理缺失 | 代理聚合/编排器 | 统一鉴权与审计 |
掌握了多服务器编排,下一个问题自然是:这些工具如何被 Agent 框架真正调用起来——这就要说到 MCP 与 ReAct、Function Calling 的集成了。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。