---
title: "Lifecycle Hooks：在 Agent 关键节点自动执行"
wiki: cli
category: "配置管理"
slug: lifecycle-hooks
url: https://learnagent.wiki/cli/cards/lifecycle-hooks
tags: ["hooks", "Claude Code", "PreToolUse", "PostToolUse", "自动化"]
last_updated: 2026-04-22
reading_time: 14
---

> Claude Code 的 **Lifecycle Hooks（生命周期钩子）**，可以理解成在 Agent 跑的过程中预埋的若干个"开关位"。每当 Agent 走到某个关键节点——比如"准备调一个工具"、"工具刚跑完"、"用户输入还没进 LLM"、"会话即将结束"——Claude Code 这个壳就会去检查 `settings.json`，看你有没有为这个节点注册脚本。注册了，它就把当前节点的全部上下文（工具名、参数、cwd、session_id 等）按 JSON 喂到脚本的标准输入；脚本怎么处理、要不要拦截，由你写的逻辑决定。

# Lifecycle Hooks：在 Agent 关键节点自动执行

## 基础概念

Claude Code 的 **Lifecycle Hooks（生命周期钩子）**，可以理解成在 Agent 跑的过程中预埋的若干个"开关位"。每当 Agent 走到某个关键节点——比如"准备调一个工具"、"工具刚跑完"、"用户输入还没进 LLM"、"会话即将结束"——Claude Code 这个壳就会去检查 `settings.json`，看你有没有为这个节点注册脚本。注册了，它就把当前节点的全部上下文（工具名、参数、cwd、session_id 等）按 JSON 喂到脚本的标准输入；脚本怎么处理、要不要拦截，由你写的逻辑决定。

为什么要单独做"hooks"，而不是让 Claude 自己在 prompt 里"记得"在某些时刻去做某事？两个原因：

1. **确定性**。Claude 是概率模型，让它"记住每次写完 Python 文件都跑一次 ruff" —— 多跑几轮就会忘掉。Hook 是 CLI 这个**进程外**的事件钩子，不进 LLM 推理，100% 必跑。
2. **零 token 成本**。Hook 跑在 LLM 之外，本身不消耗模型 token。把"格式化、跑 lint、记审计日志、拦危险命令"这些**机械动作**全交给 hook，模型上下文就能省下来做真正需要思考的事。

> 一句话说人话：**hook = "Claude 不参与的、由 CLI 这一侧自动触发的脚本"**。它不是给 Claude 用的工具，而是 Claude Code 这个外壳给你提供的"自动化插槽"。

### 核心要素

| 要素 | 作用 |
|------|------|
| **事件（event）** | 一个生命周期触发点，比如 `PreToolUse`、`PostToolUse`、`Stop`、`UserPromptSubmit`、`SessionStart` |
| **匹配器（matcher）** | 缩小触发范围，例如 `"Bash"` 只在调 Bash 工具时触发，`"Write\|Edit"` 同时匹配两种文件写入 |
| **处理器（hook handler）** | 真正被调起的脚本（`type: "command"`）或 HTTP 端点（`type: "http"`） |
| **stdin（输入）** | Claude Code 通过标准输入传入完整事件 JSON：`tool_name`、`tool_input`、`cwd`、`session_id` 等 |
| **退出码 / stdout** | exit 0 + 可选 JSON = 正常；exit 2 = 阻断（仅对部分事件生效）；其它非零 = 报警但不阻断 |
| **作用域** | `~/.claude/settings.json`（用户全局）、`.claude/settings.json`（项目共享）、`.claude/settings.local.json`（项目私有，不入库） |

### 典型 hook 触发时序

下图画出一次"用户提问 → Claude 决定调 Bash → 工具执行 → Claude 输出 → 结束"完整 turn 中各 hook 的触发位置：

```mermaid
sequenceDiagram
    participant U as 用户
    participant CC as Claude Code (CLI)
    participant H as Hook 脚本
    participant LLM as Claude 模型
    participant T as Tool (Bash)

    U->>CC: 输入 prompt
    CC->>H: UserPromptSubmit (stdin: prompt)
    H-->>CC: exit 0 / 可注入 additionalContext
    CC->>LLM: 发送 prompt + 上下文
    LLM-->>CC: 决定调用 Bash("rm -rf node_modules")
    CC->>H: PreToolUse (stdin: tool_input)
    alt hook 返回 deny
        H-->>CC: permissionDecision: deny
        CC->>LLM: 把拦截原因回灌给模型
    else hook 放行
        H-->>CC: exit 0 / allow
        CC->>T: 真正执行命令
        T-->>CC: stdout / exit code
        CC->>H: PostToolUse (stdin: tool_response)
        H-->>CC: exit 0 / 可附加反馈
    end
    LLM-->>CC: 生成回复
    CC->>H: Stop
    H-->>CC: exit 0
    CC->>U: 展示回复
```

注意几个细节：**PreToolUse 在工具真正跑之前**，所以它是唯一能"拦下危险命令"的位置；**PostToolUse 在工具已经成功执行之后**，它能给 Claude 反馈但**已经无法撤销操作**——这是新手最常踩的坑（详见"常见误区"）。

## 基础用法

写一个 hook 一共三步：① 在 `settings.json` 里登记事件 + 匹配器；② 写脚本读 stdin 里的事件 JSON；③ 用 exit code 或 stdout JSON 控制后续行为。

### 第一步：最小可运行配置

在项目根目录建 `.claude/settings.json`：

```json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auto-format.sh"
          }
        ]
      }
    ]
  }
}
```

这段配置的含义：每当 Claude 调用 `Write` 或 `Edit` 工具改完文件后，自动跑 `.claude/hooks/auto-format.sh`。`$CLAUDE_PROJECT_DIR` 是 Claude Code 启动时注入的环境变量，指向项目根，路径鲁棒性比写死好。

### 第二步：脚本读 stdin、按需做事

新建 `.claude/hooks/auto-format.sh`，给它执行权限（`chmod +x`）：

```bash
#!/bin/bash
# PostToolUse: 给刚刚被 Write/Edit 的文件按扩展名自动格式化

# Claude Code 通过 stdin 传入完整事件 JSON，这里用 jq 取出文件路径
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path')

case "$FILE_PATH" in
  *.py)   ruff format "$FILE_PATH" ;;
  *.ts|*.tsx|*.js|*.jsx) npx prettier --write "$FILE_PATH" ;;
  *.go)   gofmt -w "$FILE_PATH" ;;
  *)      exit 0 ;;  # 其他扩展名直接跳过
esac

# 把 lint 错误（如果有）回灌给 Claude
if ! ruff check "$FILE_PATH" 2>/dev/null; then
  echo "ruff 发现风格问题，请修复" >&2
fi

exit 0
```

预期效果：你让 Claude 改一个 `app.py`，Edit 工具刚执行完，**Claude 还没开始下一句话**，hook 已经跑完 `ruff format`，文件就已经是规范风格了；如果 lint 有问题，stderr 会被自动注入 Claude 的下文，让模型在下一步回复时主动修复。

### 第三步：拦截危险命令（阻断式 hook）

`PreToolUse` 是少数几个能"真的阻止 Claude 做某事"的事件之一。下面这个脚本会拦截一切 `rm -rf` 命令：

```bash
#!/bin/bash
# .claude/hooks/block-dangerous-bash.sh
# 注册到 PreToolUse + matcher: "Bash"

INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')

# 黑名单：rm -rf、sudo、curl | sh 等
if echo "$COMMAND" | grep -qE '(rm\s+-rf|sudo|curl\s+[^|]+\|\s*sh)'; then
  # 用 hookSpecificOutput 给 PreToolUse 显式 deny
  jq -n --arg cmd "$COMMAND" '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: ("拦截高危命令：" + $cmd + "。请改用更安全的方案。")
    }
  }'
  exit 0
fi

exit 0
```

对应 `settings.json` 片段：

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-dangerous-bash.sh" }
        ]
      }
    ]
  }
}
```

效果：模型若生成了 `rm -rf dist/`，命令**根本不会跑到 shell**，原因会回灌给模型，让它换一种方式（比如逐文件删）。这比依赖"我让 Claude 别用 rm -rf"的提示词靠谱得多。

> 速查：PreToolUse 控制工具执行的标准做法是 `hookSpecificOutput.permissionDecision`，可选值 `"allow" | "deny" | "ask" | "defer"`，多个 hook 同时触发时优先级 `deny > defer > ask > allow`。

### Claude Code 当前支持的事件清单（节选）

| 事件 | 触发时机 | 能否阻断 | 典型用途 |
|------|---------|:------:|---------|
| `SessionStart` | 启动 / resume / 清屏 / 压缩后 | 否 | 注入项目背景、最近 issue、环境变量 |
| `UserPromptSubmit` | 用户按下回车后、prompt 进 LLM 前 | ✅ | 敏感词拦截、自动补充上下文 |
| `PreToolUse` | 模型生成完工具参数、工具执行前 | ✅ | 拦截危险命令、改写参数、按规则放行 |
| `PostToolUse` | 工具成功返回后 | 否（无法撤销） | 自动格式化、跑 lint、写审计日志 |
| `PostToolUseFailure` | 工具执行失败后 | 否 | 补充错误诊断上下文 |
| `Stop` | Claude 结束本轮回复时 | ✅（可让它继续说） | 强制跑测试再收尾、待办未完成时阻止退出 |
| `SubagentStop` | subagent 任务结束 | ✅ | 验收 subagent 产出 |
| `PreCompact` / `PostCompact` | 上下文压缩前 / 后 | PreCompact 可阻断 | 压缩前转储重要状态、压缩后注入摘要 |
| `Notification` | Claude Code 弹通知（如等权限） | 否 | 推到桌面、机器人、企业微信 |
| `SessionEnd` | 会话结束 | 否 | 上传日志、写 token 计费、清理临时文件 |

## 同类工具对比

CLI Agent 周边有不少"自动化"机制，初学者常分不清。下面这张表把 hooks 和最容易混的几个邻居放一起对比：

| 维度 | Lifecycle Hooks | Skills | MCP 工具 | Cursor Rules / CLAUDE.md |
|------|----------------|--------|----------|-------------------------|
| 触发方 | **CLI 这一侧自动触发**（事件驱动） | Claude 自己根据情境**显式调用** | Claude 自己**显式调用**（Tool Use） | 在每次推理时**作为提示注入** |
| 是否进入 LLM 推理 | ❌ 不进 | ✅ Skill 描述 + 内容会进上下文 | ✅ Tool 描述会进上下文 | ✅ 全文注入 |
| 是否消耗 token | ❌ | ✅ | ✅ | ✅ |
| 能否阻断模型行为 | ✅（PreToolUse / Stop 等可硬拦） | ❌ 只能引导 | ❌ 只是提供工具 | ❌ 提示性 |
| 谁决定执行时机 | CLI（确定性） | 模型（概率性） | 模型（概率性） | 模型（概率性） |
| 适合做什么 | 强制 lint、安全拦截、审计、通知 | 复杂工作流的可重用"剧本" | 接外部数据源、调远程服务 | 写编码规范、风格约定 |
| 不适合做什么 | "让模型在这里做点决策"（hook 里没 LLM） | 强约束（模型可能忽略） | 同上 | 任何需要确定性的动作 |

**核心区别一句话**：hooks 是 **CLI 外壳的事件回调**，确定性、零 token、能硬拦；Skills/MCP/Rules 是 **喂给模型的上下文或工具**，灵活但不可靠。要"必须执行"用 hooks，要"灵活执行"用 Skills/MCP，要"风格约定"用 CLAUDE.md。互相不能替代。

> 互链：MCP 和 hooks 的关系详见 [/mcp/cards/what-is-mcp](/mcp/cards/what-is-mcp)；Skills 和 hooks 怎么搭配见 [/skills/cards](/skills/cards) 下相关卡。

## 常见误区

| 误区 | 准确理解 |
|------|----------|
| 以为 hook 能阻止 Claude "想"做某事 | hook 拦的是**工具调用**，不是模型推理。模型仍然会想，hook 只是不让对应动作发生在你机器上。想限制模型思考，靠的是 prompt 和权限规则，不是 hook |
| 以为可以在 hook 里调 Claude 模型 | hook 跑在 Claude Code 进程之外、**不在 LLM 推理上下文**。脚本里没有"反过来问 Claude"的官方通道；要让模型介入只能写 `decision: "block"` 把控制权交回去、并附 reason 让模型在下一步处理 |
| 以为 PostToolUse 能撤销文件修改 | PostToolUse 触发时**工具已经执行成功**。文件已经被写、命令已经跑完。你最多能给模型"反馈"让它下一步去 revert，但 hook 本身**不会回滚**。要真拦改动只能用 PreToolUse |
| 以为 exit 2 在所有事件都能阻断 | 只有部分事件能被 exit 2 阻断（PreToolUse、UserPromptSubmit、Stop、PreCompact、SubagentStop、PermissionRequest 等）。在 PostToolUse / SessionStart / Notification 上 exit 2 只是把 stderr 当反馈喂回去，**不会撤销已经发生的事** |
| 以为多个 hook 顺序执行 | 同一事件命中的多个 hook 是**并行**执行的，相同的 command 字符串还会被去重。如果你希望 A 跑完再跑 B，要么写在同一个脚本里串起来，要么靠各自退出码触发后续逻辑 |
| 在 Stop hook 里无脑 `exit 2` 想"让 Claude 一直说" | Stop hook exit 2 会把 Claude 拉回继续生成，但**可能死循环**——上次 stop 的原因若没解决，它会一直 stop、一直被拉回。务必加终止条件（达到次数、某文件存在等） |
| 把秘钥硬编码进 hook 脚本 | hook 是 plain shell，提交进 `.claude/settings.json` 的内容会被同事看到。秘钥放环境变量，HTTP hook 用 `allowedEnvVars` 白名单注入 header，不要写在脚本里 |

## 优劣势分析

| 优势 | 劣势 |
|------|------|
| **确定性**：到点必跑，不依赖模型记性，特别适合"强制规范"场景（lint、format、审计） | **调试链路长**：同一事件可能命中多条 hook、还可能并行跑，出问题时要翻 hook 日志、stdin JSON、stderr 三处定位 |
| **零 token 成本**：跑在 LLM 之外，不占上下文窗口，长会话特别划算 | **作用域有限**：只能在 Claude Code 预定义的 12+ 个事件里挂，遇到没钩子的节点（比如"模型 thinking 中途"）插不进去 |
| **能硬拦危险动作**：PreToolUse + permissionDecision: deny 是目前防"AI 一时兴起 rm -rf"最可靠的护栏 | **PreToolUse 是双刃剑**：拦得太宽 Claude 寸步难行，拦得太松形同虚设。需要持续根据 false positive / negative 调整规则 |
| **跨语言、跨工具**：脚本是 shell/python/任意可执行文件，hook 也支持 HTTP，不绑定具体语言生态 | **配置散乱风险**：用户级、项目级、local 级三层 settings 合并，加上插件 hooks，团队协作时容易出现"我机器拦了你机器没拦" |
| **可与 Skills / MCP 互补**：把"必须发生的副作用"放 hook，把"灵活的工具调用"放 MCP/Skills，分工清晰 | **跨平台兼容性**：默认 shell 是 bash/powershell，Windows 团队和 macOS/Linux 团队混跑时，要么写两套脚本要么统一走 Node/Python |

## 思考题

<details>
<summary>初级：为什么不能在 hook 里"反过来调 Claude 模型问一个问题"？</summary>

**参考答案：**

Claude Code 把 hook 设计成**进程外、单向**的事件回调：CLI 把上下文 JSON 通过 stdin 推给你的脚本，等你拿 exit code 和 stdout 回报结论，整个交互就结束了。这个设计是刻意的，原因有三：

1. **避免循环**：如果 hook 能调 Claude，Claude 调工具又触发 hook，hook 又调 Claude……极容易陷入指数级 token 爆炸和无限递归。
2. **保持确定性**：hook 的核心价值就是"必跑、可预测"。一旦它能调 LLM，行为就重新变成概率性，用 hook 的初衷被瓦解。
3. **token 责任归属**：模型调用要计费、要进上下文。hook 是 CLI 的本地副作用，让它直接花用户的模型钱、占模型上下文，权责模糊。

要让模型介入，正确做法是 hook 写 `decision: "block"` + `reason: "..."`，把控制权交回 Claude Code，由它在下一轮推理时把 reason 注入上下文，让模型自己处理。

</details>

<details>
<summary>中级：项目里要做"AI 改了 Python 文件就强制跑 ruff，跑不过就让 Claude 自己修复"。该用 PreToolUse 还是 PostToolUse？为什么？</summary>

**参考答案：**

应该用 **PostToolUse + matcher 为 `Write|Edit`**。原因：

- 你的目标是"改完之后跑 lint"，前提是文件已经被改。PreToolUse 在文件还没写时触发，那时 ruff 检查的还是旧文件，没意义。
- 让 Claude 自己修复 lint 错误，需要把 ruff 的报错"传"回模型。PostToolUse 脚本通过 `decision: "block"` + `reason` 或者直接 `stderr` + `exit 2`（在 PostToolUse 上 exit 2 会把 stderr 注入 Claude 的下文），模型下一步就会看到"ruff 报了 E501，请修复"。
- 如果你希望"文件甚至别被写出来"——比如想拦截写入危险路径——那才用 PreToolUse 拦在写之前。

完整脚本骨架：

```bash
#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path')
[[ "$FILE" == *.py ]] || exit 0

if ! OUTPUT=$(ruff check "$FILE" 2>&1); then
  echo "ruff 报错，请修复后重试：" >&2
  echo "$OUTPUT" >&2
  exit 2   # PostToolUse 上 exit 2 = 把 stderr 注入下一轮上下文
fi
```

</details>

<details>
<summary>进阶：团队 5 个人用 Claude Code，希望"所有人都自动跑 lint，但敏感命令拦截规则各自定制"，settings 文件应该怎么分？</summary>

**参考答案：**

利用 Claude Code 的三层 settings 合并机制：

| 文件 | 入库 | 放什么 |
|------|:---:|--------|
| `.claude/settings.json` | ✅ git 跟踪 | **团队统一**：PostToolUse 跑 lint / format、SessionStart 注入项目上下文 |
| `.claude/settings.local.json` | ❌ `.gitignore` | **个人私有**：自己机器上的 rm 拦截、个人通知（推到自己的 Telegram 等） |
| `~/.claude/settings.json` | n/a | **跨项目通用**：全局生效的拦截、自己的审计日志路径 |

要点：
1. 团队规则放项目级 + 入库，确保新成员 clone 即生效。
2. 个人规则放 local 或用户级，避免污染他人。
3. CI 环境可以在容器里只挂 `.claude/settings.json`，不带 local，行为更可预测。
4. 如果有"组织强制"的规则（比如生产分支禁止 `bypassPermissions`），用 managed-settings.json 配合 `allowManagedHooksOnly: true`，普通 settings 无法覆盖。
5. hooks 间的去重靠"command 字符串完全一致"——如果团队级和个人级写了一模一样的脚本路径，只会跑一次，不会重复执行。

</details>

## 参考资料

1. Claude Code Hooks 官方参考：<https://code.claude.com/docs/en/hooks>（查询日期 2026-04-22）
2. Claude Code Settings 官方文档：<https://code.claude.com/docs/en/settings>（查询日期 2026-04-22）
3. Claude Code GitHub 仓库 - Issues 关于 hook 行为的讨论：<https://github.com/anthropics/claude-code/issues/19009>（PostToolUse exit 2 行为）
4. Claude Code GitHub Issue #24327：<https://github.com/anthropics/claude-code/issues/24327>（PreToolUse exit 2 边界场景）
5. Steve Kinney - Claude Code Hook Control Flow：<https://stevekinney.com/courses/ai-development/claude-code-hook-control-flow>
6. Claude Directory Blog - Claude Code Hooks Guide 2026：<https://www.claudedirectory.org/blog/claude-code-hooks-guide>
7. Pixelmojo - Claude Code Hooks: All 12 Events with Examples (2026)：<https://www.pixelmojo.io/blogs/claude-code-hooks-production-quality-ci-cd-patterns>

---
*Source: https://learnagent.wiki/cli/cards/lifecycle-hooks*
*Markdown mirror of https://learnagent.wiki, served as text/markdown for LLM ingestion.*