ByteNoteByteNote
运行时的事件钩子:Lifecycle Hooks
字

字节笔记本

2026年10月24日 · 约 14 分钟读完

运行时的事件钩子:Lifecycle Hooks

API中转
¥120

Subagent:并行与任务拆分

本文是《从 0 写 Agent》系列第 19 章。这个系列基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版),用 Go 从零实现一个生产级 Agent 运行时;本章讲「Lifecycle Hooks:运行时的事件钩子」。

Agent 循环的每个关键节点,开始、调模型、调工具、交接、结束,都能挂钩子。挂钩子干什么?记日志、收指标、发通知、注入上下文……这是做监控、审计、自定义行为的基础。

agents-go 有 Run 级和 Agent 级两套 hook,共 7 个回调点。这一章拆解它们的触发时机和组合方式。


引子:为什么需要钩子

想象你要给 Agent 加这些能力:

  • 每次调工具前记日志("即将调用 delete_user")
  • 每次模型调用后统计耗时
  • Agent 交接时发通知("已从分诊转给退款专员")
  • 工具出错时触发告警

你不想把这些逻辑塞进 Agent 循环本身(循环已经够复杂了)。你想"在外面挂着",循环跑到某个点,自动通知你。

这就是 Lifecycle Hooks,在不修改循环代码的前提下,注入自定义行为。 这是观察者模式在 Agent 运行时的应用。


Agent 循环:一切的基础

两套 Hook:Run 级和 Agent 级

agents-go 有两套 hook(lifecycle.go):

RunHooks,跨所有 Agent

go
// agents/lifecycle.go:7
type RunHooks interface {
    OnAgentStart(ctx, rc, agent) error
    OnAgentEnd(ctx, rc, agent, output) error
    OnHandoff(ctx, rc, from, to) error
    OnToolStart(ctx, rc, agent, tool) error
    OnToolEnd(ctx, rc, agent, tool, result) error
    OnLLMStart(ctx, rc, agent, req) error
    OnLLMEnd(ctx, rc, agent, resp) error
}

RunHooks 挂在 RunOptions 上,对整个 Run 生效,不管 Run 里有几个 Agent(handoff 切换),所有 Agent 的事件都触发。

AgentHooks,单个 Agent 专属

go
// agents/lifecycle.go:64
type AgentHooks struct {
    OnStart   func(...) error
    OnEnd     func(...) error
    OnHandoff func(...) error  // 在接收方触发
    OnToolStart func(...) error
    OnToolEnd   func(...) error
    OnLLMStart  func(...) error
    OnLLMEnd    func(...) error
}

AgentHooks 挂在 Agent 定义上(Agent.Hooks),只对该 Agent 生效。

两者的关系,双发

每个事件会同时触发 Run 级和 Agent 级 hook(lifecycle.go:115 的 call* helper):

go
// 简化:callToolStart
func callToolStart(ctx, hooks, agentHooks, agent, tool) error {
    if err := hooks.OnToolStart(ctx, ...); err != nil {  // 先 Run 级
        return err
    }
    if agent.Hooks != nil && agent.Hooks.OnToolStart != nil {
        return agent.Hooks.OnToolStart(...)  // 再 Agent 级
    }
    return nil
}

先 Run 级,再 Agent 级,任一返回 error 就短路。 这让你能"全局监控"(RunHooks)和"单 Agent 定制"(AgentHooks)共存。


七个回调点

Hook触发时机典型用途
OnAgentStart / OnStartAgent 开始执行(首次或 handoff 后)记录、初始化
OnLLMStart / OnLLMStart调用模型前记录 prompt、计时
OnLLMEnd模型返回后统计耗时、token
OnToolStart / OnToolStart工具执行前记录、审批日志
OnToolEnd / OnToolEnd工具执行后记录结果、错误告警
OnHandoffAgent 切换时通知、审计
OnAgentEnd / OnEndAgent 产出最终输出收尾、统计

触发时机图

text
Run 开始
  ↓
OnAgentStart(首次 agent)
  ↓
循环 {
  OnLLMStart → 调模型 → OnLLMEnd
  ↓
  如果有工具调用:
    OnToolStart → 执行工具 → OnToolEnd
    (回到循环)
  如果 handoff:
    OnHandoff → 切 agent → OnAgentStart(新 agent)
  如果最终输出:
    OnAgentEnd → 返回
}

注意 OnLLMStart/OnLLMEnd 在 HITL resume 时可能不触发(resume 复用上次响应,不重新调模型)。这呼应第 13 章,resume 不重算。


Base Hooks:只实现关心的回调

实现 RunHooks 接口要实现全部 7 个方法,哪怕你只关心一个。这很啰嗦。agents-go 提供了 BaseRunHooks(lifecycle.go:31),空实现:

go
// 用法:嵌入 BaseRunHooks,只 override 关心的
type MyHooks struct {
    agents.BaseRunHooks  // 7 个方法都是 no-op
}

func (h *MyHooks) OnToolStart(ctx, rc, agent, tool) error {
    log.Printf("calling tool: %s", tool.ToolName())
    return nil
}
// 其他 6 个用 BaseRunHooks 的空实现

这是 Go 实现"可选回调"的惯用法,嵌入空实现的基类,只覆盖关心的方法。 比强制定义 7 个函数优雅得多。


CompositeRunHooks:组合多个 Hook

你想同时用"日志 hook"+"指标 hook"+"审计 hook"。怎么办?

CompositeRunHooks(lifecycle.go:213)把多个 hook 组合成一个:

go
opts := agents.RunOptions{
    Hooks: agents.CompositeRunHooks(
        &LoggingHooks{},
        &MetricsHooks{},
        &AuditHooks{},
    ),
}

组合后,每个事件按顺序 fan-out 给所有 hook,日志 hook 记日志、指标 hook 收指标、审计 hook 写审计。任一返回 error 短路。

这让 hook 可组合,每个 hook 单一职责,按需叠加。 跟 Unix 管道一个思路。


设计权衡:Hook 出错怎么办

call* helper 里,任一 hook 返回 error 就短路(停止后续 hook + 让 Run 失败)。这是"fail-fast"设计。

为什么不让 hook 错误静默? 因为 hook 可能做关键操作(比如审计 hook 失败了,意味着审计缺失,这在合规场景是不可接受的)。让错误冒泡,比静默吞掉安全。

但这也意味着,hook 要谨慎设计,别因为非关键错误搞挂整个 Run。 如果你的 hook 只是"记日志",日志系统临时故障不应该让 Agent 跑不了。这种情况下,hook 内部应该自己 try-catch,不让错误冒泡:

go
func (h *LoggingHooks) OnToolStart(...) error {
    if err := h.log.Write(...); err != nil {
        // 日志失败不影响 Run,静默吞掉
        return nil
    }
    return nil
}

hook 的错误语义由 hook 实现者决定,关键操作让错误冒泡,非关键操作自己吞。框架不替你决定。


用法示例:监控工具耗时

一个实用的 hook,统计每个工具的耗时:

go
type TimingHooks struct {
    agents.BaseRunHooks
    mu     sync.Mutex
    timing map[string]time.Duration  // toolName → 累计耗时
}

func (h *TimingHooks) OnToolStart(ctx, rc, agent, tool) error {
    rc.SetValue("tool_start_"+tool.ToolName(), time.Now())  // 记开始时间
    return nil
}

func (h *TimingHooks) OnToolEnd(ctx, rc, agent, tool, result) error {
    start, _ := rc.GetValue("tool_start_" + tool.ToolName()).(time.Time)
    elapsed := time.Since(start)
    h.mu.Lock()
    h.timing[tool.ToolName()] += elapsed
    h.mu.Unlock()
    return nil
}

通过 RunContext(rc.SetValue/rc.GetValue)在 OnToolStart 和 OnToolEnd 之间传递数据。 这是 hook 之间共享状态的惯用法,用 RunContext 当临时存储。


小结

读完这一章,你应该理解:

  1. 两套 hook,RunHooks(全局)+ AgentHooks(单 Agent),双发(先 Run 后 Agent)
  2. 七个回调点,AgentStart/End、LLMStart/End、ToolStart/End、Handoff
  3. BaseRunHooks,嵌入空实现,只覆盖关心的(Go 可选回调惯用法)
  4. CompositeRunHooks,组合多个 hook,fan-out,单一职责可叠加
  5. 错误语义,hook 返回 error 会短路 Run,非关键 hook 要自己吞错误

Hook 是 Agent 可观测性和自定义行为的统一入口。 后面 Part IV 的 Tracing、Part VI 的 agents-server,大量用 hook 实现监控和审计。


下一篇

第 20 章,Part III 小结:多 Agent 的拓扑与权衡。什么时候用 Handoff、什么时候用 Agent-as-Tool、什么时候单 Agent 够用。Part III 的收尾,帮你建立多 Agent 设计的决策框架。


本文是《从 0 写 Agent》系列第 19 章,案例代码基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版)。全系列目录见总览。

相关文章

分享: