上一篇 拆了 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:标识组件身份
每个时机回调都收到 RunInfo(internal/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 / CallbackOutput 是 any 类型。使用时用组件包提供的转换函数:
go
mi := model.ConvCallbackInput(input) // 把 any 转为 *model.CallbackInput
if mi != nil {
// 这是 ChatModel 的调用,可以安全访问 mi.Messages
}
(三)TimingChecker:零开销跳过
interface.go:136-145 的 TimingChecker 接口:
go
type TimingChecker interface {
Needed(ctx context.Context, info *RunInfo, timing CallbackTiming) bool
}
用 HandlerBuilder 构建的 handler 自动实现了 TimingChecker(handler_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
// ...
}
}
效果:如果只注册了 OnStart 和 OnEnd,框架在 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)-1到0)------遵循 middleware 包裹约定 - OnEnd / OnError :正向顺序(
0到len(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()
同一个 handler 的 OnStart 返回的 ctx 会传递给它的 OnEnd。用 context.WithValue 在 OnStart 存状态,在 OnEnd 读状态。
不同 handler 之间没有保证的执行顺序,也没有 context 链。要在 handler 之间共享状态,需要存在并发安全的变量中。
(六)流式 handler 副本机制
inject.go:127-141 的 BuildOnEndHandleWithCopy:
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 独立副本
}
initAgentCallbacks(adk/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 总结了常见陷阱:
- 流式副本必须 close:否则 pipeline goroutine 泄漏
- 不要修改 Input/Output:所有下游节点和 handler 共享同一个指针,修改会导致并发数据竞争
- AppendGlobalHandlers 不是线程安全的:只在初始化时调用
- 流式错误不会触发 OnError :stream 内部错误通过
Recv返回 error,不经过 OnError - 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 + 状态机表驱动,不调真模型的测试策略。