越来越多的开发者开始为 AI Agent 开发工具,无论是通过 MCP(Model Context Protocol)、Skills 脚本、还是直接使用 OpenAI/Claude 的 function calling。但很快大家发现了一个令人困惑的现象:技术上实现完全正确的工具,Agent 却用不好。 工具能跑通,schema 定义正确,API 调用成功,但是 Agent 总是选错工具,传错参数,或者在明明应该调用工具的时候却回复「我无法完成这个任务」。
问题出在哪里?问题在于:我们用写 API 的思维在写 Agent 工具。
当你为人类设计 API 时,你可以假设他们会阅读文档、理解上下文、在出错后调试代码,但 Agent 不一样:
这意味着,给 Agent 开发工具的真正挑战不是技术实现,而是设计出 Agent 能用好的工具接口,这也是我们在开发 TRAE 过程中一直在思考和解决的一个问题。
这里有一个关键的思维转换:Agent 工具是 AI Agent 的用户界面(User Interface),不是已有 REST API 的封装
传统 REST API 是为人类开发者设计的,我们假设开发者会阅读文档、理解上下文、在出错后调试代码,但 Agent 是完全不同的「用户」,它不会主动查阅文档,不擅长从上下文中推断隐含信息,每次调用都需要从头开始理解工具的用途。
换句话说:你不是在写 API,你是在教会一个智能体如何与这个世界交互。
这个智能体(LLM)有着独特的长处与局限性:
只有理解这个智能体的特性,你才能设计出它真正能用好的工具。本文将以 MCP 为主要切入点,因为它正在成为 Agent 工具开发的主流方式,但文中的设计原则适用于所有 Agent 工具开发场景。
要设计好 Agent 工具,首先需要理解它是如何被 Agent 调用的,这条调用链路决定了你的设计将如何被「消费」。
让我们从最底层开始:LLM 本身是如何调用工具的?
一个关键的认知:LLM 本身不会「执行」任何函数。 所谓的「function calling」或「tool calling」本质上是 LLM 与应用程序之间的一个多轮对话协议:
┌─────────────┐ ┌─────────────┐ │ │ ① 发送请求+工具定义 │ │ │ │ ─────────────────────────────▶│ │ │ │ │ │ │ 应用程序 │ ③ 返回「工具调用请求」 │ LLM │ │ │ ◀─────────────────────────────│ │ │ │ │ │ │ │ ⑤ 返回工具执行结果 │ │ │ │ ─────────────────────────────▶│ │ │ │ │ │ │ │ ⑥ 生成最终回复(或继续调用) │ │ │ │ ◀─────────────────────────────│ │ └──────┬──────┘ └─────────────┘ │ │ │ ▼ │ ┌─────────────┐ │ │ 外部工具 │ │ │ (API/DB/..) │ └─┴─────────────┘ ② LLM 分析用户请求,决定是否需要调用工具
以 OpenAI API 为例,工具通过 tools 参数传递给模型。每个工具定义包含三个核心部分:
tools = [{
"type": "function",
"name": "get_weather",
# 工具名称 "description": "获取指定城市的当前天气",
# 工具描述 - LLM 理解工具用途的关键 "parameters": {
# 参数的 JSON Schema "type": "object",
"properties": {
"location": {
"type": "string",
"description": "城市名称,如:深圳、北京"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "温度单位"
}
},
"required": ["location"]
}
}]
这三个部分 : na****me、description、parameters ,就是 LLM「看到」的工具的全部信息。它看不到你的代码实现,不知道函数内部做了什么。
当用户说「深圳今天天气怎么样?」时,LLM 会分析这个请求,发现需要调用 get_weather 工具。但它不会执行任何代码,而是返回一个结构化的「工具调用请求」:
{
"id": "fc_12345xyz",
"type": "function_call",
"name": "get_weather",
"arguments": "{\"location\": \"深圳\", \"unit\": \"celsius\"}"
}
注意 arguments 是一个 JSON 字符串,LLM 本质上只是在「生成文本」,只不过这段文本遵循了特定的结构化格式, 存在返回非法 JSON 格式的可能。
应用程序解析 LLM 返回的工具调用请求,执行实际的函数,这里以 Python 代码进行示例:
import json
# 解析 LLM 返回的工具调用
tool_call = response.output[0] # 获取第一个工具调用
args = json.loads(tool_call.arguments)
# 执行实际的函数(这是你的代码,不是 LLM 执行的)
weather_result = get_weather(args["location"], args.get("unit", "celsius"))
# 返回: {"temperature": 14, "condition": "晴", "humidity": 65}
执行结果需要通过 function_call_output 类型的消息返回给 LLM:
# 将工具执行结果添加到对话中
input_messages.append({
"type": "function_call_output",
"call_id": tool_call.call_id, # 关联到具体的工具调用
"output": json.dumps(weather_result)
})
# 再次调用 LLM,让它基于结果生成最终回复
final_response = client.responses.create(
model="gpt-4",
tools=tools,
input=input_messages
)
LLM 收到工具执行结果后,会生成用户可读的最终回复:
"深圳今天天气晴朗,当前气温 14°C,湿度 65%。"
这是一个容易被忽视但非常重要的细节,要理解工具设计的约束,我们需要从 LLM 实现的角度来看工具调用是如何工作的。
当你通过 API 传入 JSON 格式的工具定义时,LLM 提供商通常会将其转换为一种内部优化的格式。这是因为 JSON 对 LLM 来说并不是一个友好的格式:
相比之下,许多 LLM 提供商内部使用 类 XML 的格式 来表示工具调用:
<function_calls>
<invoke name="get_weather">
<parameter name="location">北京</parameter>
<parameter name="unit">celsius</parameter>
</invoke>
</function_calls>
类 XML 格式的优势在于:
实际上,不同提供商采用了不同的内部格式和特殊 token。这些格式在模型训练时就被专门优化过,使模型能够更准确地识别「何时应该调用工具」以及「如何正确构造调用参数」。
当 LLM 决定调用工具时,它实际上是在生成一系列遵循特定模式的 token。这些 token 随后被 API 层解析还原成 JSON 格式返回给开发者。这个解析过程本身存在一定的失败率,模型可能生成格式不完整或不合法的输出,导致工具调用失败。
一些 LLM 提供商(如 OpenAI 的 Strict Mode)使用了 Constrained Decoding 技术来保证输出一定是合法的 JSON 结构。这种技术在解码时动态限制下一个 token 的候选集,确保生成的序列符合预定义的 schema。但这种约束并非没有代价:它可能影响生成速度,在某些边界情况下也可能影响模型的表达能力。
工具定义会被注入到 LLM 的 system prompt 中,占用宝贵的 context window。当你定义了 10 个工具,每个工具有详细的描述和参数 schema,这些信息都会序列化后添加到每次请求的 prompt 中,这带来两个重要影响:
你可能会想:既然工具定义最终也是放在 prompt 里,我能不能自己在 system prompt 中定义一套工具调用的格式,让 LLM 按照我的格式输出?
技术上可以,之前也有很多 Agent 实现这样做,但效果会差很多。原因在于: LLM 在训练过程中已经对原生的工具调用格式进行了专门的优化。 模型见过大量使用这种格式的训练数据,对这些特殊 token 有更强的「注意力」和「遵循力」。使用原生格式,模型更容易:
相比之下,自定义格式需要模型「临时学习」你定义的规则,效果和稳定性都会打折扣。这也是为什么现在主流的 Agent 框架都直接使用各 LLM 提供商原生的 function calling 机制,而不是自己发明一套。
工具数量不仅影响 token 消耗,更直接影响模型的决策质量。这是一个容易被低估的问题。
每个 MCP 工具都带有 schema,描述它做什么以及如何使用。这些 schema 被注入到 system prompt 中。假设每个工具定义平均约 250-300 tokens:
| 工具数量 | 预估 tokens |
|---|---|
| 5 | 1,250-1,500 |
| 10 | 2,500-3,000 |
| 20 | 5,000-6,000 |
| 50 | 12,500-15,000 |
这些数字看起来不大,但有两个关键问题:
当可用工具较少时(比如 5-10 个),模型可以较容易地「记住」每个工具的用途,做出准确的选择。但当工具数量增加到几十甚至上百个时,问题就变得复杂:
所有这些工具开销都要付费,更长的 prompt、更多的调用、更多的步骤都意味着更高的 API 成本,不仅变慢了,还在为更差的结果花更多的钱。
OpenAI 官方建议: 尽量将工具数量控制在 20 个以内。 这虽然是一个软性建议,但背后反映的是真实的性能瓶颈。在实践中,如果你的 MCP Server 需要暴露大量功能,就应该慎重考虑一下。
MCP(Model Context Protocol)并没有改变上述的 tool calling 机制,它解决的是另一个问题: 如何标准化地定义和暴露工具。
在 MCP 出现之前,如果你想让 Agent 调用外部工具,你需要:
这就是经典的 N×M****问题 :N 个工具 × M 个 LLM = N×M 个适配器。
MCP 的解决方案是引入一个 标准化的中间层:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ LLM A │ │ LLM B │ │ LLM C │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ └───────────────────┼───────────────────┘ │ MCP Client(适配层) MCP Protocol(标准协议) │ ┌───────────────────┼───────────────────┐ │ │ │ ▼ ▼ ▼ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ MCP Server │ │ MCP Server │ │ MCP Server │ │ (GitHub) │ │ (Slack) │ │ (Database) │ └─────────────┘ └─────────────┘ └─────────────┘
MCP Server 只需要按照 MCP 协议暴露工具,MCP Client 负责将这些工具转换成各个 LLM 能理解的格式。
当你在 MCP Server 中定义一个工具时,你实际上定义的是:
// MCP Server 中的工具定义
{
name: "create_issue",
description: "Create a new issue in a GitHub repository",
inputSchema: {
type: "object",
properties: {
repo: {
type: "string",
description: "Repository name (owner/repo)"
},
title: {
type: "string",
description: "Issue title"
},
body: {
type: "string",
description: "Issue body content"
}
},
required: ["repo", "title"]
}
}
当 Agent 作为 Client 连接到这个 MCP Server 时,会发生以下转换:
由于 MCP 工具需要与 Agent 自带的工具共存,大多数 Agent 实现会给 MCP 工具添加前缀以避免命名冲突。常见的命名模式是:
mcp_< server-name >_< tool-name >
例如,一个名为 github 的 MCP Server 中的 create_issue 工具,最终呈现给 LLM 的名称可能是:
mcp_github_create_issue
这种机制看似简单,但会带来一系列问题:
如果 Agent 自带了一个 read_file 工具,而你的 MCP Server 也暴露了 read_file ,即使加了前缀变成 mcp_myserver_read_fil e,LLM 仍然可能在两个功能相似的工具之间产生困惑。更糟的是,如果描述不够清晰,LLM 可能会选错工具,或者在应该用自带工具时调用了 MCP 工具(反之亦然)。
当 server name 和 tool name 都较长时,最终的工具名可能变得冗长:
mcp_my-awesome-productivity-server_create_calendar_event_with_reminder
过长的名称不仅占用更多 token,还可能影响 LLM 对工具的「记忆」和选择准确性。一些研究表明,LLM 对简短、直观的名称有更好的响应。
更重要的是, 某些 MCP Client 有硬性的长度限制。
例如 TRAE 对 server name + tool name 的总长度限制为 60 个字符。超过限制的工具名会被截断,可能导致工具无法正常工作或产生命名冲突。这意味着在设计 MCP Server 时,你需要为工具名预留足够的「前缀空间」。
当用户连接多个 MCP Server 时,工具数量会快速累加。假设:
总工具数已达 45 个,超过了前文提到的 20 个工具的建议上限。这还没考虑一些「全功能」的 MCP Server 可能一次性暴露几十个工具的情况。
面对工具数量爆炸的问题,一些 Agent 实现开始探索 动态工具发现 (Dynamic Tool Discovery)机制:不在会话开始时注册所有工具,而是让 LLM 在需要时主动「查询/搜索」可用的工具。
这种方式的优势很明显:大幅减少了 system prompt 中的工具定义数量,避免了上下文污染。但它也有明显的局限:
尽管如此,随着 Agent 场景对工具扩展性需求的增长,以及 LLM 在 agentic 能力上的持续进化,动态工具发现很可能成为未来的主流范式。在设计 MCP Server 时,你可以为此做一些准备,比如提供清晰的工具分类和摘要描述,方便未来被动态发现机制索引。
不同的 MCP Server 可能提供功能相似但实现不同的工具。例如
对于「帮我搜索 XXX」这样的请求,LLM 需要在多个 search 工具中做选择,而这些工具的描述可能都很相似。
这是一个容易被忽视但影响很大的问题。前面我们提到,MCP Client 会将工具定义转换为各个 LLM 原生的 tool calling 格式。问题在于: 不同 LLM 厂商的 API 对 JSON Schema 的支持程度差异很大。
这不是 MCP 协议本身的限制,而是底层 LLM API 的限制。MCP Server 的开发者必须注意这个差异,在不同的 Client 和 LLM 组合下测试
此外,MCP Client 自身和 LLM API 也可能引入额外限制,限制工具的数量、限制 server name + tool name 的最大字符数等等。这些差异带来几个实际影响:
设计建议: 尽量使用简单、扁平的 schema 结构,避免深层嵌套、递归引用和复杂的联合类型。如果必须使用这些特性,要在目标 LLM 和 Client 上充分测试。
即使你的 schema 定义完全正确,LLM 生成的参数也可能出现问题:
这意味着你的 MCP Server 实现需要做好 防御性编程 :验证输入、提供合理的默认值、对错误格式做兼容处理,并返回清晰的错误信息帮助 LLM 自我纠正。
为什么要花这么多篇幅讲这条调用链路?因为它直接影响你的设计决策,理解了这条链路,我们接下来就可以开始讨论:如何站在 Agent 的角度,设计它能用好的工具接口,以及在实现和测试的过程中有哪些需要注意的细节。
在深入具体的设计原则之前,我们需要先建立一个关键的心智模型: Agent 究竟是如何「感知」和「理解」你设计的工具的? 理解这一点,是设计出好用工具的前提。
从 Agent(LLM)的视角来看,每个工具就是一个简单的 三元组 :
工具=(名称, 描述, 参数 Schema)
就这些,没有代码实现,没有注释,没有文档链接, Agent 对工具的全部认知,就来自这三个元素:
create_github_issue
名称是 Agent 对工具的「第一印象」,一个好的名称应该让 Agent 在看到的瞬间就能大致猜到这个工具是做什么的。
Create a new issue in a GitHub repository. Use this when you need to report bugs,
request features,
or track tasks.
描述是工具的「使用说明书」,告诉 Agent 这个工具能做什么、应该在什么场景下使用。
{
"type": "object",
"properties": {
"repo": {
"type": "string",
"description": "Repository in owner/repo format, e.g. facebook/react"
},
"title": {
"type": "string",
"description": "Issue title, should be concise and descriptive"
},
"body": {
"type": "string",
"description": "Issue body in markdown format"
}
},
"required": ["repo", "title"]
}
参数 Schema 告诉 Agent 调用这个工具需要提供什么信息、每个参数是什么含义、哪些是必填的。
这三个元素就是 Agent 理解工具的全部信息来源。 如果名称模糊、描述不清、参数含义不明,Agent 就会困惑、选错工具、传错参数。
这是传统 API 设计与 Agent 工具设计的 根本差异。
为什么好的 REST API 不等于好的 MCP Server?因为 REST API 的设计原则(可组合性、灵活性、自我发现)对人类开发者和 Agent 的影响完全不同:
人类开发者能从上下文推断意图。当我们看到 get_user(id) 时,会「显然」地认为 id 是用户的唯一标识符(比如 UUID)。但 Agent 没有这种隐含知识,它可能会尝试用邮箱、用户名甚至随机字符串来调用这个函数。下面我们用 Python 代码来举例,函数名可以理解为工具名,Docstring 可以理解为工具的 description :
def get_user(id):
"""获取用户信息"""
pass
def get_user_by_uuid(user_uuid: str):
"""
根据 UUID 获取用户信息。
参数:
- user_uuid: 用户的唯一标识符,格式为 'usr_xxxxxxxx'
返回:
用户信息的 JSON 对象,包含 name、email、created_at 等字段
"""
pass
这个差异贯穿整个工具设计过程:
因此,设计 Agent 工具的核心原则是: 防呆式语义化 ,假设 Agent 会完全按字面意义理解你的工具,不会做任何「显然」的推断。
人类开发者使用 API 时,通常的流程是:
但 Agent 不一样,Agent 需要 尽量一次做对 ,原因有几个:
每次工具调用都会消耗 token,一次失败的调用不仅浪费了调用本身的 token,还需要额外的 token 来处理错误、重新规划、再次尝试。在复杂任务中,这种「试错」的成本会快速累积。
想象一下:用户让 Agent「帮我在 GitHub 上创建一个 issue」,Agent 先调用了错误的工具,然后参数传错了,再然后格式不对…… 用户看着 Agent 反复折腾,体验会非常糟糕。
每次失败的尝试都会被记录在对话历史中,占用宝贵的上下文空间。随着失败尝试的累积,真正有用的信息反而被挤出了上下文窗口。
人类开发者踩过的坑会记住,下次不会再犯。但 Agent 的每次会话都是独立的,上一次学到的「这个工具要这样用」的经验,下一次会话就忘了。
这意味着: 你的工具设计必须让 Agent 在第一次看到时就能正确使用 ,不能太指望它「试几次就会了」。
我们在上面讨论过,工具定义会占用 context window,但这里我想从另一个角度来看这个问题: 上下文窗口就像 Agent 的「工作记忆」。
人类的工作记忆容量有限(著名的 7±2 法则),LLM 也是如此。虽然现代 LLM 的上下文窗口可以达到 200K 甚至更长,但这并不意味着它能同等质量地「关注」窗口中的每一部分内容。研究表明,LLM 对上下文的注意力分布是不均匀的:
更重要的是,工具定义占据的是上下文中最「特权」的位置:system prompt。当你往 system prompt 塞入大量工具定义时,模型会开始关注工具选择逻辑,而不是用户的实际意图。如果你的 Agent 开始不遵循指令,问题可能不在模型本身,而在你的工具集。这对工具设计的启示是:
综合以上分析,我们可以得出一个核心洞察: 设计工具的本质,是在设计 Agent 的认知体验。好的工具设计,就是不断减少 Agent 的认知负担。
具体来说:
接下来我们将围绕这个核心洞察,逐一展开具体的设计原则:命名、描述、输入、输出、错误处理。每一个原则的目标都是一样的, 让 Agent 更容易理解、更容易用对、更难用错。
工具名称是 Agent 对工具的「第一印象」,也是它在几十个工具中快速筛选的主要依据。一个好的名称应该让 Agent 在看到的瞬间就能判断:这个工具是不是我需要的?
前面我们强调过,Agent 不会做「显然」的推断。这个原则在命名上尤为重要:
send_message get_user delete_item
slack_send_message get_user_by_email delete_project_by_uuid
命名完整性的几个维度:
工具本质上是「动作」,命名应该以动词开头,清晰表达这个工具会「做什么」:
create_github_issue send_slack_message search_documents update_user_profile delete_expired_sessions github_issue slack_message_handler document_search
常用的动词模式:
当 Agent 面对几十个工具时,它需要快速判断哪些工具与当前任务相关。 使用一致的前缀可以帮助 Agent 进行「分类筛选」:
github_create_issue github_list_pull_requests github_merge_pull_request github_search_code slack_send_message slack_list_channels slack_get_channel_history calendar_create_event calendar_list_events calendar_update_event
这种命名模式的好处:
一个有趣的发现是: 选择前缀命名还是后缀命名,对不同 LLM 的工具使用评测有的影响。
github_search_issues github_create_issue slack_send_message slack_list_channels search_issues_github create_issue_github send_message_slack list_channels_slack
效果因 LLM 而异,没有绝对的「最佳」选择。Anthropic 的研究发现,在他们的内部工具使用评估中,前缀和后缀的选择会产生可测量的性能差异。
实践建议:
前面我们提到,某些 MCP Client 对工具名长度有限制(如 TRAE 的 60 字符限制包含 server name)。这需要在完整性和简洁性之间找到平衡:
mcp_productivity_suite_create_calendar_event_with_reminder_and_notification create_evt calendar_create_event
实用建议:
如果说名称是工具的「标题」,那么描述就是工具的「使用手册」。对于 Agent 来说, 描述是理解工具如何使用的主要信息来源 ,它会认真「阅读」每一个描述来决定工具的使用方式。
与人类开发者不同,Agent 不会跳过文档直接看代码。它会仔细分析你写的每一句描述,这意味着:
def delete_item(id):
"""删除一个项目"""
pass
def delete_item_by_uuid(item_uuid: str):
"""
根据 UUID 永久删除一个项目。
参数:
- item_uuid: 项目的唯一标识符,格式为 'item_xxxxxxxx'
返回:
- 成功时返回 "Item deleted successfully"
- 如果项目不存在,返回描述性错误信息
注意:此操作不可逆,删除前请确认。
"""
pass
一个好的工具描述应该回答以下问题:
Create a new issue in a GitHub repository.
Use this when you need to report bugs,
request features,
or track tasks.
Requires authentication. The repository must exist and you must have write access.
Returns the created issue object with id, url, and status fields.
完整示例:
{
"name": "github_create_issue",
"description": ""
"Create a new issue in a GitHub repository. Use this when you need to report bugs, request features, or track tasks. Requires write access to the target repository. Returns the created issue with id, number, html_url, and state fields. If the repository doesn't exist or access is denied, returns an error message."
"",
"inputSchema": {
...
}
}
参数的 description 字段同样重要,一个好的示例胜过千言万语:
{
"properties": {
"repo": {
"type": "string",
"description": "Repository in owner/repo format, e.g. 'facebook/react' or 'microsoft/vscode'"
},
"labels": {
"type": "array",
"items": {
"type": "string"
},
"description": "Labels to apply to the issue, e.g. ['bug', 'high-priority']"
},
"assignees": {
"type": "array",
"items": {
"type": "string"
},
"description": "GitHub usernames to assign, e.g. ['octocat', 'hubot']. Must be valid collaborators."
}
}
}
示例的作用:
除了示例,参数描述还应该遵循以下规范:
{
"repo": {
"type": "string",
"description": "(Required) Repository in owner/repo format"
},
"branch": {
"type": "string",
"description": "(Optional) Branch name, defaults to 'main'"
}
}
{
"limit": {
"type": "integer",
"description": "Maximum results to return (optional, default: 20, max: 100)"
},
"format": {
"type": "string",
"enum": ["json", "markdown", "text"],
"description": "Output format (optional, default: 'json')"
}
}
这些信息可以在官方的 MCP Inspector 中查看,帮助 Agent(和开发者)快速理解参数要求。
传统 API 文档往往只描述「成功时会怎样」,但对 Agent 来说, 知道失败时会发生什么同样重要:
""" 根据用户 ID 获取用户信息。 返回用户的姓名、邮箱和注册时间。 """
""" 根据用户 ID 获取用户信息。 返回: - 成功时返回 JSON 对象,包含 name、email、created_at 字段 - 如果用户不存在,返回 "User not found: {id}. Please verify the ID format (should be 'usr_xxx')ortry searching by email usingfind_user_by_email()." - 如果 ID 格式错误,返回格式说明和正确示例 """
这种描述方式让 Agent 知道:
当你有多个功能相似的工具时,可以在描述中 明确指导 Agent 的选择顺序:
def get_variable_value(address: str):
"""
获取指定地址的变量值(推荐首选)。
自动识别变量类型并返回格式化的字符串表示。
大多数情况下应该优先使用这个函数。
"""
pass
def read_raw_memory(address: str, size: int):
"""
读取指定地址的原始内存数据。
⚠️ 只有当 get_variable_value 失败或需要原始字节时才使用此函数。
此函数忽略类型信息,返回原始字节数组。
"""
pass
通过在描述中写明「推荐首选」和「只有当 X 失败时才使用」,可以有效引导 Agent 的工具选择策略。
至此,我们建立了 Agent 工具设计的核心认知框架,并深入探讨了命名和描述两个最基础的维度。
接下来,我们继续探讨输入设计、输出设计、错误处理这三个同样关键的维度,以及工具粒度的权衡、跨环境可移植性、Skills 与 MCP 的互补等更高级的实践模式,帮助你将这些原则应用到真实的 MCP Server 开发中。
输入设计的核心目标是: 让 Agent 更容易传对参数,更难传错参数。
用户(和 Agent)应该能够在最少配置的情况下开始使用工具,每个可选参数都应该有合理的默认值:
def search_issues(
query: str,
repo: str = None,
state: str = "open",
sort: str = "relevance",
limit: int = 20
) -> str:
"""
搜索 GitHub Issues。
参数:
- query: 搜索关键词(必填)
- repo: 限定仓库,格式 owner/repo(可选,默认搜索所有可访问仓库)
- state: Issue 状态,可选 'open'|'closed'|'all'(可选,默认 'open')
- sort: 排序方式,可选 'relevance'|'created'|'updated'(可选,默认 'relevance')
- limit: 返回数量上限(可选,默认 20,最大 100)
"""
pass
关键点:
利用 JSON Schema 的特性来约束输入,减少 Agent 传错参数的可能性:
def search_issues(
query: str,
repo: str = None,
state: str = "open",
sort: str = "relevance",
limit: int = 20
) -> str:
"""
搜索 GitHub Issues。
参数:
- query: 搜索关键词(必填)
- repo: 限定仓库,格式 owner/repo(可选,默认搜索所有可访问仓库)
- state: Issue 状态,可选 'open'|'closed'|'all'(可选,默认 'open')
- sort: 排序方式,可选 'relevance'|'created'|'updated'(可选,默认 'relevance')
- limit: 返回数量上限(可选,默认 20,最大 100)
"""
pass
{
"repo": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-]+/[a-zA-Z0-9_.-]+$",
"description": "仓库名,格式为 owner/repo"
}
}
{
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 20,
"description": "返回结果数量上限"
}
}
这是一个重要的实践原则: 在 Schema 中定义严格的规范,但在实际执行时宽容地处理变体。
def get_file_content(file_path: str) -> str:
"""
获取文件内容。
参数:
- file_path: 文件路径(支持绝对路径或相对于项目根目录的相对路径)
"""
normalized_path = normalize_path(file_path)
if not normalized_path.startswith('/'):
normalized_path = os.path.join(project_root, normalized_path)
return read_file(normalized_path)
为什么这样做?
对于可能返回大量数据的工具,分页是必要的:
def list_commits(
repo: str,
branch: str = "main",
page: int = 1,
per_page: int = 30
) -> str:
"""
列出仓库的提交历史。
参数:
- repo: 仓库名,格式 owner/repo
- branch: 分支名(默认 'main')
- page: 页码,从 1 开始(默认 1)
- per_page: 每页数量(默认 30,最大 100)
返回包含分页信息的结果:
{
"commits": [...],
"pagination": {
"page": 1,
"per_page": 30,
"total_count": 150,
"has_next": true
}
}
"""
pass
分页设计要点:
对于参数较多的工具,合理的分组可以提高可理解性:
{
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "搜索关键词"
},
"filters": {
"type": "object",
"description": "过滤条件(均为可选)",
"properties": {
"author": {
"type": "string"
},
"labels": {
"type": "array",
"items": {
"type": "string"
}
},
"created_after": {
"type": "string",
"format": "date"
}
}
},
"options": {
"type": "object",
"description": "查询选项(均为可选)",
"properties": {
"sort": {
"type": "string",
"enum": ["relevance", "created", "updated"]
},
"limit": {
"type": "integer",
"default": 20
}
}
}
},
"required": ["query"]
}
但要注意: 嵌套不宜过深。 前面提到,某些 LLM 对复杂嵌套结构的支持有限,一般建议嵌套不超过 2 层,否则会大大增加返回非法 JSON 对象的概率。
除了常规的 JSON Schema 设计原则外,还有一些针对 LLM 生成特性的高级技巧,可以提高工具调用的稳定性。
当工具参数中包含 复杂对象的数组 时,LLM 生成正确 JSON 数组的稳定性往往不如预期。这是因为数组需要 LLM 正确处理多个嵌套层级的括号匹配、逗号分隔等语法细节。
一个实用的解决方案是: 将数组展开为带编号的独立参数。 LLM 会识别 item_1 、 item_2 、 item_3 这种模式,并用更稳定的 JSON 对象方式来表达原本的数组语义。
上下滑动查看完整内容
{
"type": "object",
"properties": {
"changes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"file_path": {
"type": "string"
},
"old_content": {
"type": "string"
},
"new_content": {
"type": "string"
}
}
},
"description": "要执行的文件修改列表"
}
}
}
{
"type": "object",
"properties": {
"change_1": {
"type": "object",
"properties": {
"file_path": {
"type": "string"
},
"old_content": {
"type": "string"
},
"new_content": {
"type": "string"
}
},
"description": "第 1 个文件修改(必填)"
},
"change_2": {
"type": "object",
"properties": {
"file_path": {
"type": "string"
},
"old_content": {
"type": "string"
},
"new_content": {
"type": "string"
}
},
"description": "第 2 个文件修改(可选,如不需要则留空)"
},
"change_3": {
"type": "object",
"properties": {
"file_path": {
"type": "string"
},
"old_content": {
"type": "string"
},
"new_content": {
"type": "string"
}
},
"description": "第 3 个文件修改(可选,如不需要则留空)"
}
},
"required": ["change_1"]
}
为什么这样更稳定?
适用场景:
注意事项:
这是一个巧妙的技巧:设计一个 静态参数 ,它的值永远是固定的,但在描述中包含重要的行为提醒。当 LLM 按顺序生成工具调用参数时,它必须「输出」这个固定值,相当于在执行前进行了一次自我确认。
{
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "要写入的文件路径"
},
"content": {
"type": "string",
"description": "要写入的文件内容"
},
"CONFIRM_OVERWRITE": {
"type": "string",
"enum": ["I confirm this will overwrite existing content"],
"description": "确认提醒:此参数的值必须是 'I confirm this will overwrite existing content'。在输出此参数前,请确认:1) 你已经读取过原文件内容 2) 你确定要覆盖而非追加 3) 用户明确要求了此操作"
}
},
"required": ["file_path", "content", "CONFIRM_OVERWRITE"]
}
工作原理:
LLM 在生成工具调用时会按顺序输出每个参数。当它输出到 CONFIRM_OVERWRITE 参数时:
更多应用示例:
上下滑动查看完整内容
{
"resource_id": {
"type": "string",
"description": "要删除的资源 ID"
},
"SAFETY_CHECK": {
"type": "string",
"enum": ["CONFIRMED_PERMANENT_DELETE"],
"description": "安全检查:此参数必须填写 'CONFIRMED_PERMANENT_DELETE'。在填写前请确认:1) 这是不可逆操作 2) 已告知用户删除后果 3) 用户明确确认要删除"
}
}
{
"recipient": {
"type": "string"
},
"message": {
"type": "string"
},
"TONE_CHECK": {
"type": "string",
"enum": ["professional_tone_verified"],
"description": "语气检查:此参数必须填写 'professional_tone_verified'。在填写前请检查消息内容:1) 语气是否专业友好 2) 是否有拼写错误 3) 是否包含敏感信息"
}
}
{
"code": {
"type": "string"
},
"ENVIRONMENT_CHECK": {
"type": "string",
"enum": ["sandbox_environment_confirmed"],
"description": "环境确认:此参数必须填写 'sandbox_environment_confirmed'。请确认代码将在沙箱环境执行,不会影响生产数据"
}
}
为什么这个技巧有效?
使用建议:
工具的输出是 Agent 做出下一步决策的依据。好的输出设计应该让 Agent 能够快速理解结果、提取关键信息、决定后续行动。
当输出是需要被 Agent 解析和处理的数据时,使用 JSON:
def get_user_profile(user_id: str) -> str:
return json.dumps({
"id": "usr_123",
"name": "Alice",
"email": "[email protected]",
"role": "admin",
"created_at": "2024-01-15T10:30:00Z"
})
def list_issues(repo: str) -> str:
return json.dumps({
"issues": [
{"number": 1, "title": "Bug report", "state": "open"},
{"number": 2, "title": "Feature request", "state": "closed"}
],
"total_count": 2
})
当输出主要是给用户阅读的内容时,Markdown 更合适:
def generate_report(data: dict) -> str:
return """
# 月度报告
## 概要
- 总用户数:1,234
- 活跃用户:567
- 新增用户:89
## 详细分析
...
"""
def explain_error(error_code: str) -> str:
return """
## 错误说明
**错误代码**: AUTH_001
**含义**: 认证令牌已过期
**解决方案**:
1. 检查令牌是否在有效期内
2. 使用 refresh_token 获取新令牌
3. 重新进行认证
"""
有时候你需要同时提供结构化数据和可读内容:
def analyze_code(file_path: str) -> str:
return json.dumps({
"status": "completed",
"metrics": {
"lines": 150,
"complexity": 12,
"issues_count": 3
},
"summary": """
## 代码分析结果
发现 3 个潜在问题:
1. 函数 `processData` 复杂度过高
2. 缺少错误处理
3. 变量命名不规范
""",
"issues": [
{"line": 45, "type": "complexity", "message": "..."},
{"line": 67, "type": "error_handling", "message": "..."},
{"line": 89, "type": "naming", "message": "..."}
]
})
MCP 使用 stdio 进行通信, 工具在正常运行时不应该向 stdout 输出任何内容 ,否则可能干扰 MCP Client 的解析。
def process_data(data: str) -> str:
print("Processing...")
result = do_processing(data)
print("Done!")
return result
import logging
logger = logging.getLogger(__name__)
logger.addHandler(logging.FileHandler('/tmp/mcp-tool.log'))
def process_data(data: str) -> str:
logger.info("Processing...")
result = do_processing(data)
logger.info("Done!")
return json.dumps({"result": result})
工具返回应该优先考虑 上下文相关性 而非灵活性,避免返回底层技术细节相关的标识符。
def get_user(user_id: str) -> str:
return json.dumps({
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"256px_image_url": "https://...",
"mime_type": "image/jpeg",
"created_at_epoch": 1704067200
})
def get_user(user_id: str) -> str:
return json.dumps({
"name": "Alice Chen",
"image_url": "https://...",
"file_type": "jpeg",
"created_at": "2024-01-01T00:00:00Z"
})
关键原则:
有时 Agent 需要灵活地获取简洁或详细的回复(例如 search_user(name='jane') → send_message(id=12345))。你可以通过暴露一个简单的 response_format 枚举参数来实现:
上下滑动查看完整内容
from enum import Enum
class ResponseFormat(Enum):
DETAILED = "detailed"
CONCISE = "concise"
def search_users(query: str, response_format: str = "concise") -> str:
users = db.search(query)
if response_format == "detailed":
return json.dumps({
"users": [{
"id": u.id,
"name": u.name,
"email": u.email,
"department": u.department,
"role": u.role,
"created_at": u.created_at.isoformat(),
"last_active": u.last_active.isoformat(),
"avatar_url": u.avatar_url
} for u in users]
})
else:
return json.dumps({
"users": [{
"id": u.id,
"name": u.name
} for u in users]
})
这种模式类似于 GraphQL,让 Agent 可以选择只接收需要的信息片段。你可以添加更多格式以获得更大的灵活性。
工具回复的结构格式(XML、JSON 或 Markdown)也会影响评估性能: 没有万能的解决方案。
这是因为 LLM 是基于下一个 token 预测训练的,往往对与其训练数据匹配的格式表现更好。最佳的回复结构会因任务和 Agent 而异。我们建议你根据自己的评估选择最佳的回复结构。
工具输出应该包含足够的上下文,让 Agent 不需要额外调用就能理解结果:
def create_issue(repo: str, title: str) -> str:
issue = github.create_issue(repo, title)
return str(issue.number)
def create_issue(repo: str, title: str) -> str:
issue = github.create_issue(repo, title)
return json.dumps({
"status": "success",
"issue": {
"number": issue.number,
"title": issue.title,
"url": issue.html_url,
"state": issue.state,
"created_at": issue.created_at.isoformat()
},
"message": f"Issue #{issue.number} created successfully"
})
对于分页结果,元数据应该清晰完整:
def list_commits(repo: str, page: int = 1, per_page: int = 30) -> str:
commits, total = github.get_commits(repo, page, per_page)
return json.dumps({
"commits": [
{
"sha": c.sha[:7],
"message": c.message.split('\n')[0],
"author": c.author.login,
"date": c.date.isoformat()
}
for c in commits
],
"pagination": {
"page": page,
"per_page": per_page,
"total_count": total,
"total_pages": (total + per_page - 1) // per_page,
"has_previous": page > 1,
"has_next": page * per_page < total
}
})
优化上下文的 质量 很重要,但优化返回给 Agent 的上下文 数量 同样重要。
大量输出会占用上下文窗口,影响 Agent 的后续推理。例如, 一些主流 coding agent 会限制工具回复长度。 我们预计 Agent 的有效上下文长度会随时间增长,但对上下文高效工具的需求将持续存在。
应该主动控制输出大小:
上下滑动查看完整内容
def read_file(file_path: str, max_lines: int = 500) -> str:
content = read_file_content(file_path)
lines = content.split('\n')
if len(lines) > max_lines:
truncated = '\n'.join(lines[:max_lines])
return json.dumps({
"content": truncated,
"truncated": True,
"total_lines": len(lines),
"shown_lines": max_lines,
"message": f"File has {len(lines)} lines, showing first {max_lines}. Use read_file_range() to read specific sections.",
"suggestion": "Consider using search_in_file() for targeted lookups instead of reading the entire file."
})
return json.dumps({
"content": content,
"truncated": False,
"total_lines": len(lines)
})
def search_documents(query: str, max_results: int = 20) -> str:
"""
搜索文档内容。
💡 提示:对于知识检索任务,建议进行多次小范围、有针对性的搜索,
而非一次大范围搜索。这样可以获得更精确的结果,同时节省上下文空间。
"""
results = db.search(query, limit=max_results)
return json.dumps({
"results": results,
"count": len(results),
"tip": "For better results, try more specific queries rather than broad searches."
})
错误处理是 Agent 工具设计中最容易被忽视,却又最能体现「为 Agent 设计」思维的环节。
传统编程中,我们习惯在错误时抛出异常,让程序「快速失败」。但对于 Agent 来说,这种方式代价太高了。
想象一下:Agent 执行一个复杂任务,可能需要 5 分钟、调用 20 次工具、花费 $0.50 的 token。如果在第 15 步因为一个参数格式错误就让整个流程崩溃,用户体验会非常糟糕。
核心转变: 对于 Agent 工具,错误不是「终点」,而是「输入」,是给 Agent 的另一种反馈,帮助它调整策略继续前进。
def get_user(user_id: str):
user = db.find(user_id)
if not user:
raise UserNotFoundError(f"User {user_id} not found")
return user
def get_user(user_id: str) -> str:
"""
返回:
- 成功时返回用户信息的 JSON 字符串
- 失败时返回错误描述,包含修正建议
"""
user = db.find(user_id)
if not user:
return f"""User not found: {user_id}. Possible reasons:
1. ID format incorrect - should be 'usr_' followed by 8 characters (e.g., 'usr_a1b2c3d4')
2. User may have been deleted
Try: Use find_user_by_email() if you have the user's email address."""
return json.dumps(user)
一个好的错误信息应该回答三个问题:
User not found: usr_invalid123
The ID format is incorrect - expected 'usr_' prefix followed by 8 alphanumeric characters.
Please verify the ID format ortry searching by email usingfind_user_by_email().
完整示例:
上下滑动查看完整内容
def create_github_issue(repo: str, title: str, body: str = "") -> str:
if "/" not in repo:
return f"""Invalid repository format: '{repo}' Expected format: 'owner/repo' (e.g., 'facebook/react')
You provided: '{repo}'
Please correct the format andtry again."""
if not github.repo_exists(repo):
return f"""Repository not found: '{repo}' Possible reasons:
1. The repository doesn't exist
2. The repository is privateand you don't have access
3. Typo in owner or repo name
Try: Use github_search_repos(query="{repo.split('/')[-1]}") to find similar repositories."""
if not github.has_write_access(repo):
return f"""Permission denied for repository: '{repo}' You don't have write access to create issues in this repository. Contact the repository owner to request access."""
issue = github.create_issue(repo, title, body)
return json.dumps({
"status": "success",
"issue_number": issue.number,
"url": issue.html_url
})
当一种方式失败时,告诉 Agent 还有什么其他选择:
def get_user_by_id(user_id: str) -> str:
user = db.find_by_id(user_id)
if not user:
return f"""User not found with ID: {user_id} Alternative approaches:
1. Search by email: find_user_by_email(email=" user @example .com")
2. Search by username: find_user_by_username(username="john_doe")
3. List all users: list_users(limit=100) to browse available users"""
return json.dumps(user)
不是所有错误都需要 Agent 去「修复」。有些错误是可以重试或调整参数的,有些则需要人工介入:
def api_call_with_retry_hint(params):
if rate_limited:
return "Rate limited. Please wait 60 seconds and retry."
if invalid_params:
return f"Invalid parameter 'date': expected YYYY-MM-DD format, got '{params['date']}'"
def sensitive_operation(params):
if not_authorized:
return """Permission denied. This operation requires admin privileges.
⚠️ This cannot be resolved automatically. Please ask the user to:
1. Contact their administrator to request access, or
2. Use a different account with appropriate permissions"""
对于复杂的错误信息,结构化格式比纯文本更容易被 Agent 解析和处理:
def structured_error_response(error_type, message, suggestions):
return json.dumps({
"status": "error",
"error_type": error_type,
"message": message,
"suggestions": suggestions,
"recoverable": True
})
return structured_error_response(
error_type= "VALIDATION_ERROR",
message= "Invalid email format",
suggestions=[
"Check if the email contains '@' symbol",
"Verify there are no spaces in the email",
"Use find_user_by_id() if you have the user ID instead"
]
)
配置错误(如环境变量缺失、路径错误)不应该让工具崩溃。相反,应该在工具被调用时提供有用的诊断信息:
def github_create_issue(repo: str, title: str) -> str:
github_token = os.environ.get('GITHUB_TOKEN')
if not github_token:
return json.dumps({
"status": "configuration_error",
"error": "GitHub token not configured",
"message": """ GITHUB_TOKEN environment variable is not set. To fix this:
1. Create a GitHub Personal Access Token at https://github.com/settings/tokens
2. Set the environment variable:
- In your shell: export GITHUB_TOKEN=your_token_here
- In MCP config: add "env": {"GITHUB_TOKEN": "your_token_here"}
Required scopes: repo, read:org """,
"recoverable": False,
"requires_user_action": True
})
关键点:
设计 MCP 工具时,一个关键决策是:工具应该多「大」?是提供细粒度的原子操作,还是粗粒度的组合工作流?
一个常见的错误是直接把现有的 REST API 端点或函数封装成 MCP 工具,「反正功能都实现了,包一层不就行了?」但这忽略了一个关键问题: Agent 和传统软件有着完全不同的「可供性」(Affordances)。
LLM Agent 的上下文窗口(context window)是稀缺资源,而计算机内存是廉价且充裕的。这个根本差异决定了工具设计的方向。
让我们用一个简单的例子来说明: 在通讯录中搜索联系人。
传统软件可以高效地存储和处理整个联系人列表,逐个检查每条记录。但如果让 LLM Agent 使用一个返回「所有联系人」的工具,然后逐个 token 地阅读每一条……它就是在用最宝贵的上下文空间处理大量无关信息。
想象一下:你会通过从头到尾逐页阅读来在通讯录中找人吗?当然不会,你会直接翻到按字母排序的相关页面。Agent 也应该如此。
list_contacts() get_contact(id) search_contacts(name="张") message_contact(name="张三")
核心洞察: 好的 Agent 工具应该匹配 Agent(和人类)解决问题的自然方式,而不是底层系统的数据结构。
get_user_name (user_id) # 第 1 次调用 get_user_email (user_id) # 第 2 次调用 get_user_address (user_id) # 第 3 次调用 get_user_phone (user_id) # 第 4 次调用
问题:
get_user_all_data (user_id) # 返回 50 个字段,包含完整的历史记录、偏好设置、活动日志...
问题:
让我们通过一个具体的例子来理解粒度选择的影响。
假设你要构建一个帮助用户追踪订单的 Agent。作为人类开发者,你可能会这样使用 API:先调用 GET /users 获取用户信息,再调用 GET /orders 获取订单列表,最后调用 GET /shipments 获取物流状态。你读过文档,写好脚本,调试通过,部署上线。
如果直接把这三个 API 暴露为 MCP 工具:
get_user_by_email (email) # 第 1 次调用 list_orders (user_id) # 第 2 次调用 get_order_status (order_id) # 第 3 次调用
Agent 需要:
更好的设计:
def track_order(email: str) -> str:
"""
追踪用户的最新订单状态。
内部会自动查询用户信息、订单列表和物流状态,
返回格式化的订单追踪结果。
"""
return "Order #12345 shipped via FedEx, arriving Thursday."
同样的结果,一次调用,围绕用户目标设计。
核心原则:把编排逻辑放在你的代码里,而不是放在 LLM 的上下文窗口里。
确保每个工具都有清晰、独特的目的。 工具应该让 Agent 能够像人类一样分解和解决任务,在获得相同底层资源访问权限的情况下,同时减少中间输出本应占用的上下文空间。
过多的工具或功能重叠的工具也会分散 Agent 的注意力,使其偏离高效策略。仔细、有选择性地规划你要构建(或不构建)的工具,真的很值得。
工具可以合并功能,在底层处理多个离散操作(或 API 调用)。例如,工具可以用相关元数据丰富回复内容,或者在单次工具调用中处理经常串联的多步骤任务。以下是一些实用的例子:
list_users() list_events() create_event(...) schedule_event(participants=["[email protected]", "[email protected]"],
title="项目讨论",
duration_minutes=30)
# ❌ 不好:原始日志读取
read_logs( file = "/var/log/app.log" ) # 返回大量无关日志
# ✅ 好:智能日志搜索
search_logs(
keyword= "error",
time_range= "last_1h",
context_lines= 3
) # 只返回相关日志行及其上下文
get_customer_by_id(customer_id) list_transactions(customer_id) list_notes(customer_id) get_customer_context(customer_id)
get_user_profile(user_id) get_user_billing_info(user_id) get_user_activity_summary(user_id, days=7)
启发式方法: 如果 Agent 在 90% 的情况下调用 A 后都会调用 B,考虑合并它们。
# 观察到的调用模式: # 1. get_issue(id) # 2. get_issue_comments(id) <-- 几乎总是紧跟着调用 # ✅ 考虑合并 get_issue_with_comments(issue_id, include_comments=True)
有时候你需要同时提供「简单但有限」和「复杂但完整 」的工具。关键是 在描述中明确指导 Agent 的选择:
上下滑动查看完整内容
def search_issues(query: str, repo: str = None) -> str:
"""
搜索 GitHub Issues(推荐首选)。
这是最常用的搜索方式,自动处理分页和格式化。
大多数情况下应该优先使用这个函数。
参数:
- query: 搜索关键词
- repo: 可选,限定在特定仓库搜索
返回前 20 条最相关的结果。
"""
pass
def search_issues_advanced(
query: str,
filters: dict,
sort: str = "relevance",
per_page: int = 30,
page: int = 1
) -> str:
"""
高级 Issue 搜索,支持复杂过滤条件。
⚠️ 只有当 search_issues 无法满足需求时才使用:
- 需要精确控制过滤条件(作者、标签、日期范围等)
- 需要自定义排序方式
- 需要分页获取大量结果
filters 支持的字段:
- author: 作者用户名
- labels: 标签列表
- state: 'open' | 'closed' | 'all'
- created_after: ISO 日期字符串
- created_before: ISO 日期字符串
"""
pass
对于复杂的工作流,可以考虑提供「组合工具」:
create_branch(repo, branch_name, from_branch)
commit_changes(repo, branch, files, message)
create_pull_request(repo, branch, title, body)
def quick_fix_and_pr(
repo: str,
file_path: str,
changes: str,
description: str
) -> str:
"""
快速修复并创建 PR(一步完成)。
自动执行以下步骤:
1. 创建新分支 (fix/auto-{timestamp})
2. 应用更改并提交
3. 创建 Pull Request
适用于简单的单文件修复。
对于复杂的多文件更改,请使用 create_branch + commit_changes + create_pull_request。
"""
pass

严格控制工具数量
工具数量是影响 Agent 效果的关键因素,一个拥有 4 个精心构造工具的 Agent,效果一定会优于拥有 40 个粗制滥造工具的 Agent。需要记住,用户可能同时连接多个 MCP Server,加上 Agent 自带的工具,总数很容易超标。保守估计每个 Server 的工具数量,给其他 Server 留出空间。
一个 Server,一个职责
不要试图构建一个「全能」的 MCP Server。就像微服务架构一样,每个 Server 应该专注于一个领域:
✅ 好的拆分:
- github-server: GitHub 相关操作
- slack-server: Slack 消息和频道管理
- calendar-server: 日历和事件管理
❌ 不好的设计:
- productivity-suite-server: 包含 GitHub + Slack + Calendar + Email + Notes + ...
避免工具重叠和冗余
功能相似的工具是 Agent 混淆的主要来源。当你有 edit_tool_v1 、 edit_tool_v2 、 replace_line_with_regex 这样的工具时,模型会在它们之间反复犹豫。如前所述,有工程师观察到 Agent 尝试了 18 个编辑相关工具后才放弃。
edit_file_v1(path, content) edit_file_v2(path, changes) replace_line(path, line_number, new_content) replace_regex(path, pattern, replacement) patch_file(path, diff) edit_file(path, changes, mode="replace")
删除未使用的工具
如果一个工具在过去 30 天内从未被调用,请考虑移除它,因为未使用的工具仍然会:
按角色拆分(Admin vs User)
如果某些工具只有特定角色才能使用,考虑将它们拆分到不同的 Server:
- github-server: 常规操作(create_issue, list_repos, search_code)
- github-admin-server: 管理操作(delete_repo, manage_permissions, billing)
这样普通用户的 Agent 不会被管理功能干扰,管理员也能在需要时显式启用高权限工具。
定期统计工具使用
工具设计不是一次性的,随着使用数据积累,你应该定期统计:
提供诊断工具:info 命令模式
一个实用的最佳实践是提供一个 info 或 status 工具,用于诊断 MCP Server 的状态:
def server_info() -> str:
"""
获取 MCP Server 的状态和配置信息。
用于诊断问题或验证配置是否正确。
返回版本信息、依赖状态、配置检查结果。
"""
return json.dumps({
"version": "1.2.3",
"status": "healthy",
"dependencies": {
"github_api": {"status": "ok", "authenticated": True},
"database": {"status": "ok", "connection": "active"}
},
"configuration": {
"GITHUB_TOKEN": "configured" if os.environ.get('GITHUB_TOKEN') else "missing",
"LOG_LEVEL": os.environ.get('LOG_LEVEL', 'info'),
"MAX_RESULTS": os.environ.get('MAX_RESULTS', '100')
},
"issues": []
})
这个模式的好处:
无论是 MCP Server 还是 Skills 中的工具,脚本都需要在不同环境中可靠运行,你的本地机器、远程 Agent 环境、或者分享给其他人使用。传统的包管理方式会带来可移植性问题:依赖特定路径的解释器、需要预先创建虚拟环境、依赖隐式存在的全局包……这些不一致性会导致脚本在环境迁移时失败。
核心原则:永远不要依赖周围环境中隐式存在的包。完整且显式的依赖声明应该统一适用于代码运行的所有环境。
UV 是现代 Python 包管理器,提供两个关键能力:
使用 PEP 723 内联元数据,将依赖声明嵌入脚本本身:
import requests
import markdown
def fetch_and_convert(url: str) -> str:
response = requests.get(url)
return markdown.markdown(response.text)
运行方式:
uv run script.py uvx some-cli-tool
无需 创建虚拟环境、无需永久安装、无需 shell 配置。依赖规格随脚本一起移动,新用户 checkout 后立即可运行。
在 MCP 配置中使用 UV 启动 Server:
{
"mcpServers": {
"my-python-server": {
"command": "uv",
"args": ["run", "server.py"],
"cwd": "/path/to/server"
}
}
}
类似的,你在 SKILL.md 中也可以指定使用 UV:
运行分析脚本:
``
`bash
uv run scripts/analyze.py --input data.json
**Node.js:使用 npx 实现即时执行**
Node.js 生态中,**npx**(npm 自带)提供类似能力:
npx cowsay "Hello MCP" npx [email protected] --version npx tsx script.ts
对于需要依赖的脚本,使用 **package.json** 声明依赖,配合 **npx** 或直接运行:
```json
{
"type": "module",
"dependencies": {
"node-fetch": "^3.3.0",
"marked": "^12.0.0"
}
}
npm start npx tsx src/main.ts
Bun:更快的 JavaScript 运行时
Bun 是高性能 JavaScript 运行时,内置包管理器:
bunx cowsay "Hello from Bun"
bun run script.ts
bun install && bun run start
Bun 的优势:
**编译为独立可执行文件:**Bun 支持将 TypeScript/JavaScript 代码、所有依赖和运行时打包成单个可执行文件,实现真正的零依赖分发:
bun build ./server.ts --compile --outfile my-mcp-server bun build ./server.ts --compile --target=bun-linux-x64 --outfile my-mcp-server-linux bun build ./server.ts --compile --target=bun-darwin-arm64 --outfile my-mcp-server-mac bun build ./server.ts --compile --target=bun-windows-x64 --outfile my-mcp-server.exe
编译后的可执行文件:
Deno:安全优先的运行时
Deno 采用不同的方式,可以直接从 URL 导入依赖:
export {
serve
}
from "https://deno.land/[email protected]/http/server.ts";
export {
parse
}
from "https://deno.land/[email protected]/flags/mod.ts";
import {
serve,
parse
} from "./deps.ts";
const args = parse(Deno.args);
serve((req) => new Response("Hello MCP"));
运行方式:
deno run --allow-net --allow-read main.ts deno task start
Deno 的优势:
跨语言最佳实践总结
**关于编译型语言:**当然也可以选择 Rust、Go 等编译型语言开发 MCP Server,编译后的二进制文件同样是零依赖、单文件分发。但缺点也很明显:需要为每个目标平台(Linux/macOS/Windows × x64/arm64)单独编译和分发,增加了构建和发布的复杂度。相比之下,上述脚本语言方案只需分发源码,由运行时处理跨平台兼容性。
配置 Agent 使用正确的运行时:
在 Agent 配置或 SKILL.md 中明确指定:
## 运行时要求
- Python 脚本:使用 `uv run` 执行
- TypeScript 脚本:使用 `bun run` 或 `npx tsx` 执行
- 不要使用全局安装的包,始终通过包管理器运行
这确保脚本在任何环境中都能获得一致的行为和性能。
当工具数量增长到一定程度,MCP 的「会话开始时加载所有工具」模式会遇到瓶颈。这时,一种互补的方案是 Skills(或类似的渐进式披露机制)。
MCP 的设计是在会话开始时,Agent 通过协议获取所有可用工具的定义,并将它们注入到 system prompt 中。这意味着:
Skills 提供了一种不同的思路:按需加载。Skills 通常采用三层披露结构:
这种设计的优势:
上下文占用的具体对比
Skills 采用了一种不同的方式:保持入口点小巧,只在需要时加载详细内容。
关键差异:MCP 工具定义在每一轮对话都消耗 token,无论该轮是否使用该工具;而 Skills 的详细内容只在被调用时读取。如果你的工作涉及长代码 diff、日志或策略文档,这个差异直接决定了有多少「真正有用的内容」能放入上下文。
MCP 和 Skills 不是非此即彼的关系,而是可以互补使用:
MCP 适合:
Skills 适合:
上下滑动查看完整内容
# SKILL.md: GitHub PR Review
## 描述
帮助进行 GitHub Pull Request 代码审查。
## 使用场景
当用户要求审查 PR、查看 PR 变更、或对 PR 提出评论时使用。
## 工具
此 Skill 使用以下 MCP 工具(需确保 github-server 已连接):
- `github_get_pull_request`: 获取 PR 详情
- `github_list_pr_files`: 列出变更文件
- `github_create_review`: 提交审查意见
## 工作流程
1. 首先使用 `github_get_pull_request` 获取 PR 基本信息
2. 使用 `github_list_pr_files` 查看变更的文件列表
3. 对于需要详细审查的文件,读取具体内容
4. 分析代码变更,识别潜在问题
5. 使用 `github_create_review` 提交审查意见
## 最佳实践
- 关注代码逻辑而非格式问题(格式问题应由 linter 处理)
- 对于大型 PR,优先审查核心逻辑文件
- 提出建设性的改进建议,而非简单指出问题
通过这种方式,Skills 提供了丰富的上下文和工作流指导,而 MCP 工具提供了精确的执行能力。Agent 可以:
Skills 原生支持调用 CLI 命令,但直接调用 MCP Server 需要处理协议握手、连接管理、OAuth 认证等复杂逻辑。MCPorter 提供了一个优雅的解决方案:将任意 MCP Server 转换为独立的 CLI 工具,让 Skills 可以像调用普通命令行工具一样使用 MCP 能力。
生成 CLI 工具:
npx mcporter generate-cli --command https://mcp.linear.app/mcp
npx mcporter generate-cli --command "npx -y chrome-devtools-mcp@latest"
npx mcporter generate-cli linear --compile --output dist/linear
生成的 CLI 工具可以直接在 SKILL.md 中使用:
使用 Linear CLI 进行 Issue 操作:
```bash
linear search_issues query="bug" state=open
linear create_issue title="Fix login bug" team=ENG
**为什么需要 MCPorter 而非直接调用 MCP?**
- Daemon 进程复用连接: MCPorter 维护一个后台 daemon 进程,保持与 MCP Server 的长连接。对于 chrome-devtools 、 mobile-mcp 等有状态的 Server,这意味着 Chrome 标签页和设备会话在多次调用之间保持活跃,无需每次重新建立连接。
- 自动处理 OAuth 认证: 许多托管 MCP Server(如 Vercel、Linear、Supabase)需要 OAuth 认证。MCPorter 自动缓存 token、处理刷新,避免 Skill 执行时弹出浏览器登录窗口。
- 统一的调用接口: 无论底层是 HTTP 还是 stdio 传输,生成的 CLI 提供一致的调用体验。Skill 作者无需关心 MCP 协议细节。
- 零配置发现: MCPorter 自动合并常见 AI 客户端的 MCP 配置,无需重复配置 Server 连接信息。
**Daemon 管理:**
使用 Linear CLI 进行 Issue 操作:
# 搜索 Issues
linear search_issues query="bug" state=open
# 创建 Issue
linear create_issue title="Fix login bug" team=ENG
通过 MCPorter,你可以在 Skills 中充分利用 MCP 生态的丰富工具,同时保持 Skill 定义的简洁性,只需编写 CLI 调用指令,复杂的协议处理交给 MCPorter。
### 进阶用法:生成 TypeScript API 进行脚本编排
MCPorter 还支持将 MCP Server 转换为带类型的 TypeScript API,这为「脚本化工具编排」打开了新的可能:
npx mcporter emit-ts linear --out types/linear.d.ts npx mcporter emit-ts linear --mode client --out clients/linear.ts
生成的 TypeScript 客户端可以直接在脚本中使用:
```typescript
import { createRuntime, createServerProxy } from "mcporter";
const runtime = await createRuntime();
const linear = createServerProxy(runtime, "linear");
const issues = await linear.searchIssues({ query: "bug", state: "open" });
const issue = await linear.createIssue({ title: "Fix login", team: "ENG" });
await runtime.close();
为什么这很重要? 当 Agent 直接调用 MCP 工具时,每次调用的参数、返回值都会进入对话上下文,多步骤任务会快速消耗上下文窗口。而让 Agent 生成一个 TypeScript 脚本来编排多个工具调用:
这种「Agent 写脚本 → 脚本调用工具」的模式,是 Anthropic 在 Code Execution with MCP 中推荐的高级用法。MCPorter 让这一模式变得开箱即用。
我们从一个核心洞察出发: MCP 是 AI Agent 的用户界面,不是已有 REST API 的封装。 围绕这一理念,我们系统地探讨了 Agent 工具设计的方方面面。
理解 Agent 的认知特性
Agent 只能通过工具的名称、描述和参数 Schema 来「理解」工具。它不会阅读文档,不会从上下文推断隐含信息,每次调用都需要从头理解工具的用途。设计工具的本质,是在设计 Agent 的认知体验: 减少认知负担,让 Agent 更容易用对、更难用错。
设计的六个维度
工具数量的克制
工具数量直接影响 Agent 的决策质量。每个 MCP Server 建议控制在 5-15 个工具,避免功能重叠,定期清理未使用的工具。
超越 MCP:更广泛的适用性
这些原则不仅适用于 MCP,也适用于任何 Agent Tool Interface 设计:无论是 OpenAI 的 Function Calling、Anthropic 的 Tool Use,还是其他 Agent 框架。核心思维是一致的:
持续演进
Agent 工具设计是一个持续迭代的过程。随着 LLM 能力的提升、MCP 生态的成熟、以及 Skills 等新范式的出现,最佳实践也会不断演进。但核心理念不会改变: 你不是在写 API,你是在教会一个智能体如何与这个世界交互。
希望本文能为你的 MCP Server 开发和 Agent 工具设计提供一个系统的思考框架。好的工具设计,让 Agent 更可靠、更高效,最终让用户获得更好的体验。
如何评测 MCP Server
本文聚焦于 MCP Server 的设计原则,但并未涉及如何系统性地评测 MCP Server 的效果。如果你想了解这方面的实践,推荐阅读 GitHub 官方博客的这篇文章: Measuring what matters: How offline evaluation of GitHub MCP Server works (https://github.blog/ai-and-ml/generative-ai/measuring-what-matters-how-offline-evaluation-of-github-mcp-server-works/)
这篇文章详细介绍了 GitHub MCP Server 团队如何构建自动化离线评测管道,确保每次迭代都能提升质量而非引入回归。文章的核心内容包括:
通过离线评测,团队可以在用户感知之前发现问题,并将「感觉变好了」转化为可量化的改进。
用 Agent 为 Agent 编写工具
Anthropic 工程团队也发布了一篇关于 Agent 工具设计的深度文章: Writing effective tools for AI agents — with agents (https://www.anthropic.com/engineering/writing-tools-for-agents)
这篇文章从实践角度出发,介绍了如何通过「评测驱动」的方式迭代优化工具设计,并分享了他们在优化内部 Slack、Asana 等 MCP Server 过程中提炼的核心原则,文章的亮点包括:
这篇文章提供了更多来自 Anthropic 内部实践的具体案例和数据支撑,值得深入阅读。
更多最佳实践,欢迎关注 TRAE 官方微信公众号。