为什么模型只用了你 3 个 MCP 工具:description 的五个设计错误

0 阅读13分钟

content.png

我维护一个代码知识图谱的 MCP Server,一共 15 个工具。

跑了一段时间之后,有个很难看的事实:绝大部分调用集中在 3 个工具上,剩下 12 个要么从来没被调用,要么被调用时传错参数,然后模型放弃它,转头去 grep 或者读文件。

一开始我以为是模型能力问题。后来把 15 条 description 全部导出来排了个序,发现是我自己的问题——我一直在按"给人写 API 文档"的方式,给模型写工具说明。

这篇文章把这个排查过程完整记下来:怎么把工具描述导出来、五个具体的设计错误(每个都有真实描述对照)、一份可以直接套用的模板,以及改完怎么验证。


一、先把 15 条 description 导出来看一眼

MCP 是 stdio 上的换行分隔 JSON-RPC。不用装任何东西,一段 Python 就能把工具清单和描述全部导出来:

import json, subprocess

def list_tools(cmd):
    p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
                         stderr=subprocess.DEVNULL, text=True, bufsize=1)

    def send(o):
        p.stdin.write(json.dumps(o) + "\n")
        p.stdin.flush()

    # 1. 握手
    send({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
        "protocolVersion": "2024-11-05", "capabilities": {},
        "clientInfo": {"name": "probe", "version": "0"}}})
    p.stdout.readline()

    # 2. 必须发这条通知,否则部分 server 不理 tools/list
    send({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})

    # 3. 要工具清单
    send({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})
    while True:
        line = p.stdout.readline()
        if not line:
            break
        msg = json.loads(line)
        if msg.get("id") == 2:
            return msg["result"]["tools"]

tools = list_tools(["/path/to/your-mcp-server", "--stdio"])
for t in sorted(tools, key=lambda x: -len(x.get("description", ""))):
    print(f'{len(t["description"]):>5}  {t["name"]}')

我的 server 跑出来是这样:

 1803  query_graph
 1474  search_graph
 1030  index_repository
  936  index_status
  841  search_code
  722  trace_path
  615  check_index_coverage
  610  get_architecture
  569  detect_changes
  508  get_code_snippet
   63  get_graph_schema
   52  ingest_traces
   46  manage_adr
   31  delete_project
   25  list_projects

15 个工具,description 合计 9,325 个字符,平均 622 字符。加上 inputSchema,整套工具定义大概 2.4 万字符、7,000 多个 token,在每一轮对话里都占着上下文。

这里第一个反直觉的地方就出来了:

最长的那条 query_graph(1,803 字符)是我用得最少的工具;最短的 list_projects(25 字符,"List all indexed projects")反而是模型用得最准的之一。

原因不难想。list_projects 没有任何决策难度——模型听到"有哪些项目"就知道该调它。而 query_graph 要模型判断"我现在该写 Cypher 查图谱吗",这个判断失败率极高,我写了 1,803 个字符也没救回来。

描述长度应该跟"模型做这个决策有多难"成正比,而不是跟"这个函数实现有多复杂"成正比。 我一开始完全搞反了。

顺手把 token 成本也算一下。不用接 tokenizer,粗算够用:英文按 4 字符 ≈ 1 token,中文按 1.5 字符 ≈ 1 token。我这套定义 2.4 万字符里绝大部分是英文,算下来 6,000–7,000 token,跟客户端实际报的数字差不太多。

7,000 token 是什么概念?一个中等规模的代码任务,真正有用的上下文可能就两三万 token。工具定义吃掉了接近四分之一,而且是每一轮都吃。这笔账在设计阶段几乎没人算。


二、错误一:把 description 写成 API 文档

这是我最初的写法,也是最常见的错误:

{
  "name": "search_code",
  "description": "Search code in the repository. Supports pattern, project, mode, file_pattern, path_filter, context, regex and limit parameters. Returns matching code snippets.",
  "inputSchema": { ... }
}

功能、参数、返回值,一样不缺。人看了能懂。

但模型在这个位置要回答的问题不是"你能做什么",是"我现在该不该调你"。 这两件事完全不同。

上面这段描述里,没有一句话告诉模型:什么时候应该用你,而不是直接 grep。而模型手里有 grep、有 read_file、有 glob,它当然选自己最熟的那条路。

我后来观察到一个规律:模型对工具的选择,几乎完全由 description 的前 100 个字符决定。 因为它要在十几个候选里快速筛,不会把每条 800 字的说明读完再决策。

现在我这条描述的真实开头是这样:

Graph-augmented code search. Finds text patterns via grep, then enriches results with the knowledge graph: deduplicates matches into containing functions, ranks by structural importance (definitions first, popular functions next, tests last).

还是先讲"是什么",但紧接着就给了"和 grep 的关系"。

而同一个 server 里 search_graph 的开头更狠:

Search the code knowledge graph for functions, classes, routes, and variables. Use INSTEAD OF grep/glob when finding code definitions, implementations, or relationships.

Use INSTEAD OF grep/glob——这句话不是功能说明,是决策指令。它直接告诉模型"你现在想 grep 的时候,停下来用我"。

改完这一句之后,这个工具才算真正进了模型的选项池。

失败方案提醒:我试过把参数说明写得更详细,每个参数都加取值范围和例子,description 从 300 字涨到 900 字。结果模型该用还是不用。

因为参数是"怎么调",模型卡在"要不要调"。在错误的地方加字数,只会增加 token 成本,不会提高调用率。


三、错误二:工具名和描述互相打架

15 个工具里有这么一对:

  • search_code —— 按文本模式搜索
  • search_graph —— 按符号/关系搜索

名字里都有 search。模型面对"看一下登录逻辑在哪"这种需求时,会在两者之间犹豫,有时候选错,有时候干脆两个都不用。

这是我设计上的问题:MCP 的工具命名空间是平的,所有工具摆在模型面前是一个列表,没有目录、没有分组、没有继承。模型看到的是一张平铺的清单,它必须自己解决"这两个东西有什么区别"。

描述里如果没把这个区别说死,模型就只能靠猜。我现在的做法是在两边都写明边界:

search_graph:找定义和关系。函数在哪定义、谁调用了谁、实现了哪个接口。

search_code:找字面量和文本。某个错误消息、某个配置项、某段硬编码字符串出现在哪。

如果重来一次,我会把工具直接命名成 search_symbols 和 search_texts,从命名层面就消除歧义。

但工具名在 MCP 里是契约——客户端缓存、权限配置、提示词里引用的都是名字,改一次要动一堆地方。所以一旦工具上线,消歧的成本就得由 description 承担。这个代价在设计阶段几乎为零,在上线后极高。


四、错误三:不写前置条件,模型不会自己排顺序

get_code_snippet 是一个只能读、不能搜的工具。它需要一个精确的 qualified_name。

我最初的描述只有一句:

Read source code for a function by qualified_name.

结果模型的行为是:拿到"读一下这个方法"的需求 → 直接猜一个名字传进来 → 报错 → 放弃这个工具,改用 read_file 把整个文件读进来。

它不会自己推导"我应该先去 search_graph 拿到准确的名字"。

现在的描述是这样的:

IMPORTANT: First call search_graph to find the exact qualified_name, then pass it here. This is a read tool, not a search tool. Accepts full qualified_name (exact match) or short function name (returns suggestions if ambiguous).

两个关键点:

  1. First call ... 把调用顺序写进了工具描述里。 模型不会跨工具做规划推断,你写一句"要先调 X",它就会先调 X。
  2. This is a read tool, not a search tool 划清了意图边界。 模型经常把"读"和"搜"的需求混在一起,这句话是护栏。

改完之后,get_code_snippet 的调用成功率肉眼可见地上升,read_file 读整个文件的情况明显减少。

原则:只要工具有前置依赖,就把调用顺序写进 description,并且用 IMPORTANT / First 这类强提示词开头。 这不是啰嗦,这是给模型的操作手册。


五、错误四:不写截断和失败,模型以为搜全了

这个坑最隐蔽,因为它不表现为"报错",而是表现为模型自信地给出错误结论。

search_code 默认返回 10 条结果。模型拿到 10 条,就会认为"整个仓库只有这 10 处用了这个模式",然后基于这个前提往下写方案。

实际上底下有 200 个匹配,只是被截断了。

所以现在这条描述里专门有一段:

TRUNCATION: enriched results are capped at limit (default 10). Response carries total_grep_matches (raw grep hit count) and total_results (deduplicated function count) — compare to limit to detect truncation. There is no offset parameter; to see more, raise limit or narrow the query with file_pattern / path_filter.

三个信息缺一不可:

  1. 会截断:默认上限是多少
  2. 怎么判断被截断了:拿 total_grep_matches 跟 limit 比
  3. 截断了怎么办:没有 offset,只能提 limit 或缩小范围

第三点尤其重要。很多工具会写"支持分页"但不说"分页参数叫什么",模型就只能瞎试。而这里明确说"没有 offset",模型就不会浪费两轮去试 offset=10。

search_graph 的做法是反过来——它有 offset,所以描述里写:

PAGINATION: results are capped at limit (default 50). The response always includes total and has_more. Detect truncation with has_more, then page by re-calling with offset=offset+limit until has_more is false.

给模型的返回值契约,要写到"它知道下一步该干什么"的程度。 只说"结果可能被截断"是不够的。

这个坑踩过一次:一个跨模块重构,要找所有调用某个方法的位置,search_code 返回了 10 条,模型据此判断"调用点都在这一层",改完就收工。实际上另一层还有几处调用,因为被截断,模型根本不知道。

它没有说谎,是工具让它误以为搜全了。 这种错误在 review 阶段极难发现,因为模型给出的推理链条看起来完全合理。


六、错误五:工具数量没有预算

前面算过,15 个工具的描述加 schema 大概 7,000 token,每轮都占着上下文。

但 token 成本还不是最贵的。更贵的是选择成本:候选工具从 5 个涨到 15 个,模型选错的概率不是线性上升的。

我实际踩到的表现是:模型会挑一个"看起来差不多"的工具,参数传错,报错,然后在同一轮里换一个再试。一次任务里浪费两三次调用,每次都烧几千 token。

架构上的结论,我认为是这条:

MCP Server 的边界不应该按代码模块来划,应该按"一次决策"来划。

判断方法很简单:把这些工具摆在一个真实任务面前,问"在这一步,它们会互相竞争吗?"

  • 会竞争 → 必须放在一起,用描述划清边界(比如 search_graph 和 search_code)
  • 不会竞争 → 可以拆到不同 server,按任务阶段加载(比如"索引管理"和"代码检索"几乎不会同时用)

我最后做的拆分是五组:

分组工具什么时候需要
代码检索(常驻)search_graph、search_code、get_code_snippet、trace_path几乎每个任务
变更分析detect_changes、get_architecture改完代码复盘影响面
索引管理index_repository、index_status、check_index_coverage、list_projects、delete_project建索引、查覆盖率
图谱查询query_graph、get_graph_schema探索性分析
团队协作manage_adr、ingest_traces偶尔

只有第一组值得常驻。

注意一个协议限制:MCP 目前没有"按需动态加载工具"的标准能力,客户端在 initialize 之后就把 tools/list 全量拿到并塞进上下文。所以"按需加载"只能靠拆成多个 server,由客户端决定连哪几个来实现。这是设计 MCP Server 时必须提前想清楚的事——工具一旦上线,再拆分就是破坏性变更。


七、一份可以直接套用的描述模板

把我改完之后的写法抽出来,就四个部分,顺序不能变:

[何时用] Use this INSTEAD OF <替代方案> when <触发条件>.
[前置]   First call <前置工具> to get <必需参数>.
[返回]   Returns <返回什么>. Carries <元数据> — compare to <阈值> to detect <异常>.
[限制]   <上限 / 无 offset / 权限要求 / 已知缺陷>.

对照我实际的四条:

工具关键那一句起什么作用
search_graphUse INSTEAD OF grep/glob when finding code definitions抢 grep 的位置
get_code_snippetFirst call search_graph to find the exact qualified_name定调用顺序
search_codeCompare to limit to detect truncation防错误结论
index_repositoryskipped / parse_partial / excluded 三者的区别防误判索引失败

第四条值得单独说。index_repository 的三个字段如果你不解释,模型会把"gitignore 排除的文件"当成"索引失败",然后反复重跑索引:

skipped(完全没索引:文件过大/读不了/解析失败)、parse_partial(索引了,但列出的行范围里可能有构造没解析出来)、excluded(设计上就不索引:gitignore / .cbmignore / skip-lists,不是失败)

一句话省掉无数次重跑索引。


八、改完怎么验证

不要问模型"你是不是用了这个工具"——它会顺着你的话说是。要看它实际发出的 tool_use。

我是从三个数看的:

  1. 目标工具的调用占比:改动前后各跑一周,看日志里那几个"从不被调用"的工具有没有起来
  2. 同轮重试率:一次任务里对同类工具连续调用两次以上(第一次传错参数或选错工具)的比例
  3. 工具定义的 token 占比:tools/list 序列化后的长度 / 总上下文,超过 10% 就该拆了

第二个数最能反映问题。模型选错工具时不会报错,它会静默地换一个再试,你不看日志根本发现不了。


写在最后

给 LLM 设计接口,和给人设计 API,是两件不同的事。

人读文档会从头读到尾,遇到不懂的参数会查。模型不会。它在"要不要调你"的那一刻,只读你描述的开头一两句,读完就决策。

所以最便宜的优化永远是同一件事:把"什么时候该用我"放在第一个字,把"我有哪些参数"放到最后,甚至可以不放——参数在 schema 里,它会看。

我改完这五处之后,那批"从不被调用"的工具里,有一半开始正常工作了。剩下的一半不是描述问题,是它们本来就不该出现在这个 server 里。

如果你也在写 MCP Server,可以把你的 tools/list 导出来,按字符数排个序,看看最长的三条是不是你用得最少的三个——大概率是。


这个系列上一篇:《MCP Server 接入多个 AI 客户端:配置差异与 10 个真实踩坑》