第 11 章
工具面:内置工具、MCP 工具与工具治理
工具不是越多越好,是越准越好。
第一次看到 init 帧里的工具列表,我有点慌。
二十多个工具,从读文件到跑命令,从搜代码到开子 Agent。我第一反应是“全开,免得它不够用”。
结果它用一个 Bash 去 cat README.md,又用 Write 去改一个已经存在的测试,最后被权限拦了三次。三圈跑下来,任务没推进,上下文倒是涨了不少。
不是模型笨。是选项太多。
这一章讲工具面:给哪些工具、怎么描述、怎么接自己的工具,以及“为什么工具越窄,权限越好做”。
11.1 工具账单:每个工具都在每轮收费
第 6 章我们算过:六个工具的 schema 是 1,680 字符,占那次请求的 80%。
这不是六个工具“用了一次”的成本,是每一轮都要重发的成本。你跑 20 轮,就是 20 次。
不同工具集的 schema 大小(实测,字符):
| 工具集 | schema 字符 | 相对六工具 |
|---|---|---|
| 只读三件套(读/列/搜) | 782 | 47% |
| 只读 + 写 | 1,462 | 87% |
六个(含 run_tests) | 1,680 | 100% |
少三个写/跑工具,schema 少一半;只去掉 run_tests,也能少 13%。工具面每收一次,都是每轮省一笔。
工具越多,账单越大,而且不只是钱:
- schema 更长:每一轮都占上下文;
- 选择更多:模型要在更多工具里挑,容易挑错;
- 权限更复杂:每个工具都要有对应的策略;
- 调试更难:出了事,你要先判断“它为什么选了这个工具”。
所以工具面设计的第一原则不是“加”,是“减”。
11.2 一次工具选错
我见过一个很典型的对比。
同一个任务:“读 README,告诉我怎么跑测试。”
全量工具集:
[tool] Bash {"command": "cat README.md"}
[tool] Bash {"command": "ls"}
[tool] Read {"file_path": "README.md"}它先用 Bash 去 cat,又 ls 了一下,最后才用 Read。三圈。
窄工具集(只给 Read / Glob / Grep):
[tool] Read {"file_path": "README.md"}一圈。
差别不在模型,在选项。你给它 Bash,它就会想“我可以用 cat”;你给它 Write,它就会想“我可以新建一个文件”。工具存在本身,就是在提示模型“这条路可以走”。
所以“全开”不是“给它更多能力”,是“给它更多走错的路”。
11.3 tools / allowed_tools / disallowed_tools:三个东西,不是一个
这是 SDK 新手最容易混的一组选项。
| 选项 | 控制什么 | 不控制什么 |
|---|---|---|
tools | 模型能看见哪些内置工具 | 是否免审批 |
allowed_tools | 哪些工具免审批 | 是否可见 |
disallowed_tools | 哪些工具完全禁用 | 审批 |
三个关键点:
第一,tools 是可见性。 None 是 CLI 的默认集合,不是空;[] 是空;给列表就是收窄到这些。
第二,allowed_tools 不增加可见性。 一个工具不在 tools 里,你把它写进 allowed_tools 也不会让它出现。它只负责“可见的工具里,哪些不用问”。
第三,最危险的组合:写工具同时在 tools 和 allowed_tools 里。
options = ClaudeAgentOptions(
tools=["Read", "Edit", "Bash"],
allowed_tools=["Read", "Edit", "Bash"],
can_use_tool=my_approver,
)你以为 can_use_tool 会拦住 Edit;实际上 Edit 已经在 allowed_tools 里,CLI 直接放行,审批回调根本收不到单。
这就是第 12 章要展开的“allowed_tools 阴影审批”。工具面配错,权限就形同虚设。
11.4 默认工具集不是固定的
tools=None 时,SDK 不会把工具清空,而是用 CLI 的默认集合。
这个集合会随 CLI 版本、setting_sources、插件/技能状态变化。第 4 章那个“默认 24 个工具”的观察,只是在某个版本、某个配置下的快照,不要当常量。
想知道这一次到底有哪些工具,最靠谱的办法是看 init 帧:
if isinstance(msg, SystemMessage) and msg.subtype == "init":
print(msg.data.get("tools"))这比猜“应该有 Read/Edit/Bash 吧”可靠。
11.5 工具描述尸检:坏描述 vs 好描述
工具描述是模型唯一的“使用手册”。它不看你脑子里的设计,只看这段字。
坏描述:
Edit a file.模型会问:改哪个文件?怎么改?改了会怎样?能新建吗?它只能猜。
好描述:
Replace an exact string in an existing file.
Use for small, targeted edits; read the file first.
Fails if old_string is not unique.好描述回答了四个问题:
- 做什么:替换一个精确字符串;
- 什么时候用:小范围、定向修改;
- 前置条件:先读文件;
- 失败条件:
old_string不唯一会失败。
这四句不是“文档”,是“选择依据”。模型在 Edit 和 Write 之间犹豫时,靠的就是它们。
坏描述让模型猜,猜错就多跑几轮;好描述让它第一次就选对。工具描述写得好,比系统提示写十句“请小心”有用。
11.6 自定义工具:进程内 @tool
内置工具覆盖“读、写、跑、搜”,但覆盖不了你的业务。
比如你想让 Agent 知道“这个项目的测试命令是什么、哪条规则不能违反”,可以写一个 project_info:
@tool(
"project_info",
"Return the project's test command and the key rule from README. "
"Use this before running tests.",
{},
)
async def project_info(args: dict) -> dict:
return {
"content": [{
"type": "text",
"text": "test command: python3 -m unittest discover -s tests -v\n"
"rule: do not edit tests; fix the implementation",
}]
}
server = create_sdk_mcp_server("book", tools=[project_info])挂到 options 上:
options = ClaudeAgentOptions(
cwd=str(ROOT),
tools=["Read", "Glob", "Grep"],
allowed_tools=["Read", "Glob", "Grep", "mcp__book__project_info"],
disallowed_tools=["Bash"],
mcp_servers={"book": server},
)三个细节:
- 命名。 工具名是
mcp__<server>__<tool>,这里是mcp__book__project_info。写错一个下划线,allowed_tools就不匹配。 - 进程内。
@tool的函数跑在你的 Python 进程里,不需要额外的 server 进程、端口、协议握手。对“调一下公司内部 Python 函数”这种需求,少一层协议。 - 可见性。
mcp_servers注册工具;allowed_tools里的完整名字负责免审批。别只写project_info。
我见过一次真实的失败:allowed_tools 里写的是 project_info,模型调的是 mcp__book__project_info;名字没匹配上,调用被拒,Agent 又试了两次,最后放弃。
MCP 工具的名字是完整的,不能简写。
什么时候该写自定义工具?
- 业务数据/内部 API,内置工具碰不到;
- 项目特定的检查(测试命令、发布流程、合规规则);
- 你想把一段逻辑收成“一个动作”,而不是让模型用
Bash拼命令。
什么时候不该写?
- 内置工具已经能做,而且做得更好(读、写、搜、跑);
- 你只是想把提示词里的规则换个地方放;
- 工具描述会很长,但每次只用一次(考虑 Skill 或子 Agent)。
11.7 外部 MCP:多一层进程,多一层失败
除了进程内 @tool,SDK 也能接外部 MCP server(stdio / HTTP)。
适合:
- 已有的 MCP 生态工具;
- 别的语言/团队维护的服务;
- 需要独立进程隔离的能力。
代价:
| 代价 | 具体是什么 |
|---|---|
| 启动/握手 | 多一个进程或连接,多一次初始化 |
| 运维 | 进程/端口/重连,都要管 |
| 命名 | 工具名更长,权限配置更麻烦 |
| 失败模式 | 连不上、超时、重连、版本不匹配 |
能用进程内 @tool 解决的,优先进程内。外部 MCP 留给“确实需要独立服务”的场景。
11.8 工具治理清单
- 工具有几个?每个都必要吗?
- 每个工具的描述写清“什么时候用、返回什么、什么时候失败”了吗?
- 读和写分开了吗?写工具是否单独走审批?
- 工具结果有上限吗?会不会把大文件/大日志塞回上下文?
- 自定义工具的命名是
mcp__server__tool吗?allowed_tools写的是全名吗? - 有没有“用
Bash也能做,但专门工具更好管”的能力?
六条里过不了四条,先别加新工具;先做减法。
11.9 跑一遍
完整代码在 code/sdkagent/ch11_tools.py。
python3 -m pip install "claude-agent-sdk==0.2.168"
export ANTHROPIC_API_KEY="..."
cd code
python3 sdkagent/ch11_tools.py2026-10-21 的实际输出(SDK 0.2.168 + CLI 2.1.296 + deepseek-flash):
[init] tools: ['Glob', 'Grep', 'Read', 'mcp__book__project_info']
[tool] Read {'file_path': '.../README.md'}
[tool] mcp__book__project_info {}
[result] success结果:它读了 README,调了进程内 project_info,然后回答了测试命令和三条规则;没有调 Bash。
三个可以对照的点:
tools确实收窄了。 init 帧只有Glob/Grep/Read+ 那个 MCP 工具;Bash被disallowed_tools去掉了。- MCP 工具名是完整的。 它调的是
mcp__book__project_info,不是project_info。 - 这次没有打印 cost。 脚本只打印了
subtype和result;要记录成本,在ResultMessage里取num_turns/total_cost_usd。别把“没打印”当成“没花钱”。
说明:init 帧里的工具列表会随 CLI 版本、setting_sources 和插件状态变化;以你实际看到的为准。这个 demo 只跑了 2 个工具调用,成本很小,但仍是真实调用。
11.10 下一章:权限
工具面决定“模型能看见什么”,权限决定“它看见之后能做什么”。
下一章把 tiny 的 Policy 接到 SDK 的 permission_mode / can_use_tool 上,讲清楚 allowed_tools 阴影审批、Write(路径) 不生效、审批超时和沙箱。
本章代码 工具面示例:https://github.com/kevinkelin/claude-agent-sdk-book/blob/master/code/sdkagent/ch11_tools.py 说明:本章代码未在本机运行(环境没装 SDK/凭据);API 形状按 0.2.168 基线;init 帧的工具列表以实际为准。