用 Agent SDK 搭最小 Agent:那 9 行替我发出了 86 个工具

Agent SDK 与可观测性第 1 / 2 篇

本文的每个数字都是抓包抓出来的。 环境是 @anthropic-ai/claude-agent-sdk@0.3.260 + claude CLI 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 ✅

⚠️ 这里有两件事,别混成一件:

  1. 成本方差很大($0.0096 ~ $0.0630,6.5 倍), 同一个任务、同一个模型、同样的选项。成本主要由缓存命中和轮数决定, 而这两样每次都不同。报「这个任务花多少钱」时给单次数字是没意义的。 (在没隔离的配置下我还量到过一次 $0.1668:那一次 cache_creation_input_tokens 是 80,160、cache_read 是 0 —— 整个前缀都是新建的缓存, 一次都没命中。)
  2. 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 里

八、能带走的四条

  1. allowedTools 是权限闸门,不是载荷筛选。 想收窄发出去的东西, 用 settingSources: [] + strictMcpConfig: true。
  2. 默认继承本机 Claude Code 的全部配置,包括连着邮箱和网盘的远程 MCP。 隔离是要显式开的,不开就是继承。给别人跑、或者在 CI 里跑之前,先想清楚这一条。
  3. 工具失败没有 SDK 级重试,异常会变成 is_error: true 的 tool_result 交给模型, 而 run 整体仍是 is_error: false。别用 result.is_error 判断任务成没成。
  4. 想量它,就把 ANTHROPIC_BASE_URL 指向本地。 不需要证书, 十几行转发脚本就能看见全部载荷 —— 但记得处理 gzip,以及别在跑的时候删日志文件。

九、没有回答的问题

  • 那 77,988 字符的内置工具定义能不能减? 我没找到官方选项。 disallowedTools 我没测过它影不影响载荷(只测了 allowedTools)。
  • 第二轮才加载远程 MCP,是时序还是策略? 我只观察到「第一次 50、第二次 86」, 没有独立验证原因,也没试过预热能不能让它一开始就齐。
  • 成本方差 6.5 倍里,缓存和轮数各占多少? 需要固定轮数再测,我没做。
  • 在一台干净的机器上(没装任何插件),隔离前后还差多少? 本文的 86 和 28 都来自我这台机器。28 这个数我认为是接近地板的,但没有第二台机器佐证。