Callback 系统:Eino 的“事件总线“(第52篇-E38)

系列「企业级 AI Agent 实现拆解」E38 篇,Part 9 源码深度篇第三章。上一篇 用了 callbacks.OnEnd 拿 Token 用量。这篇拆 callback 系统的源码,看"在每个节点执行前后插入逻辑"这件事底层是怎么做到的。

读完这篇你会知道

  • manager struct:handler 怎么随 context 流动
  • GlobalHandlers vs 每次调用注册的 handlers:两条注册路径
  • EnsureRunInfo:为什么每个组件方法开头都要调它
  • On[T] 核心函数:TimingChecker 过滤 → 分发 → 执行顺序
  • RunInfo 的生命周期:OnStart 消费,OnEnd 读取
  • Stream handler 的 Copy 机制和泄漏陷阱

全景

Eino 的 callback 系统本质上是把 handler 列表藏在 context 里,然后在 context 流经每个节点时触发。没有全局事件总线、没有注册表、没有反射------只有 context。

scss 复制代码
注册时:
callbacks.CtxWithHandlers(ctx, handlers)
    └── 把 manager{handlers, globalHandlers, runInfo} 塞进 ctx

执行时(组件内部):
EnsureRunInfo(ctx, ...)     ← 确保 ctx 有 RunInfo
callbacks.OnStart(ctx, in)  ← 从 ctx 读 manager,逆序触发所有 handler.OnStart
... 组件业务逻辑 ...
callbacks.OnEnd(ctx, out)   ← 正序触发 handler.OnEnd

Context 作为 carrier:manager struct

go 复制代码
// internal/callbacks/manager.go
type manager struct {
    globalHandlers []Handler  // 进程级 handlers(AppendGlobalHandlers 设置)
    handlers       []Handler  // 此次调用注册的 handlers(CtxWithHandlers 设置)
    runInfo        *RunInfo   // 当前组件身份(类型、名字、组件种类)
}

type CtxManagerKey struct{}  // context 的 key(空结构体,避免 string key 冲突)

func ctxWithManager(ctx context.Context, m *manager) context.Context {
    return context.WithValue(ctx, CtxManagerKey{}, m)
}

func managerFromCtx(ctx context.Context) (*manager, bool) {
    v := ctx.Value(CtxManagerKey{})
    m, ok := v.(*manager)
    if ok && m != nil {
        n := *m        // ← 注意:浅拷贝,不共享同一指针
        return &n, true
    }
    return nil, false
}

关键设计: managerFromCtx 返回的是 *m 的一个副本n := *m),不是指向原结构体的指针。这意味着每个节点读到的是独立的 manager 实例,对 manager 的修改不会影响其他节点------Graph 并发执行时不会有数据竞争。


两条注册路径

路径一:全局注册(进程级,对所有节点生效)

go 复制代码
// 进程启动时调用一次
callbacks.AppendGlobalHandlers(myTracingHandler, myMetricsHandler)

// 内部实现
var GlobalHandlers []Handler  // 全局 slice

func AppendGlobalHandlers(handlers ...Handler) {
    GlobalHandlers = append(GlobalHandlers, handlers...)
}

适合:链路追踪(OpenTelemetry)、全局 Token 计量、Langfuse 集成------任何需要观测所有节点的场景。

路径二:每次调用注册(通过 context,对此次调用生效)

go 复制代码
// 在发起 Agent 调用之前
ctx = callbacks.CtxWithHandlers(ctx, []callbacks.Handler{
    &SessionTokenTracker{sessionID: sid},
    &AuditLogger{requestID: rid},
})
sr, err := agent.Stream(ctx, messages)

CtxWithHandlers 把 handlers 塞入 manager,context 带着 manager 流经整个调用链。Graph 里每个节点都能从 ctx 里读到它们。


EnsureRunInfo:组件方法开头的必要调用

每个组件方法(GenerateStreamInvoke......)开头都会调:

go 复制代码
ctx = callbacks.EnsureRunInfo(ctx, cm.GetType(), components.ComponentOfChatModel)

做了什么:

go 复制代码
func EnsureRunInfo(ctx context.Context, typ string, comp components.Component) context.Context {
    cbm, ok := managerFromCtx(ctx)
    if !ok {
        // ctx 里没有 manager → 用全局 handlers 创建一个新的
        return InitCallbacks(ctx, &RunInfo{Type: typ, Component: comp})
    }
    if cbm.runInfo == nil {
        // manager 存在但没有 RunInfo → 补充 RunInfo
        return ReuseHandlers(ctx, &RunInfo{Type: typ, Component: comp})
    }
    return ctx  // 已经有 RunInfo,直接返回
}

为什么需要: callback handler 需要知道"触发这次事件的是哪个组件"(类型、名字、种类),这就是 RunInfo。Graph 在调度节点时会提前注入 RunInfo;但如果你是直接调用 chatModel.Generate()(不经过 Graph),就没有人提前注入,EnsureRunInfo 兜底处理这种情况。


On[T] 核心函数

所有 OnStart/OnEnd/OnError 最终都走同一个泛型函数:

go 复制代码
func On[T any](ctx context.Context, inOut T, handle Handle[T], timing CallbackTiming, start bool) (context.Context, T) {
    mgr, ok := managerFromCtx(ctx)
    if !ok {
        return ctx, inOut  // 没有注册任何 handler,直接返回
    }
    nMgr := *mgr

    // RunInfo 的生命周期管理
    var info *RunInfo
    if start {
        // OnStart:消费 manager 里的 RunInfo,存到 CtxRunInfoKey
        info = nMgr.runInfo
        nMgr.runInfo = nil                             // ← 清空,防止被多次使用
        ctx = context.WithValue(ctx, CtxRunInfoKey{}, info)
    } else {
        // OnEnd/OnError:RunInfo 已经被 OnStart 存到 ctx 了
        if nMgr.runInfo != nil {
            info = nMgr.runInfo
        } else {
            info, _ = ctx.Value(CtxRunInfoKey{}).(*RunInfo)  // ← 从 ctx 读回
        }
    }

    // 收集有效 handler(支持 TimingChecker 过滤)
    hs := make([]Handler, 0, len(nMgr.handlers)+len(nMgr.globalHandlers))
    for _, handler := range append(nMgr.handlers, nMgr.globalHandlers...) {
        timingChecker, ok_ := handler.(TimingChecker)
        if !ok_ || timingChecker.Needed(ctx, info, timing) {
            hs = append(hs, handler)  // 没实现 TimingChecker,或者 Needed 返回 true
        }
    }

    // 分发给具体的 handle 函数(OnStartHandle、OnEndHandle...)
    var out T
    ctx, out = handle(ctx, inOut, info, hs)
    return ctxWithManager(ctx, &nMgr), out
}

执行顺序:OnStart 逆序,OnEnd 正序

这是一个刻意的设计,对应中间件/洋葱模型

go 复制代码
// OnStart:逆序(最后注册的 handler 最先执行)
func OnStartHandle[T any](...) {
    for i := len(handlers) - 1; i >= 0; i-- {
        ctx = handlers[i].OnStart(ctx, runInfo, input)
    }
}

// OnEnd:正序(最先注册的 handler 最先执行)
func OnEndHandle[T any](...) {
    for _, handler := range handlers {
        ctx = handler.OnEnd(ctx, runInfo, output)
    }
}

假设注册了 [A, B, C]

  • OnStart 顺序:C → B → A(逆序,像中间件 wrap)
  • OnEnd 顺序:A → B → C(正序,unwrap)

实践意义: 如果你有一个计时 handler 想测量"某个内层 handler 的执行耗时",可以利用这个顺序:外层 handler 在 OnStart 记开始时间,OnEnd 记结束时间,内层 handler 在中间执行业务逻辑。


TimingChecker:避免不必要的开销

如果一个 handler 只关心 OnEnd(比如用量统计),但 Stream 场景会触发 OnEndWithStreamOutput------这需要复制 StreamReader,很贵。

go 复制代码
type TimingChecker interface {
    Needed(ctx context.Context, info *RunInfo, timing CallbackTiming) bool
}

实现 Needed 返回 false 的 timing,框架会跳过该 handler:

go 复制代码
// 用 HandlerBuilder,自动实现 TimingChecker
handler := callbacks.NewHandlerBuilder().
    OnEndFn(func(ctx context.Context, info *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context {
        // 只关心 OnEnd
        return ctx
    }).
    Build()
// handler.Needed(ctx, info, TimingOnEndWithStreamOutput) 自动返回 false
// → 不会为这个 handler 复制 Stream

Stream 的 Copy 机制

Stream handler(OnEndWithStreamOutput)拿到的是 StreamReader 的一份独立副本

go 复制代码
// 伪代码
cpy := output.Copy          // output.Copy 是 func(n int) []*StreamReader[T]
inOuts := cpy(len(handlers) + 1)  // 复制 len+1 份:len 份给 handlers,1 份给主流程

for i, handler := range handlers {
    ctx = handler.OnEndWithStreamOutput(ctx, runInfo, inOuts[i])  // 第 i 份
}

return ctx, inOuts[len(inOuts)-1]  // 最后一份还给主流程

必须 Close: 每个 handler 必须调 sr.Close() 关闭自己的那份,否则底层 goroutine 无法退出,造成泄漏。

go 复制代码
func (t *MyHandler) OnEndWithStreamOutput(ctx context.Context, info *callbacks.RunInfo,
    output *schema.StreamReader[callbacks.CallbackOutput]) context.Context {
    defer output.Close()  // ← 必须
    for {
        chunk, err := output.Recv()
        if err != nil { break }
        // 处理 chunk...
    }
    return ctx
}

写 Handler 的正确姿势

go 复制代码
// 1. 用 NewHandlerBuilder:自动实现 TimingChecker,只注册你需要的 timing
handler := callbacks.NewHandlerBuilder().
    OnStartFn(func(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
        // 只处理 ChatModel 节点
        if info == nil || info.Component != components.ComponentOfChatModel {
            return ctx
        }
        return context.WithValue(ctx, startTimeKey{}, time.Now())  // 存开始时间
    }).
    OnEndFn(func(ctx context.Context, info *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context {
        if info == nil || info.Component != components.ComponentOfChatModel {
            return ctx
        }
        start, _ := ctx.Value(startTimeKey{}).(time.Time)
        log.Printf("[%s] latency=%v", info.Name, time.Since(start))  // 读开始时间
        return ctx
    }).
    Build()

// 2. 不要 mutate input/output:多个节点并发执行时共享同一指针
// BAD:
func (h *BadHandler) OnEnd(ctx, info, output) context.Context {
    out := output.(*model.CallbackOutput)
    out.Message.Content = "modified"  // ← 数据竞争!
    return ctx
}

// 3. context 从 OnStart 流到 OnEnd:同一 handler 实例内部传状态用 ctx.WithValue

小结

Eino callback 系统的四个核心设计:

  1. Context 作为 carriermanager 塞进 ctx 的 key-value,随调用链流动,无全局状态(GlobalHandlers 除外)
  2. EnsureRunInfo 兜底:组件方法开头调用,确保不经 Graph 直接调用时也能触发 callback
  3. On[T] 统一分发:TimingChecker 过滤不需要的 timing → 分发给具体 handle 函数 → OnStart 逆序 / OnEnd 正序
  4. Stream 独立副本:每个 handler 拿独立 StreamReader,必须 Close,否则 goroutine 泄漏

代码来源:eino/callbacks · eino/internal/callbacks

相关推荐
晨米酱2 小时前
Matt Pocock Skills:整套设计只为一个目标
面试·架构·agent
我要割麦子2 小时前
Agent Memory 多维分类——从认知科学到工程实现
agent
咏方舟【长江支流】2 小时前
【开源】跨语言·跨平台·跨数据库(4) ——用宝框架 ORM 数据源适配器
数据库·开源·ai编程·orm·咏方舟-长江支流·用宝框架·用宝框架orm
ckjoker2 小时前
让AI给AI当法官:手敲LLM-as-a-Judge评估系统,25次调用跑出最弱维度
llm·agent·测试
用户1492812178302 小时前
SSE 双队列架构进化之路:从 OOM 到网络漂移,一个后端项目的八次蜕变
ai编程
TrisighT2 小时前
Agent 聊了 20 轮 token 翻了 8 倍——三种截断方案实测,最蠢的那种反而最稳
aigc·openai·ai编程
奋飛2 小时前
Agent系列:从 grill-me 学写 Skill——先找默认错误,再写最小约束
ai编程
ArixBit2 小时前
用Go手撸 Agent 框架#8:手写一个最小 Agent,先让 loop 跑起来
go·agent
赫媒派2 小时前
MCP Server 安全防护:5 个实用配置清单
ai编程