Agent 开发进阶(八):不改主循环也能扩展功能,Hook 系统的设计与实现

1 阅读7分钟

Agent 开发进阶(八):不改主循环也能扩展功能,Hook 系统的设计与实现

本文是「从零构建 Coding Agent」系列的第八篇,适合想在不修改核心代码的情况下扩展系统功能的开发者。

先问一个问题

当你需要为 Agent 添加新功能时,你是怎么做的?

  • 直接修改主循环代码,在合适的地方加 if/else?
  • 把所有逻辑都塞进工具处理器里?
  • 还是希望有一个更优雅的方式,在固定时机插入额外行为?

如果你的答案是第一种,那么你可能已经感受到了主循环越来越臃肿的痛苦。

Agent 的「扩展困境」问题

到了这一阶段,你的 Agent 已经具备了多种能力:

  • 核心循环运行
  • 工具使用与分发
  • 会话内规划
  • 子智能体机制
  • 技能加载
  • 上下文压缩
  • 权限系统

但随着功能的增加,主循环代码变得越来越复杂:

  • 每增加一个新功能,就要修改主循环
  • 不同功能的逻辑交织在一起,难以维护
  • 第三方插件难以接入,只能修改核心代码

这就是 Hook 系统要解决的核心问题:在不修改主循环的情况下,在关键时机插入额外行为

Hook 系统的核心设计:事件驱动的扩展机制

用一个图来表示 Hook 系统的工作流程:

主循环继续往前跑
  |
  +-- 到了某个预留时机
  |
  +-- 调用 hook runner
  |
  +-- 收到 hook 返回结果
  |
  +-- 决定继续、阻止、还是补充说明

关键点只有两个:

  1. 事件触发:主系统在固定时机发出事件
  2. 钩子处理:扩展逻辑响应事件并返回结果

几个必须搞懂的概念

Hook(钩子)

你可以把 Hook 理解成一个「预留插口」。

意思是:

  1. 主系统运行到某个固定时机
  2. 把当前上下文交给 Hook
  3. Hook 返回结果
  4. 主系统再决定下一步怎么继续

事件(Event)

事件是主系统在关键时机发出的信号,例如:

  • SessionStart:会话开始时
  • PreToolUse:工具执行前
  • PostToolUse:工具执行后

统一返回约定

Hook 执行后的返回结果,教学版建议统一为:

退出码含义
0正常继续
1阻止当前动作
2注入一条补充消息,再继续

最小实现

1. 定义事件处理器

# 会话开始钩子
def on_session_start(payload):
    """会话开始时的处理"""
    print("🎉 会话开始,欢迎使用智能助手!")
    return {"exit_code": 0, "message": ""}

# 工具执行前钩子
def pre_tool_guard(payload):
    """工具执行前的检查"""
    tool_name = payload.get("tool_name")
    tool_input = payload.get("input")
    
    # 示例:禁止危险的 bash 命令
    if tool_name == "bash" and "rm -rf" in tool_input.get("command", ""):
        return {
            "exit_code": 1,
            "message": "危险命令:rm -rf 被禁止执行"
        }
    
    return {"exit_code": 0, "message": ""}

# 工具执行后钩子
def post_tool_log(payload):
    """工具执行后的日志记录"""
    tool_name = payload.get("tool_name")
    output = payload.get("output")
    
    # 示例:记录工具执行结果
    print(f"📝 工具 {tool_name} 执行完成,输出长度:{len(str(output))}")
    
    return {"exit_code": 0, "message": ""}

2. 事件到处理器的映射

# 事件到处理器的映射
HOOKS = {
    "SessionStart": [on_session_start],
    "PreToolUse": [pre_tool_guard],
    "PostToolUse": [post_tool_log],
}

3. 统一运行 Hook

def run_hooks(event_name, payload):
    """运行指定事件的所有钩子"""
    handlers = HOOKS.get(event_name, [])
    
    for handler in handlers:
        try:
            result = handler(payload)
            # 检查返回值格式
            if isinstance(result, dict) and "exit_code" in result:
                # 如果钩子返回阻止或注入消息,立即返回
                if result["exit_code"] in (1, 2):
                    return result
        except Exception as e:
            print(f"钩子执行出错: {e}")
    
    # 默认返回正常继续
    return {"exit_code": 0, "message": ""}

4. 集成到主循环

def agent_loop(state):
    """智能体主循环"""
    # 会话开始时运行 SessionStart 钩子
    session_start_result = run_hooks("SessionStart", {"state": state})
    if session_start_result["exit_code"] == 2:
        state["messages"].append({"role": "user", "content": session_start_result["message"]})
    
    while True:
        # 调用模型
        response = call_model(state["messages"])
        
        if response.stop_reason != "tool_use":
            return response.content
        
        results = []
        for block in response.content:
            if block.type == "tool_use":
                # 工具执行前运行 PreToolUse 钩子
                pre_result = run_hooks("PreToolUse", {
                    "tool_name": block.name,
                    "input": block.input,
                    "state": state
                })
                
                if pre_result["exit_code"] == 1:
                    # 钩子阻止执行
                    results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": pre_result["message"]
                    })
                    continue
                
                if pre_result["exit_code"] == 2:
                    # 钩子注入补充消息
                    state["messages"].append({"role": "user", "content": pre_result["message"]})
                    # 重新调用模型
                    response = call_model(state["messages"])
                    break
                
                # 执行工具
                output = run_tool(block.name, block.input)
                
                # 工具执行后运行 PostToolUse 钩子
                post_result = run_hooks("PostToolUse", {
                    "tool_name": block.name,
                    "input": block.input,
                    "output": output,
                    "state": state
                })
                
                if post_result["exit_code"] == 2:
                    # 钩子注入补充说明
                    output = f"{output}\n\n{post_result['message']}"
                
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": output
                })
        
        if results:
            state["messages"].append({"role": "user", "content": results})

核心事件的使用场景

1. SessionStart 事件

时机:会话开始时

使用场景

  • 打印欢迎信息
  • 初始化会话状态
  • 加载用户偏好设置
  • 检查系统状态

2. PreToolUse 事件

时机:工具执行前

使用场景

  • 额外的安全检查
  • 输入参数验证
  • 执行条件判断
  • 权限二次确认

3. PostToolUse 事件

时机:工具执行后

使用场景

  • 审计日志记录
  • 结果格式化
  • 错误处理
  • 副作用清理

新手最容易犯的 4 个错

1. 把 Hook 当成「到处插 if」

# ❌ 错误
def agent_loop():
    # 会话开始
    if session_start:
        print("欢迎信息")
    
    # 工具执行前
    if tool_use:
        if check_safety():
            execute_tool()
    
    # 工具执行后
    if tool_executed:
        log_result()

# ✅ 正确
# 使用事件驱动的 Hook 系统

Hook 系统的核心是事件驱动,而不是散落在主循环里的条件分支。

2. 没有统一的返回结构

# ❌ 错误
def bad_hook():
    if something:
        return "阻止"
    else:
        return True

# ✅ 正确
def good_hook():
    if something:
        return {"exit_code": 1, "message": "阻止"}
    else:
        return {"exit_code": 0, "message": ""}

统一的返回结构让主循环处理逻辑更清晰。

3. 一上来就把所有事件做全

# ❌ 错误
events = [
    "SessionStart", "SessionEnd", "PreToolUse", "PostToolUse",
    "PreCompact", "PostCompact", "SubAgentStart", "SubAgentEnd",
    "ErrorOccurred", "ConfigChanged"...
]

# ✅ 正确
# 先实现核心的 3 个事件
events = ["SessionStart", "PreToolUse", "PostToolUse"]

先掌握核心事件,再逐步扩展。

4. 让 Hook 做太多主循环的工作

# ❌ 错误
def pre_tool_hook(payload):
    # Hook 不应该直接执行工具
    result = execute_tool(payload["tool_name"], payload["input"])
    return {"exit_code": 0, "message": result}

# ✅ 正确
def pre_tool_hook(payload):
    # Hook 只做检查和准备工作
    if not validate_input(payload["input"]):
        return {"exit_code": 1, "message": "输入无效"}
    return {"exit_code": 0, "message": ""}

Hook 是扩展点,不是主循环的替代品。

为什么这很重要

因为一个可扩展的系统,不应该让核心代码随着功能的增加而变得越来越臃肿。

Hook 系统让你能够:

  1. 保持核心简洁:主循环只负责核心逻辑,扩展功能通过 Hook 实现
  2. 支持插件化:第三方可以通过 Hook 扩展功能,无需修改核心代码
  3. 提高可维护性:功能模块化,逻辑清晰,易于调试和测试
  4. 增强灵活性:可以根据需要动态添加或移除 Hook

这就是 Hook 系统的真正价值:在不破坏核心结构的前提下,为系统增加无限可能

推荐的实现步骤

  1. 第一步:定义核心事件(SessionStart、PreToolUse、PostToolUse)
  2. 第二步:实现统一的 Hook 运行器
  3. 第三步:将 Hook 系统集成到主循环
  4. 第四步:编写几个实用的 Hook 处理器
  5. 第五步:测试和调优

Hook 系统与后续章节的关系

  • s08 Hook 系统:解决在固定时机插入行为的问题
  • s09 记忆系统:解决跨会话信息持久化的问题
  • s10 Prompt 系统:会使用 Hook 来定制提示词生成
  • s11 错误恢复:会使用 Hook 来处理错误情况

所以 Hook 系统是后续很多高级功能的基础。

下一章预告

有了 Hook 系统,你的 Agent 已经具备了良好的扩展能力。下一章我们将探讨记忆系统,让 Agent 能够跨会话记住重要信息,不再每次都从零开始。


一句话总结:Hook 系统让系统可扩展,但不要求主循环理解每个扩展需求。


如果觉得有帮助,欢迎关注,我会持续更新「从零构建 Coding Agent」系列文章。