一个团队给 Agent 接了一个"订单服务"的工具,参数有十二个:操作类型、订单号、金额、状态、备注、时间范围……调用成功率只有六成,而且失败时很难判断是参数错了还是服务拒绝了。后来拆成六个单一职责的小工具,成功率升到九成五。
MCP 工具的粒度问题不是"接口设计风格",而是模型能不能正确调用的问题。参数越多、语义越含糊,模型越容易填错;而错误一旦发生,工具本身不会告诉你"我理解错了你的意思"。
1. 粒度粗的四种代价
| 代价 | 说明 |
|---|---|
| 参数填错 | 十二个参数里选错一个的概率显著上升 |
| 语义歧义 | "操作类型"这种参数需要模型自己映射到枚举 |
| 错误不可归因 | 失败时不知道是哪一步理解错了 |
| 描述难写 | 一个工具的描述要覆盖十二种行为 |
粒度还会影响可测试性:一个十二参数的工具,测试组合几乎无法覆盖;六个单参数的工具,每个都能被明确测试。工具层的缺陷往往不在服务端,而在"模型按错误的方式调用了它"——这类缺陷只有逐工具测试才抓得住。
第二项最典型:枚举型参数把"选择"变成了模型的负担,而模型对枚举值的理解往往来自名字,而不是文档。
2. 拆分的三个判据
| 判据 | 说明 |
|---|---|
| 动词单一 | 一个工具只做一件动作(创建/查询/取消) |
| 必填可少 | 必填参数不超过三个 |
| 失败可区分 | 失败时能定位到具体原因 |
命名还要避免"抽象动词":handle、process、manage 这类词对模型没有信息量,它们不构成意图提示。工具名本身就是给模型的提示,应当直接体现它解决的问题。
第一项是核心:工具名应当是一个动词。create_order、query_order、cancel_order 天然互斥,而 manage_order 需要模型先做一次意图分类。
// 粗粒度:一个工具承担多种意图
- name: manage_order
params: [operation, order_id, amount, status, note, time_range, ...]
# 模型需要先决定 operation 是什么,再做参数填充
// 细粒度:动词单一,参数少
- name: query_order
params: [order_id]
- name: cancel_order
params: [order_id, reason]
- name: create_order
params: [user_id, amount, items]
3. 参数设计的三个约束
| 约束 | 原因 |
|---|---|
| 必填尽量少 | 每个必填都是一次填错机会 |
| 类型强约束(枚举、格式) | 减少自由文本 |
| 语义互补而非重叠 | 避免"两个参数含义相近" |
参数描述里给出一个真实示例,收益远高于一段解释:
// 描述里带示例:模型对示例的模仿远好于对规则的理解
order_id:
type: string
description: "订单号,形如 O-7781(字母 O,短横线,四位以上数字)"
example: "O-7781"
第三项常见于"开始时间/结束时间/时间范围"三者并存——重叠的参数会让模型随机选一个,而结果看起来都对。
{
"name": "query_order",
"description": "按订单号查询单个订单的状态与金额。不返回订单明细,明细请用 query_order_items。",
"inputSchema": {
"type": "object",
"properties": {
"order_id": { "type": "string", "pattern": "^O-[0-9]{4,}$" }
},
"required": ["order_id"]
}
}
注意 description 里写了两件事:做什么,以及不做什么。后者决定了模型会不会把它用在错误的场景。
4. 工具数量与可发现性
| 工具数 | 问题 |
|---|---|
| < 5 | 通常可控 |
| 5–15 | 需要清晰命名与描述 |
| 15–40 | 需要分组或分层(先选领域再选动作) |
| > 40 | 模型选择准确率明显下降 |
分层还带来一个副作用要处理:模型的上下文里同时只有一小组工具,这意味着上一轮用过的工具可能"看不见"了。可行做法是把用过的工具固定在上下文顶部,或允许按名字直接调用。
第三档需要"工具集"的概念:先让模型选一个领域(订单、用户、库存),再暴露该领域的具体工具。它把一次四十选一变成两次小范围选择。
// 分层暴露:按领域分组,避免一次给出全部工具
step1: list_domains() → ["order", "user", "inventory"]
step2: list_tools(domain="order") → [query_order, cancel_order, create_order]
step3: call_tool("query_order", { order_id })
// 代价:多一轮交互;收益:选择准确率与可维护性
5. 错误信息也是接口的一部分
| 错误内容 | 模型能否据此修正 |
|---|---|
| "参数错误" | 否 |
| "order_id 格式不匹配 ^O-[0-9]{4,}$" | 能 |
| "订单不存在:O-9999" | 能(换参数或告知用户) |
| "服务内部错误" | 否(应重试或上报) |
错误返回还要区分"工具层的错"与"业务的否":
// 两类结果必须分开,否则模型会去修一个不需要修的东西
TOOL_ERROR :参数不合法、权限不足、下游超时 → 模型可修正或重试
BUSINESS_NO :余额不足、状态不允许、重复操作 → 模型应转述给用户
// 把业务否误报成工具错,会让 Agent 陷入"改参数重试"的死循环
第二项与第三项的差别在于是否可行动。工具的错误返回应当让模型能决定"改参数、换工具、还是告诉用户",而不是只能重试。
// 错误返回:给出可行动的字段,而不是一句 message
return ToolResult.error(Map.of(
"code", "INVALID_ARGUMENT",
"field", "order_id",
"expected", "^O-[0-9]{4,}$",
"retryable", false));
工具粒度的调整还有个现实约束:每次调整都要重新验证模型的实际调用行为。纸面上更清晰的拆分,可能让模型的调用分布变得更分散,所以调整后应当用一批真实任务跑一遍,对比成功率与错误类型。
6. 排错:五种"工具调不对"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 参数被填成默认值 | 必填过多、描述模糊 | 减少必填、写清示例 |
| 选错工具 | 工具名语义重叠 | 改名为动词、加"不做什么" |
| 反复重试同一错误 | 错误不可行动 | 返回字段级原因 |
| 工具太多选不准 | 一次暴露过多 | 分层暴露 |
| 工具描述与行为不符 | 描述未随实现更新 | 把描述纳入测试 |
第五项很实际:描述漂移是工具层的隐性缺陷。做法是把"描述里的承诺"写成一条测试——比如描述说"不返回明细",就用一个断言验证返回体里没有明细字段。
7. 边界:什么情况粗粒度更好
| 场景 | 为什么 | 做法 |
|---|---|---|
| 操作天然是一个事务(转账) | 拆开会出现中间态 | 保留一个工具 |
| 参数本来就是一批同质数据 | 拆开只是重复 | 用一个接受列表的工具 |
| 工具数极少(< 3) | 选择压力小 | 不必拆 |
| 内部脚本调用(非模型调用) | 无理解问题 | 按工程习惯设计 |
还有一条与演进有关的边界:工具是接口,一旦发布就有调用方(模型与它的提示词)。改名、改参数语义都会让既有提示失效,所以它应当像对外 API 一样有版本与兼容期,而不是"随手改一下更清楚"。
判断口径一句话:这个工具的参数需不需要模型做"意图分类"。需要,就该拆;不需要,粗粒度反而是优点(一次调用完成一件事)。
8. 自测
- 粒度粗的四种代价中,哪一项把"选择"变成了模型的负担?
- 拆分的三个判据是什么?为什么"工具名应当是一个动词"是核心?
- "开始时间/结束时间/时间范围"三者并存会引发什么问题?
- 工具数超过 15 后为什么需要分层暴露?分层带来什么代价?
- 什么样的错误返回能让模型自行修正?"服务内部错误"为什么不行?
- 判断"该粗还是该细"的那个问题是什么?