Agent 开发进阶(八):不改主循环也能扩展功能,Hook 系统的设计与实现
本文是「从零构建 Coding Agent」系列的第八篇,适合想在不修改核心代码的情况下扩展系统功能的开发者。
先问一个问题
当你需要为 Agent 添加新功能时,你是怎么做的?
- 直接修改主循环代码,在合适的地方加 if/else?
- 把所有逻辑都塞进工具处理器里?
- 还是希望有一个更优雅的方式,在固定时机插入额外行为?
如果你的答案是第一种,那么你可能已经感受到了主循环越来越臃肿的痛苦。
Agent 的「扩展困境」问题
到了这一阶段,你的 Agent 已经具备了多种能力:
- 核心循环运行
- 工具使用与分发
- 会话内规划
- 子智能体机制
- 技能加载
- 上下文压缩
- 权限系统
但随着功能的增加,主循环代码变得越来越复杂:
- 每增加一个新功能,就要修改主循环
- 不同功能的逻辑交织在一起,难以维护
- 第三方插件难以接入,只能修改核心代码
这就是 Hook 系统要解决的核心问题:在不修改主循环的情况下,在关键时机插入额外行为。
Hook 系统的核心设计:事件驱动的扩展机制
用一个图来表示 Hook 系统的工作流程:
主循环继续往前跑
|
+-- 到了某个预留时机
|
+-- 调用 hook runner
|
+-- 收到 hook 返回结果
|
+-- 决定继续、阻止、还是补充说明
关键点只有两个:
- 事件触发:主系统在固定时机发出事件
- 钩子处理:扩展逻辑响应事件并返回结果
几个必须搞懂的概念
Hook(钩子)
你可以把 Hook 理解成一个「预留插口」。
意思是:
- 主系统运行到某个固定时机
- 把当前上下文交给 Hook
- Hook 返回结果
- 主系统再决定下一步怎么继续
事件(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 系统让你能够:
- 保持核心简洁:主循环只负责核心逻辑,扩展功能通过 Hook 实现
- 支持插件化:第三方可以通过 Hook 扩展功能,无需修改核心代码
- 提高可维护性:功能模块化,逻辑清晰,易于调试和测试
- 增强灵活性:可以根据需要动态添加或移除 Hook
这就是 Hook 系统的真正价值:在不破坏核心结构的前提下,为系统增加无限可能。
推荐的实现步骤
- 第一步:定义核心事件(SessionStart、PreToolUse、PostToolUse)
- 第二步:实现统一的 Hook 运行器
- 第三步:将 Hook 系统集成到主循环
- 第四步:编写几个实用的 Hook 处理器
- 第五步:测试和调优
Hook 系统与后续章节的关系
- s08 Hook 系统:解决在固定时机插入行为的问题
- s09 记忆系统:解决跨会话信息持久化的问题
- s10 Prompt 系统:会使用 Hook 来定制提示词生成
- s11 错误恢复:会使用 Hook 来处理错误情况
所以 Hook 系统是后续很多高级功能的基础。
下一章预告
有了 Hook 系统,你的 Agent 已经具备了良好的扩展能力。下一章我们将探讨记忆系统,让 Agent 能够跨会话记住重要信息,不再每次都从零开始。
一句话总结:Hook 系统让系统可扩展,但不要求主循环理解每个扩展需求。
如果觉得有帮助,欢迎关注,我会持续更新「从零构建 Coding Agent」系列文章。