系列「企业级 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.go
type GraphCompileCallback interface {
    OnFinish(ctx context.Context, info *GraphInfo)
}

OnFinishgraph.Compile() 完成后同步调用,此时图的所有节点、边、分支都已确定,*GraphInfo 携带完整的静态结构:

// eino/compose/introspect.go
type GraphInfo struct {
    Nodes     map[string]GraphNodeInfo   // 节点 key → 节点信息(组件类型、实例、字段映射)
    Edges     map[string][]string        // 控制边:from → []to
    DataEdges map[string][]string        // 数据边:from → []to
    Branches  map[string][]GraphBranch   // 分支路由:起始节点 → []分支
    InputType, OutputType reflect.Type   // 图整体的输入/输出 Go 类型
    Name      string                     // 图名(WithGraphName 设置)
    // ...
}

关键点: GraphInfo静态结构,不包含运行时数据(没有输入值、没有输出值)。devops 用它来渲染 Canvas 图和判断哪些节点可以作为调试起点。


全局注册

devops.Init 调用 compose.InitGraphCompileCallbacks

// devops/internal/apihandler/debug.go
func InitDebug(opt *model.DevOpt) {
    compose.InitGraphCompileCallbacks([]compose.GraphCompileCallback{
        service.NewGlobalDevGraphCompileCallback(),
    })
}
// eino/compose/graph_compile_options.go
var globalGraphCompileCallbacks []GraphCompileCallback

func InitGraphCompileCallbacks(cbs []GraphCompileCallback) {
    globalGraphCompileCallbacks = cbs  // 全局赋值,影响所有后续 Compile 调用
}
// eino/compose/generic_graph.go
func (g *graph) Compile(ctx context.Context, opts ...GraphCompileOption) (...) {
    if len(globalGraphCompileCallbacks) > 0 {
        // 全局 callbacks 优先级最高,prepend 到 opts 前面
        opts = append([]GraphCompileOption{
            WithGraphCompileCallbacks(globalGraphCompileCallbacks...)},
            opts...)
    }
    // ...编译逻辑...
    // 编译完成后,触发所有 callbacks
    for _, 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.go
func NewGlobalDevGraphCompileCallback() compose.GraphCompileCallback {
    onFinish := func(ctx context.Context, graphInfo *compose.GraphInfo) {
        frame := getCompileFrame(graphCompileCallerDepth)  // 走调用栈
        // ...
        graphName := graphInfo.Name
        if graphName == "" {
            graphName = genGraphName(frame)  // 用调用者的 file:func:line 生成名字
        }
        ContainerSVC.AddGraphInfo(graphName, graphInfo)
    }
}

func getCompileFrame(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
}

func genGraphName(frame runtime.Frame) string {
    // 取文件名(去 .go 后缀)+ 函数名(去包路径前缀)+ 行号
    // 例如:main.setupAgent:42
    return 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.go
var add func(parentID, parentName string, gi *compose.GraphInfo, ...) string
add = 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.go
graph, err := model.BuildDevGraph(c.GraphInfo, fromNode)  // 构建从 fromNode 开始的子图
// 执行子图,每个节点完成后推一帧 SSE

BuildDevGraph 接收 GraphInfo 和起始节点名,裁剪出该节点及其所有后续节点组成的子图,重新编译后执行。

每个节点完成后推送 NodeDebugState

type NodeDebugState struct {
    NodeKey    string
    Input      json.RawMessage
    Output     json.RawMessage
    Metrics    NodeDebugMetrics
    Err        string
}

type NodeDebugMetrics struct {
    StartTime    int64  // Unix 毫秒
    EndTime      int64
    InputTokens  int
    OutputTokens int
}

调试面板实时展示每个节点的输入、输出、耗时和 Token 数——这些数据不来自 GraphInfo(静态),而来自真正执行时的 Callback 注入。


设计亮点:编译时捕获 vs 运行时追踪

编译时(GraphCompileCallback) 运行时(节点 Callback)
触发时机 Compile() 完成后 每次节点执行时
捕获内容 静态结构:节点列表、边、类型信息 动态数据:输入值、输出值、耗时
用途 渲染 Canvas 图、创建调试线程 NodeDebugState SSE 流
实现 GraphCompileCallback.OnFinish Eino callbacks.Handler

两个钩子系统分工清晰:编译期给 devops 看结构,运行期给 devops 看数据。任何 Graph 只要被 Compile(),结构就自动注册;只要有调试线程在执行,节点数据就自动推送。


小结

devops.Init 这一行代码的核心机制:

  1. compose.InitGraphCompileCallbacks 向 Eino 全局注册一个 GraphCompileCallback
  2. 每次 Compile() 完成,OnFinish(ctx, *GraphInfo) 被调用,GraphInfo 携带完整静态结构
  3. 自动图命名通过 runtime.Callers 走调用栈找到"你的代码"的位置
  4. 子图递归展开,每个嵌套图都被单独注册到内存注册表
  5. 调试执行时,BuildDevGraph 按起始节点裁剪子图,运行时节点 Callback 推送 NodeDebugState SSE 帧

整个系统的入侵性极低:你的 Agent 代码不需要改任何一行,只要 devops.Init 在程序启动时调用即可。


代码来源:eino/compose/introspect.go · eino-ext/devops

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐