写在前面

learn-claude-code 项目目前有两条教程线,一条是之前的 s12 章节的,另一条是最近更新的 s20 章节的,大家如果刚学习这个项目的话推荐直接看最新的 s20 章节的教程即可,由于博主之前学习过 s12 章节的内容,因此打算把 s20 章节中新增章节的内容给补充学习,内容重复的章节博主这边就跳过了。

下面是旧版到新版的对应关系:

Legacy 12-lesson track Current 20-lesson track Topic
old s01 new s01 Agent Loop
old s02 new s02 Tool Use
old s03 new s05 TodoWrite
old s04 new s06 Subagent
old s05 new s07 Skill Loading
old s06 new s08 Context Compact
old s07 new s12 Task System
old s08 new s13 Background Tasks
old s09 new s15 Agent Teams
old s10 new s16 Team Protocols
old s11 new s17 Autonomous Agents
old s12 new s18 Worktree Isolation
new only s03, s04, s09, s10, s11, s14, s19, s20 Permission, Hooks, Memory, System Prompt, Error Recovery, Cron, MCP, Comprehensive Agent

从上表中我们可以看出我们需要补充的内容包括 s03(已补充)、s04、s09、s10、s11、s14、s19 以及 s20 八个章节的内容。

新版学习路径如下:

主线:能动手 → 能做复杂任务 → 能记住和恢复 → 能长期运行 → 能协作 → 能扩展并合体

在这里插入图片描述

前言

在上篇文章 Learn-Claude-Code | 笔记 | Tools & Execution | s03_new Permission 中,我们介绍了开源项目 learn-claude-code 新版第三个章节 s03_new: Permission 的内容,这篇文章我们继续跟着教程文档来学习工具与执行相关内容,记录下个人学习笔记,和大家一起分享交流😄

Note:本篇文章主要学习记录 新版教程 第一部分 Tools & Execution 中 s04: Hooks 章节的内容。

githubhttps://github.com/shareAI-lab/learn-claude-code

referencehttps://chatgpt.com/

1. s04: Hooks

到了 s04,项目开始从 “能不能安全执行工具” 继续往前走一步,转向一个更工程化的问题:当 Agent Loop 已经稳定之后,后续新增的扩展逻辑到底应该写在哪里?

在 s03 Permission 中,我们已经给 Agent 增加了权限检查能力。模型不能再直接执行工具,而是必须先经过 harness 层的 permission pipeline。这样一来,模型只负责提出工具调用请求,真正是否执行由运行时决定。

但是新的问题也随之出现:如果后续我们还想继续增加更多能力,比如记录每一次工具调用、在工具执行后检查输出大小、在用户输入进入模型前注入上下文、在 Agent 结束前打印统计信息,那么这些逻辑是不是都要继续塞进 agent_loop() 里面?

如果是这样,Agent Loop 很快就会从一个清晰的 “模型调用 → 工具执行 → 结果回填” 的主循环,变成一个堆满各种业务逻辑的 “大杂烩”。这就是 s04 要解决的问题:扩展能力应该挂在循环上,而不是写进循环里

2. 问题

在 s03 中,权限检查是直接写在 agent_loop() 里的。也就是说,当模型返回 tool_use 之后,循环会遍历工具调用 block,然后显式调用 check_permission(block)。如果权限检查通过,才会继续找到对应的工具 handler 并执行。

这种方式在 s03 中没有问题,因为当时只有一个权限检查逻辑。但是一旦扩展点变多,问题就会变得非常明显。

例如,我们可能希望在工具执行前做权限检查,也可能希望记录工具调用日志,还可能希望在工具执行后检查输出是否过大,甚至希望在用户输入进入模型前自动补充一些上下文。如果这些逻辑都直接写在 agent_loop() 里,代码就会变成这样:

def agent_loop(messages):
    while True:
        # ... LLM call ...
        for block in response.content:
            if block.type == "tool_use":
                log_to_file(block)
                check_permission(block)
                notify_slack(block)
                output = execute(block)
                auto_git_add(block)
                check_large_output(output)

表面上看,这些代码只是多加了几行逻辑;但从架构上看,它已经破坏了 Agent Loop 的核心边界。因为 agent_loop() 本来应该只负责一件事情:维护模型与工具之间的交互循环。至于 “执行前要不要检查权限” “执行后要不要记录日志” “退出前要不要做 summary”,这些都属于可插拔的扩展逻辑,不应该和主循环强绑定。

换句话说,s03 解决的是 “工具调用是否安全” 的问题,而 s04 解决的是 “扩展逻辑如何不污染主循环” 的问题。

3. 解决方案

s04 的解决方案就是引入 Hook 机制

所谓 Hook,本质上就是在 Agent Loop 的关键节点预留一些 “挂载点”。主循环本身不关心具体要做什么扩展逻辑,它只负责在合适的时机调用 trigger_hooks(event, ...)。真正的扩展逻辑则通过 register_hook(event, callback) 注册到对应事件上。

下图是教程文档提供的 hooks overview 总体流程图:

这张图表达的核心思想非常清楚:Agent Loop 仍然保持原来的基本形态,但在关键节点上多了一层 Hook Register

用户输入提交之后,进入模型之前,会触发 UserPromptSubmit hook;模型返回工具调用之后,工具执行之前,会触发 PreToolUse hook;工具执行完成之后,结果回填给模型之前,会触发 PostToolUse hook;当模型不再请求工具、循环准备结束时,会触发 Stop hook。

也就是说,s04 并没有推翻 s03 的权限系统,而是把 s03 中硬编码在循环里的 check_permission() 移到了 PreToolUse hook 里面。这样做之后,权限检查不再是 Agent Loop 的固定逻辑,而是一个注册到工具执行前的扩展回调。

整个事件链路可以理解为:

UserPromptSubmit
    ↓
   LLM
    ↓
PreToolUse
    ↓
Tool Handler
    ↓
PostToolUse
    ↓
Tool Result
    ↓
   LLM
    ↓
   Stop

这样一来,Agent Loop 就重新变得干净了。它只知道 “在某个事件点触发 hook”,但不知道具体会有哪些 hook,也不关心这些 hook 做什么。权限检查、日志记录、输出检查、退出总结都变成了外部注册的 callback。

这也是 s04 标题里说的那句话:挂在循环上,不写进循环里

4. Hook Workbench 流程图分析

这一节的 Web 示例图叫做 Hook Workbench,它不是在讲某一个具体工具,而是在讲 Hook 系统如何插入到 Agent 的完整执行周期中。

在这里插入图片描述

第一张图展示的是 Hook 系统的整体结构。左侧是 Hook Registry,里面按照事件类型分成了四个槽位:UserPromptSubmitPreToolUsePostToolUseStop。右侧是本轮执行的状态区域,用来展示当前用户输入、模型选择的工具、hook 执行结果以及审计日志。

这张图最重要的地方在于:Hook Registry 是独立存在的,它不是写死在 Agent Loop 里的逻辑分支。也就是说,循环并不需要知道 “有哪些 hook”,它只需要在特定事件点调用 trigger_hooks(),至于具体执行哪个 callback,则由 registry 决定。

这和 s03 的 Permission 有明显区别。s03 是把权限检查直接插入到循环里,而 s04 是把权限检查也变成一个 hook。这样,后续我们想加新的扩展行为时,不需要继续修改主循环,只需要新增一个 callback 并注册到对应事件上。

在这里插入图片描述

第二张图展示的是 UserPromptSubmit 阶段。用户输入 Read README.md and summarize it. 之后,请求并不会立刻进入 LLM,而是先经过 UserPromptSubmit hook。

在教学代码中,这个 hook 主要做了一件简单的事情:打印当前工作目录。它没有修改用户输入,也没有阻止请求继续执行。但从架构角度看,这个位置非常重要,因为它是 “用户输入进入模型前” 的唯一拦截点。

也就是说,如果将来要做输入校验、上下文注入、自动附加项目路径、自动加载用户偏好,甚至做 prompt 改写,都可以挂在这个事件上,而不是写进主循环里。

在这里插入图片描述

第三张图强调了一个关键点:引入 Hook 之后,Agent Loop 的核心行为并没有改变。模型仍然根据上下文决定是否调用工具,例如这里模型选择了:

read_file({ path: "README.md" })

这说明 Hook 不是用来替代模型决策的。模型仍然负责理解任务、选择工具、生成工具参数;Hook 只是围绕这些动作提供额外的运行时扩展能力。

所以 s04 的架构边界非常清楚:模型负责 “想做什么”,工具 handler 负责 “怎么执行”,Hook 负责 “执行前后还要附加什么规则”。

在这里插入图片描述

第四张图展示的是 PreToolUse 阶段。当模型已经生成 tool_use block 之后,工具还没有真正执行,系统会先触发 PreToolUse hooks。

在这个示例中,PreToolUse 下面挂了两个 hook:一个是 permission_hook,另一个是 log_hook。前者负责复用 s03 的权限检查逻辑,判断本次工具调用是否允许执行;后者负责记录工具调用日志。

这一步是 s04 最核心的变化。因为在 s03 中,权限检查是 agent_loop() 里的固定代码;而在 s04 中,权限检查被移动到了 hook registry 中。于是主循环只需要写:

blocked = trigger_hooks("PreToolUse", block)

如果 hook 返回非 None,说明本次工具调用被拦截;如果返回 None,说明可以继续执行工具。

在这里插入图片描述

第五张图展示的是 PostToolUse 阶段。工具 handler 已经执行完毕,系统拿到了工具输出,但结果还没有进入下一轮模型上下文。在这个位置触发 hook,就可以对工具执行结果做额外处理。

教学代码中注册了一个 large_output_hook,用来检查工具输出是否过大。如果输出超过一定长度,就打印一个 warning。这个例子虽然简单,但它表达的设计非常关键:工具执行后的扩展逻辑也不应该写进主循环,而应该通过 PostToolUse 挂载。

例如真实系统里常见的 “自动 git add” “保存工具输出摘要” “对超大输出做截断” “把工具调用写入审计日志”,都可以放在这个阶段。

在这里插入图片描述

第六张图展示的是 Stop 阶段。当模型不再返回 tool_use,也就是 response.stop_reason != "tool_use" 时,Agent Loop 准备退出。此时系统会触发 Stop hook。

在教学代码中,Stop hook 主要用于统计本轮会话使用了多少次工具调用,并打印 summary。虽然这里没有强制续跑,但代码结构里已经预留了能力:如果 Stop hook 返回了某个非空内容,循环可以把它重新塞回 messages,从而让 Agent 再跑一轮。

这说明 Stop 并不只是 “结束时打印日志”,它更像是 Agent 退出前的最后一道可扩展检查点。真实系统中可以在这里做 session summary、上下文压缩、完成度检查、甚至阻止 Agent 过早退出。

完整动画演示如下图所示:

在这里插入图片描述

5. 工作原理(代码分析)

s04 的代码实现并不复杂,但它的架构意义非常大。它把 s03 中写死在循环里的权限检查逻辑抽出来,变成一个通用的 Hook Registry。这样后续任何扩展逻辑都可以通过注册 hook 的方式接入,而不用继续修改 agent_loop()

我们先看整体结构。s04 的工具实现基本沿用了 s02 和 s03,包括 bashread_filewrite_fileedit_fileglob 这些工具。工具本身并不是这一节的重点,这一节真正新增的是下面这套 hook 系统:

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:
            return result
    return None

这里的 HOOKS 就是整个扩展系统的注册表。它用事件名作为 key,每个事件下面挂一个 callback 列表。register_hook() 负责把某个函数注册到指定事件上;trigger_hooks() 则负责在运行时触发这个事件下的所有回调。

注意 trigger_hooks() 里面有一个关键判断:

if result is not None:
    return result

这意味着 hook 不只是 “旁路观察者”,它还可以影响主流程。只要某个 hook 返回了非 None,后续 hook 就不会继续执行,并且这个返回值会被交给主循环处理。在教学版中,这个机制主要用于 PreToolUse 阶段:如果权限 hook 返回了拒绝原因,那么工具调用就不会真正执行。

1. UserPromptSubmit:用户输入进入模型前触发

s04 注册的第一个 hook 是 context_inject_hook

def context_inject_hook(query: str):
    print(f"\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\033[0m")
    return None

这个 hook 的逻辑很简单,只是打印当前工作目录。但它所在的位置非常关键,因为它是在用户输入进入 LLM 之前触发的。

在主程序入口中,用户输入后会先执行:

trigger_hooks("UserPromptSubmit", query)
history.append({"role": "user", "content": query})
agent_loop(history)

也就是说,请求进入 agent_loop() 之前,系统已经有机会对用户输入做额外处理。教学版这里只是打印日志,但真实系统中可以在这里做输入校验、注入当前目录、加载项目规则、附加用户偏好等。

从架构上看,UserPromptSubmit 让 “用户输入处理” 从主循环中解耦出来。主循环不需要知道这些输入前处理逻辑,只要在入口处触发对应事件即可。

2. PreToolUse:把 s03 的权限检查移到 hook 中

s04 最关键的改动,是把 s03 的权限检查逻辑包装成了 permission_hook

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", ""):
                print(f"\n\033[31m⛔ Blocked: '{pattern}'\033[0m")
                return "Permission denied by deny list"

        for kw in DESTRUCTIVE:
            if kw in block.input.get("command", ""):
                print(f"\n\033[33m⚠  Potentially destructive command\033[0m")
                print(f"   Tool: {block.name}({block.input})")
                choice = input("   Allow? [y/N] ").strip().lower()
                if choice not in ("y", "yes"):
                    return "Permission denied by user"

    if block.name in ("write_file", "edit_file"):
        path = block.input.get("path", "")
        if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):
            print(f"\n\033[33m⚠  Writing outside workspace\033[0m")
            print(f"   Tool: {block.name}({block.input})")
            choice = input("   Allow? [y/N] ").strip().lower()
            if choice not in ("y", "yes"):
                return "Permission denied by user"

    return None

这段逻辑和 s03 的权限检查本质上是一致的:危险命令会被 deny list 直接拦截,潜在破坏性命令需要人工确认,写文件或编辑文件时也要检查路径是否逃逸工作区。

但区别在于,s03 是在 agent_loop() 里直接调用 check_permission(block),而 s04 把这段逻辑注册到了 PreToolUse 事件上:

register_hook("PreToolUse", permission_hook)

这就完成了一个非常重要的架构迁移:权限检查不再是主循环的一部分,而是工具执行前的一个 hook。

同一个事件上还可以继续注册其他 hook,例如 log_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

register_hook("PreToolUse", log_hook)

这样,每次工具执行前,系统会先跑权限检查,再跑日志记录。权限检查如果返回非 None,说明工具调用被阻止,日志 hook 就不会继续执行;如果权限检查返回 None,说明可以继续往下走,日志 hook 会记录这次工具调用。

这就是 hook 链的基本行为:一个事件可以挂多个 callback,前面的 callback 可以通过返回值阻止后续流程

3. PostToolUse:工具执行后再做输出检查

工具执行之后,s04 又增加了 PostToolUse hook:

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

register_hook("PostToolUse", large_output_hook)

这个 hook 的作用是检查工具输出是否过大。如果输出超过 100000 个字符,就打印 warning。

虽然这只是一个教学例子,但它展示了 PostToolUse 的典型用途:工具已经执行完成,系统已经拿到了输出结果,但结果还没有进入下一轮模型上下文。这个位置非常适合做输出清洗、日志记录、结果截断、自动保存、自动 git add 等操作。

对应到 agent_loop() 中,工具执行完成后会调用:

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})

这里可以看到,PostToolUse 被放在 handler 执行之后、tool_result 回填之前。也就是说,它不会影响模型选择工具,也不会影响工具是否执行,而是专门处理 “工具执行完成后的附加逻辑”。

4. Stop:循环退出前的收尾 hook

当模型不再请求工具时,agent_loop() 会进入退出分支:

if response.stop_reason != "tool_use":
    force = trigger_hooks("Stop", messages)
    if force:
        messages.append({"role": "user", "content": force})
        continue
    return

这里的 Stop hook 是在循环真正退出前触发的。教学代码中注册的是 summary_hook

def summary_hook(messages: list):
    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

register_hook("Stop", summary_hook)

这个 hook 会统计当前会话中一共使用了多少次工具,并在 Agent 准备结束时打印出来。

更重要的是,agent_loop()Stop hook 的返回值做了特殊处理:如果 force 不为空,就会把这个内容作为新的 user message 塞回上下文,然后继续下一轮循环。也就是说,Stop hook 理论上可以阻止 Agent 结束,让它继续执行。

这说明 s04 的 Stop hook 不只是一个 “退出日志”,而是一个真正的 “退出前控制点”。真实系统中可以用它做任务完成度检查、自动总结、强制修正、上下文压缩等操作。

5. agent_loop:主循环变得更干净

最后我们看 s04 中最关键的主循环变化:

def agent_loop(messages: list):
    while True:
        response = client.messages.create(
            model=MODEL, system=SYSTEM, messages=messages,
            tools=TOOLS, max_tokens=8000,
        )
        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason != "tool_use":
            force = trigger_hooks("Stop", messages)
            if force:
                messages.append({"role": "user", "content": force})
                continue
            return

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

            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})

        messages.append({"role": "user", "content": results})

这段代码相比 s03 的核心变化其实只有几处:

第一,退出前多了:

trigger_hooks("Stop", messages)

第二,工具执行前不再直接调用 check_permission(),而是调用:

blocked = trigger_hooks("PreToolUse", block)

第三,工具执行后增加:

trigger_hooks("PostToolUse", block, output)

除此之外,Agent Loop 的基本结构没有变化。它仍然是模型调用、判断是否工具调用、执行工具、回填结果、进入下一轮。

这正是 Hook 机制的价值:主循环没有变复杂,但扩展能力变强了

博主在给定下面的提示词情况下:

Read the file README.md

想通过调试看看整个过程发生了什么,我们来具体分析下:

UserPromptSubmit Hook

在这里插入图片描述

UserPromptSubmit:context inject hook 调用

从调试结果可以看到,用户输入 Read the file README.md 之后,并不是马上进入模型调用,而是先触发了 UserPromptSubmit 阶段的 Hook。这里对应的就是 context_inject_hook(query),它在模型真正看到用户问题之前执行。

这个 Hook 的逻辑非常简单:打印当前工作目录信息,也就是:

print(f"\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\033[0m")

这说明 UserPromptSubmit Hook 的定位不是处理工具调用,而是处理 “用户输入进入模型之前” 的上下文准备工作。它可以用来记录工作目录、注入额外上下文、做输入校验,或者在更复杂的系统里自动补充项目背景信息。

也就是说,从 s04 开始,主循环不再需要把 “输入前要做什么” 硬编码在 agent_loop() 里,而是通过事件点把这类逻辑挂出去。模型调用仍然是模型调用,但模型调用之前的准备动作已经被抽象成了一个可插拔的 Hook 阶段。

Loop 1 Response

在这里插入图片描述

模型第一次响应结果

接着模型进入第一次响应。从调试截图可以看到,模型返回的内容仍然保持了前面章节熟悉的结构:前面是一个 ThinkingBlock,表示模型在内部判断 “用户想读取 README.md 文件”;后面是一个 ToolUseBlock,表示模型决定调用 read_file 工具,并传入参数:

{
    "path": "/home/zhouwenguang/project/learn-claude-code/README.md"
}

这一点非常关键:Hook 并没有改变模型选择工具的方式。模型依然按照 Tool Use 的机制输出工具调用,agent_loop() 也依然遍历 response.content,找到其中的 tool_use block,然后准备执行对应 handler。

换句话说,s04 并不是重新发明了一套 agent loop,而是在原来的 loop 关键节点上增加了事件触发点。模型负责 “想做什么”,工具系统负责 “怎么做”,Hook 系统负责 “在做之前、做之后、停止之前额外插入什么逻辑”。

PreToolUse Hook: Permission and Log

在这里插入图片描述

PreToolUse:permission hook 调用

在这里插入图片描述

PreToolUse:log hook 调用

当主循环发现模型请求调用 read_file 工具之后,并没有马上执行 handler,而是先触发了 PreToolUse 阶段的 Hook。这里实际上注册了两个 Hook:一个是 permission_hook,另一个是 log_hook

permission_hook(block) 的作用,和 s03 Permission 章节里的权限检查功能完全相同。它会在工具真正执行前检查这次调用是否安全。比如如果工具是 bash,它会检查命令里是否包含 rm -rf /sudoshutdownreboot 等危险模式;如果是 write_fileedit_file,它会检查路径是否越过当前 workspace。

这次模型调用的是 read_file,读取的是当前项目目录下的 README.md,所以没有触发危险命令,也没有触发越权写入,自然可以通过权限检查。图中可以看到 permission_hook 最终返回 None,这代表它没有拦截本次工具调用。

紧接着执行的是 log_hook(block)。这个 Hook 不改变执行结果,也不阻止工具运行,而是负责记录工具调用信息。它会打印工具名和部分参数预览:

print(f"\033[90m[HOOK] {block.name}({args_preview})\033[0m")

因此终端中会看到类似:

[HOOK] read_file(['/home/zhouwenguang/project/learn-claude-code/README.md'])

这说明 PreToolUse 不一定只做权限控制,它可以同时承载多种 “工具执行前” 的横切逻辑。比如权限检查、审计日志、参数规范化、危险操作提示,都可以挂在这个阶段。更重要的是,这些逻辑都没有塞进 agent_loop() 主体里,而是通过 Hook 注册表统一调度。

PostToolUse Hook

在这里插入图片描述

PostToolUse:large output hook 调用

工具 handler 执行完成之后,主循环继续触发 PostToolUse 阶段的 Hook。这里对应的是 large_output_hook(block, output)

从代码上看,它的职责是检查工具输出是否过大:

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

也就是说,它不关心工具调用之前能不能执行,而是关心工具执行之后产生了什么结果。如果输出过长,就打印一个警告,提醒当前工具结果可能会污染上下文、增加 token 消耗,甚至影响后续模型判断。

这正好体现了 Hook 的分层思想:PreToolUse 负责 “执行前是否允许、如何记录”,PostToolUse 负责 “执行后如何检查、如何补充副作用”。这次读取 README.md 的输出虽然比较长,但没有超过代码里设定的阈值,因此 large_output_hook 没有打印额外警告,只是正常返回。

这里可以看出 s04 相对于 s03 的一个明显变化:s03 的权限逻辑是直接插入 agent_loop() 中的一个判断;而 s04 把这种逻辑进一步泛化成 Hook 系统。权限检查只是 Hook 的一种应用,日志、输出检查、上下文注入、结束统计也都可以成为 Hook。

Loop 1 Tool Result

在这里插入图片描述

工具第一次执行结果

接下来,read_file handler 的执行结果会被包装成标准的 tool_result,再追加回 messages

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

messages.append({"role": "user", "content": results})

从调试结果可以看到,results[0] 的结构依然是我们前面章节熟悉的形式:typetool_resulttool_use_id 对应前面模型发出的工具调用 ID,content 则是 README.md 的文件内容。

这说明 Hook 虽然在工具执行前后做了额外工作,但它并没有破坏主循环的消息协议。对模型来说,下一轮它看到的仍然是标准的工具执行结果;对 harness 来说,Hook 只是增强了执行过程中的可观测性、可控性和可扩展性。

这也是 Hook 设计得比较干净的地方:它没有把结果格式改得乱七八糟,也没有改变 Tool Use 的核心数据流,而是在工具调用生命周期中插入额外处理逻辑。

Loop 2 Response

在这里插入图片描述

模型第二次响应结果

第二轮模型收到 README.md 的内容后,就不再继续请求工具,而是直接生成最终回答。从截图可以看到,模型先在 ThinkingBlock 中总结 README.md 的主要内容,然后在 TextBlock 中输出用户可见的总结。

这也说明整个调用链路已经完成了闭环:用户请求进入系统,UserPromptSubmit Hook 先运行;模型选择 read_file 工具;PreToolUse Hook 在工具执行前检查和记录;handler 正式读取文件;PostToolUse Hook 在工具执行后检查输出;工具结果返回模型;模型基于工具结果给出最终回答。

此时 response.stop_reason 不再是 tool_use,所以主循环准备退出。但在真正退出之前,s04 又增加了最后一个 Hook 阶段:Stop

Stop Hook

在这里插入图片描述

Stop:summary hook 调用

当模型不再请求工具、主循环准备结束时,会触发 Stop 阶段的 Hook。这里对应的是 summary_hook(messages)

它会遍历当前会话中的消息,统计其中有多少个 tool_result,然后打印本轮会话使用了多少次工具调用:

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")

从调试结果看,本次任务只调用了一次 read_file,所以最终打印:

[HOOK] Stop: session used 1 tool calls

这个 Hook 的价值在于,它把 “会话结束时的清理和统计” 也变成了可插拔逻辑。以后如果要做会话摘要、成本统计、轨迹保存、指标上报,或者把本轮 agent 的行为写入日志系统,都可以放在 Stop Hook 中,而不是继续污染主循环。

完整的输出如下图所示:

在这里插入图片描述

从完整输出可以看到,整个执行过程非常清晰:用户输入后先打印 UserPromptSubmit 的工作目录;模型发起 read_file 工具调用后,PreToolUse 阶段打印工具调用日志;工具执行完成后,模型基于 README.md 内容生成总结;最后 Stop Hook 打印本次会话使用了 1 次工具调用。

这次调试最重要的结论是:Hook 并不是替代 Agent Loop,而是把 Agent Loop 中那些容易膨胀的横切逻辑拆出去。在没有 Hook 之前,如果我们想加入权限检查、日志记录、输出检查、会话统计,往往只能不断修改 agent_loop(),最后主循环会越来越臃肿;而有了 Hook 之后,主循环只需要在固定阶段触发事件,具体要执行什么扩展逻辑交给 Hook Registry 决定。

所以 s04 的核心不是 “多了几个打印函数”,而是引入了一种更工程化的扩展方式:主循环保持稳定,扩展逻辑通过 UserPromptSubmitPreToolUsePostToolUseStop 这些生命周期事件挂载进去。这样一来,权限、安全、日志、观测、统计、上下文注入都可以作为独立模块演进,而不会破坏 agent loop 的主干结构。

OK,以上就是 s04 Hooks 工作原理的完整分析了。

那大家感兴趣的话可以试试下面这些 prompt 感受下 Hook 机制引入后的一些变化:

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)

6. 相对 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”, …) 可注入上下文

7. 小结

s04 Hooks 这一节的核心价值在于,它让我们第一次看到 Agent Harness 的工程化扩展方式。

在 s01 到 s03 中,我们一直围绕 Agent Loop 本身做增强:先有循环,再有工具,再有权限。但如果每次增强都直接修改循环,循环迟早会变成一坨混杂业务逻辑、权限逻辑、日志逻辑、审计逻辑的代码。s04 通过 hook registry 把这些横切逻辑从循环中拆出来,让循环只保留最稳定的主干流程:调用模型、处理工具、反馈结果、判断退出。

从这个角度看,Hook 并不是一个简单的 “回调函数技巧”,而是一种 Agent Runtime 的扩展机制。它让权限检查、日志记录、上下文注入、输出检查、收尾总结都可以挂在固定事件上,而不需要不断修改核心循环。

如果说 s03 的 Permission 让模型失去了 “直接执行权”,那么 s04 的 Hooks 则让 Agent Loop 失去了 “被随意污染的风险”。前者解决安全边界,后者解决扩展边界。两者结合起来,Agent 才开始从一个简单 demo,逐渐变成一个真正可维护、可扩展的运行时系统。

OK,以上就是本期想要分享的全部内容了。

结语

本篇文章我们围绕新版教程 s04 Hooks 这一节,从问题出发,结合 Hook Workbench 流程图与代码实现,完整梳理了 Agent 是如何通过 Hook 机制,将权限、日志、上下文注入、输出检查等横切逻辑从主循环中解耦出来的。

相比 s03 的 Permission,s04 最本质的变化并不是新增了某个能力,而是第一次正式回答了一个更工程化的问题:当系统能力不断增长时,扩展逻辑到底应该写在哪里。

在没有 Hook 之前,所有新增逻辑几乎都只能继续塞进 agent_loop():权限检查写进去、日志记录写进去、输出校验写进去、退出统计也写进去。短期看只是多了几行代码,但长期来看,主循环会逐渐失去边界,最终变成一个混杂各种业务逻辑的大型函数。

s04 给出的解法非常克制:不去推翻已有 Loop,也不重写工具系统,而是在生命周期关键节点上增加统一事件入口 — UserPromptSubmitPreToolUsePostToolUseStop。主循环只负责在合适时机触发事件,真正的扩展行为则通过 Hook Registry 动态挂载。

也正因为如此,Permission 在 s03 中还是 “写进循环里的逻辑”,而到了 s04,它已经变成了一个普通的 PreToolUse Hook。权限检查本身没有改变,但它的组织方式已经发生了质变:扩展逻辑开始从“循环内部”迁移到“循环外部”。

从工程视角来看,这一步其实非常关键。因为从这一节开始,Agent Loop 不再承担所有系统复杂性,而开始逐渐退化为一个稳定的 Runtime Skeleton:Loop 负责维持执行闭环;Tool 负责真正执行能力;Hook 负责运行时扩展。

这种分层让整个系统第一次具备了真正意义上的 “可演进性”。后续无论是权限治理、上下文注入、日志审计、输出裁剪,还是更复杂的 Memory、Error Recovery、MCP,都可以继续作为 Hook 挂载,而不需要不断侵入主循环本身。

进一步来看,s04 的真正价值并不只是“支持回调函数”,而是让整个 Agent Harness 第一次具备了一种非常重要的架构能力:核心循环保持稳定,而扩展能力可以持续增长。

如果说 s01 建立的是 Agent 的 “最小闭环”,s02 建立的是 “工具执行能力”,s03 建立的是 “权限边界”,那么 s04 建立的,就是整个 Agent Runtime 的 “扩展边界”。而这一步,也正是 Agent 系统从 demo 逐渐走向工程化框架的重要转折点。

下篇文章我们将来学习新版教程 s09 Memory 章节的内容,敬请期待🤗

参考

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐