Coze Studio 深度文档 06:Eino 与工作流引擎深度

把工作流引擎的内脏拆开:Eino 的核心抽象、节点注册机制、字段填充与类型转换、Loop/Batch 并发模型、变量作用域、子工作流、LLM 节点全流程走读。

主文档:01 - 原理与使用

目录

  1. [Eino 框架核心抽象](#Eino 框架核心抽象 "#1-eino-%E6%A1%86%E6%9E%B6%E6%A0%B8%E5%BF%83%E6%8A%BD%E8%B1%A1")
  2. [节点注册机制 NodeAdaptor](#节点注册机制 NodeAdaptor "#2-%E8%8A%82%E7%82%B9%E6%B3%A8%E5%86%8C%E6%9C%BA%E5%88%B6-nodeadaptor")
  3. [节点元数据 NodeTypeMeta](#节点元数据 NodeTypeMeta "#3-%E8%8A%82%E7%82%B9%E5%85%83%E6%95%B0%E6%8D%AE-nodetypemeta")
  4. 字段填充与类型转换
  5. [Loop / Batch 并发执行模型](#Loop / Batch 并发执行模型 "#5-loop--batch-%E5%B9%B6%E5%8F%91%E6%89%A7%E8%A1%8C%E6%A8%A1%E5%9E%8B")
  6. 变量作用域与执行上下文
  7. [Sub-workflow 嵌套与上下文继承](#Sub-workflow 嵌套与上下文继承 "#7-sub-workflow-%E5%B5%8C%E5%A5%97%E4%B8%8E%E4%B8%8A%E4%B8%8B%E6%96%87%E7%BB%A7%E6%89%BF")
  8. [LLM 节点完整执行流程走读](#LLM 节点完整执行流程走读 "#8-llm-%E8%8A%82%E7%82%B9%E5%AE%8C%E6%95%B4%E6%89%A7%E8%A1%8C%E6%B5%81%E7%A8%8B%E8%B5%B0%E8%AF%BB")
  9. [Checkpoint 与中断恢复](#Checkpoint 与中断恢复 "#9-checkpoint-%E4%B8%8E%E4%B8%AD%E6%96%AD%E6%81%A2%E5%A4%8D")

1. Eino 框架核心抽象

Eino 是 CloudWeGo 出的 Go LLM 编排框架,Coze 用版本 v0.4.8(见 backend/go.mod)。

1.1 五个核心包

在 Coze 中的角色
eino/compose DAG/Chain 组合器------节点、边、子图、并行、状态、Lambda 包装
eino/schema 流与消息抽象 ------StreamReader[T]Message、Role
eino/components/model LLM 接口:BaseChatModelToolCallingChatModel
eino/components/tool 工具接口:BaseTool(插件、工作流、知识库都包成这个)
eino/components/prompt ChatTemplate(Prompt 模板渲染)
eino/callbacks 全链路回调(用于 token 统计、流式监听)
eino/flow/agent/react 现成的 ReAct Agent 实现(LLM 节点工具调用就是用它)

1.2 Coze 用 Eino 的三个层次

c 复制代码
┌─────────────────────────────────────────────────────────┐
│ 层 1:把整个工作流 = 一个 compose.Workflow              │
│   compose.Workflow[map[string]any, map[string]any]      │
│   输入是 map(全局变量+Entry 输入),输出也是 map        │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 层 2:每个工作流节点 = 一个 compose.Lambda 或子 Graph    │
│   简单节点(TextProcessor):InvokableLambda             │
│   复合节点(LLM、Loop):内嵌 compose.NewGraph 子图      │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 层 3:LLM 节点内部再嵌套 ReAct Agent                     │
│   react.NewAgent(...) → ExportGraph → AddGraphNode      │
│   最终运行时是 Workflow ▶ LLM-Lambda ▶ ReAct-Graph     │
└─────────────────────────────────────────────────────────┘

1.3 三类节点的代码差异

go 复制代码
// 简单节点:InvokableLambda
node := compose.InvokableLambda(func(ctx context.Context, in map[string]any) (map[string]any, error) {
    return map[string]any{"out": in["in"].(string) + "!"}, nil
})

// 流式节点:StreamableLambda
node := compose.StreamableLambda(func(ctx context.Context, in map[string]any) (*schema.StreamReader[map[string]any], error) {
    // 返回流
})

// 复合节点(子图):
g := compose.NewGraph[map[string]any, map[string]any]()
g.AddChatModelNode("llm", chatModel)
g.AddLambdaNode("post", postProcess)
g.AddEdge("llm", "post")
runnable, _ := g.Compile(ctx)

1.4 流式协议:StreamReader

schema.StreamReader[T] 是 Eino 流式编程的根基。两个关键方法:

go 复制代码
chunk, err := stream.Recv()         // 拉一个 chunk,err==io.EOF 表示结束
stream.Close()                       // 关闭流(必须调用,否则 leak)

Coze 用 schema.StreamReaderWithConvert 在管道中零拷贝转换流类型:

go 复制代码
// 关键文件:backend/domain/workflow/internal/compose/field_fill.go
return schema.StreamReaderWithConvert(input, func(in map[string]any) (map[string]any, error) {
    return fn(ctx, in)
})

这是工作流"边执行边推到前端 SSE"的核心机制------上游 chunk 一到,转换函数立刻处理,推到下游,无需缓冲完整结果。


2. 节点注册机制 NodeAdaptor

源码:backend/domain/workflow/internal/nodes/node.go

2.1 全局注册表

go 复制代码
var (
    nodeAdaptors   = map[entity.NodeType]func() NodeAdaptor{}
    branchAdaptors = map[entity.NodeType]func() BranchAdaptor{}
)

func RegisterNodeAdaptor(et entity.NodeType, f func() NodeAdaptor) {
    nodeAdaptors[et] = f
}

func GetNodeAdaptor(et entity.NodeType) (NodeAdaptor, bool) {
    na, ok := nodeAdaptors[et]
    if !ok {
        panic(fmt.Sprintf("node type %s not registered", et))
    }
    return na(), ok
}

注意:未注册类型直接 panic,不会优雅降级。这是合理的,因为画布存进库的 NodeType 必然是合法的,出现未注册说明 schema 漂移。

2.2 NodeAdaptor 接口

每个节点实现两组方法:

go 复制代码
type NodeAdaptor interface {
    // 设计期:画布 Node → 后端 NodeSchema
    Adapt(ctx context.Context, n *vo.Node, others ...any) (*schema.NodeSchema, error)
    // 执行期:NodeSchema → 可执行对象
    Build(ctx context.Context, ns *schema.NodeSchema, others ...any) (any, error)
}

可执行对象的接口由节点决定:

  • Invoke(ctx, in) (out, err) ------ 同步
  • Stream(ctx, in) (StreamReader, err) ------ 流式
  • Transform(ctx, StreamReader) (StreamReader, err) ------ 流转流
  • Collect(ctx, StreamReader) (out, err) ------ 流聚合

Eino 自动按节点实现的接口选择调用方式。

2.3 集中注册位置

backend/domain/workflow/internal/canvas/adaptor/to_schema.goinit():

go 复制代码
func init() {
    nodes.RegisterNodeAdaptor(entity.NodeTypeLLM, func() nodes.NodeAdaptor { return &llm.Config{} })
    nodes.RegisterNodeAdaptor(entity.NodeTypeLoop, func() nodes.NodeAdaptor { return &loop.Config{} })
    nodes.RegisterNodeAdaptor(entity.NodeTypeBatch, func() nodes.NodeAdaptor { return &batch.Config{} })
    nodes.RegisterNodeAdaptor(entity.NodeTypeCode, func() nodes.NodeAdaptor { return &code.Config{} })
    nodes.RegisterNodeAdaptor(entity.NodeTypeKnowledgeRetriever, func() nodes.NodeAdaptor { return &knowledge.Config{} })
    // ... 30+ 节点
}

新节点只要加一行 + 包级 import 副作用。


3. 节点元数据 NodeTypeMeta

源码:backend/domain/workflow/entity/node_meta.go

3.1 元数据结构

go 复制代码
type NodeTypeMeta struct {
    ID           int64    // 数字 ID(前后端对齐)
    Key          NodeType // 字符串 Key("LLM")
    Name         string   // 中文名(展示)
    Category     string   // 分类(logic / database / data / ai / utilities)
    SupportBatch bool     // 是否可被 Batch 节点包裹

    ExecutableMeta struct {
        IsComposite             bool  // 复合节点(自带子图)
        PreFillZero             bool  // 入参缺失时填零值
        PostFillNil             bool  // 出参缺失时填 nil
        InputSourceAware        bool  // 节点感知输入来自哪个上游
        IncrementalOutput       bool  // 增量流式输出
        PersistInputOnInterrupt bool  // 中断时持久化输入(用于恢复)
    }
}

3.2 ExecutableMeta 的实战意义

字段 典型场景
IsComposite Loop / Batch / SubWorkflow 等内含子图,Eino 调度需特殊处理
PreFillZero / PostFillNil field_fill.go 在执行前后自动补值,节点代码不必再判 nil
InputSourceAware 极少数节点(如 Selector)需要知道输入来自哪条分支
IncrementalOutput LLM、TextProcessor 等可流式;true 时 Eino 走 Stream 模式
PersistInputOnInterrupt QuestionAnswer、SubWorkflow 中断后恢复时,需要原始入参

4. 字段填充与类型转换

工作流 DSL 是强类型的------节点声明输入字段及其类型,引擎自动从上游/全局变量收集并校验。

4.1 字段填充 field_fill.go

源码:backend/domain/workflow/internal/compose/field_fill.go

填充策略:

go 复制代码
type FillStrategy int
const (
    FillZero FillStrategy = iota   // 缺失时填零值(string→"", int→0, array→[])
    FillNil                          // 缺失时填 nil
)

func FillIfNotRequired(tInfo *vo.TypeInfo, container map[string]any,
    k string, strategy FillStrategy, isInsideObject bool) error

三个调用点:

函数 时机 策略
inputValueFiller 节点执行,补全缺的入参 FillZero
outputValueFiller 节点执行,补全缺的出参 FillNil
streamInputValueFiller 流式入参缺失 FillZero

为什么这样分?入参少了用零值 (避免下游 nil panic),出参少了用 nil(让后续 Selector 节点能识别"这个字段没产生")。

4.2 类型转换 type_convert.go

源码:backend/domain/workflow/internal/canvas/convert/type_convert.go

go 复制代码
func CanvasVariableToTypeInfo(v *vo.Variable) (*vo.TypeInfo, error) {
    switch v.Type {
    case vo.VariableTypeString:  tInfo.Type = vo.DataTypeString
    case vo.VariableTypeInteger: tInfo.Type = vo.DataTypeInteger
    case vo.VariableTypeNumber:  tInfo.Type = vo.DataTypeNumber
    case vo.VariableTypeBoolean: tInfo.Type = vo.DataTypeBoolean
    case vo.VariableTypeObject:  /* 递归处理 schema */
    case vo.VariableTypeArray:   /* 递归处理 element type */
    case vo.VariableTypeFile:    tInfo.Type = vo.DataTypeFile
    }
}

支持隐式转换的方向(典型):

typescript 复制代码
string  ↔  number   (字符串数字互转)
string  →  array    (split 逻辑)
object  →  string   (JSON 序列化)
file    →  string   (URL)

精确规则散布在各节点的 Build 中,但通用类型系统(7 种)是统一的:String / Integer / Number / Boolean / Object / Array / File


5. Loop / Batch 并发执行模型

5.1 Loop 节点

源码:backend/domain/workflow/internal/nodes/loop/loop.go

go 复制代码
type Config struct {
    LoopType         Type                          // 数组循环 / for-N / 条件循环
    InputArrays      []string                      // 哪些数组同步迭代
    IntermediateVars map[string]*vo.TypeInfo       // 循环内变量
}

特性:

  • IsComposite: true------Loop 内含一个子工作流(Loop body)
  • PersistInputOnInterrupt: true------循环到第 N 次中断后,可从 N 恢复
  • 三种循环类型:
    • 数组循环 :遍历 InputArrays 各位置,同长度下并行迭代
    • for-N 循环:固定次数
    • 条件循环:while-like,有 break 节点
  • 不并发:Loop 是顺序的,要并发用 Batch

5.2 Batch 节点

源码:backend/domain/workflow/internal/nodes/batch/batch.go

go 复制代码
// 关键参数
const (
    MaxBatchSizeKey    = "batchSize"        // 单批大小
    ConcurrentSizeKey  = "concurrentSize"   // 并发度
)

行为:

  1. 输入是数组,按 batchSize 切片
  2. 每片内部并发执行子工作流,并发数受 concurrentSize 限制
  3. 各片结果聚合为输出数组,顺序与输入一致

典型用法:批量调用 LLM 处理多条数据,concurrentSize=5 防止打爆 RPM 限流。

5.3 Loop vs Batch 选型

场景
必须顺序(后一次依赖前一次输出) Loop
独立、可并发的数据处理 Batch
有 break/continue 控制流 Loop
需要限流并发 Batch

6. 变量作用域与执行上下文

源码:backend/domain/workflow/internal/execute/context.go

6.1 分层 Context

go 复制代码
type Context struct {
    RootCtx          // 根工作流上下文(整个执行的根)
    SubWorkflowCtx   // 当前所在的(子)工作流上下文
    NodeCtx          // 当前节点上下文
    BatchInfo        // 批处理迭代信息
    AppVarStore      // 应用级变量存储
}

type BatchInfo struct {
    Index            int             // 当前是第几个 item
    Items            map[string]any  // 当前 item 的字段
    CompositeNodeKey vo.NodeKey      // 所属的 Loop/Batch 节点
}

6.2 变量作用域规则

csharp 复制代码
┌──────────────────────────────────────────────────────────┐
│ 全局变量(AppVarStore + 用户 Variables)                  │
│   生命周期:整个执行                                      │
│   任何节点可读,部分节点(VariableAssigner)可写           │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ 工作流级变量(SubWorkflowCtx)                            │
│   生命周期:当前(子)工作流                              │
│   子工作流内变量不污染父工作流                            │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ Loop/Batch 迭代变量(BatchInfo + IntermediateVars)       │
│   生命周期:单次迭代                                      │
│   每次迭代独立,iterIndex 也是变量                        │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ 节点输出(在引擎内存中,按 nodeKey 索引)                  │
│   生命周期:整个工作流执行                                │
│   下游节点通过 ref { blockID, name } 引用                 │
└──────────────────────────────────────────────────────────┘

6.3 TokenCollector

Context 还携带 TokenCollector,在 LLM 节点的 callbacks 里累加 token 使用量:

go 复制代码
OnEndWithStreamOutput: func(ctx context.Context, runInfo *callbacks.RunInfo,
    output *schema.StreamReader[*model.CallbackOutput]) context.Context {
    safego.Go(ctx, func() {
        for {
            chunk, err := output.Recv()
            if chunk.TokenUsage != nil {
                c.addTokenUsage(chunk.TokenUsage)   // 累加到执行上下文
            }
        }
    })
}

执行结束写入 workflow_execution.usage 字段,前端就能展示 "本次执行用了 1234 token"。


7. Sub-workflow 嵌套与上下文继承

源码:backend/domain/workflow/internal/nodes/subworkflow/sub_workflow.go

go 复制代码
type SubWorkflow struct {
    Runner compose.Runnable[map[string]any, map[string]any]
}

func (s *SubWorkflow) Invoke(ctx context.Context, in map[string]any,
    opts ...nodes.NodeOption) (map[string]any, error) {
    nestedOpts, nodeKey, err := prepareOptions(ctx, opts...)
    return s.Runner.Invoke(ctx, in, nestedOpts...)
}

7.1 继承的内容

通过 prepareOptions,父 → 子继承:

  • TokenCollector(token 跨工作流累加)
  • AppVarStore(全局变量共享)
  • 中断恢复机制(子工作流的中断也能向上传递)

7.2 隔离的内容

  • 节点输出表(子工作流的节点 ID 与父隔离)
  • IntermediateVars(子工作流的内部变量不泄露)

7.3 与 Workflow as Tool 的差异

Sub-workflow 节点 Workflow as Tool(挂给 Bot)
触发方式 父工作流静态调用 LLM 在对话中动态决定
上下文 完全继承 独立(仅传 LLM 提供的参数)
实现 subworkflow/sub_workflow.go compose/workflow_tool.go 包装成 BaseTool

8. LLM 节点完整执行流程走读

源码:backend/domain/workflow/internal/nodes/llm/llm.go

LLM 节点是工作流最复杂的节点,内部是一整张子图。

8.1 配置 schema(Config)

go 复制代码
type Config struct {
    SystemPrompt    string                // 系统提示
    UserPrompt      string                // 用户提示模板
    OutputFormat    Format                // Text / Markdown / JSON
    LLMParams       *vo.LLMParams         // 模型参数
    FCParam         *vo.FCParam           // Function Call 参数(挂哪些工具)
    ChatHistorySetting *vo.ChatHistorySetting // 是否携带历史
}

8.2 Build 阶段:构建子图

go 复制代码
g := compose.NewGraph[map[string]any, map[string]any](
    compose.WithGenLocalState(func(ctx context.Context) llmState { return llmState{} }))

子图节点:

scss 复制代码
┌─────────────────────────────────────────────────────────┐
│ 1. promptNode    (Lambda) 把入参套进 Prompt 模板         │
│ 2. llmNodeKey    (ChatModel 或 ReAct Agent Graph)       │
│ 3. outputConvert (Lambda) 解析输出(JSON 解析等)        │
└─────────────────────────────────────────────────────────┘

8.3 模型构建

go 复制代码
chatModel, info, err := modelbuilder.BuildModelByID(ctx,
    c.LLMParams.ModelType,
    c.LLMParams.ToModelBuilderLLMParams())

modelbuilder.BuildModelByID(在 bizpkg/llm/modelbuilder/)按 protocol 路由到具体 builder(openai/ark/claude/...),返回统一的 ToolCallingChatModel

8.4 工具挂载(有 FCParam 时)

go 复制代码
if len(tools) > 0 {
    reactConfig := react.AgentConfig{
        ToolCallingModel: m,
        ToolsConfig:      compose.ToolsNodeConfig{Tools: tools},
        GraphName:        reactGraphName,
    }
    reactAgent, _ := react.NewAgent(ctx, &reactConfig)
    agentNode, opts := reactAgent.ExportGraph()
    g.AddGraphNode(llmNodeKey, agentNode, opts...)  // ReAct Agent 替换原 ChatModel
}

工具来源(FCParam):

  • WorkflowFCParam:把别的工作流当工具
  • PluginFCParam:把插件当工具
  • KnowledgeFCParam:把知识库当工具(LLM 自己决定查不查)

8.5 知识库注入

go 复制代码
if knowledgeRecallConfig != nil {
    err := injectKnowledgeTool(ctx, g, c.UserPrompt, knowledgeRecallConfig)
    userPrompt = fmt.Sprintf("{{%s}}%s", knowledgeUserPromptTemplateKey, userPrompt)
}

知识库参数:TopKMinScoreSearchTypeEnableNL2SQLEnableQueryRewriteEnableRerank

8.6 流式输出与 token 统计

LLM 节点是天然的流式节点(IncrementalOutput: true),通过 callbacks 接收每个 token chunk:

go 复制代码
OnEndWithStreamOutput: func(ctx, runInfo, output) context.Context {
    safego.Go(ctx, func() {
        for {
            chunk, err := output.Recv()
            if chunk.TokenUsage != nil {
                c.addTokenUsage(chunk.TokenUsage)
            }
        }
    })
}

8.7 输出后处理

go 复制代码
switch format {
case FormatJSON:
    jsonSchema := vo.TypeInfoToJSONSchema(ns.OutputTypes, nil)
    userPrompt = userPrompt + fmt.Sprintf(jsonPromptFormat, jsonSchema)
    // 加一个解析节点,把 LLM 输出的 JSON 字符串解析成 map
    convertNode := compose.InvokableLambda(func(ctx, msg *schema.Message) (map[string]any, error) {
        return jsonParse(ctx, msg.Content, ns.OutputTypes)
    })
    g.AddLambdaNode(outputConvertNodeKey, convertNode)

case FormatMarkdown:
    userPrompt = userPrompt + markdownPrompt
}

JSON 模式的实现是Prompt 工程 + 后置解析 ------把 schema 拼到 prompt 里告诉 LLM,然后用解析 Lambda 兜底。比纯 response_format: json 兼容更多模型。


9. Checkpoint 与中断恢复

9.1 Eino 的 CheckPointStore 接口

Coze 在工作流编译时,如有 QuestionAnswer 等可中断节点,会标记 requireCheckpoint = true:

go 复制代码
// backend/domain/workflow/internal/compose/workflow.go:145
if wf.requireCheckpoint {
    compileOpts = append(compileOpts, compose.WithCheckPointStore(workflow2.GetRepository()))
}

workflow2.GetRepository() 实现了 Eino 的 CheckPointStore 接口。

9.2 双后端

实现 文件 用途
内存 backend/infra/checkpoint/mem.go 测试 / 单机
Redis backend/infra/checkpoint/redis.go 生产默认

Redis 实现:

  • Key:checkpoint_key:{checkpointID}(UUID)
  • Value:Eino 序列化的二进制执行快照
  • TTL:7 天(用户 7 天没回 QuestionAnswer 就只能重跑)

9.3 中断事件

go 复制代码
type InterruptEvent struct {
    NodeKey       vo.NodeKey
    NodeType      entity.NodeType
    NodeTitle     string
    InterruptData string  // JSON,告诉前端"等什么"
}

中断时:

  1. compose.NewInterruptAndRerunErr(event)
  2. Eino 框架捕获,把当前快照存到 CheckPointStore
  3. Coze 把 InterruptEvent 存到 DB(SaveInterruptEvents)
  4. SSE 推一个中断事件给前端

9.4 恢复

前端调 /v1/workflow/stream_resume:

markdown 复制代码
1. PopFirstInterruptEvent(exeID)  → 取出待处理事件
2. 从 Redis 读 Checkpoint
3. 把用户输入注入恢复点
4. Eino 续跑

9.5 多次中断

工作流可有多个 QuestionAnswer 串成"对话流"------每次中断都存,每次恢复都续,直到走到 Exit。这正是 Coze "ChatFlow" 的实现基础。


关键路径速查

主题 路径
Workflow 主入口 backend/domain/workflow/internal/compose/workflow.go
Node 注册接口 backend/domain/workflow/internal/nodes/node.go
Node 集中注册 backend/domain/workflow/internal/canvas/adaptor/to_schema.go
NodeTypeMeta 表 backend/domain/workflow/entity/node_meta.go
字段填充 backend/domain/workflow/internal/compose/field_fill.go
类型转换 backend/domain/workflow/internal/canvas/convert/type_convert.go
Loop 节点 backend/domain/workflow/internal/nodes/loop/loop.go
Batch 节点 backend/domain/workflow/internal/nodes/batch/batch.go
执行上下文 backend/domain/workflow/internal/execute/context.go
SubWorkflow 节点 backend/domain/workflow/internal/nodes/subworkflow/sub_workflow.go
LLM 节点 backend/domain/workflow/internal/nodes/llm/llm.go
ModelBuilder backend/bizpkg/llm/modelbuilder/
Workflow as Tool backend/domain/workflow/internal/compose/workflow_tool.go
Checkpoint 内存 backend/infra/checkpoint/mem.go
Checkpoint Redis backend/infra/checkpoint/redis.go
相关推荐
神奇小汤圆2 小时前
Spring Bean 的生命周期
后端
神奇小汤圆2 小时前
我研读了 500 个 Spring Boot 生产级代码库,90% 都犯了这 7 个致命错误
后端
空中海2 小时前
03 MyBatis Spring Boot 集成、事务、测试与工程化体系
spring boot·后端·mybatis
ElonMuscle2 小时前
GO环境速建笔记
后端
用户298698530142 小时前
Java 从零生成 Word 文档:段落、图片与表格操作
java·后端
SimonKing3 小时前
OpenCode 在 IDEA 中使用 ACP 协议 VS 直接使用 TUI,哪个编程方式更是你的菜?
java·后端·程序员
Gopher_HBo3 小时前
Disruptor多生产者多消费者分析
后端
杨运交3 小时前
[013][缓存模块]基于Redis的计数器缓存模板设计——AbstractCounterCacheTemplate 技术解析
spring boot·后端