把工作流引擎的内脏拆开:Eino 的核心抽象、节点注册机制、字段填充与类型转换、Loop/Batch 并发模型、变量作用域、子工作流、LLM 节点全流程走读。
主文档:01 - 原理与使用
目录
- [Eino 框架核心抽象](#Eino 框架核心抽象 "#1-eino-%E6%A1%86%E6%9E%B6%E6%A0%B8%E5%BF%83%E6%8A%BD%E8%B1%A1")
- [节点注册机制 NodeAdaptor](#节点注册机制 NodeAdaptor "#2-%E8%8A%82%E7%82%B9%E6%B3%A8%E5%86%8C%E6%9C%BA%E5%88%B6-nodeadaptor")
- [节点元数据 NodeTypeMeta](#节点元数据 NodeTypeMeta "#3-%E8%8A%82%E7%82%B9%E5%85%83%E6%95%B0%E6%8D%AE-nodetypemeta")
- 字段填充与类型转换
- [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")
- 变量作用域与执行上下文
- [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")
- [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")
- [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 接口:BaseChatModel、ToolCallingChatModel |
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.go 的 init():
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" // 并发度
)
行为:
- 输入是数组,按
batchSize切片 - 每片内部并发执行子工作流,并发数受
concurrentSize限制 - 各片结果聚合为输出数组,顺序与输入一致
典型用法:批量调用 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)
}
知识库参数:TopK、MinScore、SearchType、EnableNL2SQL、EnableQueryRewrite、EnableRerank。
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,告诉前端"等什么"
}
中断时:
- 抛
compose.NewInterruptAndRerunErr(event) - Eino 框架捕获,把当前快照存到 CheckPointStore
- Coze 把 InterruptEvent 存到 DB(
SaveInterruptEvents) - SSE 推一个中断事件给前端
9.4 恢复
前端调 /v1/workflow/stream_resume:
markdown
1. PopFirstInterruptEvent(exeID) → 取出待处理事件
2. 从 Redis 读 Checkpoint
3. 把用户输入注入恢复点
4. Eino 续跑
9.5 多次中断
工作流可有多个 QuestionAnswer 串成"对话流"------每次中断都存,每次恢复都续,直到走到 Exit。这正是 Coze "ChatFlow" 的实现基础。