19两套执行引擎:Pregel vs DAG
同一 run 循环靠替换 channel 切换语义;AnyPredecessor(支持环)vs AllPredecessor(skip 传播)。
compose/graph_run.go:241compose/graph_manager.go:29compose/pregel.go:25compose/dag.go:50compose/graph.go:679同一个循环,两种世界观
前一章你看到,ADK 里的一切——ReAct、多智能体、中断续跑——最终都编译成 compose 的图去跑。但”图”本身也有两种截然不同的执行方式。这一章我们钻进 compose 引擎最核心的那口”锅”:同一段执行循环,如何靠替换一个组件,就在两种世界观之间切换?
先给结论,后面逐段验证:compose 只有一个 run 循环,但它接受两种”channel”策略——一种叫 Pregel(容忍环),一种叫 DAG(禁止环、会传播 skip)。你在 Part I/III 见过的 ReAct 循环之所以能有返回边,正因为它跑在 Pregel 模式下。
flowchart TB
LOOP["唯一的 run 循环(逐轮推进)<br/>提交任务 → 等待完成 → 算下一批<br/>compose/graph_run.go"]
LOOP -->|"每轮询问 channel.get()"| CH{"channel 策略"}
CH --> PREGEL["Pregel channel<br/>任一上游到货即激活<br/>容忍环 · 有 maxSteps 上限"]
CH --> DAG["DAG channel<br/>全部依赖就绪才激活<br/>禁止环 · 传播 skip"]
PREGEL -.-> REACT["ReAct 返回边靠它"]
一个逐轮推进的 run 循环 + 两种可替换的 channel 策略
唯一的 run 循环
整台引擎的心脏,是 graph_run.go 里那个 runner.run(compose/graph_run.go:109)。无论 Invoke 还是 Transform、无论 Pregel 还是 DAG,最终都汇进它。循环体本身极其规整(compose/graph_run.go:241):
func (r *runner) run(ctx context.Context, isStream bool, input any, opts ...Option) (result any, err error) { haveOnStart := false // delay triggering onGraphStart until state initialization is complete, so that the state can be accessed within onGraphStart. defer func() { if !haveOnStart { ctx, input = onGraphStart(ctx, input, isStream) } if err != nil { ctx, err = onGraphError(ctx, err) } else { ctx, result = onGraphEnd(ctx, result, isStream) } }()
var runWrapper runnableCallWrapper runWrapper = runnableInvoke if isStream { runWrapper = runnableTransform }
// Initialize channel and task managers. cm := r.initChannelManager(isStream) tm := r.initTaskManager(runWrapper, getGraphCancel(ctx), opts...) maxSteps := r.options.maxRunSteps
maxSteps, err = r.resolveMaxSteps(maxSteps, opts) if err != nil { return nil, err }
// Extract and validate options for each node. optMap, extractErr := extractOption(r.chanSubscribeTo, opts...) if extractErr != nil { return nil, newGraphRunError(fmt.Errorf("graph extract option fail: %w", extractErr)) }
// Extract CheckPointID checkPointID, writeToCheckPointID, stateModifier, forceNewRun := getCheckPointInfo(opts...) if checkPointID != nil && r.checkPointer.store == nil { return nil, newGraphRunError(fmt.Errorf("receive checkpoint id but have not set checkpoint store")) }
// Extract subgraph path, isSubGraph := getNodePath(ctx)
// load checkpoint from ctx/store or init graph initialized := false var nextTasks []*task if cp := getCheckPointFromCtx(ctx); cp != nil {// … 省略 204 行;完整声明 L109–360,点击上方「浏览完整文件」// Main execution loop.for step := 0; ; step++ { // ...检查 ctx 取消 if !r.dag && step >= maxSteps { // ← 唯一按模式分叉的地方之一 return nil, newGraphRunError(ErrExceedMaxSteps) } // 1. submit next tasks // 2. get completed tasks // 3. calculate next tasks err = tm.submit(nextTasks) // 提交本轮任务 // ... completedTasks, canceled, canceledTasks := tm.wait() // 等待完成 // ...calculateNextTasks 算出下一轮}每一轮就干三件事:提交任务 → 等待完成 → 算出下一批。注意 if !r.dag && step >= maxSteps 这一行(compose/graph_run.go:249)——这是循环里少数几处显式看模式的地方:Pregel 模式(!r.dag)有最大轮数上限兜底(还记得第 14 章 ReAct 的 MaxIterations=20 吗?就是它),DAG 模式因为无环、不会无限跑,所以不设这个上限。
📝 这个「一轮」有个学名:super-step
图计算的 Pregel 模型把这样的一轮叫 super-step:把计算切成一轮轮,每一轮里所有”就绪”的节点并行算一次,轮与轮之间同步一次。Eino 的这个
for step循环就是它的实现——本章为了好读,后面一律叫它”轮”。理解这一点,下面”channel 决定谁就绪”才有意义——循环负责推进轮次,channel 负责判断每个节点这一轮该不该被激活。
切换语义的开关:channel 接口
那么”谁该在这一轮激活”由谁决定?答案是每个节点前面挂着的一个 channel。它是一个接口(compose/graph_manager.go:29):
type channel interface { reportValues(map[string]any) error // 上游送来一个值 reportDependencies([]string) // 上游"我完成了" reportSkip([]string) bool // 上游"我这条路不走了" get(bool, string, *edgeHandlerManager) (any, bool, error) // 我这一轮就绪了吗? // ...}关键是 get 方法:循环每一轮都问每个 channel”你就绪了吗?”——返回 true,对应节点就在这一轮被激活。Pregel 和 DAG 的全部差异,几乎都浓缩在这个 get 的判断条件里。
🔑 本章的设计钥匙
compose 把”执行调度”和”就绪判断”解耦成了两层:上层是一个模式无关的逐轮循环(
compose/graph_run.go:241),下层是一个可替换的channel策略(compose/graph_manager.go:29)。想换执行语义,不用改循环,只需换 channel 的实现。“环 vs 无环”这个看似根本的区别,在架构上只是一次策略替换——这正是”用组合代替分支”的设计哲学在引擎底层的体现:两种引擎不是两套代码,而是同一套循环 + 两种 channel。
Pregel:任一上游到货就激活
Pregel 的 channel 实现朴素得惊人(compose/pregel.go:25):
type pregelChannel struct { Values map[string]any // 上游送来的值,攒在这 mergeConfig FanInMergeConfig}它的 get 判断只有一句话(compose/pregel.go:55):
func (ch *pregelChannel) get(isStream bool, name string, edgeHandler *edgeHandlerManager) ( any, bool, error) { if len(ch.Values) == 0 { return nil, false, nil } defer func() { ch.Values = map[string]any{} }() values := make([]any, len(ch.Values)) names := make([]string, len(ch.Values)) i := 0 for k, v := range ch.Values { resolvedV, err := edgeHandler.handle(k, name, v, isStream) if err != nil { return nil, false, err } values[i] = resolvedV names[i] = k i++ }
if len(values) == 1 { return values[0], true, nil }
// merge mergeOpts := &mergeOptions{ streamMergeWithSourceEOF: ch.mergeConfig.StreamMergeWithSourceEOF, names: names, } v, err := mergeValues(values, mergeOpts) if err != nil { return nil, false, err } return v, true, nil}func (ch *pregelChannel) get(...) (any, bool, error) { if len(ch.Values) == 0 { return nil, false, nil // 一个上游都还没到 → 不就绪 } // ...只要有值就就绪}只要有任意一个上游送来了值,这个节点就在下一轮激活——这就是 AnyPredecessor(任一前驱)语义(定义见 compose/types.go:42)。它对依赖不做任何记账:reportSkip 直接返回 false、reportDependencies 是空函数(compose/pregel.go:90)。
const ( // AnyPredecessor means that the node will be triggered when any of its predecessors is included in the previous completed super step. // Ref:https://www.cloudwego.io/docs/eino/core_modules/chain_and_graph_orchestration/orchestration_design_principles/#runtime-engine AnyPredecessor NodeTriggerMode = "any_predecessor" // AllPredecessor means that the current node will only be triggered when all of its predecessor nodes have finished running. AllPredecessor NodeTriggerMode = "all_predecessor")func (ch *pregelChannel) reportSkip(_ []string) bool { return false}正因为”任一上游到货即激活、且不追踪依赖是否齐全”,Pregel 天然容忍环。ReAct 的 AfterToolCalls → ChatModel 那条返回边,在下一轮里给 ChatModel 的 channel 又送来一个值,它就又激活一次——循环于是转起来了,永不死锁。源码注释把这条设计写得很直白(compose/graph.go:42):“runTypePregel … Can have cycles in graph.”
// END is the end node of the graph. You can add your last edge with END.const END = "end"
// graphRunType is a custom type used to control the running mode of the graph.type graphRunType string
const ( // runTypePregel is a running mode of the graph that is suitable for large-scale graph processing tasks. Can have cycles in graph. Compatible with NodeTriggerType.AnyPredecessor. runTypePregel graphRunType = "Pregel" // runTypeDAG is a running mode of the graph that represents the graph as a directed acyclic graph, suitable for tasks that can be represented as a directed acyclic graph. Compatible with NodeTriggerType.AllPredecessor. runTypeDAG graphRunType = "DAG")
// String returns the string representation of the graph run type.func (g graphRunType) String() string { return string(g)}
type graph struct { nodes map[string]*graphNode controlEdges map[string][]string dataEdges map[string][]string branches map[string][]*GraphBranch startNodes []string// … 这是 L38–62 的片段(该行不在任何顶层声明内);点击上方「浏览完整文件」DAG:所有上游齐活才激活,还要传播 skip
DAG 的 channel 就复杂多了(compose/dag.go:50),因为它要记账:
type dagChannel struct { ControlPredecessors map[string]dependencyState // 每个控制前驱:等待/就绪/跳过 Values map[string]any DataPredecessors map[string]bool // 每个数据前驱:是否到货 Skipped bool // ...}它的 get(compose/dag.go:128)要求所有前驱都有了交代才就绪:
for _, state := range ch.ControlPredecessors { if state == dependencyStateWaiting { return nil, false, nil // 还有控制前驱在等 → 不就绪 }}for _, ready := range ch.DataPredecessors { if !ready { return nil, false, nil // 还有数据前驱没到 → 不就绪 }}这就是 AllPredecessor(所有前驱)语义(compose/types.go:45)。但这里冒出一个问题:如果图里有分支(branch),某条路没被选中,那条路上的节点永远不会”到货”,下游岂不是永远等不齐?
答案是 skip 传播。当一条分支不走时,它会 reportSkip(compose/dag.go:106)把自己标成”跳过”;如果一个节点的所有控制前驱都被跳过了,它自己也被标记 Skipped=true 并返回 true——这个返回值触发 channelManager.reportBranch(compose/graph_manager.go:219)继续向下游级联,把”跳过”像涟漪一样传遍未选中的整条子图。于是”等齐所有前驱”和”分支”就能共存:没被选中的前驱不是”永远等”,而是”明确地跳过”。
func (ch *dagChannel) reportSkip(keys []string) bool { for _, k := range keys { if _, ok := ch.ControlPredecessors[k]; ok { ch.ControlPredecessors[k] = dependencyStateSkipped } if _, ok := ch.DataPredecessors[k]; ok { ch.DataPredecessors[k] = true } }
allSkipped := true for _, state := range ch.ControlPredecessors { if state != dependencyStateSkipped { allSkipped = false break } } ch.Skipped = allSkipped
return allSkipped}func (c *channelManager) reportBranch(from string, skippedNodes []string) error { var nKeys []string for _, node := range skippedNodes { skipped := c.channels[node].reportSkip([]string{from}) if skipped { nKeys = append(nKeys, node) } }
for i := 0; i < len(nKeys); i++ { key := nKeys[i]
if key == END { continue } if _, ok := c.successors[key]; !ok { return fmt.Errorf("unknown node: %s", key) } for _, successor := range c.successors[key] { skipped := c.channels[successor].reportSkip([]string{key}) if skipped { nKeys = appendIfNotExist(nKeys, successor) } // todo: detect if end node has been skipped? } } return nil}⚠️ 为什么 DAG 必须无环
DAG 的
get要求所有控制前驱脱离Waiting状态才激活。若图里有环,环上某个节点的前驱会永远停在Waiting(它在等一个还没跑、而那个又在等它的节点)——直接死锁。所以 DAG 模式在编译期就用validateDAG(compose/graph.go:1077)做一次拓扑检查,发现环就返回DAGInvalidLoopErr(compose/graph.go:1129,报错文案”DAG is invalid, has loop”)。注意:禁止环这件事,不是 channel 自己拒绝的,而是编译期校验器提前拦下的——channel 遇到环只会默默死锁。func validateDAG(chanSubscribeTo map[string]*chanCall, controlPredecessors map[string][]string) error {m := map[string]int{}for node := range chanSubscribeTo {if edges, ok := controlPredecessors[node]; ok {m[node] = len(edges)for _, pre := range edges {if pre == START {m[node] -= 1}}} else {m[node] = 0}}hasChanged := truefor hasChanged {hasChanged = falsefor node := range m {if m[node] == 0 {hasChanged = truefor _, subNode := range chanSubscribeTo[node].controls {if subNode == END {continue}m[subNode]--}for _, subBranch := range chanSubscribeTo[node].writeToBranches {for subNode := range subBranch.endNodes {if subNode == END {continue}m[subNode]--}}m[node] = -1}}}var loopStarts []stringfor k, v := range m {if v > 0 {loopStarts = append(loopStarts, k)}}if len(loopStarts) > 0 {return fmt.Errorf("%w: %s", DAGInvalidLoopErr, formatLoops(findLoops(loopStarts, chanSubscribeTo)))}// … 省略 2 行;完整声明 L1077–1126,点击上方「浏览完整文件」// DAGInvalidLoopErr indicates the graph contains a cycle and is invalid.var DAGInvalidLoopErr = errors.New("DAG is invalid, has loop")
谁来选模式
最后一块拼图:引擎怎么知道该用哪种 channel?答案在编译期 graph.compile(compose/graph.go:679):
func (g *graph) compile(ctx context.Context, opt *graphCompileOptions) (*composableRunnable, error) { if g.buildError != nil { return nil, g.buildError }
// get run type runType := runTypePregel cb := pregelChannelBuilder if isChain(g.cmp) || isWorkflow(g.cmp) { if opt != nil && opt.nodeTriggerMode != "" { return nil, errors.New(fmt.Sprintf("%s doesn't support node trigger mode option", g.cmp)) } } if (opt != nil && opt.nodeTriggerMode == AllPredecessor) || isWorkflow(g.cmp) { runType = runTypeDAG cb = dagChannelBuilder }
// get eager type eager := false if isWorkflow(g.cmp) || runType == runTypeDAG { eager = true } if opt != nil && opt.eagerDisabled { eager = false }
if len(g.startNodes) == 0 { return nil, errors.New("start node not set") } if len(g.endNodes) == 0 { return nil, errors.New("end node not set") }
// toValidateMap isn't empty means there are nodes that cannot infer type for _, v := range g.toValidateMap { if len(v) > 0 { return nil, fmt.Errorf("some node's input or output types cannot be inferred: %v", g.toValidateMap) } }
for key := range g.fieldMappingRecords { // not allowed to map multiple fields to the same field toMap := make(map[string]bool) for _, mapping := range g.fieldMappingRecords[key] { if _, ok := toMap[mapping.to]; ok { return nil, fmt.Errorf("duplicate mapping target field: %s of node[%s]", mapping.to, key) }// … 省略 171 行;完整声明 L674–892,点击上方「浏览完整文件」// get run typerunType := runTypePregel // 默认 Pregelcb := pregelChannelBuilder// ...if (opt != nil && opt.nodeTriggerMode == AllPredecessor) || isWorkflow(g.cmp) { runType = runTypeDAG // 显式要求 AllPredecessor,或本身是 Workflow cb = dagChannelBuilder}默认是 Pregel;只有你显式指定 AllPredecessor 触发模式、或者你构建的是 Workflow(下一章讲),才切到 DAG。选定的 cb(channel builder)随后在 initChannelManager(compose/graph_run.go:948)里被用来给每个节点造 channel——同一段建造代码,只是喂进去的 builder 不同。这就是”一个循环、两种 channel”最干净的落地:模式选择只影响”用哪个 builder”,循环本身一字不改。
func (r *runner) initChannelManager(isStream bool) *channelManager { builder := r.chanBuilder if builder == nil { builder = pregelChannelBuilder }
chs := make(map[string]channel) for ch := range r.chanSubscribeTo { chs[ch] = builder(r.controlPredecessors[ch], r.dataPredecessors[ch], r.chanSubscribeTo[ch].action.inputZeroValue, r.chanSubscribeTo[ch].action.inputEmptyStream) }
chs[END] = builder(r.controlPredecessors[END], r.dataPredecessors[END], r.outputZeroValue, r.outputEmptyStream)
dataPredecessors := make(map[string]map[string]struct{}) for k, vs := range r.dataPredecessors { dataPredecessors[k] = make(map[string]struct{}) for _, v := range vs { dataPredecessors[k][v] = struct{}{} } } controlPredecessors := make(map[string]map[string]struct{}) for k, vs := range r.controlPredecessors { controlPredecessors[k] = make(map[string]struct{}) for _, v := range vs { controlPredecessors[k][v] = struct{}{} } }
for k, v := range chs { if cfg, ok := r.mergeConfigs[k]; ok { v.setMergeConfig(cfg) } }
return &channelManager{ isStream: isStream, channels: chs, successors: r.successors, dataPredecessors: dataPredecessors, controlPredecessors: controlPredecessors,
edgeHandlerManager: r.edgeHandlerManager, preNodeHandlerManager: r.preNodeHandlerManager, }}💡 动手
打开
compose/pregel.go:55和compose/dag.go:128,把两个get方法并排读一遍。用一句话概括它们的差异,再回想第 14 章的 ReAct 图——它为什么必须跑在 Pregel 模式?(提示:那条返回边。)然后想:如果你要搭一个”多个数据源并行拉取、全部到齐再汇总”的流程,你会选哪种模式?
本章小结
- compose 只有一个逐轮循环
runner.run(compose/graph_run.go:241):每轮”提交→等待→算下一批”,循环里几乎不看模式。 - 语义切换靠可替换的
channel策略(compose/graph_manager.go:29);核心是get——决定每个节点这一轮是否就绪。 - Pregel(
compose/pregel.go:25):任一上游到货即激活(AnyPredecessor),不记账,容忍环——ReAct 返回边就靠它。 - DAG(
compose/dag.go:50):所有前驱齐活才激活(AllPredecessor),用 skip 传播(compose/dag.go:106)处理未选中的分支;禁止环由编译期validateDAG(compose/graph.go:1077)拦截,而非 channel 自身。 - 模式在编译期选定(
compose/graph.go:679):默认 Pregel,AllPredecessor或Workflow切 DAG;只换 builder,循环不变。 - 设计钥匙:执行调度与就绪判断解耦,让”环 vs 无环”降格成一次策略替换。
下一章我们上一层楼:看 Graph、Chain、Workflow 这三个面向用户的构建器,如何在同一个底层图上叠出不同的表达力,以及编译期的类型检查如何在你写错连线时当场报错。