Eino devops 调试系统源码:GraphCompileCallback 怎么工作(第50篇-E36)
系列「企业级 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)
}
OnFinish 在 graph.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 这一行代码的核心机制:
compose.InitGraphCompileCallbacks向 Eino 全局注册一个GraphCompileCallback- 每次
Compile()完成,OnFinish(ctx, *GraphInfo)被调用,GraphInfo携带完整静态结构 - 自动图命名通过
runtime.Callers走调用栈找到"你的代码"的位置 - 子图递归展开,每个嵌套图都被单独注册到内存注册表
- 调试执行时,
BuildDevGraph按起始节点裁剪子图,运行时节点 Callback 推送NodeDebugStateSSE 帧
整个系统的入侵性极低:你的 Agent 代码不需要改任何一行,只要 devops.Init 在程序启动时调用即可。
更多推荐


所有评论(0)