系列「企业级 AI Agent 实现拆解」E36 篇,Part 9 源码深度篇第二章。上一篇 介绍了
devops.Init这一行代码能捕获所有 Graph 的调试信息。这篇拆源码,看这行代码在编译期和运行期分别插了什么钩子。
读完这篇你会知道
GraphCompileCallback接口:钩子在编译期还是运行期触发 InitGraphCompileCallbacks:全局注册 vs 单 Graph 注册的差别 GraphInfo:编译时捕获了哪些结构信息 自动图命名:用 runtime.Callers走调用栈的技巧AddGraphInfo递归:子图是怎么被一并注册的
全景图
devops.Init(ctx)│▼compose.InitGraphCompileCallbacks([NewGlobalDevGraphCompileCallback()]) ← 注册到全局 slice│↓ (当任意 graph.Compile() 调用时)compose.GraphCompileCallback.OnFinish(ctx, *GraphInfo)│├── getCompileFrame() ← 走调用栈,自动生成图名├── ContainerSVC.AddGraphInfo(name, graphInfo) ← 存到内存注册表│ └── 递归注册所有子图│▼HTTP Server 127.0.0.1:52538├── GET /graphs ← 列出所有注册的图├── GET /graphs/{id}/canvas ← 返回节点-边拓扑 (Canvas)├── POST /graphs/{id}/threads ← 创建调试线程└── POST /graphs/{id}/threads/{tid}/stream ← SSE 逐节点输出 NodeDebugState
编译期钩子:GraphCompileCallback
Eino 的 compose.GraphCompileCallback 是一个极简接口:
// eino/compose/introspect.gotype GraphCompileCallback interface {OnFinish(ctx context.Context, info *GraphInfo)}
OnFinish 在 graph.Compile() 完成后同步调用,此时图的所有节点、边、分支都已确定,*GraphInfo 携带完整的静态结构:
// eino/compose/introspect.gotype GraphInfo struct {Nodes map[string]GraphNodeInfo // 节点 key → 节点信息(组件类型、实例、字段映射)Edges map[string][]string // 控制边:from → []toDataEdges map[string][]string // 数据边:from → []toBranches map[string][]GraphBranch // 分支路由:起始节点 → []分支InputType, OutputType reflect.Type // 图整体的输入/输出 Go 类型Name string // 图名(WithGraphName 设置)// ...}
关键点:GraphInfo 是静态结构,不包含运行时数据(没有输入值、没有输出值)。devops 用它来渲染 Canvas 图和判断哪些节点可以作为调试起点。
全局注册
devops.Init 调用 compose.InitGraphCompileCallbacks:
// devops/internal/apihandler/debug.gofunc InitDebug(opt *model.DevOpt) {compose.InitGraphCompileCallbacks([]compose.GraphCompileCallback{service.NewGlobalDevGraphCompileCallback(),})}
// eino/compose/graph_compile_options.govar globalGraphCompileCallbacks []GraphCompileCallbackfunc InitGraphCompileCallbacks(cbs []GraphCompileCallback) {globalGraphCompileCallbacks = cbs // 全局赋值,影响所有后续 Compile 调用}
// eino/compose/generic_graph.gofunc(g *graph) Compile(ctx context.Context, opts ...GraphCompileOption) (...) {if len(globalGraphCompileCallbacks) > 0 {// 全局 callbacks 优先级最高,prepend 到 opts 前面opts = append([]GraphCompileOption{WithGraphCompileCallbacks(globalGraphCompileCallbacks...)},opts...)}// ...编译逻辑...// 编译完成后,触发所有 callbacksfor _, cb := range compileOpts.callbacks {cb.OnFinish(ctx, &graphInfo)}}
只要 devops.Init 在任何 Compile 调用之前执行,之后所有图都会自动被捕获,不需要修改 Agent 代码。
自动图命名:call stack trick
如果你没调 compose.WithGraphName("my-graph"),devops 会自动生成一个名字——通过走 Go 调用栈来找"是谁调了 Compile":
// devops/internal/service/callback.gofuncNewGlobalDevGraphCompileCallback() compose.GraphCompileCallback {onFinish := func(ctx context.Context, graphInfo *compose.GraphInfo) {frame := getCompileFrame(graphCompileCallerDepth) // 走调用栈// ...graphName := graphInfo.Nameif graphName == "" {graphName = genGraphName(frame) // 用调用者的 file:func:line 生成名字}ContainerSVC.AddGraphInfo(graphName, graphInfo)}}funcgetCompileFrame(startSkip int) runtime.Frame {for i := 0; i < 15; i++ {pcs := make([]uintptr, 1)runtime.Callers(startSkip+i, pcs)frame, _ := runtime.CallersFrames(pcs).Next()if !strings.Contains(frame.Function, "github.com/cloudwego/eino/") {break // ← 找到第一个"跳出 Eino 包"的帧}}return frame}funcgenGraphName(frame runtime.Frame) string {// 取文件名(去 .go 后缀)+ 函数名(去包路径前缀)+ 行号// 例如:main.setupAgent:42return fmt.Sprintf("%s.%s:%d", file, fun, frame.Line)}
原理:OnFinish 被 Eino 内部调用,调用栈深处是你的业务代码。getCompileFrame 从栈顶往下遍历,找到第一个不属于 github.com/cloudwego/eino/ 的帧——那就是你调 chain.Compile() 的位置。
防递归守卫:
if strings.Contains(frame.Function, devIdentify) {return // devIdentify = "devops/internal/model.(*Graph).Compile"}
devops 自己在构建调试子图时也会调 Compile,这个检查防止它递归注册自己。
递归子图注册
一个 Graph 可以嵌套子 Graph。AddGraphInfo 递归展开:
// devops/internal/service/container.govar add func(parentID, parentName string, gi *compose.GraphInfo, ...) stringadd = func(pgid, pgn string, pgi *compose.GraphInfo, ...) string {gid := generateID(pgn)for nodeName, nodeInfo := range pgi.Nodes {if nodeInfo.GraphInfo == nil {continue // 普通节点,跳过}// 节点是一个子图 → 递归注册sgid, _ := add(gid, nodeName, nodeInfo.GraphInfo, ...)}container[gid] = &GraphContainer{Name: pgn,GraphInfo: &model.GraphInfo{GraphInfo: pgi},ParentID: pgid,}return gid}
最终,调试面板能展示每个子图的独立 Canvas,并且知道哪个子图属于哪个父图——这来自 ParentID 字段。
运行期:BuildDevGraph + NodeDebugState
编译期捕获的是结构,运行期调试需要真正执行并推送节点状态。
当用户在调试面板选择"从 ChatModel 节点开始执行"时,调用 StreamDebugRun:
// devops/internal/service/container.gograph, err := model.BuildDevGraph(c.GraphInfo, fromNode) // 构建从 fromNode 开始的子图// 执行子图,每个节点完成后推一帧 SSE
BuildDevGraph 接收 GraphInfo 和起始节点名,裁剪出该节点及其所有后续节点组成的子图,重新编译后执行。
每个节点完成后推送 NodeDebugState:
type NodeDebugState struct {NodeKey stringInput json.RawMessageOutput json.RawMessageMetrics NodeDebugMetricsErr string}type NodeDebugMetrics struct {StartTime int64 // Unix 毫秒EndTime int64InputTokens intOutputTokens int}
调试面板实时展示每个节点的输入、输出、耗时和 Token 数——这些数据不来自 GraphInfo(静态),而来自真正执行时的 Callback 注入。
设计亮点:编译时捕获 vs 运行时追踪
Compile() | ||
NodeDebugState | ||
GraphCompileCallback.OnFinish |
两个钩子系统分工清晰:编译期给 devops 看结构,运行期给 devops 看数据。任何 Graph 只要被 Compile(),结构就自动注册;只要有调试线程在执行,节点数据就自动推送。
小结
devops.Init 这一行代码的核心机制:
compose.InitGraphCompileCallbacks向 Eino 全局注册一个 GraphCompileCallback每次 Compile()完成,OnFinish(ctx, *GraphInfo)被调用,GraphInfo携带完整静态结构自动图命名通过 runtime.Callers走调用栈找到"你的代码"的位置子图递归展开,每个嵌套图都被单独注册到内存注册表 调试执行时, BuildDevGraph按起始节点裁剪子图,运行时节点 Callback 推送NodeDebugStateSSE 帧
整个系统的入侵性极低:你的 Agent 代码不需要改任何一行,只要 devops.Init 在程序启动时调用即可。
代码来源:eino/compose/introspect.go · eino-ext/devops
夜雨聆风