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——你用的是三个面向用户的构建器:Graph、Chain、Workflow。这一章我们要弄清两件事:这三者是什么关系(剧透:后两者都是 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。而 Chain 和 Workflow,本质上都是”往这个内部 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 typefor _, 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],只是把组件标记成 ComponentOfChain。Chain 结构(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 的流式与记忆。