Eino Callbacks 回调机制:在 Agent 执行每个环节注入自定义逻辑(第95篇-E81)

上一篇 拆了 Deep Agent 文件系统工具链------7 个工具、Backend 可插拔、offloading 降级。但有一个问题没讲:怎么在 Agent 执行的每个环节插入监控、日志、计费?

答案就是 Eino 的 callbacks 系统。它提供了一套标准化钩子,在组件(ChatModel、Tool、Agent 等)的 5 个生命周期时机注入自定义逻辑。

五分钟速览

go 复制代码
handler := callbacks.NewHandlerBuilder().
    OnStartFn(func(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
        mi := model.ConvCallbackInput(input)
        log.Printf("[%s] 开始调用,消息数: %d", info.Name, len(mi.Messages))
        return ctx
    }).
    OnEndFn(func(ctx context.Context, info *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context {
        mo := model.ConvCallbackOutput(output)
        log.Printf("[%s] 调用完成,tokens: %d", info.Name, mo.Message.ResponseMeta.Usage.TotalTokens)
        return ctx
    }).
    Build()

runnable.Invoke(ctx, input, compose.WithCallbacks(handler))

就这么简单:HandlerBuilder 构造 → 注册时机 → 附加到调用。

(一)5 个生命周期时机

interface.go:114-134 定义了 5 个时机:

时机 何时触发 输入/输出
OnStart 组件开始处理前 完整输入值(非流式)
OnEnd 组件成功返回后 输出值。只在成功时触发
OnError 组件返回 error 时 error 对象
OnStartWithStreamInput 组件收到流式输入 输入流副本(必须 close)
OnEndWithStreamOutput 组件返回流式输出 输出流副本(必须 close)

一个典型的调用链路:OnStart → 组件执行 → OnEnd(或 OnStart → OnError)。

RunInfo:标识组件身份

每个时机回调都收到 RunInfointernal/callbacks/interface.go:26-32):

go 复制代码
type RunInfo struct {
    Name      string           // 节点名(compose.WithNodeName),不保证唯一
    Type      string           // 实现类型,如 "OpenAI"
    Component components.Component  // 组件类别,如 ComponentOfChatModel
}

Handler 应该用 RunInfo 过滤组件类型,而不是假设固定执行顺序。

(二)HandlerBuilder:按需注册

handler_builder.go 提供了 Builder 模式:

go 复制代码
handler := callbacks.NewHandlerBuilder().
    OnStartFn(func(ctx context.Context, info *RunInfo, input CallbackInput) context.Context {
        // 处理开始
        return ctx
    }).
    OnEndFn(func(ctx context.Context, info *RunInfo, output CallbackOutput) context.Context {
        // 处理结束
        return ctx
    }).
    OnErrorFn(func(ctx context.Context, info *RunInfo, err error) context.Context {
        // 处理错误
        return ctx
    }).
    OnStartWithStreamInputFn(func(ctx context.Context, info *RunInfo, input *schema.StreamReader[CallbackInput]) context.Context {
        defer input.Close() // 必须 close,否则 pipeline goroutine 泄漏
        return ctx
    }).
    OnEndWithStreamOutputFn(func(ctx context.Context, info *RunInfo, output *schema.StreamReader[CallbackOutput]) context.Context {
        defer output.Close()
        return ctx
    }).
    Build()

只注册你需要的时机,不需要的留空。

输入输出类型安全

CallbackInput / CallbackOutputany 类型。使用时用组件包提供的转换函数:

go 复制代码
mi := model.ConvCallbackInput(input)   // 把 any 转为 *model.CallbackInput
if mi != nil {
    // 这是 ChatModel 的调用,可以安全访问 mi.Messages
}

(三)TimingChecker:零开销跳过

interface.go:136-145TimingChecker 接口:

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

HandlerBuilder 构建的 handler 自动实现了 TimingCheckerhandler_builder.go:90-105):

go 复制代码
func (hb *handlerImpl) Needed(_ context.Context, _ *RunInfo, timing CallbackTiming) bool {
    switch timing {
    case TimingOnStart:        return hb.onStartFn != nil
    case TimingOnEnd:          return hb.onEndFn != nil
    case TimingOnError:        return hb.onErrorFn != nil
    // ...
    }
}

效果:如果只注册了 OnStartOnEnd,框架在 OnError 时机不会分配 goroutine、不会复制 stream。零开销。

(四)全局 + 按调用:两层附加

全局 handlers

interface.go:86-105

go 复制代码
func AppendGlobalHandlers(handlers ...Handler) {
    callbacks.GlobalHandlers = append(callbacks.GlobalHandlers, handlers...)
}

在程序启动时调用一次,所有后续的 graph 执行都会带上这些 handlers。适合分布式追踪、metrics 等全局可观测性。

注意AppendGlobalHandlers 不是线程安全的,只在初始化时调用。

按调用 handlers

go 复制代码
runnable.Invoke(ctx, input, compose.WithCallbacks(handler))

还可以指定节点:

go 复制代码
compose.WithCallbacks(handler).DesignateNode("nodeName")

handler 继承:如果父 graph 的 context 携带了 handlers,子 graph 的整个运行自动继承这些 handlers。

执行顺序

inject.go:107-125 定义了关键规则:

  • OnStart :反向顺序(len(handlers)-10)------遵循 middleware 包裹约定
  • OnEnd / OnError :正向顺序(0len(handlers)-1

全局 handlers 在 handlers 列表中排在 per-invocation handlers 后面,所以 OnStart 时全局 handlers 先执行 ,OnEnd 时全局 handlers 后执行

(五)同一 Handler 内状态传递

doc.go:91-104

go 复制代码
handler := NewHandlerBuilder().
    OnStartFn(func(ctx context.Context, info *RunInfo, _ CallbackInput) context.Context {
        return context.WithValue(ctx, startTimeKey{}, time.Now())
    }).
    OnEndFn(func(ctx context.Context, info *RunInfo, _ CallbackOutput) context.Context {
        start := ctx.Value(startTimeKey{}).(time.Time)
        log.Printf("duration: %v", time.Since(start))
        return ctx
    }).Build()

同一个 handlerOnStart 返回的 ctx 会传递给它的 OnEnd。用 context.WithValueOnStart 存状态,在 OnEnd 读状态。

不同 handler 之间没有保证的执行顺序,也没有 context 链。要在 handler 之间共享状态,需要存在并发安全的变量中。

(六)流式 handler 副本机制

inject.go:127-141BuildOnEndHandleWithCopy

go 复制代码
func BuildOnEndHandleWithCopy[T any](copyFn func(T, int) []T) Handle[T] {
    return func(ctx context.Context, output T, runInfo *RunInfo, handlers []Handler) (context.Context, T) {
        if len(handlers) == 0 {
            return ctx, output
        }
        copies := copyFn(output, len(handlers))
        for i, handler := range handlers {
            ctx = handler.OnEnd(ctx, runInfo, copies[i])
        }
        return ctx, output
    }
}

每个 handler 收到 output 的独立副本。这意味着:

  • Handler A 修改副本不影响 Handler B
  • 原始 output 保持完整,传给下游

重要:流式副本必须 close。N 个 handler 注册了流式时机,stream 被复制 N+1 次。如果任何 handler 的副本没 close,原始 stream 无法释放,整个 pipeline goroutine 泄漏。

(七)ADK Agent 层 callbacks

adk/callback.go:29-44 定义了 Agent 专用的回调输入输出:

go 复制代码
type AgentCallbackInput struct {
    Input      *AgentInput  // 新运行时非 nil,恢复时为 nil
    ResumeInfo *ResumeInfo  // 恢复时非 nil,新运行时为 nil
}

type AgentCallbackOutput struct {
    Events *AsyncIterator[*AgentEvent]  // 每个 handler 独立副本
}

initAgentCallbacksadk/callback.go:116-128)在 flowAgent 启动时调用:

go 复制代码
func initAgentCallbacks(ctx context.Context, agentName, agentType string, opts ...AgentRunOption) context.Context {
    ri := &callbacks.RunInfo{
        Name:      agentName,
        Type:      agentType,
        Component: ComponentOfAgent,
    }
    o := getCommonOptions(nil, opts...)
    if len(o.handlers) == 0 {
        return icb.ReuseHandlers(ctx, ri)
    }
    return icb.AppendHandlers(ctx, ri, o.handlers...)
}

flow.go:489-492 中,OnEnd 使用 BuildOnEndHandleWithCopy + copyAgentCallbackOutput 把事件流复制给每个 handler。

(八)重要注意事项

doc.go:110-128 总结了常见陷阱:

  1. 流式副本必须 close:否则 pipeline goroutine 泄漏
  2. 不要修改 Input/Output:所有下游节点和 handler 共享同一个指针,修改会导致并发数据竞争
  3. AppendGlobalHandlers 不是线程安全的:只在初始化时调用
  4. 流式错误不会触发 OnError :stream 内部错误通过 Recv 返回 error,不经过 OnError
  5. RunInfo 可能为 nil:组件 standalone 使用时(不在 Graph 内),务必 nil-check

小结

问题 答案 关键源码
有哪些时机 5 个:OnStart/OnEnd/OnError/OnStartWithStreamInput/OnEndWithStreamOutput interface.go:114-134
怎么构造 handler HandlerBuilder 链式调用,只注册需要的时机 handler_builder.go
怎么跳过不需要的时机 TimingChecker 接口,HandlerBuilder 自动实现 interface.go:136-145, handler_builder.go:90-105
怎么附加 全局(AppendGlobalHandlers)+ 按调用(compose.WithCallbacks) interface.go:86-105
执行顺序 OnStart 反向(全局先),OnEnd/OnError 正向(per-invocation 先) inject.go:107-125
状态怎么传递 同一 handler 内用 context.WithValue;不同 handler 不保证顺序 doc.go:91-104
流式怎么处理 BuildOnEndHandleWithCopy 复制 N 份,每个 handler 独立副本 inject.go:127-141
Agent 层怎么用 AgentCallbackInput/Output + initAgentCallbacks adk/callback.go:29-44, 116-128

几个设计判断:

  • HandlerBuilder + TimingChecker 是"按需付费"模式。 不注册的时机零开销------不分配 goroutine,不复制 stream。这对性能敏感的场景很重要。

  • OnStart 反向顺序是 middleware 包裹约定。 如果你注册了 3 个 handler A、B、C,OnStart 执行顺序是 C→B→A,OnEnd 是 A→B→C。这跟 HTTP middleware 的洋葱模型一致。

  • 全局 + 按调用两层附加覆盖了两种场景。 全局适合基础设施(tracing、metrics),按调用适合业务逻辑(日志、计费)。

  • Agent 层的 OnEnd 用 BuildOnEndHandleWithCopy 复制事件流。 每个 handler 收到独立的事件流副本,可以异步消费而不阻塞 Agent 执行。

下一篇(E96)讲 Agent 怎么测试------mock LLM + 状态机表驱动,不调真模型的测试策略。

相关推荐
YakProject1 小时前
如何让 AI Agent 跑得更快???
人工智能·测试工具·网络安全·agent·yakit·yaklang
icestone20001 小时前
从Cursor到Claude Code:一年半AI编程实战经验
ai编程·claude·cursor
怕浪猫1 小时前
深入 Cordis 核心:这个插件框架比 Webpack Tapable 还好用
aigc·agent·ai编程
sg_knight2 小时前
openCode 安装与初始化配置(Windows / macOS / Linux)
linux·windows·macos·llm·agent·ai编程·opencode
AI发掘2 小时前
知网AIGC检测怎么降?快降重和比话降AI谁更稳?实测对比
人工智能·aigc
Helen_Liang_七仔的AI工具箱2 小时前
找工作难在投错方向,附自动找Offer skill安装地址
aigc·找工作
七牛云行业应用4 小时前
Cursor 如何接入自定义模型:Override Base URL 完整配置与四类踩坑速查
人工智能·agent·ai编程
张忠琳11 小时前
【deepseek-harness】DeepSeek Harness (dsh) 系统级架构分析之三
ai·agent·deepseek·harness