给 AI Agent 装个行车记录仪:用 Claude Code 和 Codex 的 Hook 追踪 Skill 调用
Posted on 一 01 6月 2026 in Tech
| Abstract | 给 AI Agent 装个行车记录仪:用 Claude Code 和 Codex 的 Hook 追踪 Skill 调用 |
|---|---|
| Authors | Walter Fan |
| Category | Tech |
| Status | v1.0 |
| Updated | 2026-06-01 |
| License | CC-BY-NC-ND 4.0 |
一、AI 说它用了 skill,咱凭啥信?
前几天我让 Claude Code 帮我改一篇博客,顺手挂了一个 lazy-blog-write 的 skill 上去。它煞有介事回了一句"已调用 lazy-blog-write skill",产出却还是一股翻译腔。我盯着屏幕愣了半天:到底是 skill 没真触发,还是触发了被它忽略了?还是触发了但匹配错了 genre?
这场景在咱们这行不陌生。线上系统出问题,老程序员的第一反应不是猜,是去翻日志。Agent 也是个程序,跑得再花哨,本质就是一个不断调工具的循环。它说调了什么,可咱们不能只听它自己说——得有个旁证。
好在 Claude Code 和 Codex CLI 都已经把这扇门留好了,叫 Hook。这玩意儿就像 Git 的 pre-commit、Web 后端的中间件,能在 Agent 生命周期的特定点插一段你自己的脚本。Agent 每次要调 skill,咱们就把它的输入输出抄一份下来,存成 JSONL 慢慢看。
本文给一份能直接抄的配置:两家 CLI 的 hook 各写一份,落地一个 skill_usage.jsonl,再加一个简单的查询脚本。所有配置以官方文档为准,不靠猜。
二、为啥要追踪 skill 调用
skill 这东西,本质是给 Agent 的"招式手册"——它在合适的时机能调出来用。但实际跑起来,有三类问题特别让人挠头:
- 该用的没用上。你 skill 的 description 写得不够刺激,Agent 看着任务发懵,最后还是用通用方法干。
- 用了但效果不对。Agent 触发了 skill,可它只读了 SKILL.md 的开头,后面的约束没认真执行。这种问题如果不留痕,咱们事后都不知道该怪 skill 写得不行,还是 Agent 偷懒。
- 多个 skill 抢戏。两个 skill description 写得太像,Agent 来回切换,最后哪个也没真用透。
老程序员都懂一个道理:可观测性是工程基本功,Agent 也不例外。光盯着终端输出滚屏不算数,咱要的是结构化的、可查的、能跨会话回放的记录。
三、Hook 的心智模型,三十秒讲清楚
Claude Code 和 Codex 都把 Agent 的运行抽象成一组生命周期事件,咱们感兴趣的主要是这几个:
SessionStart——会话刚起来UserPromptSubmit——你按下回车,prompt 还没进模型PreToolUse——Agent 决定调某个工具,但还没真调PostToolUse——工具调完,结果回来了Stop——这一轮收尾
Hook 就是注册到事件上的脚本。Claude Code 把整个事件的 JSON 从 stdin 喂给你,你的脚本想做啥都行——记日志、改返回值、拦截调用都可以。Codex CLI 的设计完全一致,事件名都没改,方便从一边迁到另一边。
skill 调用具体落在哪个事件上,两家略有不同:
- Claude Code:Agent 隐式调用 skill 时,会走一个名叫
Skill的工具,匹配PreToolUse/PostToolUse+matcher: "Skill"就能逮住;用户直接打/skillname这种斜杠命令是另一条路,走UserPromptExpansion。 - Codex CLI:skill 通过
$skillname显式触发,或者由模型按 description 隐式选用,但没有专门的Skill工具名。skill 的实际执行最终落到Bash、apply_patch或 MCP 工具调用上。要在 Codex 里追踪 skill,咱们抓两头:UserPromptSubmit看用户有没有$skill调用、PreToolUse/PostToolUse看后续工具链。
这点要说在前头,免得后面看配置时一头雾水。
四、Claude Code 的 Hook 配置
Claude Code 的 hook 文件放在 ~/.claude/settings.json(用户级)或者 .claude/settings.json(项目级),用 JSON 写。
下面这份配置干两件事:Agent 一调 skill,咱们就在 PreToolUse 抄下 tool_input;调完了再在 PostToolUse 抄一份 tool_response。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Skill",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/log_skill.sh",
"args": ["pre"]
}
]
}
],
"PostToolUse": [
{
"matcher": "Skill",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/log_skill.sh",
"args": ["post"]
}
]
}
],
"UserPromptExpansion": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/log_skill.sh",
"args": ["expansion"]
}
]
}
]
}
}
文档要点:
PreToolUse的matcher只匹配大写小写完全相同的Skill;带.或|会被当 JS 正则解析。UserPromptExpansion不支持 matcher,全量触发——所以咱们在脚本里看expansion_type字段过滤就好。
对应的脚本 .claude/hooks/log_skill.sh:
#!/usr/bin/env bash
# log_skill.sh — append skill events to JSONL
set -euo pipefail
PHASE="${1:-unknown}"
LOG_DIR="${HOME}/.claude/skill-usage"
mkdir -p "$LOG_DIR"
LOG_FILE="${LOG_DIR}/skill_usage_$(date +%Y%m%d).jsonl"
# stdin 是 Claude Code 喂给咱们的事件 JSON
INPUT="$(cat)"
# 用 jq 给每条事件打上 phase 和本地时间戳,方便后面查
echo "$INPUT" | jq -c \
--arg phase "$PHASE" \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'. + {phase: $phase, logged_at: $ts}' \
>> "$LOG_FILE"
# Hook 必须静默退出,stdout 在 PreToolUse 里会被当成决策返回值
exit 0
记得 chmod +x .claude/hooks/log_skill.sh。
跑一轮看看,~/.claude/skill-usage/skill_usage_20260601.jsonl 里就有了这样的记录(节选):
{"session_id":"abc-123","tool_name":"Skill","tool_input":{"skill_name":"lazy-blog-write","prompt":"..."},"phase":"pre","logged_at":"2026-06-01T13:55:21Z"}
{"session_id":"abc-123","tool_name":"Skill","tool_response":{"success":true,"duration_ms":1832},"phase":"post","logged_at":"2026-06-01T13:55:23Z"}
到这一步,咱们至少知道了:哪个会话、什么时候、调了哪个 skill、传了什么 prompt、跑了多久。这就是行车记录仪的基本功。
五、Codex CLI 的 Hook 配置
Codex 的 hook 写在 ~/.codex/hooks.json(推荐这种写法)或者 ~/.codex/config.toml 里的 inline [hooks] 表。项目级放在 <repo>/.codex/hooks.json,但是项目级 hook 需要先 trust 这个项目,Codex 才会加载。
下面这份配置同时盯两个角度:用户有没有 $skill 显式调用、Agent 实际跑了哪些工具。
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "/usr/bin/env python3 ~/.codex/hooks/log_skill.py prompt"
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash|apply_patch|mcp__.*",
"hooks": [
{
"type": "command",
"command": "/usr/bin/env python3 ~/.codex/hooks/log_skill.py pre"
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash|apply_patch|mcp__.*",
"hooks": [
{
"type": "command",
"command": "/usr/bin/env python3 ~/.codex/hooks/log_skill.py post"
}
]
}
],
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "/usr/bin/env python3 ~/.codex/hooks/log_skill.py session"
}
]
}
]
}
}
文档要点:Codex 的 hook 默认是开的(
[features].hooks = true)。PreToolUse的matcher是正则,能匹配Bash、apply_patch,以及任何mcp__<server>__<tool>形式的 MCP 工具名。UserPromptSubmit不支持 matcher,会全量触发。
第一次启动 Codex 会让你 /hooks 里 review 并 trust 这个 hook,不 trust 就不会跑——这是设计上的安全闸门,别绕过。
对应脚本 ~/.codex/hooks/log_skill.py:
#!/usr/bin/env python3
"""log_skill.py — append Codex skill-relevant events to JSONL."""
import json
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
LOG_DIR = Path.home() / ".codex" / "skill-usage"
LOG_DIR.mkdir(parents=True, exist_ok=True)
LOG_FILE = LOG_DIR / f"skill_usage_{datetime.now().strftime('%Y%m%d')}.jsonl"
SKILL_INVOCATION = re.compile(r"\$([a-zA-Z][\w-]*)")
def main() -> None:
phase = sys.argv[1] if len(sys.argv) > 1 else "unknown"
try:
event = json.load(sys.stdin)
except json.JSONDecodeError:
# 收不到合法 JSON 时静默退出,不要拖累主流程
return
record = {
"phase": phase,
"logged_at": datetime.now(timezone.utc).isoformat(),
"session_id": event.get("session_id"),
"turn_id": event.get("turn_id"),
"hook_event_name": event.get("hook_event_name"),
}
if phase == "prompt":
prompt = event.get("prompt", "")
# 把用户输入里的 $skill 显式调用挑出来
skills = SKILL_INVOCATION.findall(prompt)
record["skill_invocations"] = skills
record["prompt_preview"] = prompt[:200]
elif phase in {"pre", "post"}:
record["tool_name"] = event.get("tool_name")
record["tool_use_id"] = event.get("tool_use_id")
# 参数留前 500 字符就够取证,别把整个 patch 都写进日志
ti = event.get("tool_input")
if ti is not None:
record["tool_input_preview"] = json.dumps(ti)[:500]
elif phase == "session":
record["source"] = event.get("source")
record["cwd"] = event.get("cwd")
with LOG_FILE.open("a") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
if __name__ == "__main__":
try:
main()
finally:
# 任何异常都不要影响 Agent 主流程,silently exit 0
sys.exit(0)
chmod +x ~/.codex/hooks/log_skill.py,然后在 Codex 里 /hooks review 一下,就能用了。
一个常见疑问:为啥 Codex 这边要在
UserPromptSubmit里用正则扫$skill-name?因为按官方文档,Codex 的 skill 没有专属Skill工具名,隐式调用会直接落到 Bash/apply_patch/MCP,显式调用走的是斜杠/$ 命令的提示词扩展。两头都抓,才能拼出完整故事。
六、日志怎么看:三条最有用的查询
JSONL 落下来不查,等于没追踪。jq 一行命令就够用:
# 1. 过去一天,哪些 skill 被实际调用了,按次数排序(Claude Code 视角)
cat ~/.claude/skill-usage/skill_usage_$(date +%Y%m%d).jsonl \
| jq -r 'select(.phase=="pre") | .tool_input.skill_name' \
| sort | uniq -c | sort -rn
# 2. Codex 里,用户主动 $触发 的 skill 都有哪些
cat ~/.codex/skill-usage/skill_usage_$(date +%Y%m%d).jsonl \
| jq -r 'select(.phase=="prompt") | .skill_invocations[]?' \
| sort | uniq -c | sort -rn
# 3. 单次 skill 调用平均耗时(Claude Code,配对 pre/post)
cat ~/.claude/skill-usage/skill_usage_*.jsonl \
| jq -s '
group_by(.tool_use_id // .session_id)
| map(select(length==2))
| map({
skill: (.[0].tool_input.skill_name // "unknown"),
ms: ((.[1].logged_at | fromdate) - (.[0].logged_at | fromdate)) * 1000
})
| group_by(.skill)
| map({skill: .[0].skill, avg_ms: (map(.ms) | add / length)})
'
跑出来的数据,咱终于不用再凭感觉评价"这个 skill 好不好用"了。
七、避坑清单:5 条用 hook 别栽跟头
技术上能跑通是一回事,能让团队长期用下去是另一回事。下面这几条是我栽过、也见别人栽过的坑:
| # | 坑 | 怎么躲 |
|---|---|---|
| 1 | hook 脚本里同步发网络请求,每次工具调用都卡 1 秒 | 只写本地文件,要发远端就异步起子进程,或者用 & 丢到后台 |
| 2 | tool_input 里有 API key / Token 被原封不动写进日志 | 写入前做 redact,比如正则替换 sk-[A-Za-z0-9]{20,} 为 *** |
| 3 | 日志一直追加,几个月后占满磁盘 | 按天分文件已经是基础,再加一条 logrotate 规则或 cron 删 30 天前的 |
| 4 | hook 脚本里 set -e,一个小报错让 Agent 整个 turn 失败 |
用 set -uo pipefail 但允许失败,最后 exit 0;Codex 文档也强调 hook 失败别影响主流程 |
| 5 | Claude Code 的 PreToolUse hook 不小心往 stdout 输出了普通日志,被当成 permissionDecision 解析 |
调试 print 全部走 stderr,stdout 留给 JSON 决策;空 stdout + exit 0 = 静默通过 |
第 2 条尤其要紧。AI 时代,prompt 和工具参数里夹带敏感信息的概率比日志里高得多,咱们当观察者的,别反过来成了泄密源头。
八、把它装上车
讲到这儿,整套机制其实就一句话:Agent 跑哪儿,咱们的探针就跟到哪儿;写下来的,才算数。
如果你跟我一样,在用 Claude Code 或者 Codex 配各种自研 skill,强烈建议今天就花二十分钟把这套行车记录仪装上。一周后回头看那份 JSONL,你会发现一些你怎么也想不到的事——比如某个被你寄予厚望的 skill 一次都没被触发过,又比如某个明明只该跑一次的 skill 被反复触发了二十遍。
工程的乐趣,无他,惟数据说话尔。
Skill 追踪能力总览

@startmindmap
* Skill Usage Hook
** Claude Code
*** PreToolUse / matcher: Skill
*** PostToolUse / matcher: Skill
*** UserPromptExpansion (斜杠命令)
*** 落点: ~/.claude/settings.json
** Codex CLI
*** UserPromptSubmit (扫 $skill)
*** PreToolUse / Bash|apply_patch|mcp__.*
*** PostToolUse / 同上
*** SessionStart
*** 落点: ~/.codex/hooks.json
** 日志策略
*** JSONL 按天分文件
*** 敏感字段 redact
*** logrotate / cron 清理
** 查询
*** skill 调用次数排行
*** 平均耗时
*** $显式 vs 隐式触发比例
** 安全闸门
*** Codex 需 /hooks trust
*** hook 失败不阻断 Agent
*** stdout 仅放决策 JSON
@endmindmap
参考文档:
- Claude Code Hooks Reference — https://docs.claude.com/en/docs/claude-code/hooks
- Codex Hooks — https://developers.openai.com/codex/hooks
- Codex Skills — https://developers.openai.com/codex/skills
- Claude Code Skills — https://docs.claude.com/en/docs/claude-code/skills