用 Agent SDK 搭最小 Agent:那 9 行替我发出了 86 个工具
本文的每个数字都是抓包抓出来的。 环境是
@anthropic-ai/claude-agent-sdk@0.3.260+claudeCLI 2.1.258 + node 22.22.3, 模型claude-haiku-4-5,验证代码与完整抓包分析在仓库experiments/agent-sdk/。 ⚠️ 本文里的工具数量和字符数取决于「这台机器装了什么」,换一台就不一样。 能搬走的不是那几个数,是「怎么把它们量出来」和那个 43% 与 57% 的分界。
复核:2026-09-07(SDK 升到
0.3.263,CLI 仍 2.1.258)✅ 凭据那条结论完整复现:无 key 仍能跑(
provider: firstParty), 塞一个格式合法的无效 key 仍然硬失败在 401、不回退。 连那个坑也还在 —— 认证失败时result.subtype依旧是'success', 只有is_error: true和空 usage 分得开。⚠️ 说清这次复核的范围:只重跑了
probe-auth.mjs(凭据探针), 没有重跑抓包那部分(proxy.mjs+run.mjs)—— 所以正文里那批工具数、字符数、43%/57% 的分界没有重新量过。 它们本来也取决于这台机器当天装了什么,见上一段。📌 顺带量到一件与正文相关的事:那个「最小请求」的缓存前缀 从
0.3.260的 17,272 变成了0.3.263的 17,025 —— 两个补丁版之间差 247。这类数字随版本飘,引用时引量级不引具体值。
Agent SDK 的卖点是「你只写提示词,循环和工具交给我」。这话是真的 ——
同一个「统计目录下每个 .txt 文件有多少行」的任务,
SDK 版的 agent 本体是 9 行,我手写的版本是 64 行:
| 行数 | 内容 | |
|---|---|---|
| SDK 版 | 9 | 一次 query() 调用,连选项一起 |
| 手写版 | 64 | 工具实现 32 + HTTP 封装 9 + 循环与终止条件 23 |
省下的 55 行不是白省的。这篇讲的是那 9 行替我做了什么, 以及为什么我认为其中一部分你必须显式关掉。
一、先看那 9 行发出去的是什么
SDK 走 HTTPS,正常情况下看不见内容。但它认 ANTHROPIC_BASE_URL ——
把它指向一个本地转发脚本就能全看见,不需要装证书、不需要改系统代理、不碰 TLS:
node proxy.mjs & # 监听 127.0.0.1:8787
ANTHROPIC_BASE_URL=http://127.0.0.1:8787 node run.mjs
任务是数四个文本文件的行数。抓到的第一个请求:
| 项 | 值 |
|---|---|
tools |
50 个,约 90,022 字符 |
system |
2 块,共 136 字符 |
messages |
1 条(就是我的那句提示词) |
stream |
恒为 true |
🚨 system 只有 136 字符。 完整内容就这两句:
x-anthropic-billing-header: cc_version=2.1.260.582; cc_entrypoint=sdk-cli;
You are a Claude agent, built on Anthropic's Claude Agent SDK.
⭐ 我原本以为那个巨大的前缀是「Claude Code 的系统提示词」。不是。
系统提示词小到可以忽略,前缀几乎百分之百是工具定义。
这个判断我一开始写错过一次,是抓包纠正的 —— 而光看 usage 里的
cache_creation_input_tokens 永远纠正不了,那个数只告诉你「很大」,不告诉你「大在哪」。
二、工具集在中途变大了
同一次运行的四个请求:
| 第几次 | tools |
工具定义字符 | messages |
|---|---|---|---|
| 1 | 50 | 90,022 | 1 |
| 2 | 86 | 134,798 | 3 |
| 3 | 86 | 134,798 | 5 |
| 4 | 86 | 134,798 | 7 |
第二次请求比第一次多了 36 个工具。它们是:
ListMcpResourcesTool, ReadMcpResourceDirTool, ReadMcpResourceTool,
mcp__claude_ai_Gmail__* (16 个:读邮件、建草稿、打标签、移到垃圾箱…)
mcp__claude_ai_Google_Drive__* (8 个:搜文件、读内容、看权限…)
mcp__claude_ai_Three_js_3D_Viewer__*(2 个)
我写的是一个数文本文件行数的程序。 它在第二轮把「读取我的 Gmail」 和「读取我的 Google Drive」的工具定义发了出去。
这些不是 SDK 的默认工具,是我这台机器上 Claude Code 的配置 —— 装的插件、连的远程 MCP 连接器。SDK 借用已登录 CLI 的凭据时, 把那个 CLI 的整个配置面也一起借了过来,包括第一次请求时还没加载完、 第二轮才补上的那批远程连接器。
📌 这解释了另一件怪事:为什么第二次请求会有
cache_creation_input_tokens: 6009 —— 工具列表变了,缓存前缀就失效了。
换句话说,这批工具不只是被发出去,它们还让第一轮建好的缓存作废了一部分。
三、allowedTools 不管「发什么」,只管「准调什么」
我给的是 allowedTools: ['Read', 'Glob', 'Bash'] —— 三个。发出去的是 86 个。
⚠️ 这不是 bug,是这个选项的定义:它是权限闸门,不是载荷筛选。 但如果你按名字猜它的作用(我猜过),就会得出「我已经把工具面收窄了」的错误结论。
真正管载荷的是另外两个选项:
query({ prompt, options: {
settingSources: [], // 不读 ~/.claude/settings.json 等磁盘配置
strictMcpConfig: true, // 只用显式传进来的 MCP server
}})
同一个任务、同一个模型,加上这两行之后:
| 工具数 | 工具定义字符 | 其中 mcp__ 工具 |
|
|---|---|---|---|
| 默认 | 50 → 86 | 134,798 | 54 |
| 显式隔离 | 28(全程不变) | 77,988 | 0 |
⭐ 两个数一起看才有意义:
- MCP 那 54 个工具能减掉,减掉之后工具集还变成了恒定的(不再中途膨胀)
- 但字符数只从 134,798 降到 77,988 —— 只减掉 43%。
剩下的 77,988 字符是 SDK 内置工具(
Bash/Read/Edit/Task/WebSearch…),allowedTools减不掉,settingSources也减不掉
⇒ 「最小 agent」的最小值不是零。 哪怕你只想数几行文件、 哪怕你只允许三个工具,SDK 的地板就是二十几个工具、七万多字符。 要低于这个地板,你要的就不是 Agent SDK 了(见文末)。
四、「工具失败了 SDK 会重试」—— 没有这回事
这是我写这篇之前最想验证的一条,因为它是最常见的那类「SDK 会帮你处理」的说法。
做法:用 createSdkMcpServer 挂一个必然抛错的工具,
任务写成「只能靠这个工具拿到读数」,然后数它被调用了几次。
tool('get_reading', '读取传感器读数。必须用这个工具,没有别的办法。',
{ sensor: z.string() },
async () => { hits.push(Date.now()); throw new Error('SENSOR_OFFLINE: 传感器离线') })
跑三次,三次结果完全一致:
| 第 1 次 | 第 2 次 | 第 3 次 | |
|---|---|---|---|
| 工具被调用次数 | 1 | 1 | 1 |
num_turns |
2 | 2 | 2 |
run 的 is_error |
false |
false |
false |
一次都没重试,也就谈不上退避策略。 抓包里的现场是这样的:
[tool_use] mcp__flaky__get_reading {"sensor":"S1"}
[tool_result] is_error: true | "SENSOR_OFFLINE: 传感器离线"
SDK 做的事是:把异常包成一个 is_error: true 的 tool_result,
塞回对话,然后由模型决定下一步。这次模型决定的是「转述给用户」:
传感器 S1 的读数返回结果:SENSOR_OFFLINE: 传感器离线
🚨 而整个 run 的 is_error 是 false。
从调用方看,这次运行「成功」了 —— 成功地报告了一次失败。
如果你的代码靠 result.is_error 判断任务成没成,这一类失败会全部漏掉。
⇒ 能带走的一条:工具错误不是 SDK 的事,是模型的事。 重试逻辑该写在你的工具实现里(它是你的代码), 指望 SDK 那一层有退避策略,是把责任放错了地方。
五、行为对比:省下的 55 行买到了什么,又付出了什么
⚠️ 先说清楚什么不可比。 手写版打的是本地 Ollama
(qwen2.5:7b-instruct-q4_K_M),SDK 版打的是 claude-haiku-4-5。
模型不同、端点不同 —— token 数、耗时、正确率都不能直接比。
(为什么不用同一个模型:这台机器上没有可用的 API key,
Agent SDK 借的是已登录 CLI 的凭据,而那条凭据没有暴露给别的程序的出口。)
能比的是形状:
| SDK 版 | 手写版 | |
|---|---|---|
| 我写的工具 | 0 个(用内置 Bash) |
2 个(list_files / read_file) |
| 我写的循环 | 0 行 | 23 行 |
| 终止条件 | SDK 决定 | 2 个,我自己写(无工具调用 / MAX_TURNS) |
| 解题路径 | 每次都不同 | 5 次完全相同 |
手写版跑 5 次,工具调用序列一字不差:
list_files → read_file → read_file → read_file → read_file → read_file
SDK 版跑 4 次(已隔离配置),每次都不一样:
| 第几次 | 工具调用 | 轮数 | total_cost_usd |
结果 |
|---|---|---|---|---|
| 1 | Bash ×2 |
3 | $0.0630 | ✅ |
| 2 | Bash ×2 |
3 | $0.0411 | ✅ |
| 3 | Bash ×2 |
3 | $0.0096 | ❌ 只返回了 alpha.txt |
| 4 | Bash ×1 |
2 | $0.0165 | ✅ |
⚠️ 这里有两件事,别混成一件:
- 成本方差很大($0.0096 ~ $0.0630,6.5 倍),
同一个任务、同一个模型、同样的选项。成本主要由缓存命中和轮数决定,
而这两样每次都不同。报「这个任务花多少钱」时给单次数字是没意义的。
(在没隔离的配置下我还量到过一次 $0.1668:那一次
cache_creation_input_tokens是 80,160、cache_read是 0 —— 整个前缀都是新建的缓存, 一次都没命中。) - 4 次里错了 1 次。 我不打算把它写成「错误率 25%」——
n=4 撑不起一个百分比。能说的只有「在这个平凡任务上它不是每次都对」,
而手写版那 5 次的确定性,来自
temperature=0加一条写死的解题路径, 不是来自“手写更准”。
📌 真正的取舍在这儿:SDK 用不确定性换掉了你的 55 行代码。 任务越开放,这笔交易越划算;任务越是「就该这么做」,它越亏。 数四个文件的行数是后者。
六、抓包本身怎么做(这是判据的全部依托)
这篇的 stub 给自己定的判据是:
凡是「SDK 会帮你处理」的说法,都要有抓包证据; 文档和实际行为不一致时以抓包为准。
所以抓包链路本身不能出错。踩到的两个坑都不报错,特别值得单独说:
🚨 坑一:响应是 gzip 的,而解错了不会报错
第一版直接 buf.toString('utf8') 去解析 SSE,结果是
events: {}、usage: null —— 看起来就像“这次响应里没有事件”。
量具坏掉和被测对象没动静,长得一模一样。
真正的原因在响应头的第 22 个字段:content-encoding: gzip。
修法不只是加解压,还要让解压失败变成一条显眼的记录, 而不是退化成一个空对象:
catch (e) { resBody = { _decodeFailed: String(e.message), bytes: buf.length } }
🚨 坑二:代理开着的时候删日志文件,日志会写进一个没有名字的文件
代理启动时 createWriteStream(OUT, {flags:'a'}) 持有了 fd。
我在两次实验之间 rm capture.jsonl 重置数据,之后所有记录都写进了
那个已被删除的 inode。表现是「跑完了,capture.jsonl 不存在」,
而代理的 stderr 一切正常,照常打印 [proxy #N] … → 200。
我踩了两次同一个坑。改成每条重新 open-append-close 之后不再复现。
顺带:脱敏是硬性要求,不是附加功能
代理会看到 Authorization 头,那是真凭据,而 capture.jsonl 是要入库、
要在文章里引用的。所以敏感头一律换成:
<redacted len=... sha256=前8位>
留长度和哈希前缀,是为了还能回答「两次请求用的是不是同一个凭据」—— 而这两样都还原不出原值。(这条会在下一篇展开。)
七、抓包顺带核对上的几件小事
| 说法 | 抓包所见 |
|---|---|
请求的是 claude-haiku-4-5 |
线上是 claude-haiku-4-5-20251001 —— 别名在服务端被解析成了带日期的快照 |
| SDK 是不是每轮一个请求 | 是。每轮 1 次 POST /v1/messages?beta=true,另有启动时 1 次 HEAD /api/hello |
| 是否强制流式 | 是,stream 恒为 true,没找到关掉的办法 |
| 响应里有什么额外信息 | anthropic-ratelimit-unified-5h-utilization 等一组限额头 —— 订阅额度的用量在响应头里,不在 body 里 |
八、能带走的四条
allowedTools是权限闸门,不是载荷筛选。 想收窄发出去的东西, 用settingSources: []+strictMcpConfig: true。- 默认继承本机 Claude Code 的全部配置,包括连着邮箱和网盘的远程 MCP。 隔离是要显式开的,不开就是继承。给别人跑、或者在 CI 里跑之前,先想清楚这一条。
- 工具失败没有 SDK 级重试,异常会变成
is_error: true的tool_result交给模型, 而 run 整体仍是is_error: false。别用result.is_error判断任务成没成。 - 想量它,就把
ANTHROPIC_BASE_URL指向本地。 不需要证书, 十几行转发脚本就能看见全部载荷 —— 但记得处理 gzip,以及别在跑的时候删日志文件。
九、没有回答的问题
- 那 77,988 字符的内置工具定义能不能减? 我没找到官方选项。
disallowedTools我没测过它影不影响载荷(只测了allowedTools)。 - 第二轮才加载远程 MCP,是时序还是策略? 我只观察到「第一次 50、第二次 86」, 没有独立验证原因,也没试过预热能不能让它一开始就齐。
- 成本方差 6.5 倍里,缓存和轮数各占多少? 需要固定轮数再测,我没做。
- 在一台干净的机器上(没装任何插件),隔离前后还差多少? 本文的 86 和 28 都来自我这台机器。28 这个数我认为是接近地板的,但没有第二台机器佐证。