跳到主要内容
文章
Agent4 分钟阅读

MCP 实战:给 Agent 接上真实世界的手

MCP 解决的是接入问题,不解决工具设计问题。这篇文章讲清楚它到底该用在哪、怎么切工具边界,以及我踩过的三个坑。

一句话结论

工具边界应该按「业务能力」切,不是按「API 端点」切。

本文目录 · 6

关于 MCP 的讨论大多停在「它是什么」。这篇讲什么时候该用、怎么用、以及用错的代价

先说结论:MCP 解决的是接入标准化问题。它不解决工具设计问题。 如果你的工具本来就切错了,接上 MCP 只会让你更快地撞到墙。

MCP 真正解决的问题#

不用 MCP 的时候,工具是这样注册的:

ts
const tools = {
  get_inventory: {
    description: "查询库存",
    parameters: z.object({ sku: z.string() }),
    execute: async ({ sku }) => db.inventory.find(sku),
  },
  get_price_floor: {
    description: "查询底价",
    parameters: z.object({ sku: z.string() }),
    execute: async ({ sku }) => db.pricing.floor(sku),
  },
  // ... 每加一个,改一次这个文件
};

三个具体问题:

  1. 改工具 = 改主流程。 每次新增都要动 Agent 的核心代码,回归风险随时间线性上升。
  2. 工具与 Agent 同进程。 一个工具里有个慢查询,整个 Agent 卡住。
  3. 工具无法复用。 第二个 Agent 想用同一个库存查询,只能复制一份。

MCP 把工具变成独立部署的服务,Agent 通过协议发现和调用:

ts
// Agent 侧:不关心有几个工具、是谁提供的
const client = new Client({ name: "agent", version: "1.0.0" });
await client.connect(new StdioTransport({ command: "erp-mcp-server" }));
 
const { tools } = await client.listTools();
ts
// 工具侧:独立进程,独立部署,独立测试
const server = new McpServer({ name: "erp", version: "1.0.0" });
 
server.tool(
  "get_inventory",
  "查询指定 SKU 的可用库存与在途数量",
  { sku: z.string().describe("产品 SKU,例如 AB-1200") },
  async ({ sku }) => ({
    content: [{ type: "text", text: JSON.stringify(await erp.stock(sku)) }],
  }),
);
 
await server.connect(new StdioServerTransport());

加工具变成加服务。主流程零改动。

坑一:按 API 端点切工具,切出一堆废工具#

我最初的做法是「每个后端接口对应一个工具」。结果是 9 个工具里, 模型在 80% 的情况下只用了 2 个。

问题在于模型看到的是工具的语义,不是你的 API 结构

反例:

ts
// 按 API 端点切 —— 模型不知道什么时候该用哪个
server.tool("get_product_basic", ...);    // 基础信息
server.tool("get_product_stock", ...);    // 库存
server.tool("get_product_pricing", ...);  // 价格
server.tool("get_product_shipping", ...); // 运费

模型收到「客户问 500 件能不能 11 月前到货」时,需要连续调用 4 个工具才能回答。 它经常会漏掉其中一个。

正例:

ts
// 按业务能力切 —— 一次调用回答一个问题
server.tool(
  "answer_availability_question",
  "回答客户关于「能不能发货 / 什么时候到 / 多少钱」的问题。一次返回库存、价格与交期。",
  {
    sku: z.string(),
    quantity: z.number(),
    targetDate: z.string().optional().describe("客户期望到货日期 ISO 格式"),
  },
  async (args) => { /* 内部聚合 4 个接口 */ },
);

工具应该按「模型要回答的问题」切,不是按你的后端结构切。

这条原则的推论是:工具内部可以调很多接口,但对外只暴露一个语义完整的动作。

坑二:description 写成给人看的文档#

工具描述是给模型看的 prompt,不是 API 文档。

对比:

text
❌ get_inventory(sku: string) — 查询库存
   模型不知道:返回什么格式?没有库存时返回什么?
   会不会抛错?sku 大小写敏感吗?
 
✅ get_inventory(sku: string) — 查询指定 SKU 的可用库存与在途数量。
   返回 JSON:available(可立即发货数)、incoming(在途数)、
   incomingEta(预计到货日期,可能为 null)。
   若 SKU 不存在,返回 available=0 而非报错。
   SKU 不区分大小写。

第二版把「返回什么」「边界情况怎么处理」都写进去了。 模型不需要猜,也不需要试探性地调用一次看结果。

代价是描述变长、消耗 token。但省下的是往返调用,绝对划算。

坑三:工具太多,模型选择困难#

工具数量超过 10 个之后,选择准确率明显下降。我实测的拐点在 12–15 个之间。

三个应对方式,按推荐顺序:

1. 合并语义相近的工具(首选) 前面说的「按业务能力切」就是这个。通常能把工具数压掉一半。

2. 按场景分组,动态加载 不同任务阶段只暴露相关工具:

ts
// 询盘分类阶段只需要读工具
const tools = await client.listTools({ group: "read-only" });
 
// 报价阶段再加载写入工具
const tools = await client.listTools({ group: "quote" });

3. 加一层工具路由 用一个轻量模型先判断该用哪一类工具,再加载对应的子集。 成本低,但多了一次往返。

什么时候不需要 MCP#

说清楚这个比推销它更重要。

  • 工具少于 5 个,且只有一个 Agent 用 → 直接注册,MCP 是过度设计
  • 纯本地脚本,不需要独立部署 → 没必要拆进程
  • 对延迟极度敏感 → MCP 多一层进程通信,会有额外开销

我是在工具涨到 9 个、且第二个 Agent 想复用其中 4 个的时候才迁的。 迁移成本两天,在此之前用直接注册完全够用。

不要在痛之前先上架构。但要知道痛的形状,痛的时候能立刻认出来。

一个实际收益#

迁移后最有价值的副产品不是「加工具方便」,而是工具可以独立测试了

ts
// 工具变成纯函数式服务,可以脱离 Agent 单独跑测试
describe("get_inventory", () => {
  it("SKU 不存在时返回 available=0 而不是抛错", async () => {
    const result = await callTool("get_inventory", { sku: "NOT-EXIST" });
    expect(result.available).toBe(0);
  });
});

在旧架构里,这段测试要先起一个完整的 Agent。现在它是个独立进程。

可测试性才是模块化的真正收益。


具体到这个项目里的工具层设计,见 AI Digital Employee 项目页