26可观测性、调试与部署
callback → Langfuse/APMPlus/CozeLoop、Eino Dev 可视化、A2UI 全栈。
callbacks/interface.go:85callbacks/aspect_inject.go:74eino-ext/callbacks/langfuse/langfuse.go:128eino-examples/devops/visualize/mermaid.go:74给系统装上眼睛:可观测、可调试、可上线
功能跑通只是开始。一个 Agent 上了生产,你要能回答三个问题:它现在在做什么?出问题时哪一步错了?我怎么把它稳定地部署出去? 这一章讲 Eino 的可观测性三件套——callback 机制、Langfuse/APMPlus/CozeLoop 集成、Eino Dev 可视化——以及它们背后同一个设计原则:观测能力不该侵入业务代码,而应作为切面被”注入”。
flowchart TB GRAPH["业务图(每个节点自动埋点)"] --> H["callbacks.Handler 接口<br/>OnStart / OnEnd / OnError<br/>OnStartWithStreamInput / OnEndWithStreamOutput"] H -->|"换 handler,业务零改动"| LF["Langfuse<br/>trace 树 / Generation"] H --> AP["APMPlus<br/>OTel 指标 + span"] H --> CL["CozeLoop<br/>einoTracer span"] H -.->|"TimingChecker.Needed"| GATE["不关心的时机零开销跳过"]
callback 切面:五时机,一个接口,三种后端
callback:一个五时机的切面接口
一切可观测性的地基,是 callbacks.Handler 这个接口(callbacks/interface.go:85,实际定义在 internal/callbacks/interface.go:38):
// Handler is the unified callback handler interface. Implement all five// methods (OnStart, OnEnd, OnError, OnStartWithStreamInput,// OnEndWithStreamOutput) or use [NewHandlerBuilder] to set only the timings// you care about.//// Each method receives the context returned by the previous timing of the// SAME handler, which lets a single handler pass state between its OnStart// and OnEnd calls via context.WithValue. There is NO guaranteed execution// order between DIFFERENT handlers, and the context chain does not flow// from one handler to the next — do not rely on handler ordering.//// Implement [TimingChecker] (the Needed method) on your handler so the// framework can skip timings you have not registered; this avoids unnecessary// stream copies and goroutine allocations on every component invocation.//// Stream handlers (OnStartWithStreamInput, OnEndWithStreamOutput) receive a// [*schema.StreamReader] that has already been copied; they MUST close their// copy after reading. If any handler's copy is not closed, the original stream// cannot be freed, causing a goroutine/memory leak for the entire pipeline.//// Important: do NOT mutate the Input or Output values. All downstream nodes// and handlers share the same pointer (direct assignment, not a deep copy).// Mutations cause data races in concurrent graph execution.type Handler = callbacks.Handlertype Handler interface { OnStart(ctx, info *RunInfo, input CallbackInput) context.Context OnEnd(ctx, info *RunInfo, output CallbackOutput) context.Context OnError(ctx, info *RunInfo, err error) context.Context OnStartWithStreamInput(ctx, info *RunInfo, input *schema.StreamReader[CallbackInput]) context.Context OnEndWithStreamOutput(ctx, info *RunInfo, output *schema.StreamReader[CallbackOutput]) context.Context}五个方法,对应五个时机:开始、结束、出错,以及流式输入的开始、流式输出的结束。每个方法都带一个 RunInfo(internal/callbacks/interface.go:26)——里面有节点名、类型、组件类别,告诉你”现在是谁在触发这个回调”。注意每个方法都返回 context.Context:这是 handler 在 OnStart 里种下状态、OnEnd 里取回来做关联的通道。
type RunInfo struct { // Name is the graph node name for display purposes, not unique. // Passed from compose.WithNodeName(). Name string Type string Component components.Component}框架怎么调这些回调?组件在自己的方法里主动埋点,调用切面函数,比如 callbacks.OnStart[T](callbacks/aspect_inject.go:74):
// OnStart invokes the OnStart timing for all registered handlers in the// context. This is called by component implementations that manage their own// callbacks (i.e. implement [components.Checker] and return true from// IsCallbacksEnabled). The returned context must be propagated to subsequent// OnEnd/OnError calls so handlers can correlate start and end events.//// Handlers are invoked in reverse registration order (last registered = first// called) to match the middleware wrapping convention.//// Example — typical usage inside a component's Generate method://// func (m *myChatModel) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {// ctx = callbacks.OnStart(ctx, &model.CallbackInput{Messages: input})// resp, err := m.doGenerate(ctx, input, opts...)// if err != nil {// callbacks.OnError(ctx, err)// return nil, err// }// callbacks.OnEnd(ctx, &model.CallbackOutput{Message: resp})// return resp, nil// }func OnStart[T any](ctx context.Context, input T) context.Context { ctx, _ = callbacks.On(ctx, input, callbacks.OnStartHandle[T], TimingOnStart, true)
return ctx}func (m *myChatModel) Generate(ctx context.Context, input []*schema.Message, ...) { ctx = callbacks.OnStart(ctx, &model.CallbackInput{Messages: input}) resp, err := m.doGenerate(ctx, input, opts...) if err != nil { callbacks.OnError(ctx, err); return nil, err } callbacks.OnEnd(ctx, &model.CallbackOutput{Message: resp}) return resp, nil}业务代码只管”发生了什么”,观测代码在别处订阅。而且框架会在图节点外面自动注入这些回调,所以你甚至不用手写——挂上 handler,整张图的每个节点就都被观测到了。
🔑 本章的设计钥匙
Eino 的可观测性是一次干净的面向切面(AOP)实践:观测不是散落在业务逻辑里的
log.Print,而是通过五时机的Handler接口从外部注入。这带来三个连锁的好处。其一,零侵入:同一份业务图,挂 Langfuse 就上报到 Langfuse,挂 APMPlus 就变成 OTel 指标,挂 CozeLoop 就成 span——业务代码一行不改。其二,性能可控:handler 通过TimingChecker.Needed(internal/callbacks/interface.go:52)声明自己只关心哪些时机,不需要流拷贝的时机就零开销跳过。其三,顺序即语义:OnStart按注册的逆序调用、OnEnd/OnError按正序调用——完全是中间件”洋葱模型”的包裹/解包约定。观测、调试、追踪,本质上都是同一个切面机制的不同 handler 实现而已。type TimingChecker interface {Needed(ctx context.Context, info *RunInfo, timing CallbackTiming) bool}
三种后端,同一个接口
正因为 handler 是接口,接同一份图到不同观测后端就成了”换一个 handler”的事:
Langfuse(eino-ext/callbacks/langfuse/langfuse.go:128)——LLM 专用的 trace 平台。它的 handler(eino-ext/callbacks/langfuse/langfuse.go:180)在 OnStart 里判断组件类型:如果是 ChatModel,就建一个 Generation(带输入消息、模型元数据);否则建一个普通 Span。span 之间靠 ParentObservationID 嵌套成一棵调用树。状态通过 context 里的私有 key(langfuseStateKey{},eino-ext/callbacks/langfuse/langfuse.go:191)在 OnStart→OnEnd 间传递。
func NewLangfuseHandler(cfg *Config) (handler *CallbackHandler, flusher func()) { var langfuseOpts []langfuse.Option if cfg.Threads > 0 { langfuseOpts = append(langfuseOpts, langfuse.WithThreads(cfg.Threads)) } if cfg.Timeout > 0 { langfuseOpts = append(langfuseOpts, langfuse.WithTimeout(cfg.Timeout)) } if cfg.MaxTaskQueueSize > 0 { langfuseOpts = append(langfuseOpts, langfuse.WithMaxTaskQueueSize(cfg.MaxTaskQueueSize)) } if cfg.MaxEventSizeBytes > 0 { langfuseOpts = append(langfuseOpts, langfuse.WithMaxEventSizeBytes(cfg.MaxEventSizeBytes)) } if cfg.FlushAt > 0 { langfuseOpts = append(langfuseOpts, langfuse.WithFlushAt(cfg.FlushAt)) } if cfg.FlushInterval > 0 { langfuseOpts = append(langfuseOpts, langfuse.WithFlushInterval(cfg.FlushInterval)) } if cfg.SampleRate > 0 { langfuseOpts = append(langfuseOpts, langfuse.WithSampleRate(cfg.SampleRate)) } if len(cfg.LogMessage) > 0 { langfuseOpts = append(langfuseOpts, langfuse.WithLogMessage(cfg.LogMessage)) } if cfg.MaskFunc != nil { langfuseOpts = append(langfuseOpts, langfuse.WithMaskFunc(cfg.MaskFunc)) } if cfg.MaxRetry > 0 { langfuseOpts = append(langfuseOpts, langfuse.WithMaxRetry(cfg.MaxRetry)) }
cli := langfuse.NewLangfuse( cfg.Host, cfg.PublicKey, cfg.SecretKey, langfuseOpts..., )
return &CallbackHandler{ cli: cli,
name: cfg.Name, userID: cfg.UserID, sessionID: cfg.SessionID, release: cfg.Release, tags: cfg.Tags,// … 省略 3 行;完整声明 L128–178,点击上方「浏览完整文件」type CallbackHandler struct { cli langfuse.Langfuse
name string userID string sessionID string release string tags []string public bool}type langfuseStateKey struct{}APMPlus——基于 OpenTelemetry 的指标 + 追踪。它在 OnStart 起一个 OTel span,同时记录 token 用量、调用次数、耗时、首 token 延迟(TTFT)等一组直方图。它是”指标”取向的,和 Langfuse 的”trace 树”取向不同,但对外都是同一个 Handler。
CozeLoop——它用一个包装 handler 套一个内部 tracer(einoTracer),OnStart 里 StartSpan、OnEnd 里 span.Finish,并用可插拔的 CallbackDataParser 把 Eino 的输入输出转成 CozeLoop 的 span 标签。
三者实现天差地别(trace 树 / OTel 指标 / span),但接入方式完全一致:构造 handler,然后 callbacks.AppendGlobalHandlers(...) 全局挂上,或 compose.WithCallbacks(...) 单次调用挂上。这就是接口抽象的红利:业务图对”用哪个观测后端”一无所知。
流式回调的性能闸门
有个容易忽略但很关键的设计:流式时机。当组件的输入/输出是流,框架会给每个 handler 各发一份独立的流拷贝(用 Part IV 的 Copy),这样一个 handler 消费流不会影响别人。但流拷贝有成本——所以有了 TimingChecker:
type TimingChecker interface { Needed(ctx, info *RunInfo, timing CallbackTiming) bool}用 HandlerBuilder 构造的 handler 会自动实现 Needed:哪个时机的回调函数是 nil,就对哪个时机返回 false。于是不消费流的 handler,框架根本不给它拷流。性能不是靠使用者小心,而是靠接口设计天然规避——这是”零成本抽象”在观测层的体现。而每个消费流的 handler 都必须 defer input.Close(),否则会漏掉 pipeline 的 goroutine——三个官方集成无一例外都遵守。
Eino Dev 与可视化:从 GraphInfo 长出一张图
光有运行时的 trace 还不够,你还想在编译期就看清图长什么样。Eino 用另一类回调做到:图编译回调 GraphCompileCallback(compose/introspect.go:55):
type GraphCompileCallback interface { OnFinish(ctx context.Context, info *GraphInfo)}编译完成时,框架把一份完整的 GraphInfo(compose/introspect.go:41,含所有节点、控制边、数据边、分支、输入输出类型)交给回调。Mermaid 可视化器就是这么工作的——它的入口 OnFinish(eino-examples/devops/visualize/mermaid.go:74)拿到 GraphInfo 后,递归遍历节点和边,吐出一段 Mermaid 图代码。有个巧妙的判定:如果控制边数量多于数据边(eino-examples/devops/visualize/mermaid.go:83),就认定这是个 Workflow,用不同的样式渲染——这正好呼应 Ch20 讲的”Workflow 用字段映射改写连线”。
// GraphInfo the info which end users pass in when they are compiling a graph.// it is used in compile callback for user to get the node info and instance.// you may need all details info of the graph for observation.type GraphInfo struct { CompileOptions []GraphCompileOption Nodes map[string]GraphNodeInfo // node key -> node info Edges map[string][]string // edge start node key -> edge end node key, control edges DataEdges map[string][]string Branches map[string][]GraphBranch // branch start node key -> branch InputType, OutputType reflect.Type Name string
NewGraphOptions []NewGraphOption GenStateFn func(context.Context) any}// OnFinish is the compile callback entrypoint invoked by Eino after graph compilation.// It reads the compile-time GraphInfo and writes a complete Mermaid diagram to the writer.func (m *MermaidGenerator) OnFinish(_ context.Context, info *compose.GraphInfo) { m.generate(info)}// generate orchestrates diagram construction by delegating to renderGraph.// The top-level direction is TD (top-down) for readability and consistency.func (m *MermaidGenerator) generate(info *compose.GraphInfo) { isWorkflow := m.workflowStyle if !isWorkflow { if len(info.Edges) > len(info.DataEdges) { isWorkflow = true }
if !isWorkflow { for from, edges := range info.Edges { dataEdges, ok := info.DataEdges[from] if !ok { isWorkflow = true break }
if len(edges) != len(dataEdges) { isWorkflow = true break }
for i := range edges { edge := edges[i] found := false for _, dEdge := range dataEdges { if dEdge == edge { found = true break } } if !found { isWorkflow = true break } } } } }
sb := &strings.Builder{} sb.WriteString("graph TD\n") m.renderGraph(sb, info, "", 1, isWorkflow) if m.w != nil && !m.autoWrite { _, _ = fmt.Fprint(m.w, sb.String()) return }
// … 省略 26 行;完整声明 L78–151,点击上方「浏览完整文件」更进一步,eino-ext 的 devops 包提供了 Eino Dev 调试服务器:devops.Init(ctx) 启一个本地 HTTP 服务(默认 127.0.0.1:52538),配合 IDE 插件,你能在浏览器里看图的拓扑、单步调试每个节点。这就是把”编译期 GraphInfo + 运行期 callback”两类信息合起来,变成一个可视化调试台。
📝 两类回调,别混淆
- 运行时回调(
callbacks.Handler):观测每次执行——输入、输出、耗时、错误。用于 Langfuse/APMPlus/CozeLoop 上报。- 编译期回调(
GraphCompileCallback):观测每次编译——图的静态结构。用于 Mermaid 出图、Eino Dev 拓扑展示。同一个”回调”思想,一个盯执行、一个盯结构——这是把可观测性贯彻到框架每个阶段的体现。
部署:从 A2UI 到全栈
回到上一章的 chatwitheino,它已经示范了一条务实的部署路径:用 Hertz hserver.Default 起 HTTP 服务、注册 /chat、/approve、/abort 等路由并 Spin(quickstart/chatwitheino/server/server.go:182),聊天走 SSE 流式推送(sse.NewStream + 定时心跳保活),配置全部走环境变量。而观测 handler 在 main 启动时用 callbacks.AppendGlobalHandlers 全局挂上(quickstart/chatwitheino/main.go:58),于是每次请求都自动上报——挂不挂 CozeLoop 只取决于环境变量是否给了 token。部署不是另起炉灶,而是把这一章的观测 + 上一章的应用 + 一个 Web 框架拼起来——依然是组合。
// Spin starts the HTTP server (blocking).func (s *Server[M]) Spin() { h := hserver.Default(hserver.WithHostPorts(":" + s.cfg.Port))
h.GET("/", func(ctx context.Context, c *app.RequestContext) { data, err := os.ReadFile("static/index.html") if err != nil { c.JSON(consts.StatusNotFound, map[string]string{"error": "index.html not found"}) return } c.Data(consts.StatusOK, "text/html; charset=utf-8", data) })
h.POST("/sessions", func(ctx context.Context, c *app.RequestContext) { id := uuid.New().String() if _, err := s.cfg.Store.GetOrCreate(id); err != nil { c.JSON(consts.StatusInternalServerError, map[string]string{"error": err.Error()}) return } c.JSON(consts.StatusOK, map[string]string{"id": id}) })
h.GET("/sessions", func(ctx context.Context, c *app.RequestContext) { metas, err := s.cfg.Store.List() if err != nil { c.JSON(consts.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if metas == nil { metas = []mem.SessionMeta{} } c.JSON(consts.StatusOK, metas) })
h.DELETE("/sessions/:id", func(ctx context.Context, c *app.RequestContext) { id := c.Param("id") // Stop any running loop for this session. ts := s.getTurnState(id) ts.mu.Lock() if ts.loop != nil { ts.loop.Stop(adk.WithImmediate()) ts.loop = nil } ts.mu.Unlock()
if err := s.cfg.Store.Delete(id); err != nil { c.JSON(consts.StatusInternalServerError, map[string]string{"error": err.Error()}) return// … 省略 27 行;完整声明 L181–255,点击上方「浏览完整文件」func main() { ctx := context.Background()
// setup cozeloop tracing (optional) // COZELOOP_WORKSPACE_ID=your workspace id // COZELOOP_API_TOKEN=your token cozeloopApiToken := os.Getenv("COZELOOP_API_TOKEN") cozeloopWorkspaceID := os.Getenv("COZELOOP_WORKSPACE_ID") if cozeloopApiToken != "" && cozeloopWorkspaceID != "" { client, err := cozeloop.NewClient( cozeloop.WithAPIToken(cozeloopApiToken), cozeloop.WithWorkspaceID(cozeloopWorkspaceID), ) if err != nil { log.Fatalf("cozeloop.NewClient failed: %v", err) } defer func() { time.Sleep(5 * time.Second) client.Close(ctx) }() callbacks.AppendGlobalHandlers(clc.NewLoopHandler(client)) }
switch msgops.KindFromEnv() { case msgops.KindAgentic: runTyped[*schema.AgenticMessage](ctx) default: runTyped[*schema.Message](ctx) }}本章小结
- 可观测性的地基是五时机的
callbacks.Handler接口(callbacks/interface.go:85),组件用切面函数callbacks.OnStart[T]等(callbacks/aspect_inject.go:74)主动埋点,框架在图节点外自动注入。 - 同一份图,换 handler 就换后端:Langfuse 建 trace 树(
eino-ext/callbacks/langfuse/langfuse.go:128)、APMPlus 出 OTel 指标、CozeLoop 出 span——业务代码零改动。 - 性能靠
TimingChecker.Needed(internal/callbacks/interface.go:52)天然规避:不消费流的时机零开销;调用顺序遵循中间件逆序/正序的洋葱约定。 - 编译期回调
GraphCompileCallback(compose/introspect.go:55)把GraphInfo交给 Mermaid 可视化器(eino-examples/devops/visualize/mermaid.go:74)出图;Eino Dev 起本地服务做单步调试。 - 部署即组合:观测 handler + 应用(agent + server)+ Hertz/SSE,拼成可上线的全栈应用。
- 设计钥匙:可观测性是一次干净的 AOP——观测/调试/追踪只是同一个切面机制的不同 handler。
最后一章,我们挑一个真正复杂的综合项目深读,并对全书所见的 Eino 设计哲学做一次收束式的复盘。