给 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 的"招式手册"——它在合适的时机能调出来用。但实际跑起来,有三类问题特别让人挠头:

  1. 该用的没用上。你 skill 的 description 写得不够刺激,Agent 看着任务发懵,最后还是用通用方法干。
  2. 用了但效果不对。Agent 触发了 skill,可它只读了 SKILL.md 的开头,后面的约束没认真执行。这种问题如果不留痕,咱们事后都不知道该怪 skill 写得不行,还是 Agent 偷懒。
  3. 多个 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 的实际执行最终落到 Bashapply_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"]
          }
        ]
      }
    ]
  }
}

文档要点:PreToolUsematcher 只匹配大写小写完全相同的 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)。PreToolUsematcher 是正则,能匹配 Bashapply_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 追踪能力总览

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