目录 · 第 20 / 28 章
EinoPart IV · 支撑 ADK 的编排引擎

20Graph / Chain / Workflow 与类型检查

reflect.Type 传播 + toValidateMap 硬校验;Chain 是线性糖;Workflow 用字段映射并强制 DAG。

compose/generic_graph.go:93compose/graph.go:561compose/chain.go:560compose/workflow.go:43

三种写法,一个底座

上一章你看清了 compose 的执行内核:一个循环 + 两种 channel。但你在真实代码里几乎不会直接摆弄 channel——你用的是三个面向用户的构建器:GraphChainWorkflow。这一章我们要弄清两件事:这三者是什么关系(剧透:后两者都是 Graph 的糖),以及一个更关键的问题——当你把两个类型对不上的节点连起来时,框架凭什么能在编译期就报错,而不是等到线上炸掉?

flowchart TB
  CHAIN["Chain[I,O]<br/>线性写法糖<br/>nodeIdx / preNodeKeys"]
  WF["Workflow[I,O]<br/>字段级映射糖<br/>DAG 编排"]
  GRAPH["Graph[I,O]<br/>对外泛型 · 类型安全"]
  INNER["*graph(内部·类型擦除)<br/>节点 / 边 / toValidateMap / 校验"]
  CHAIN --> GRAPH
  WF --> GRAPH
  GRAPH --> INNER

Graph 是底座,Chain/Workflow 是语法糖

底座:泛型的 Graph

一切的地基是泛型 Graph[I, O](compose/generic_graph.go:93):

// I: 编译产物的输入类型;O: 输出类型
type Graph[I, O any] struct {
*graph // 内嵌一个非泛型的内部 graph
}

注意这个设计:对外是带类型参数的 Graph[I, O],给你端到端的类型安全;对内嵌一个”擦掉类型”的 *graph,承载真正的节点、边、校验逻辑。构造入口 NewGraph[I, O](compose/generic_graph.go:72)就是给内部 graph 记上 I/O 两个 reflect.Type。而 ChainWorkflow,本质上都是”往这个内部 graph 上叠 API 糖”。

// NewGraph create a directed graph that can compose components, lambda, chain, parallel etc.
// simultaneously provide flexible and multi-granular aspect governance capabilities.
// I: the input type of graph compiled product
// O: the output type of graph compiled product
//
// To share state between nodes, use WithGenLocalState option:
//
// type testState struct {
// UserInfo *UserInfo
// KVs map[string]any
// }
//
// genStateFunc := func(ctx context.Context) *testState {
// return &testState{}
// }
//
// graph := compose.NewGraph[string, string](WithGenLocalState(genStateFunc))
//
// // you can use WithStatePreHandler and WithStatePostHandler to do something with state
// graph.AddNode("node1", someNode, compose.WithPreHandler(func(ctx context.Context, in string, state *testState) (string, error) {
// // do something with state
// return in, nil
// }), compose.WithPostHandler(func(ctx context.Context, out string, state *testState) (string, error) {
// // do something with state
// return out, nil
// }))
func NewGraph[I, O any](opts ...NewGraphOption) *Graph[I, O] {
options := &newGraphOptions{}
for _, opt := range opts {
opt(options)
}
g := &Graph[I, O]{
newGraphFromGeneric[I, O](
ComponentOfGraph,
options.withState,
options.stateType,
opts,
),
}
return g
}

编译期类型检查:reflect.Type 的传播与硬闸门

先讲最有分量的机制,因为它是 Eino”类型安全”承诺的兑现处。

当你调 AddEdge(a, b)(compose/generic_graph.go:106),它转调内部的 addEdgeWithMappings(compose/graph.go:232)。这里不只是”记一条边”,它当场做两件事(compose/graph.go:285):把这条边登记进一个待校验表 toValidateMap,然后立刻跑一次 updateToValidateMap() 去校验。

// AddEdge adds an edge to the graph, edge means a data flow from startNode to endNode.
// the previous node's output type must be set to the next node's input type.
// NOTE: startNode and endNode must have been added to the graph before adding edge.
// e.g.
//
// graph.AddNode("start_node_key", compose.NewPassthroughNode())
// graph.AddNode("end_node_key", compose.NewPassthroughNode())
//
// err := graph.AddEdge("start_node_key", "end_node_key")
func (g *Graph[I, O]) AddEdge(startNode, endNode string) (err error) {
return g.graph.addEdgeWithMappings(startNode, endNode, false, false)
}
func (g *graph) addEdgeWithMappings(startNode, endNode string, noControl bool, noData bool, mappings ...*FieldMapping) (err error) {
if g.buildError != nil {
return g.buildError
}
if g.compiled {
return ErrGraphCompiled
}
if noControl && noData {
return fmt.Errorf("edge[%s]-[%s] cannot be both noDirectDependency and noDataFlow", startNode, endNode)
}
defer func() {
if err != nil {
g.buildError = err
}
}()
if startNode == END {
return errors.New("END cannot be a start node")
}
if endNode == START {
return errors.New("START cannot be an end node")
}
if _, ok := g.nodes[startNode]; !ok && startNode != START {
return fmt.Errorf("edge start node '%s' needs to be added to graph first", startNode)
}
if _, ok := g.nodes[endNode]; !ok && endNode != END {
return fmt.Errorf("edge end node '%s' needs to be added to graph first", endNode)
}
if !noControl {
for i := range g.controlEdges[startNode] {
if g.controlEdges[startNode][i] == endNode {
return fmt.Errorf("control edge[%s]-[%s] have been added yet", startNode, endNode)
}
}
g.controlEdges[startNode] = append(g.controlEdges[startNode], endNode)
if startNode == START {
g.startNodes = append(g.startNodes, endNode)
}
if endNode == END {
g.endNodes = append(g.endNodes, startNode)
}
}
if !noData {
for i := range g.dataEdges[startNode] {
// … 省略 15 行;完整声明 L232–294,点击上方「浏览完整文件」

toValidateMap(字段定义 compose/graph.go:65)是一张”还没确认类型兼容的边”的清单。真正的校验发生在 updateToValidateMap(compose/graph.go:561)里,它反复扫描这张表,对每条边取出上游的输出类型和下游的输入类型,做一次 checkAssignable(compose/graph.go:592):

type graph struct {
nodes map[string]*graphNode
controlEdges map[string][]string
dataEdges map[string][]string
branches map[string][]*GraphBranch
startNodes []string
endNodes []string
toValidateMap map[string][]struct {
endNode string
mappings []*FieldMapping
}
stateType reflect.Type
stateGenerator func(ctx context.Context) any
newOpts []NewGraphOption
expectedInputType, expectedOutputType reflect.Type
*genericHelper
fieldMappingRecords map[string][]*FieldMapping
buildError error
cmp component
compiled bool
handlerOnEdges map[string]map[string][]handlerPair
handlerPreNode map[string][]handlerPair
handlerPreBranch map[string][][]handlerPair
}
// updateToValidateMap after update node, check validate map
// check again if nodes in toValidateMap have been updated. because when there are multiple linked passthrough nodes, in the worst scenario, only one node can be updated at a time.
func (g *graph) updateToValidateMap() error {
var startNodeOutputType, endNodeInputType reflect.Type
for {
hasChanged := false
for startNode := range g.toValidateMap {
startNodeOutputType = g.getNodeOutputType(startNode)
for i := 0; i < len(g.toValidateMap[startNode]); i++ {
endNode := g.toValidateMap[startNode][i]
endNodeInputType = g.getNodeInputType(endNode.endNode)
if startNodeOutputType == nil && endNodeInputType == nil {
continue
}
// update toValidateMap
g.toValidateMap[startNode] = append(g.toValidateMap[startNode][:i], g.toValidateMap[startNode][i+1:]...)
i--
hasChanged = true
// assume that START and END type isn't empty
if startNodeOutputType != nil && endNodeInputType == nil {
g.nodes[endNode.endNode].cr.inputType = startNodeOutputType
g.nodes[endNode.endNode].cr.outputType = g.nodes[endNode.endNode].cr.inputType
g.nodes[endNode.endNode].cr.genericHelper = g.getNodeGenericHelper(startNode).forSuccessorPassthrough()
} else if startNodeOutputType == nil /* redundant condition && endNodeInputType != nil */ {
g.nodes[startNode].cr.inputType = endNodeInputType
g.nodes[startNode].cr.outputType = g.nodes[startNode].cr.inputType
g.nodes[startNode].cr.genericHelper = g.getNodeGenericHelper(endNode.endNode).forPredecessorPassthrough()
} else if len(endNode.mappings) == 0 {
// common node check
result := checkAssignable(startNodeOutputType, endNodeInputType)
if result == assignableTypeMustNot {
return fmt.Errorf("graph edge[%s]-[%s]: start node's output type[%s] and end node's input type[%s] mismatch",
startNode, endNode.endNode, startNodeOutputType.String(), endNodeInputType.String())
} else if result == assignableTypeMay {
// add runtime check edges
if _, ok := g.handlerOnEdges[startNode]; !ok {
g.handlerOnEdges[startNode] = make(map[string][]handlerPair)
}
g.handlerOnEdges[startNode][endNode.endNode] = append(g.handlerOnEdges[startNode][endNode.endNode], g.getNodeGenericHelper(endNode.endNode).inputConverter)
}
continue
}
if len(endNode.mappings) > 0 {
// … 省略 31 行;完整声明 L559–637,点击上方「浏览完整文件」

result := checkAssignable(startNodeOutputType, endNodeInputType)
if result == assignableTypeMustNot {
return fmt.Errorf("graph edge[%s]-[%s]: start node's output type[%s] and end node's input type[%s] mismatch", ...)
}

类型对不上,AddEdge 这一行当场返回 error——不是等到运行、不是等到 Compile,就在你连线的那一刻。这就是为什么 Eino 敢说”编排是类型安全的”。

🔑 本章的设计钥匙

Eino 的类型安全,靠的是编译期的类型传播 + 分层的校验闸门reflect.Type 沿着边在图里传播:一旦一端类型已知,updateToValidateMap 就能推断另一端,甚至把类型”灌”进中间的无类型 passthrough 节点(compose/graph.go:561)。校验分两道闸:硬性不兼容(assignableTypeMustNot)在 AddEdge 当场报错;而涉及接口的”可能兼容”(assignableTypeMay)则不硬拦,改为挂一个运行时转换器延后处理。这种”能编译期确定的就编译期拦、拦不了的才降到运行时”的分层,正是把错误尽量左移(shift-left)的工程哲学。

还有第二道闸,在 Compile 时兜底(compose/graph.go:708):

// 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)
}
}

如果编译时还有边留在 toValidateMap 里没被消化——通常是几个无类型 passthrough 节点首尾相连、始终没有一个”有类型的锚”能推断出来——Compile 就拒绝出图。两道闸各司其职:第一道抓”连错类型”,第二道抓”类型推不出来”。

Chain:线性的语法糖

理解了底座,Chain 就一目了然了。NewChain[I, O](compose/chain.go:37)内部就是 NewGraph[I, O],只是把组件标记成 ComponentOfChainChain 结构(compose/chain.go:72)持有那个 *Graph[I, O],外加一点点线性记账:nodeIdx(自动生成节点名用)、preNodeKeys(上一个节点是谁)。

// NewChain create a chain with input/output type.
func NewChain[I, O any](opts ...NewGraphOption) *Chain[I, O] {
ch := &Chain[I, O]{
gg: NewGraph[I, O](opts...),
}
ch.gg.cmp = ComponentOfChain
return ch
}
// Chain is a chain of components.
// Chain nodes can be parallel / branch / sequence components.
// Chain is designed to be used in a builder pattern (should Compile() before use).
// And the interface is `Chain style`, you can use it like: `chain.AppendXX(...).AppendXX(...)`
//
// Normal usage:
// 1. create a chain with input/output type: `chain := NewChain[inputType, outputType]()`
// 2. add components to chainable list:
// 2.1 add components: `chain.AppendChatTemplate(...).AppendChatModel(...).AppendToolsNode(...)`
// 2.2 add parallel or branch node if needed: `chain.AppendParallel()`, `chain.AppendBranch()`
// 3. compile: `r, err := c.Compile()`
// 4. run:
// 4.1 `one input & one output` use `r.Invoke(ctx, input)`
// 4.2 `one input & multi output chunk` use `r.Stream(ctx, input)`
// 4.3 `multi input chunk & one output` use `r.Collect(ctx, inputReader)`
// 4.4 `multi input chunk & multi output chunk` use `r.Transform(ctx, inputReader)`
//
// Using in graph or other chain:
// chain1 := NewChain[inputType, outputType]()
// graph := NewGraph[](runTypePregel)
// graph.AddGraph("key", chain1) // chain is an AnyGraph implementation
//
// // or in another chain:
// chain2 := NewChain[inputType, outputType]()
// chain2.AppendGraph(chain1)
type Chain[I, O any] struct {
err error
gg *Graph[I, O]
nodeIdx int
preNodeKeys []string
hasEnd bool
}

所有的 AppendXxx 最终都汇进 addNode(compose/chain.go:560),它做的事朴素得可爱:

// 自动生成 key,加节点
err := c.gg.addNode(nodeKey, node, options)
// ...第一个节点自动从 START 连过来
if len(c.preNodeKeys) == 0 {
c.preNodeKeys = append(c.preNodeKeys, START)
}
// 把上一个节点连到当前节点
for _, preNodeKey := range c.preNodeKeys {
c.gg.AddEdge(preNodeKey, nodeKey)
}
c.preNodeKeys = []string{nodeKey} // 当前节点成为下一个的"上游"

Chain 就是”自动帮你 AddEdge 成一条直线”的 Graph,没有任何新执行语义。它甚至不选执行模式——上一章说过,模式在 graph.compile 里定,Chain 依旧默认跑 Pregel。它的价值纯粹是省掉手写线性连线的样板。

Workflow:字段级映射 + 强制 DAG

Workflow 则走向另一个方向:它不是”简化连线”,而是”升级连线的表达力”。看它的结构(compose/workflow.go:43)和那句点睛的注释:

// Workflow is wrapper of graph, replacing AddEdge with declaring dependencies
// and field mappings between nodes.
// Under the hood it uses NodeTriggerMode(AllPredecessor), so does not support cycles.
type Workflow[I, O any] struct {
g *graph
workflowNodes map[string]*WorkflowNode
// ...
}

两个关键词:字段映射AllPredecessor(强制 DAG)。

字段映射是 Workflow 最大的表达力升级。普通 AddEdge 是”把上游的整个输出喂给下游”;而 Workflow 用 AddInput(compose/workflow.go:197)可以精确到字段级:

// AddInput creates both data and execution dependencies between nodes.
// It configures how data flows from the predecessor node (fromNodeKey) to the current node,
// and ensures the current node only executes after the predecessor completes.
//
// Parameters:
// - fromNodeKey: the key of the predecessor node
// - inputs: field mappings that specify how data should flow from the predecessor
// to the current node. If no mappings are provided, the entire output of the
// predecessor will be used as input.
//
// Example:
//
// // Map between specific field
// node.AddInput("userNode", MapFields("user.name", "displayName"))
//
// // Use entire output
// node.AddInput("dataNode")
//
// Returns the current node for method chaining.
func (n *WorkflowNode) AddInput(fromNodeKey string, inputs ...*FieldMapping) *WorkflowNode {
return n.addDependencyRelation(fromNodeKey, inputs, &workflowAddInputOpts{})
}
// 把上游 A 的 Score 字段,映射到当前节点输入的 Rating 字段
node.AddInput("A", compose.MapFields("Score", "Rating"))

这里的 MapFields(compose/field_mapping.go:85)、FromField(compose/field_mapping.go:65)、ToField(compose/field_mapping.go:73)构造出 FieldMapping(compose/field_mapping.go:31)。底层 addDependencyRelation(compose/workflow.go:316)把每条映射翻译成一次带 mappings 的图内边——所以字段映射并不是新引擎,它仍然落在同一个内部 graph 上,只是这条边额外携带了”取哪个字段、放哪个字段”的信息。

// MapFields creates a FieldMapping that maps a single predecessor field to a single successor field.
// Field: either the field of a struct, or the key of a map.
func MapFields(from, to string) *FieldMapping {
return &FieldMapping{
from: from,
to: to,
}
}
// FromField creates a FieldMapping that maps a single predecessor field to the entire successor input.
// This is an exclusive mapping - once set, no other field mappings can be added since the successor input
// has already been fully mapped.
// Field: either the field of a struct, or the key of a map.
func FromField(from string) *FieldMapping {
return &FieldMapping{
from: from,
}
}
// ToField creates a FieldMapping that maps the entire predecessor output to a single successor field.
// Field: either the field of a struct, or the key of a map.
func ToField(to string, opts ...FieldMappingOption) *FieldMapping {
fm := &FieldMapping{
to: to,
}
for _, opt := range opts {
opt(fm)
}
return fm
}
type FieldMapping struct {
fromNodeKey string
from string
to string
customExtractor func(input any) (any, error)
}
func (n *WorkflowNode) addDependencyRelation(fromNodeKey string, inputs []*FieldMapping, options *workflowAddInputOpts) *WorkflowNode {
for _, input := range inputs {
input.fromNodeKey = fromNodeKey
}
if options.noDirectDependency {
n.addInputs = append(n.addInputs, func() error {
var paths []FieldPath
for _, input := range inputs {
paths = append(paths, input.targetPath())
}
if err := n.checkAndAddMappedPath(paths); err != nil {
return err
}
if err := n.g.addEdgeWithMappings(fromNodeKey, n.key, true, false, inputs...); err != nil {
return err
}
n.dependencySetter(fromNodeKey, noDirectDependency)
return nil
})
} else if options.dependencyWithoutInput {
n.addInputs = append(n.addInputs, func() error {
if len(inputs) > 0 {
return fmt.Errorf("dependency without input should not have inputs. node: %s, fromNode: %s, inputs: %v", n.key, fromNodeKey, inputs)
}
if err := n.g.addEdgeWithMappings(fromNodeKey, n.key, false, true); err != nil {
return err
}
n.dependencySetter(fromNodeKey, normalDependency)
return nil
})
} else {
n.addInputs = append(n.addInputs, func() error {
var paths []FieldPath
for _, input := range inputs {
paths = append(paths, input.targetPath())
}
if err := n.checkAndAddMappedPath(paths); err != nil {
return err
}
if err := n.g.addEdgeWithMappings(fromNodeKey, n.key, false, false, inputs...); err != nil {
return err
}
n.dependencySetter(fromNodeKey, normalDependency)
return nil
})
// … 省略 4 行;完整声明 L316–367,点击上方「浏览完整文件」

强制 DAG 则是刻意的取舍。上一章讲过,graph.compile 里只要 isWorkflow(g.cmp) 为真就切 DAG 模式。为什么 Workflow 甘愿放弃环?因为字段级数据流水线的心智模型天然是”数据从上游字段流向下游字段”的有向无环图;放弃环,换来的是”所有前驱齐活才触发”的确定性,以及 skip 传播带来的分支能力。这是一个非常清醒的产品决策:Chain 用无环换线性简洁,Workflow 用无环换字段级数据编排,而需要环的场景(如 ReAct)才回到 Pregel 的 Graph

📝 三者如何选

  • Chain:纯线性流水线(A→B→C),要的就是少写样板。
  • Graph:需要环、需要复杂分支/返回边(ReAct、多智能体循环)——跑 Pregel。
  • Workflow:多输入汇聚、要精确到字段的数据映射、且天然无环(表单审批、并行研究后汇总)——跑 DAG。

三者共享同一个类型检查底座和同一个执行内核,只在”如何声明拓扑”和”跑哪种 channel”上分道。

💡 动手

写两段等价的流水线:一段用 Chain.AppendXxx,一段用 Graph.AddNode+AddEdge 手连。对照 compose/chain.go:560,确认 Chain 帮你省掉的正是那几行 AddEdge。然后故意把两个类型不兼容的节点用 AddEdge 连起来,观察它在哪一行报错——是 AddEdge 还是 Compile?回到 compose/graph.go:592 验证你的判断。

本章小结

  • 底座是泛型 Graph[I, O](compose/generic_graph.go:93):对外类型安全,对内嵌一个擦除类型的 *graph 承载节点/边/校验。
  • 类型检查reflect.Type 沿边传播 + 两道闸:AddEdge 当场跑 updateToValidateMap(compose/graph.go:561),硬性不兼容立即报错(compose/graph.go:592);Compile 再兜底”类型推不出来”(compose/graph.go:708)。涉及接口的”可能兼容”降级为运行时转换。
  • Chain(compose/chain.go:72)是线性语法糖:addNode(compose/chain.go:560)自动 AddEdge 成直线,无新语义,默认仍 Pregel。
  • Workflow(compose/workflow.go:43)升级连线表达力:AddInput+FieldMapping(compose/field_mapping.go:31)做字段级数据映射,并强制 DAG(放弃环换确定性)。
  • 设计钥匙:三者共享类型底座与执行内核,只在拓扑声明方式与 channel 选择上分化——用同一个底座长出三种表达力

下一章我们钻进最后两块基石:流(stream)如何被复制、合并、装箱,以及状态(state)如何按类型在嵌套图里被逐级找到——这两者支撑起了 ADK 的流式与记忆。

源码

正在读取完整文件…