首页
/ learn-claude-code s04:Hooks——如何把扩展逻辑挂在 Agent 循环上而不侵入循环

learn-claude-code s04:Hooks——如何把扩展逻辑挂在 Agent 循环上而不侵入循环

2026-09-04 20:28:45作者:农烁颖Land

本篇技术文章基于 learn-claude-code 课程第 4 章(s04)的文档与源码,完整讲解 Agent 循环的 Hook 扩展机制:如何用一张事件注册表加两个入口函数,把权限检查、日志、输入注入、退出控制等扩展逻辑从循环体中剥离出去。读完你可以掌握四个生命周期事件(UserPromptSubmit、PreToolUse、PostToolUse、Stop)的触发时机与返回值语义,并能对照 s04_hooks/code.py 复现一个"循环只负责调度、扩展全部挂在外面"的 Agent。

learn-claude-code s04 Hooks 概览:messages 经 LLM 后,在 PreToolUse、PostToolUse、Stop 等固定点触发 hook 回调

问题:扩展逻辑硬编码进循环,核心循环迅速膨胀

s03 阶段的 Agent 已经有了权限检查(见 s03_permission/README.zh.md),但它是以 check_permission() 的形式硬编码agent_loop 函数体内的。文档指出,每新增一个检查——"记录每次 bash 调用"、"操作后自动 git add"——都要修改 agent_loop 函数,循环很快变成这样:

def agent_loop(messages):
    while True:
        # ... LLM call ...
        for block in response.content:
            if block.type != "tool_use":
                continue
            log_to_file(block)          # 加一行
            check_permission(block)     # 加一行
            notify_slack(block)         # 又加一行
            output = execute(block)
            auto_git_add(block)         # 再加一行
            # ... 很快循环就认不出来了

你想扩展的是 Agent 的行为,但你改的却是循环本身。s04 的设计原则因此被概括为一句话(引自 s04_hooks/README.zh.md):

"挂在循环上,不写进循环里" —— hook 在工具执行前后注入扩展逻辑。

解决方案:四个生命周期事件覆盖一个完整的 agent cycle

s03 的循环和权限逻辑完全保留,唯一的变动是把 check_permission() 从循环体内移到 hook 上。循环不再直接调用任何检查函数,改为 trigger_hooks("PreToolUse", block),由注册表决定跑什么。四个事件覆盖一个完整 agent cycle:

事件 触发时机 典型用途
UserPromptSubmit 用户输入提交后、进入 LLM 前 输入验证、注入上下文
PreToolUse 工具执行前 权限检查、日志记录
PostToolUse 工具执行后 副作用(自动 git add 等)、输出检查
Stop 循环即将退出时 收尾清理、决定是否继续循环

扩展通过 register_hook() 添加,循环只调用 trigger_hooks()

核心实现:Hook 注册表

注册表与两个入口函数

实现位于 s04_hooks/code.py,就是一个字典加两个函数:

HOOKS = {"UserPromptSubmit": [], "PreToolUse": [], "PostToolUse": [], "Stop": []}

def register_hook(event: str, callback):
    HOOKS[event].append(callback)

def trigger_hooks(event: str, *args):
    for callback in HOOKS[event]:
        result = callback(*args)
        if result is not None:  # A hook result blocks this tool call.
            return result
    return None

trigger_hooks 按注册顺序依次执行回调,只要任一回调返回非 None 值就立即短路返回;全部返回 None 则整体返回 None。从源码结构看,这个"首个非 None 返回值生效"的约定是整个机制的控制流基础。

返回值语义:None 表示放行,非 None 表示干预

不同事件的返回值含义并不相同,这是使用 s04 hook 系统最容易混淆的一点:

  • PreToolUse:返回非 None 时,本次工具执行被阻止,返回值字符串会作为 tool_result 的 content 回喂给模型;
  • Stop:返回非 None 时,循环不退出,返回值被注入为一条 user 消息后继续循环;
  • UserPromptSubmit 与 PostToolUse:返回值不参与控制流,这两个事件纯粹用于观察与副作用。

五个 hook 回调逐一拆解

s04_hooks/code.py 末尾一次性注册了全部回调:

register_hook("UserPromptSubmit", context_inject_hook)
register_hook("PreToolUse", permission_hook)
register_hook("PreToolUse", log_hook)
register_hook("PostToolUse", large_output_hook)
register_hook("Stop", summary_hook)

UserPromptSubmit:context_inject_hook——进入 LLM 前拦截用户输入

def context_inject_hook(query: str):
    """Inject current working directory info into every prompt."""
    print(f"\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\033[0m")
    return None   # return None = no modification, let prompt through

它在主循环中、用户输入之后立即触发(s04_hooks/code.py):

query = input("s04 >> ")
trigger_hooks("UserPromptSubmit", query)   # ← 进入 LLM 之前
history.append({"role": "user", "content": query})
agent_loop(history)

典型用途是输入验证和上下文注入,例如在此处把工作目录、时间等元信息附加到每条 prompt 上。

PreToolUse:permission_hook——s03 权限逻辑的迁移

这是 s04 相对 s03 最实质的变化。s03 的权限管道在 s03_permission/code.py 中是三道闸门串联的 check_permission()(硬拒绝 → 规则匹配 → 用户确认),在 s03_permission/code.py 处被直接写进循环。s04 把同样的逻辑包装成 permission_hooks04_hooks/code.py):

DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]

def permission_hook(block):
    """PreToolUse: s03 check_permission() logic moved here."""
    if block.name == "bash":
        for pattern in DENY_LIST:
            if pattern in block.input.get("command", ""):
                return "Permission denied by deny list"
        for kw in DESTRUCTIVE:
            if kw in block.input.get("command", ""):
                choice = input("   Allow? [y/N] ").strip().lower()
                if choice not in ("y", "yes"):
                    return "Permission denied by user"
    if block.name in ("read_file", "write_file", "edit_file"):
        path = block.input.get("path", "")
        if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):
            choice = input("   Allow? [y/N] ").strip().lower()
            if choice not in ("y", "yes"):
                return "Permission denied by user"
    return None

注意它的返回约定:命中 DENY_LIST 直接返回拒绝字符串(硬拒绝);命中 DESTRUCTIVE 或路径逃逸出 WORKDIR 时询问用户,拒绝则返回字符串(软询问);都没命中则 return None 放行。被阻止时,返回的字符串会进入 tool_result,模型能看到拒绝原因并自行调整策略。

PreToolUse:log_hook 与 PostToolUse:large_output_hook——纯观察型 hook

def log_hook(block):
    """PreToolUse: log every tool call."""
    args_preview = str(list(block.input.values())[:2])[:60]
    print(f"\033[90m[HOOK] {block.name}({args_preview})\033[0m")
    return None

def large_output_hook(block, output):
    """PostToolUse: warn on large output."""
    if len(str(output)) > 100000:
        print(f"\033[33m[HOOK] Large output from {block.name}: {len(str(output))} chars\033[0m")
    return None

这两个 hook 永远返回 None,不参与控制流。文档中的 PostToolUse 示例还展示了"自动 git add"这类副作用场景的挂载点。注意 log_hook 对参数做了 60 字符截断预览(args_preview),避免把整条命令打印到终端——日志 hook 本身也应当克制。

Stop:summary_hook——退出前触发,可强制继续

Stop 在 stop_reason != "tool_use" 时、循环即将退出前触发(s04_hooks/code.py):

if response.stop_reason != "tool_use":
    force = trigger_hooks("Stop", messages)
    if force:
        # hook returned a message → inject it and continue
        messages.append({"role": "user", "content": force})
        continue
    return
def summary_hook(messages: list):
    """Print a summary when the loop is about to stop."""
    tool_count = sum(1 for m in messages
                     for b in (m.get("content") if isinstance(m.get("content"), list) else [])
                     if isinstance(b, dict) and b.get("type") == "tool_result")
    print(f"\033[90m[HOOK] Stop: session used {tool_count} tool calls\033[0m")
    return None   # return None = allow stop, return string = force continuation

summary_hook 通过遍历消息中的 tool_result 块统计本次会话的工具调用次数。这里体现了 Stop 事件的双面性:返回 None 允许正常退出;返回字符串则把该字符串作为新的 user 消息注入并强制循环继续——从源码结构看,这为"任务没做完不让 Agent 停"这类质量门禁提供了挂载点。

循环体:只改了一处

s04 的 agent_loop 与 s03 结构完全一致,差异点只有一处——执行前由硬编码检查改为触发 PreToolUse(s04_hooks/code.py):

for block in response.content:
    if block.type != "tool_use":
        continue

    # s03: if not check_permission(block): ...
    # s04: hook 替代硬编码
    blocked = trigger_hooks("PreToolUse", block)
    if blocked:
        results.append({"type": "tool_result", "tool_use_id": block.id,
                        "content": str(blocked)})
        continue

    handler = TOOL_HANDLERS.get(block.name)
    output = handler(**block.input) if handler else f"Unknown: {block.name}"

    trigger_hooks("PostToolUse", block, output)

    results.append({"type": "tool_result", "tool_use_id": block.id,
                    "content": output})

四个 hook 覆盖了 agent cycle 的关键节点:输入 → 执行前 → 执行后 → 退出。循环只负责调用 trigger_hooks(),具体逻辑全在 hook 回调里。

相对 s03 的变更

组件 之前 (s03) 之后 (s04)
扩展方式 check_permission() 硬编码在循环里 HOOKS 注册表 + trigger_hooks()
新函数 register_hooktrigger_hooks
hook 回调 context_inject_hookpermission_hooklog_hooklarge_output_hooksummary_hook
循环 直接调用 check_permission() 调用 trigger_hooks("PreToolUse", ...)
退出控制 trigger_hooks("Stop", ...) 可阻止退出
输入拦截 trigger_hooks("UserPromptSubmit", ...) 可注入上下文

运行与验证

前置条件:安装 requirements.txt 中声明的依赖(anthropic>=0.25.0python-dotenv),并设置环境变量 MODEL_ID(必需)、ANTHROPIC_BASE_URL(可选,源码通过 s04_hooks/code.pyos.environ["MODEL_ID"] 读取模型名,未设置会直接抛 KeyError)。

cd learn-claude-code
python s04_hooks/code.py

文档建议的三个测试 prompt(引自 s04_hooks/README.zh.md):

  1. Read the file README.md —— 应该直接通过,观察 [HOOK] 日志;
  2. Create a file called test.txt —— 通过后观察 PostToolUse 是否触发;
  3. Delete all temporary files in /tmp —— bash + rm 触发权限 hook。

观察重点:每次工具执行前是否出现 [HOOK] 日志;权限被拒时,是 hook 拦截的而不是循环里硬编码的。

后续演进:hook 注册表成为课程的复用底座

s04 引入的这套注册表并非一次性玩具。在仓库中检索 trigger_hooks / register_hook 可以发现,从 s05_todo_write/code.pys06_subagent/code.py 一直到 s16_workflow_runtime/code.py,后续各章节的 Agent 实现都沿用了同一套事件模型。在最终的整合实现 s15_integrated_harness/code.py 中,HOOKS 注册表原样保留,源码注释点明了设计意图:"Hooks are intentionally outside tool handlers. The loop can add permission, logging, and stop behavior without changing each individual tool."(hook 刻意放在工具处理器之外,循环可以添加权限、日志与停止行为而无需改动任何单个工具)。从源码结构看,s15 中 permission_hook 还针对非交互场景做了增强,例如检测到异步线程中无法弹交互确认时直接拒绝——这正体现了把权限逻辑挂在 hook 上的好处:策略可以独立演进,而不用回头改循环

小结

s04 用约 10 行代码(一张字典 + 两个函数)完成了 Agent harness 的扩展点抽象:

  • 注册表HOOKS)把"事件名 → 回调列表"显式化,扩展以 register_hook() 追加,循环零改动;
  • 短路返回约定(非 None 即干预)让单个 hook 具备阻止工具执行或阻止循环退出的能力;
  • 事件语义分离:PreToolUse 与 Stop 参与控制流,UserPromptSubmit 与 PostToolUse 只做观察与副作用;
  • 迁移而非重写:s03 的权限逻辑原封不动包装为 permission_hook,验证了"扩展逻辑可以整体外移"这一设计。

理解了这套机制,就掌握了 s04_hooks/README.md 中给出的下一课线索——给 Agent 一个 TodoWrite 计划工具(s05),以及后续各章中所有 hook 回调的实际形态。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
981
502
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384