MCP Client实战翻车记:两个Server同时跑,AI调错了Tool

前面写完 MCP Server 那篇文章之后,我又接了个需求:让一个 Agent 同时调两个 MCP Server。一个查 SQLite 数据库,一个查本地文件系统。Agent 跑了一轮,返回了一个答案。我看着答案觉得不太对,对了一下原始数据------AI 给出了文件系统里的"张三",但用户问的是数据库里张三的订单记录。

不是 Server 写错了,不是模型不行。是 AI 调错了 Tool。

两个 Server 都有 search 方法,AI 选了文件系统的 search,但它应该调数据库的 query。这时候我才真正理解 MCP Client 是干什么用的------它不是简单的"连接器",而是一个"翻译官",把多组 Server 的能力转译成 AI 能正确理解的语言。

Client 到底是干什么的

先花最短的时间说清楚角色分工。

MCP Server MCP Client
职责 提供 Tool 给外部调用 把 Tool 翻译给 AI 理解
类比 一个 API 接口 API 文档 + 调用说明书
出问题的地方 Tool 内部逻辑出错 AI 选错了 Tool / 参数传不对
管理粒度 每个 Server 独立运行 一个 Client 可对接多个 Server

Server 只负责"我有这个能力",Client 负责"AI 怎么知道该用哪个"。这个区分听起来简单,但只有当你手上有两个以上 Server 的时候,才知道 Client 的设计直接决定了 AI 会不会乱来。

翻车现场还原

先说场景。我在本地分别启动了 Node.js 写的两个 Server,一个暴露了 search、query、insert 三个 Tool 来操作 SQLite,另一个暴露了 search、read、write 来做文件读写。然后写了一个 Client 把它们连起来。以下代码基于 @modelcontextprotocol/sdk 0.6.x 版本。

第一版 Client 长这样:

js 复制代码
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';

const dbClient = new Client({ name: 'database-client' });
const fileClient = new Client({ name: 'filesystem-client' });

await dbClient.connect(
  new StdioClientTransport({ command: 'node', args: ['db-server.mjs'] })
);
await fileClient.connect(
  new StdioClientTransport({ command: 'node', args: ['file-server.mjs'] })
);

// 列出各 Server 的 Tool
const dbTools = await dbClient.listTools();
const fileTools = await fileClient.listTools();

代码看起来没毛病对吧?两个 Client 实例分别连接不同的 Server,各管各的。但是问题出在 AI 这一侧------当我把两个 Server 的 Tool 列表合并给 LLM 时,AI 看到的工具列表是这样的:

diff 复制代码
可用工具:
- search(来自 database-server)
- query(来自 database-server)
- insert(来自 database-server)
- search(来自 filesystem-server)
- read(来自 filesystem-server)
- write(来自 filesystem-server)

两个 search。同名。LLM 选 search 的时候,你不能保证它一定选数据库的那个。

事实上我跑了三组对话测试,其中有两次 AI 选了文件系统的 search。"张三"这个关键词在文件里确实有------某个文档里提到了这个名字------但用户问的是"张三的订单",应该在数据库里查 order 表。AI 拿到文件名和对应的内容片段,以为那就是答案。

这个场景其实挺典型。MCP 协议本身不限制 Tool 命名唯一性,Server 开发者各自命名自己的 Tool,撞名是常态。问题是,LLM 在做 Tool 选择时,依赖的是 Tool name + description 的语义匹配。当两个 Tool 的名字一模一样,description 又都跟"搜索"相关,模型大概率猜错。

翻车之后怎么修

发现问题后第一个想法是:给 Tool 名字加上命名空间前缀。

js 复制代码
class NamespaceClient {
  constructor(namespace, client) {
    this.namespace = namespace;
    this.client = client;
  }

  async listTools() {
    const tools = await this.client.listTools();
    return tools.map(tool => ({
      ...tool,
      name: `${this.namespace}_${tool.name}`,
      description: `[${this.namespace}] ${tool.description}`
    }));
  }

  async callTool(name, args) {
    const originalName = name.replace(`${this.namespace}_`, '');
    return this.client.callTool(originalName, args);
  }
}

const dbClient = new NamespaceClient('db', originalDbClient);
const fileClient = new NamespaceClient('fs', originalFileClient);

// 现在 AI 看到的列表变成了:
// - db_search
// - db_query
// - db_insert
// - fs_search
// - fs_read
// - fs_write

加上前缀之后,AI 看到的就是 db_search 和 fs_search,名字不同,选错的概率降了很多。我跑了七八轮测试,没有再出现调错 Tool 的情况。

不过这里有个细节值得说------description 也要改。光是改名字不够,因为 LLM 选 Tool 时 description 权重很高。我在 description 前面加了 [db][fs] 标签,相当于给 AI 一个视觉锚点。后面我翻过一些文章,有人用 XML 标签、有人用 Emoji,我试了一圈觉得纯文本前缀最稳定,模型解析出错率最低。

翻车二:一个 Server 挂了,全链路卡死

修完命名冲突之后,我以为这件事搞定了。直到第二个问题冒出来。

数据库 Server 那边有一次查询跑了很久------那张表数据量到了一定规模,索引没有建好,一次模糊查询拖了近两分钟。Client 一直在等 db_server 返回,file_server 的服务也跟着没法继续。

我查了一下日志才发现问题:Client 是串行处理 Tool 调用的。一个 Server 的 Tool 没返回,后续的调用全部排队等着。

这是一个设计上的取舍。MCP Client 默认不隔离不同 Server 的超时行为,一个慢 Server 会拖慢整个链路。不是每次都会遇到,但遇到就卡死整条链路。

修复方式很直接------给每个 Server 配独立超时:

js 复制代码
import { Client } from '@modelcontextprotocol/sdk/client/index.js';

const dbClient = new Client(
  { name: 'database-client' },
  { transportTimeout: 8000 }   // 8秒超时
);

const fileClient = new Client(
  { name: 'filesystem-client' },
  { transportTimeout: 3000 }   // 文件操作一般快得多
);

配了超时之后,db_server 那次慢查询在 8 秒后被 Client 主动中断,file_server 的调用正常执行。当然,超时本身不是完美的方案------超时意味着那个 Tool 调用失败了,AI 需要重试或者走 fallback。但至少不会让其他 Server 跟着陪葬。

后来我又加了一层更细的隔离:给每个 Server 的 Tool 调用包了一层 try/catch,让一个 Server 的失败不会传播到另一个。

js 复制代码
async function safeCall(client, toolName, args) {
  try {
    return await client.callTool(toolName, args);
  } catch (err) {
    console.error(`[${client.id}] Tool ${toolName} failed:`, err.message);
    return { error: true, message: `暂无法访问 ${client.id}` };
  }
}

其实就是加了个错误边界,但效果很明显------一个 Server 挂掉不会影响另一个。

多 Server 管理的决策框架

写完这个项目之后,我自己整理了一个判断逻辑,什么场景下用什么策略:

场景 推荐方案 原因
两个 Server 功能完全不重叠 命名空间前缀直接拆 简单,互不干扰
Server 数量 >= 3 但功能有交集 代理聚合模式,统一入口 减少 AI 选择成本
有 Server 偶尔超时/不稳定 独立超时 + try/catch 隔离 一个挂了不拖累全局
多个 Server 服务同一场景 合并成一个 Server 减少跨 Server 通信
外部不可控 Server(第三方) 包装层 + 降级策略 不能假设第三方永远可用

代理聚合模式是什么?就是用一层代理 Server 包装下面多个子 Server 的 Tool,对外只有一个入口。子 Server 的 Tool 全部通过代理转发,Client 只需要连一个代理 Server 就行。适合 Server 数量多的时候,减少 AI 面对的选择空间。

这个方案的边界

以上做法能解决大部分多 Server 集成问题,但不是银弹。

命名空间前缀有一类场景搞不定------当 LLM 需要跨两个 Server 的数据做推理时。比如"对比数据库里张三的订单和文件系统里张三的简历",AI 需要同时调 db_search 和 fs_search,两次结果合并做分析。前缀方案只能防选错,不能加速跨 Server 协作。

跨 Server 数据融合是另一个话题了,可能需要 Client 侧做一层缓存或结果聚合。这个我还没完全想好,目前遇到的对比例子不多,方案还不够成熟。

另外超时配置的数值得根据实际场景调。我设的 8 秒和 3 秒是基于本地测试的,放到线上环境网络延迟不同,要重新压测。具体设多少没有万能公式,我自己的做法是先设成 5 秒,跑一周看日志,如果有 Tool 频繁超时就调大,如果服务器响应都很稳定就逐步缩紧。

你现在就可以做的一件事

打开你的 MCP 配置文件(一般是 mcp.jsonclaude_desktop_config.json),看看有没有配多个 Server。如果有,检查一下它们的 Tool 名字------有没有同名的?如果有,加个前缀,花不了五分钟,但能省掉后面排查"AI 为什么拿错数据"的时间。

我当时就是觉得"两个 search 应该没关系吧"------结果查了接近两小时才发现是这个原因。这个亏吃一次就够了。

相关推荐
甲维斯19 小时前
Kimi 停售,GLM限购,国内用户还是难啊!
ai编程·vibecoding
腻害兔19 小时前
【若依项目-产品经理视角】深度拆解 RuoYi-Vue-Pro 认证与权限:RBAC + 数据权限,这套“门禁系统“到底怎么设计的?
java·vue.js·人工智能·产品经理·ai编程
Lxy2018010119 小时前
试着用扣子做小说大纲,发现它更适合整理主线、支线和章节节奏
ai编程
在水一缸20 小时前
当 AI 拥有了“核按钮”:深入解析 MCP 服务器与命令执行护栏
运维·服务器·人工智能·命令执行·智能体·ai安全·mcp
李剑一21 小时前
AI大模型扎堆上新?Kimi K3、Qwen 3.8、DeepSeek v4马上上线,旧的还没用上,新的就来了!比当年山寨机上新还快!
agent·ai编程·deepseek
武子康21 小时前
Java 后端 → 实时语音 AI 转型复盘:4 个 FDE 技术底座 + 6 个差距 + 6 类职业资产路线
人工智能·后端·openai
_瑞21 小时前
AI Coding 那么快,为什么还需要 SDD?
人工智能·ios·ai编程
小岛前端21 小时前
展示!我 Vibe 了一个 Codex HUD
前端·ai编程