Agent Mentor Learn
可观测性与调试:看清 Agent 的每一步 · 第 5 / 6 节

第 5 课:在关口安探头:hooks 与定位流程

学习目标:

  • 说清 hooks 是什么:在 Agent 生命周期固定点自动执行的用户自定义处理器,分「每会话一次 / 每轮一次 / 循环内每次工具调用」三种节奏,并能为一个观测需求挑对事件、写对 matcher、指出该用载荷里的哪些字段
  • 避开两个真会咬人的坑:hook 子进程拿不到 harness 的 OTel 导出配置,以及转录文件异步写入、hook 触发时可能还不含最近的消息
  • 用一条五步流程在非确定性下定位问题:按 prompt id 收窄、找第一处分岔、用一模一样的输入重放观察、反复压同一个组件、修复后从出错处恢复

前置要求:读完第 1–4 课(非确定性与「说不清」、原始记录作为第一手证据、结构化日志与指标、trace 树与遥测管道的陷阱) | 上一课 第 4 课 << | 下一课 第 6 课 >>

你想在工具执行前后各记一笔

第 3 课教你在自己的 harness 里给每次工具调用记一条结构化日志,第 4 课教你把这些记录串成一棵 trace 树。那是你写的循环,源码在手上,想在哪儿插一行 log() 就在哪儿插。可现在生产上跑的是 Claude Code——你没有它的 runToolUses。你要的东西并不复杂:工具执行之前记一笔(它打算用什么参数),执行之后再记一笔(它拿到了什么)。但插桩点在别人的进程里。

产品早想到了这件事,答案叫 hooks。

hooks 是在 Claude Code 生命周期的特定点自动执行的用户自定义命令、HTTP 端点或 LLM 提示1。翻成大白话:你在配置里声明「某某时刻,帮我执行这个脚本」,Claude Code 跑到那个时刻就执行它。而且不是空手来的——当一个事件触发、并且 matcher 命中时,Claude Code 会把这个事件的 JSON 上下文交给你的处理器1

本系列第 7 门课里,你在 harness 循环中写过一个审批阀——遇到 HIGH_IMPACT 的工具,执行前停下来等人确认。那是你手搓的一个拦截点。hooks 做的是同一件事,但把「循环里有哪些值得停一下的位置」整理成了一份具名清单,每个位置的载荷固定、可依赖。对可观测性来说,这份清单的价值不在于「能改行为」,而在于能不改行为地看

三种节奏:会话、轮、工具调用

事件分三种节奏1

  • 每会话一次SessionStartSessionEnd
  • 每轮一次UserPromptSubmitStopStopFailure(从名字推断,StopFailure 对应轮次没能正常收尾的那条出口——官方引文只给了节奏归类,具体语义以你手上的参考页为准)
  • 循环内每次工具调用PreToolUsePostToolUse

对照本系列第 7 门课讲的循环解剖,三层立刻就对上了:

text
SessionStart                       ← 会话开始(整个 runAgent 启动)  UserPromptSubmit                 ← 一轮开始(用户提交提示)    while (stop_reason === "tool_use") {       ... 模型请求 ...       PreToolUse                  ← 循环体内,参数已生成、还没执行       ... 工具执行 ...       PostToolUse                 ← 循环体内,工具执行完了    }  Stop / StopFailure               ← 一轮结束SessionEnd                         ← 会话结束

选错节奏会得到很难解释的数:想统计「一次任务用了多少次工具」,插在每轮的关口上只能拿到零。挑事件之前先问一句:我要数的东西,一次会话里发生几次?

一个细节值得单拎出来:SessionStart 在新开会话时触发,恢复已有会话时也触发1。本系列第 9 门课讲过 --resume,从循环角度那不是「新开始」,但它同样会敲响 SessionStart。写「会话开始时初始化一份新日志」的人,第一次遇到 resume 就会把上一段记录覆盖掉。

观测用得上的关口细节

PreToolUse 运行在 Claude 已经创建好工具参数之后、处理这次工具调用之前1。这个夹缝很关键:参数已经定型(你能看到模型到底打算用什么参数),但工具还没跑。第 2 课讲过一个真实案例——官方发现 Claude 会往搜索工具的 query 参数里多余地追加 2025,把结果带偏2。这类毛病的证据就长在 PreToolUse 能看到的那份参数里。

PostToolUse 在工具已经成功执行之后触发,输入里同时包含 tool_input(发给工具的参数)和 tool_response(工具返回的结果)1。一次触发就给你一条完整的调用记录,不用自己把「哪次请求配哪次响应」拼回去。第 2 课强调过的原则——完整往返才是第一手证据2——在这里是一个字段对一个字段直接拿到的。注意它的触发条件是「已经成功执行」1;想覆盖参数生成了、但执行没成功的情况,得把 PreToolUse 一起配上,用两边的记录做差。

matcher 怎么写:想让一个钩子在任何工具完成后都跑,省略 matcher 或者设成 "*" 就行1。观测场景要的正是这种一钩全记——你不知道哪个工具会出问题,所以哪个都记。

处理器怎么收发数据:命令钩子通过 stdin 接收 JSON 数据,通过退出码、stdout 和 stderr 传回结果1。所以一个最小的观测处理器就是「从 stdin 读一段 JSON,挑几个字段追加进文件,退出码 0」,没有任何魔法。

这就是第 3 课那份 JSON Lines 日志,只不过写日志的人从「你的 harness」换成了「你挂在别人循环上的一个小脚本」。配置项与字段名的确切写法以你手上这个版本的参考页为准。

载荷里有耗时,但口径要看清:载荷里带一个可选字段,是工具执行的毫秒数,不含花在权限提示和 PreToolUse 钩子上的时间1。后半截是重点:用户体感上的「这一步等了多久」包含等他点确认的那段,而这个数把它剔掉了。拿它回答「工具慢不慢」是对的,拿它回答「用户等了多久」会系统性偏小。第 4 课提过同一件事的另一个说法——工具 span 底下挂着两个子 span,一个是等权限决定的时间、一个是执行本身3,分开记正是因为这两段不该混。

hook 自己也在 trace 里:每个用户提示开启一个 claude_code.interaction 根 span,API 调用、工具调用和 hook 执行都记录成它的子节点3。观测手段本身也是被观测的对象。

两条会咬人的告诫

第一条:hook 子进程不继承 OTEL_* 导出变量。 有一组变量是不被继承的——Claude Code 会从它派生的每一个子进程里移除 OTEL_* 导出变量,hooks 也不例外1

这句话直接否掉一个非常自然的想法:「harness 那边已经把 endpoint、协议、认证头都配好了,我在 hook 里引一个 OTel SDK,环境变量顺手就能用。」——用不了。处理器进程启动时那些变量已经被剥掉了,数据要么发不出去,要么打到默认端点上石沉大海。更别指望有人喊一嗓子提醒你:第 4 课讲过,CLI 自己那套导出连失败都是静默的4,你在 hook 里自建的 exporter 默认也不会更吵——没数据到后端时,两边都安安静静。

能走的路有两条:一是 hook 自带一整套导出配置(在脚本里显式写 endpoint 和认证,不指望继承);二是别在 hook 里发遥测,让它落一份结构化日志,靠 ID 在后端跟遥测对齐。第二条路有官方支撑:hook 载荷里那个标识当前正在处理的用户提示的 UUID,与 OpenTelemetry 事件上的 prompt.id 属性是同一个值,所以你能把 hook 的输出和同一次提示的遥测关联起来1。两边各写各的,最后用同一个 prompt id join——跟第 4 课「靠关联 ID 串成一棵树」是同一招,只是这次跨了两个数据源。

第二条:转录文件是异步写的。 载荷里会给你一个指向对话 JSON 的路径,但这个文件是异步写入的,可能落后于内存里的对话,所以 hook 触发时它可能还不包含当前这一轮最近的消息1

坑人的地方在于它不报错,只给你旧数据。有人会想「载荷字段不够用,我直接读转录文件,那里什么都有」,结果是记录时好时坏地缺半截。tool_inputtool_response 就用载荷里的字段,那是 PostToolUse 明确保证会给你的1。转录适合事后回看,不适合在 hook 触发那一瞬间当实时数据源。

顺带一条跟你机器有关的提醒:命令钩子是以你的完整用户权限执行 shell 命令的,能修改、删除、访问你的用户账户能访问的任何文件,所以把任何 hook 命令加进配置之前,先审再装1

hook 自己不干活的时候

你挂了钩子,日志文件里空空如也。是没触发?matcher 没命中?还是脚本自己崩了?这时候要观测的对象变成了观测手段本身,第 4 课那个道理在这里又用一遍:探测器自己要先验证。

hook 的执行细节——哪些钩子命中了、它们的退出码、完整的 stdout 和 stderr——都会写进 debug 日志文件1。拿到它有两种方式:用 claude --debug-file <path> 写到你指定的位置,或者跑 claude --debug,然后去 ~/.claude/debug/<session-id>.txt1。想看得更细,把 CLAUDE_CODE_DEBUG_LOG_LEVEL 设成 verbose,会多出一些日志行,比如 hook matcher 的计数1

text
排查顺序:日志文件是空的 └─ debug 日志里有这个钩子的命中记录吗?      没有  → matcher 写错了;先用省略 matcher 的版本确认能触发      有    → 退出码是 0 吗?               不是 → 读 stderr,多半是脚本路径、权限或 JSON 解析               是   → 跑了但没写对地方;检查写入路径与目录是否存在

定位流程:非确定性下怎么查一个问题

前半课讲探头装在哪儿,后半课讲拿着这些数据怎么真的查出一个问题。

先把难处摆正。第 1 课说过:Agent 做的是动态决策,两次运行之间是非确定性的,即使给一模一样的提示词也一样,这让调试变难5。传统调试的第一步是「复现」,而这一步在这里不成立——你重跑一遍,它可能走一条完全不同、但同样合法的路。

下面这五步是本课自己的编排,不是哪家的官方方法论;但每一步踩的那块砖各自有出处。

第一步:收窄

在前四课攒下的记录里,先把范围缩到「这一次出问题的提示」上。第 4 课给过办法:要追踪单条提示触发的全部活动,按某个具体的 prompt.id 值过滤你的事件3

这一步的意义不在技术,在心态。「Agent 出问题了」是个没法查的命题;「这一条 prompt id 底下的 11 条事件里有一条不对劲」是个能查的命题。

第二步:找第一处分岔

从头往后读,找行为开始偏离预期的第一步。为什么执着于「第一处」?因为一步失败会让 Agent 探索完全不同的轨迹,导向不可预测的结果5。你在末尾看到的那些荒唐(引用了不存在的文件、连着报错、绕远路),绝大多数是下游噪声。去修第 8 条事件,多半是在给第 4 条的错误擦屁股。

判断「偏离」有几个好用的形状2:调了不该调的工具、调了对的工具但参数不对、该调的工具调得太少、或者拿到工具响应之后处理错了。最后一种最难看出来,因为工具本身返回的是成功,日志上一片绿——错的是 Agent 对这个成功结果的理解。

第三步:重放观察

官方的做法是:为了理解提示词的效果,用系统里一模一样的提示词和工具搭模拟环境,然后逐步观看 Agent 干活;这个办法立刻暴露了几种失败模式——已经拿到足够结果了还继续往下找、搜索词写得过于啰嗦、选错了工具5

「立刻暴露」四个字值得琢磨。同样这些毛病,在聚合指标里看不见(成功率还挺高),在事后日志里要一条条读才能察觉,但盯着它一步一步跑,人眼几秒钟就能看出「它明明已经够了还在搜」。重放时两件事要控住:输入必须是同一份(提示词、工具定义都不能顺手改),观察必须是逐步的。

第四步:反复压同一个组件

如果嫌疑落在某个工具身上,单跑几次很可能什么也看不出来——非确定性会让毛病时隐时现。

官方造过一个工具测试 Agent:给它一个有缺陷的 MCP 工具,它去试着用,然后重写工具描述来避开失败;通过把这个工具测试几十次,这个 Agent 找出了关键的细节和 bug5

「几十次」是重点。一次运行藏得住的毛病,几十次会把它逼出来:某个边界输入下的返回形态特别、某句描述有歧义、某个错误文案让模型误判成「重试就好」。第 3 课的诊断读法在这里接得上——大量重复调用暗示分页或 token 上限参数需要调,大量参数无效的报错暗示工具描述该写清楚、该配例子2。顺带一句能省很多次往返的事:工具报错时把错误响应也当提示词来写,清楚说明具体该怎么改,而不是甩一串不透明的错误码或调用栈2

第五步:修复后从出错处恢复

改完了,别习惯性地按「重新开始」。官方的说法很直接:错误发生时不能只是从头重启——重启既贵又让用户难受;他们建的系统是从 Agent 出错的那个位置恢复5

本系列第 9 门课讲过恢复机制怎么做,那一课的场景是「任务被打断了怎么接着跑」。在调试里它是另一种用法:出错点之前的几十次工具调用是有效的、花过钱的、结果正确的,重跑一遍除了烧 token 什么也不多给你,还引入一堆新的非确定性,让你分不清「这次好了」是修对了还是运气好。

关于「复现」,说句诚实话

你可能在别处见过一套让 Agent 可复现的技法:固定随机种子、把 temperature 设成 0、录制真实的工具返回做回放桩。

这些做法在工程上确实有人用,本系列第 8–10 门课实战里那个桩 client 就是同一个路子——把一次真实运行的 tool_result 存下来,之后每次都返回同一份,工具这一侧就变成了确定的。用来验证「我改的这行代码有没有改坏解析逻辑」很好使。

但要说清两件事。第一,这些技法没有任何一手来源背书。上面五步流程的每一步我都给了出处,这一段我不给,因为确实没有。你在网上看到「官方推荐用 temperature 0 来复现 Agent 问题」之类的说法,让对方把链接发过来。

第二,它们能锁住的东西比听上去少。桩住工具返回锁住的是环境,模型这一侧仍然是非确定性的5。所以它把「两个变量都在动」变成了「只有一个变量在动」——这已经很有价值,但不是那种「同样输入必得同样输出」的复现。别拿它当保证,拿它当降噪。

什么问题值得走这套流程

五步走完是有成本的:收窄要查日志,重放要搭模拟,压组件要跑几十次。一个粗糙但够用的分法:

  • 低频、无害的抖动——比如某次多调了一次搜索,结果照样对。记在案,攒着。单看每一条都不值得查,攒到十几条之后往往能看出共同的模式,那时候再一次性查划算得多。
  • 高影响的——给出了错的事实、动了不该动的文件、卡死不返回。不管频率多低都立案,一次就够贵了。
  • 复发的——同一个形状的问题第三次出现,说明它不是运气,是结构问题。立案。

第 1 课那句话在这里是判断依据:一个用户可见的症状底下,压着好几个从外面看不可区分的内部原因5。你决定不查一个问题,等于接受「我不知道它是哪个原因」——对无害抖动这没关系,对高影响问题这就是在赌。

本课到此为止是纸上的。第 6 课把两半合起来动手:给本系列第 7 门课的 harness 装上一整层观测,然后拿一个「说不清」的症状从头追到底。

💻 练习

小结

  • hooks 是在 Claude Code 生命周期特定点自动执行的用户自定义命令、HTTP 端点或 LLM 提示;事件触发且 matcher 命中时,Claude Code 把该事件的 JSON 上下文交给你的处理器1
  • 事件分三种节奏——每会话一次(SessionStart/SessionEnd)、每轮一次(UserPromptSubmit/Stop/StopFailure)、循环内每次工具调用(PreToolUse/PostToolUse1。挑事件之前先定节奏。SessionStart 在恢复会话时也触发1
  • PreToolUse 跑在参数已生成、调用尚未处理之间;PostToolUse 在工具成功执行后触发,输入里同时带 tool_inputtool_response,一次触发给你一条完整的调用记录1。matcher 省略或设成 "*" 就是一钩全记1
  • 命令钩子从 stdin 收 JSON,用退出码、stdout 和 stderr 回话1;载荷里那个执行毫秒数不含权限提示和 PreToolUse 花掉的时间1,别拿它当用户等待时长。
  • 两条负面结论:Claude Code 会从每个派生的子进程(包括 hooks)里移除 OTEL_* 导出变量1,想在 hook 里发遥测得自带导出配置;载荷给的转录文件是异步写入的,hook 触发时可能还不含当前轮最近的消息1。命令钩子以你的完整用户权限执行,装之前先审1
  • hook 自己不干活时,去 debug 日志里看命中了哪些钩子、退出码和完整的 stdout/stderr,用 --debug-file 指定位置1;想看 matcher 计数就把日志级别设成 verbose1。hook 载荷里的 prompt id 与遥测事件上的 prompt.id 是同一个值,两边数据能对齐1
  • 定位流程五步(本课的编排):按 prompt.id 过滤出这次提示触发的全部事件3 → 找第一处分岔,因为一步失败会让整条轨迹改道5 → 用一模一样的提示词和工具做模拟、逐步观看5 → 把嫌疑组件反复用几十次把毛病逼出来5 → 修好后从出错处恢复而不是从头重启5
  • 固定种子、temperature 0、录制回放工具返回这些技法没有一手来源背书,本课按工程实践口径介绍:它们锁住的是环境这一侧,模型仍然是非确定性的5,所以是降噪,不是复现保证。
  • 不是每个问题都值得走全流程:低频无害的抖动记在案、攒成模式再查;高影响或复发的才立案深查。

>> 第 6 课:实战:给 harness 装上观测层

Footnotes

  1. Hooks reference — Claude Code 官方文档 — https://code.claude.com/docs/en/hooks 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31

  2. Writing effective tools for agents — with agents — Anthropic Engineering — https://www.anthropic.com/engineering/writing-tools-for-agents 2 3 4 5

  3. Monitoring — Claude Code 官方文档 — https://code.claude.com/docs/en/monitoring-usage 2 3 4

  4. Observability with OpenTelemetry — Claude Agent SDK 官方文档 — https://code.claude.com/docs/en/agent-sdk/observability

  5. How we built our multi-agent research system — Anthropic Engineering — https://www.anthropic.com/engineering/multi-agent-research-system 2 3 4 5 6 7 8 9 10 11 12

练习

01

有三个观测需求。对每一个,写出:挑哪个事件、matcher 怎么写、用载荷里的哪些字段、以及这个需求特有的坑是什么。不需要写完整代码,配置片段和一两句说明就够。

Level 1:给三个观测需求配钩子
  1. 记录每次工具调用的完整参数与结果,落成 JSON Lines。
  2. 统计每一轮(用户提交一次提示到这一轮结束)的耗时。
  3. 会话恢复时,提醒用户「上次遗留了一个未完成的任务」。
完成标准 · 本地勾选
02

症状:用户报告「它给我的汇总报告里引用了根本不存在的文件」。

Level 2:一份病例,走一遍五步

你按这条提示的 prompt id 过滤,拿到下面这段事件摘要(原始提示是「读一下 reports/ 目录,把本季度三份周报的结论汇总给我」):

text
prompt.id = 8f2c1a94-...   (同一条用户提示触发的事件摘要,按时间排序;                            input/response 就是 hook 载荷里的 tool_input/tool_response,此处为排版缩写;                            tool_decision 每次调用都有一条,摘要里只保留 #3 一条示意)
#1   user_prompt     prompt_length=27#2   llm_request     dur=1840ms  stop_reason=tool_use#3   tool_decision   tool=list_files   decision=allow(在允许列表内,无人工等待)#4   tool_result     tool=list_files   input={"path":"reports/"}                     response={"entries":[]}   success=true   dur=12ms#5   llm_request     dur=2210ms  stop_reason=tool_use#6   tool_result     tool=read_file    input={"path":"reports/2026-Q2-week03.md"}                     success=false   error="ENOENT: no such file or directory"#7   llm_request     dur=1990ms  stop_reason=tool_use#8   tool_result     tool=read_file    input={"path":"reports/q2-summary.md"}                     success=false   error="ENOENT: no such file or directory"#9   llm_request     dur=2400ms  stop_reason=tool_use#10  tool_result     tool=search_notes input={"query":"Q2 周报 结论 2026"}                     response={"hits":[3 条与本项目无关的旧笔记]}   success=true#11  llm_request     dur=5100ms  stop_reason=end_turn#12  最终回答        「根据 reports/2026-Q2-week03.md 与 reports/q2-summary.md                     两份周报,本季度……」

不用写代码。按五步流程回答四个问题:

  1. 第一处分岔是哪一条事件? 指到具体编号,并说清为什么它前面那几条不算、后面那几条只是下游。
  2. 重放观察怎么做? 用哪一份输入、盯着看什么。
  3. 「反复压同一个组件」在这里压的是什么? 说清压的对象和要观察的现象。
  4. 修好之后从哪里恢复? 说清恢复点和为什么不从 #1 重跑。
完成标准 · 本地勾选