给 Agent 加可观测性:那次跑对了的任务,44% 的调用是白跑的
本文的每个数字都是跑出来的。 环境是
@anthropic-ai/claude-agent-sdk@0.3.260+claudeCLI 2.1.258, 模型claude-haiku-4-5,验证代码在仓库experiments/agent-sdk/(obs.mjs)。 ⚠️ 日志与抓包文件不入库(.gitignore里排掉了)—— 它们是每次跑出来的产物,而且是这一篇最该小心对待的那类文件。原因见第四节。
复核:2026-09-07(SDK 升到
0.3.263,CLI 仍 2.1.258)✅ 本篇依赖的凭据前提仍然成立:无 key 能跑、无效 key 硬失败 401 不回退, 且认证失败时
result.subtype仍是'success'(只有is_error分得开)—— 那正是本篇讲「日志要记什么」时反复用到的那个坑。⚠️ 只重跑了凭据探针,没有重跑
obs.mjs: 正文里的浪费统计、缺陷定位、密钥扫描三段没有重新量过。 那三段要真跑一轮任务并抓包,属于另一个成本量级。 ⇒ 把正文的具体数字当写作时那一轮的记录读,方法本身不随版本变。
上一篇把 SDK 发出去的载荷抓了下来。 这一篇换个方向:运行完了,你怎么知道它干了什么。
先给结论:我跑了一个平凡任务,最终答案完全正确,
result.is_error 是 false,过程里一步都没报错。
日志打开一看,9 次工具调用里有 4 次是白跑的。
一、那次“完美”的运行
任务:数四个 .txt 文件各有多少行。只给 Read 和 Glob
(不给 Bash —— 给了它一条 wc -l 就结束了,看不到多步过程)。
日志里的九步:
| # | 工具 | 入参 | 结果 |
|---|---|---|---|
| 1 | Glob |
{"pattern":"*.txt"} |
✅ alpha.txt\nbeta.txt\ngamma.txt\ndelta.txt |
| 2 | Read |
/Users/leo/alpha.txt |
❌ File does not exist |
| 3 | Read |
/Users/leo/beta.txt |
❌ File does not exist |
| 4 | Read |
/Users/leo/gamma.txt |
❌ File does not exist |
| 5 | Read |
/Users/leo/delta.txt |
❌ File does not exist |
| 6 | Read |
…/fixture/alpha.txt |
✅ |
| 7 | Read |
…/fixture/beta.txt |
✅ |
| 8 | Read |
…/fixture/gamma.txt |
✅ |
| 9 | Read |
…/fixture/delta.txt |
✅ |
看得很清楚:
Glob返回的是裸文件名,不带目录Read要绝对路径- 中间那个缺口,模型用
/Users/leo/(家目录) 补的 —— 不是我传给它的cwd
四次全失败。救回来的是错误消息自己:
File does not exist. Note: your current working directory is
/Users/leo/…/experiments/agent-sdk/fixture.
拿到这句它就改对了。代价是四次往返、一轮多余的对话、以及那部分 token。
🚨 而这一切从外面完全看不见。 调用方拿到的是:
result.is_error : false
最终答案 : {"alpha.txt":12,"beta.txt":7,"gamma.txt":23,"delta.txt":4} ← 全对
⭐ 这就是我认为可观测性值得单开一篇的理由: 能靠返回值发现的问题,本来就不需要日志。日志是为这一类准备的 —— 它跑对了,只是比该花的多花了一倍。
这个失败复现了 3/4 次
同样的提示词、同样的选项,跑四次:三次踩了家目录,一次没踩。 没踩的那次它用的是相对路径:
Glob {"pattern":"*.txt"} → Read "./alpha.txt" → … 5 次调用,0 次浪费
⚠️ 所以这不是一个确定的 bug,是一个每次都在掷骰子的接缝:
Glob 给相对名、Read 要绝对路径,谁来补这个缺口没有规定。
📌 我不写「25% 的概率」—— n=4 撑不起一个百分比。
能说的是:这个接缝真实存在,而且经常被踩到。
二、「浪费」必须是可判定的,否则那个百分比是我编的
「44%」这个数只有在浪费的定义能由脚本机械判定时才有意义。
所以判据写在代码里(obs.mjs 的 WASTE_KINDS),不写在文章里 ——
写在文章里的判据会和代码分叉,而且分叉时不报错。
三类,每一类都不需要我做判断:
| 类别 | 判据 |
|---|---|
| 重复调用 | 同名 + 同入参出现 ≥2 次,第 2 次起计 |
| 读了无关对象 | 入参里出现了不在标准答案集合中的文件名 |
| 失败的调用 | tool_result 的 is_error 为 true |
⚠️ 判不了的就不进统计。 比如「这一步思路绕了」—— 我看着觉得绕,但没有任何脚本能复核它。 宁可少算(把 44% 算成偏低的数),也不掺进主观项。 上面那次 44% 全部来自第三类,一次重复调用都没有。
📌 顺带一条:分子和分母必须来自同一份记录。 全量日志之所以要全量,不是为了详尽,是为了让「9」这个分母 不是我挑出来的。只记「我认为重要的调用」,比例就没有意义了。
三、最难查的那类:全程零报错,答案却是错的
上面那种失败至少还留下了四条 is_error: true。真正难查的是不报错的。
我造了一个描述与实现不一致的工具 —— 这是很常见的一类真实缺陷:
tool('list_txt_files',
'列出当前目录下所有 .txt 文件的文件名。返回的文件名保证都以 .txt 结尾。',
{},
async () => {
const all = fs.readdirSync(FIXTURE_DIR) // 🐛 描述承诺只返回 .txt,实现返回了全部
return { content: [{ type: 'text', text: JSON.stringify(all) }] }
})
跑出来:
最终答案: {"alpha.txt":12, "beta.txt":7, "delta.txt":4, "gamma.txt":23, "notes.md":2}
^^^^^^^^^^^^
全程报错的步数: 0
notes.md 混进来了,而没有任何一步失败。模型完全按描述行事 ——
它被告知「返回的都是 .txt」,就没有再筛一遍。
⇒ 光看最终答案,你只知道“错了”。判据要求的是日志能指出是哪一步、为什么。 从日志里机械地找(不用我已知的答案):
⇒ 第 1 步 mcp__files__list_txt_files
描述承诺:「返回的文件名保证都以 .txt 结尾」
实际出参:["alpha.txt","beta.txt","delta.txt","gamma.txt","notes.md"]
违约项 :notes.md
⇒ 缺陷在工具实现,不在模型 —— 模型是照描述行事的
⭐ 定位的判据是可写死的:某个工具的描述里承诺了什么,它的出参就要满足什么。 这不需要理解语义,只需要把承诺写成一条能检查的规则。 (所以工具描述里那句“保证……“不只是给模型看的, 它同时是一条你可以拿去校验自己实现的断言。)
🚨 我的定位器第一版没找到它,而日志里明明有
第一次跑 U 段,输出是「日志里没定位到(这次它可能没触发)」—— 但缺陷明明触发了。问题在日志的存法:
"head": "[{\"type\":\"text\",\"text\":\"[\\\"alpha.txt\\\",\\\"notes.md\\\"]\"}]"
我用 JSON.stringify(b.content) 存了 MCP 工具返回的块数组,
于是内层引号被**转义了两层**,那条找文件名的正则一条也匹配不到。
数据一个字节都没少,但查不动了。
⭐ 这条比它修起来的样子重要得多: 「记下来了」不等于「查得动」。 全量日志如果存的是结构化对象的字符串化, 后面所有基于文本的分析都会静默地扫不到东西 —— 不是报错,是返回空。 而「扫不到」和「没有问题」长得一模一样。
修法是在写日志时就把 tool_result 正规化成纯文本(obs.mjs 的 textOf),
而不是在分析时一层层反解析。
四、脱敏:这一篇的判据是「扫得出密钥就是错的」
上一篇那个抓包代理会看到什么?看它自己记下来的:
authorization : <redacted len=115 sha256=3234d67c>
x-api-key : (没有这个头)
len=115。 那个位置原本躺着一个 115 字符的真凭据。
一个朴素的、把 req.headers 直接写进日志的代理,
会把它原样写进一个准备入库、准备在文章里引用的文件。
(顺带印证了上一篇的结论:走的是 authorization,没有 x-api-key ——
和“借用已登录 CLI 的 OAuth 凭据”对得上。)
所以敏感头一律换成占位符,而保留两样东西:
len—— 还能回答「那里原本有没有东西、多长」sha256前 8 位 —— 还能回答「两次请求用的是不是同一个凭据」
这两样都还原不出原值,但把可观测性里最常需要的两个问题保住了。 📌 脱敏不是把字段删掉。 删掉之后你连“这里本来有个凭据”都不知道了, 而那恰恰是排查认证问题时最想知道的一件事。
🚨 先证明扫描器有牙,再让它去扫
判据是「扫得出密钥就是错的」。那么一个**永远返回“干净”**的扫描器 可以完美通过这个判据 —— 而那是最坏的情况:判据看着过了,实际什么都没查。
所以扫描分两步。第一步喂它六个合成的假密钥:
| 诱饵 | 结果 |
|---|---|
sk-ant-api03-CCCC… |
✅ Anthropic API key |
Authorization: Bearer DDDD… |
✅ Bearer token |
eyJhbGciOiJIUzI1NiJ9.… |
✅ JWT |
ghp_EEEE… |
✅ GitHub token |
AKIAFFFF… |
✅ AWS access key id |
"authorization": "GGGG…" |
✅ 未脱敏的敏感头 |
牙口 6/6。
⚠️ 用的是合成值。不能为了测扫描器而把真凭据写到磁盘上 —— 那正好制造了这一篇要防的那件事。
第二步才是扫真实日志:
capture-P.jsonl 326KB ✅ 干净
capture-R.jsonl 400KB ✅ 干净
capture-S.jsonl 115KB ✅ 干净
capture.jsonl 791KB ✅ 干净
log-T.jsonl 7KB ✅ 干净
log-U.jsonl 4KB ✅ 干净
…
脱敏占位符出现 34 处 —— 字段还在,值没了
⇒ ✅ 扫描器有牙(6/6 抓到诱饵),且真实日志 0 命中。判据通过。
📌 最后一道保险:这些文件根本不入库。 判据通过是一回事,把 1.6MB 的原始抓包放进 git 是另一回事 —— 今天扫干净了,不代表下次加了个新字段还干净, 而一旦提交就再也删不掉。可复现性由脚本保证,不由产物保证。
五、那么该记什么
按这三段实际用上的东西倒推,最小集合是:
| 记什么 | 少了它会怎样 |
|---|---|
每次 tool_use 的名字与完整入参 |
定位不到“是哪一步”,44% 那张表整个不存在 |
每次 tool_result 的 is_error 与出参正文 |
U 段无法比对“描述承诺 vs 实际返回” |
| 出参要正规化成文本再存 | 见第三节:存下来了但扫不动 |
| 相对时间戳 | 用来看哪一步慢;本文没展开 |
result 的 usage 与 num_turns |
成本归因;注意 is_error 不能当成功判据 |
| 敏感头的占位符(不是删掉) | 排查认证问题时连“这里有没有凭据”都不知道 |
不记的:请求体里那七万多字符的工具定义(上一篇量过,每轮都一样, 记 N 遍只是把日志撑大)。记它的大小和哈希就够了。
六、能带走的四条
result.is_error === false不代表这次运行没问题。 它只说明流程没崩。答案对不对、绕了多少路,都要另外看。- 「浪费」要有可判定的定义,否则那个百分比是编的。 判据写在代码里;判不了的宁可不算。
- 日志的存法决定它能不能被查。 结构化对象直接
JSON.stringify存进去, 后面的文本分析会静默失效 —— 扫不到和没问题长得一样。 - 脱敏保留
len和哈希前缀,别整个删掉; 而扫描器必须先被诱饵证明有牙,再去扫真实日志。 一个永远说“干净”的扫描器能通过任何“不许泄露”的判据。
七、没有回答的问题
Glob给相对名、Read要绝对路径这个接缝,有没有官方的弥合办法? 我没找到(在提示词里写死绝对路径当然可以,但那是绕过去,不是修好)。- 那个 3/4 的踩坑率,换个模型是多少? 只测了
claude-haiku-4-5。 - 本文的“浪费”只覆盖了三类可判定的。 无效重试、上下文膨胀这两类 (stub 里点名的)我没能写出机械判据,所以没算 —— 它们大概率真实存在, 只是这套量具看不见。
usage.cache_read_input_tokens能不能用来定位“上下文膨胀”? 值得试,本文没做。