learn-claude-code s04:Hooks——如何把扩展逻辑挂在 Agent 循环上而不侵入循环
本篇技术文章基于 learn-claude-code 课程第 4 章(s04)的文档与源码,完整讲解 Agent 循环的 Hook 扩展机制:如何用一张事件注册表加两个入口函数,把权限检查、日志、输入注入、退出控制等扩展逻辑从循环体中剥离出去。读完你可以掌握四个生命周期事件(UserPromptSubmit、PreToolUse、PostToolUse、Stop)的触发时机与返回值语义,并能对照 s04_hooks/code.py 复现一个"循环只负责调度、扩展全部挂在外面"的 Agent。
问题:扩展逻辑硬编码进循环,核心循环迅速膨胀
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_hook(s04_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_hook、trigger_hooks |
| hook 回调 | — | context_inject_hook、permission_hook、log_hook、large_output_hook、summary_hook |
| 循环 | 直接调用 check_permission() |
调用 trigger_hooks("PreToolUse", ...) |
| 退出控制 | 无 | trigger_hooks("Stop", ...) 可阻止退出 |
| 输入拦截 | 无 | trigger_hooks("UserPromptSubmit", ...) 可注入上下文 |
运行与验证
前置条件:安装 requirements.txt 中声明的依赖(anthropic>=0.25.0、python-dotenv),并设置环境变量 MODEL_ID(必需)、ANTHROPIC_BASE_URL(可选,源码通过 s04_hooks/code.py 用 os.environ["MODEL_ID"] 读取模型名,未设置会直接抛 KeyError)。
cd learn-claude-code
python s04_hooks/code.py
文档建议的三个测试 prompt(引自 s04_hooks/README.zh.md):
Read the file README.md—— 应该直接通过,观察[HOOK]日志;Create a file called test.txt—— 通过后观察 PostToolUse 是否触发;Delete all temporary files in /tmp—— bash + rm 触发权限 hook。
观察重点:每次工具执行前是否出现 [HOOK] 日志;权限被拒时,是 hook 拦截的而不是循环里硬编码的。
后续演进:hook 注册表成为课程的复用底座
s04 引入的这套注册表并非一次性玩具。在仓库中检索 trigger_hooks / register_hook 可以发现,从 s05_todo_write/code.py、s06_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 回调的实际形态。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00