合集

Claude Agent SDK 实战

已更新 27 章
阅读进度 44%

第 11 章

工具面:内置工具、MCP 工具与工具治理

工具不是越多越好,是越准越好。

第一次看到 init 帧里的工具列表,我有点慌。

二十多个工具,从读文件到跑命令,从搜代码到开子 Agent。我第一反应是“全开,免得它不够用”。

结果它用一个 Bash 去 cat README.md,又用 Write 去改一个已经存在的测试,最后被权限拦了三次。三圈跑下来,任务没推进,上下文倒是涨了不少。

不是模型笨。是选项太多。

这一章讲工具面:给哪些工具、怎么描述、怎么接自己的工具,以及“为什么工具越窄,权限越好做”。


11.1 工具账单:每个工具都在每轮收费

第 6 章我们算过:六个工具的 schema 是 1,680 字符,占那次请求的 80%。

这不是六个工具“用了一次”的成本,是每一轮都要重发的成本。你跑 20 轮,就是 20 次。

不同工具集的 schema 大小(实测,字符):

工具集schema 字符相对六工具
只读三件套(读/列/搜)78247%
只读 + 写1,46287%
六个(含 run_tests)1,680100%

少三个写/跑工具,schema 少一半;只去掉 run_tests,也能少 13%。工具面每收一次,都是每轮省一笔。

工具越多,账单越大,而且不只是钱:

  • schema 更长:每一轮都占上下文;
  • 选择更多:模型要在更多工具里挑,容易挑错;
  • 权限更复杂:每个工具都要有对应的策略;
  • 调试更难:出了事,你要先判断“它为什么选了这个工具”。

所以工具面设计的第一原则不是“加”,是“减”。


11.2 一次工具选错

我见过一个很典型的对比。

同一个任务:“读 README,告诉我怎么跑测试。”

全量工具集:

text
[tool] Bash {"command": "cat README.md"}
[tool] Bash {"command": "ls"}
[tool] Read {"file_path": "README.md"}

它先用 Bash 去 cat,又 ls 了一下,最后才用 Read。三圈。

窄工具集(只给 Read / Glob / Grep):

text
[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 里。

python
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 帧:

python
if isinstance(msg, SystemMessage) and msg.subtype == "init":
    print(msg.data.get("tools"))

这比猜“应该有 Read/Edit/Bash 吧”可靠。


11.5 工具描述尸检:坏描述 vs 好描述

工具描述是模型唯一的“使用手册”。它不看你脑子里的设计,只看这段字。

坏描述:

text
Edit a file.

模型会问:改哪个文件?怎么改?改了会怎样?能新建吗?它只能猜。

好描述:

text
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:

python
@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 上:

python
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。

bash
python3 -m pip install "claude-agent-sdk==0.2.168"
export ANTHROPIC_API_KEY="..."
cd code
python3 sdkagent/ch11_tools.py

2026-10-21 的实际输出(SDK 0.2.168 + CLI 2.1.296 + deepseek-flash):

text
[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 帧的工具列表以实际为准。