系列「企业级 AI Agent 实现拆解」补充篇。前两篇讲了 Graph 和 Chain:Graph 能画任意流程,Chain 是线性管道的语法糖。但它们有一个共同的隐含假设------上一个节点的输出类型,正好是下一个节点的输入类型。现实世界不总是这样。这一篇讲 Workflow,专门解决"类型不匹配"问题的第三个编排工具。
读完这篇你会知道:
- Workflow 解决什么问题(用一个具体例子讲清楚)
- 字段映射的 4 个 API:FromField / ToField / MapFields / MapFieldPaths
- 数据流和控制流分离:
WithNoDirectDependency和AddDependency - 静态值注入:
SetStaticValue - 流式场景下的字段映射
- Workflow vs Graph vs Chain 的选型总结
先看一个 Graph 做起来很别扭的场景
假设你要搭一个"文字统计"流程:
- 输入一条消息(包含正文和搜索词)
- 统计正文中搜索词出现的次数
- 统计推理内容中搜索词出现的次数
- 两个结果合并输出
输入类型长这样:
go
type message struct {
*schema.Message
SubStr string // 要搜索的子串
}
统计函数的输入类型长这样:
go
type counter struct {
FullStr string // 被搜索的完整字符串
SubStr string // 搜索词
}
问题来了:message ≠ counter。Graph 的边默认要求"上一个的输出 = 下一个的输入",但这里 message.Content 要映射到 counter.FullStr,message.SubStr 要映射到 counter.SubStr。
用 Graph 你得手写一个"转换函数"来桥接:
go
// Graph 方案:手动写适配层
g.AddLambdaNode("adapter", compose.InvokableLambda(
func(ctx context.Context, msg message) (counter, error) {
return counter{FullStr: msg.Content, SubStr: msg.SubStr}, nil
},
))
每个类型不匹配的地方都要写一个适配层。节点多了,适配层比业务逻辑还多。
Workflow 就是来消灭这些适配层的。
Workflow 是什么:带字段映射的 Graph
Workflow 底层也是一个 Graph(workflow.go:45):
go
type Workflow[I, O any] struct {
g *graph // ← 内部包了一个 Graph
// ...
}
和 Graph 的区别只有一点:连边的时候可以指定"哪个字段对应哪个字段",而不是只能"整体传过去"。
Graph 的边:
css
节点A 的全部输出 ──────→ 节点B 的全部输入
Workflow 的边:
css
节点A.field1 ──→ 节点B.fieldX
节点A.field2 ──→ 节点B.fieldY
你可以把 Workflow 想象成"带标签的管道"------Graph 是一根直管,水(数据)从一头灌进去,另一头原样流出来。Workflow 则是在管子上贴标签:"这个口的 A 液体,接到那个口的 X 接口;B 液体接到 Y 接口"。
还有一个限制:Workflow 不支持循环 (源码注释写得很明确:Under the hood it uses NodeTriggerMode(AllPredecessor), so does not support cycles)。原因在于它要求每个节点"等所有前驱都到齐"才触发,这种模式天然容不下环。所以 ReAct 循环不能用 Workflow,还是得用 Graph。
字段映射 4 个 API
核心数据结构是 FieldMapping(field_mapping.go:31):
go
type FieldMapping struct {
fromNodeKey string // 来源节点
from string // 来源字段(空 = 全部输出)
to string // 目标字段(空 = 全部输入)
}
4 个构造函数:
| API | 含义 | 类比 |
|---|---|---|
FromField("X") |
取来源的 X 字段 → 目标的全部输入 | "只要 X,其他不要" |
ToField("Y") |
来源的全部输出 → 目标的 Y 字段 | "全给 Y" |
MapFields("X", "Y") |
取来源的 X → 目标的 Y | "X 接到 Y" |
MapFieldPaths(["a","b"], ["c"]) |
嵌套路径映射 | "a.b 接到 c" |
用上面的文字统计例子,完整写法:
go
wf := compose.NewWorkflow[message, map[string]any]()
// 节点 c1:统计正文中的词频
wf.AddLambdaNode("c1", compose.InvokableLambda(wordCounter)).
AddInput(compose.START,
compose.MapFields("SubStr", "SubStr"), // SubStr → SubStr(同名字段)
compose.MapFieldPaths( // 嵌套路径映射
[]string{"Message", "Content"}, // 来源:message.Message.Content
[]string{"FullStr"}, // 目标:counter.FullStr
))
// 节点 c2:统计推理内容中的词频
wf.AddLambdaNode("c2", compose.InvokableLambda(wordCounter)).
AddInput(compose.START,
compose.MapFields("SubStr", "SubStr"),
compose.MapFieldPaths(
[]string{"Message", "ReasoningContent"}, // 来源换了一个字段
[]string{"FullStr"},
))
// END 节点:收集两个统计结果
wf.End().
AddInput("c1", compose.ToField("content_count")). // c1 全部输出 → map["content_count"]
AddInput("c2", compose.ToField("reasoning_content_count")) // c2 全部输出 → map["reasoning_content_count"]
run, _ := wf.Compile(ctx)
result, _ := run.Invoke(ctx, message{
Message: &schema.Message{
Content: "Hello world!",
ReasoningContent: "I need to say something meaningful",
},
SubStr: "o",
})
// result = map[string]any{
// "content_count": 1, // "Hello world!" 里 1 个 "o"
// "reasoning_content_count": 0, // "I need to say..." 里 0 个 "o"
// }
数据流全景:
arduino
message{Message, SubStr}
│
┌────┴────┐
│ │
▼ ▼
c1 c2 ← 两个节点并行执行
统计 统计
正文 推理内容
│ │
▼ ▼
content_ reasoning_
count content_count
│ │
└────┬────┘
▼
map[string]any ← END 节点合并两个结果
没有手写任何适配层。MapFields 和 MapFieldPaths 替你做了字段提取和赋值。
数据流和控制流分离
普通依赖(AddInput)同时做两件事:
- 控制依赖:等前驱节点执行完,当前节点才开始
- 数据依赖:把前驱节点的输出传给当前节点
有时候你只想传数据,不想建立直接的控制依赖。Workflow 提供了拆分手段。
场景:计算器(加法 × 乘法)
css
输入: calculator{Add: [2, 5], Multiply: 3}
│
├────→ adder([2, 5]) = 7
│ │
│ ▼
│ multiplier(A=7, B=3) = 21
│ │
└──────────────┘ ← Multiply 字段直接给 multiplier 的 B
但不建立"START → mul"的控制依赖
因为 mul 等的是 adder 完成就行
go
type calculator struct {
Add []int
Multiply int
}
wf := compose.NewWorkflow[calculator, int]()
wf.AddLambdaNode("adder", compose.InvokableLambda(adder)).
AddInput(compose.START, compose.FromField("Add")) // 只要 Add 字段
wf.AddLambdaNode("mul", compose.InvokableLambda(multiplier)).
AddInput("adder", compose.ToField("A")). // adder 的全部输出 → mul.A(控制+数据)
AddInputWithOptions( // START → mul.B(仅数据,不控制)
compose.START,
[]*compose.FieldMapping{compose.MapFields("Multiply", "B")},
compose.WithNoDirectDependency(), // ← 关键:数据流过,但不建控制依赖
)
wf.End().AddInput("mul")
WithNoDirectDependency() 的意思是:"我只想要 START 的数据,但 START 执行完不等于我可以开始了------我等的是 adder。"
把控制流想成"等人到齐了再开会"------adder 的结果到了 mul 才能开始。但 START 的 Multiply 数据只是"一份参考资料",不需要等人到齐。
AddDependency:只控制,不传数据
反过来也有:只要控制依赖,不传数据。
go
wf.AddLambdaNode("b2", compose.InvokableLambda(bidder)).
AddDependency("b1"). // 等 b1 执行完,但不接收 b1 的任何数据
AddInputWithOptions(compose.START, ...) // 数据从 START 来
这是"b2 必须在 b1 之后执行,但 b2 的输入和 b1 没关系"的场景。比如 b1 是前置校验,b2 是业务逻辑,校验结果不传给业务逻辑,但业务逻辑必须等校验通过。
静态值注入:编译期常量
有些字段不是从上游来的,是固定值。用 SetStaticValue 在编译时注入:
go
type bidInput struct {
Price float64
Budget float64
}
wf.AddLambdaNode("b1", compose.InvokableLambda(bidder)).
AddInput(compose.START, compose.ToField("Price")). // 动态:START 输出 → Price
SetStaticValue([]string{"Budget"}, 3.0) // 静态:Budget = 3.0
编译时(workflow.go:469),静态值会被包装成一个 handler,在节点执行前把静态值和动态输入合并:
go
// 编译时伪代码
pair := handlerPair{
invoke: func(in any) (any, error) {
values := []any{in, staticValues} // 合并动态输入和静态值
return mergeValues(values, nil)
},
}
静态值就像表格里的"默认值"------大部分行从上游填,少数字段写死一个值。
SetStaticValue让你不用为此专门写一个 Lambda。
流式字段映射
前面的例子都是 Invoke(一次性进出)。Workflow 也支持流式------用 Transform 模式(StreamReader 进,StreamReader 出)。
go
// wordCounter 现在处理流式输入
wordCounter := func(ctx context.Context, c *schema.StreamReader[counter]) (
*schema.StreamReader[int], error,
) {
var subStr, cachedStr string
return schema.StreamReaderWithConvert(c, func(co counter) (int, error) {
if len(co.SubStr) > 0 {
subStr = co.SubStr // 静态值可能在后面的 chunk 到
return strings.Count(cachedStr + co.FullStr, subStr), nil
}
if len(subStr) > 0 {
return strings.Count(co.FullStr, subStr), nil
}
cachedStr += co.FullStr // 静态值还没到,先缓存
return 0, schema.ErrNoValue
}), nil
}
// Workflow 定义跟非流式几乎一样
wf := compose.NewWorkflow[*schema.Message, map[string]int]()
wf.AddLambdaNode("c1", compose.TransformableLambda(wordCounter)).
AddInput(compose.START, compose.MapFields("Content", "FullStr")).
SetStaticValue([]string{"SubStr"}, "w") // 静态值在流式场景也能用
wf.AddLambdaNode("c2", compose.TransformableLambda(wordCounter)).
AddInput(compose.START, compose.MapFields("ReasoningContent", "FullStr")).
SetStaticValue([]string{"SubStr"}, "w")
wf.End().
AddInput("c1", compose.ToField("content_count")).
AddInput("c2", compose.ToField("reasoning_content_count"))
run, _ := wf.Compile(ctx)
// 用 Transform 而不是 Invoke
stream, _ := run.Transform(ctx, schema.StreamReaderFromArray([]*schema.Message{
{ReasoningContent: "I need to say something meaningful"},
{Content: "Hello world!"},
}))
流式字段映射的核心在 field_mapping.go:568:
go
func streamFieldMap(mappings []*FieldMapping, ...) func(streamReader) streamReader {
return func(input streamReader) streamReader {
return packStreamReader(schema.StreamReaderWithConvert(
input.toAnyStreamReader(), fieldMap(mappings, true, ...)))
}
}
每个 chunk 都过一遍 fieldMap,把 chunk 里的指定字段提取出来,塞进目标结构。静态值通过 StreamReaderFromArray 在流的头部注入,跟动态 chunk 一起流过节点。
字段映射的编译期校验
Workflow 在 Compile 时会做类型校验(field_mapping.go:645)。如果你映射了一个不存在的字段,编译直接报错:
go
// 编译时报错:type[MyStruct] has no field[nonExist]
wf.AddLambdaNode("n1", ...).AddInput(compose.START, compose.FromField("nonExist"))
校验逻辑:
- 沿着
FieldPath逐层检查类型------结构体用FieldByName,map 用 key 类型检查 - 遇到
interface{}类型无法静态检查的,延迟到运行时校验 - 检查来源字段类型能否赋值给目标字段类型
编译期校验是 Workflow 相比手写适配层的另一个优势------写错了编译就不通过,不用等到运行时才发现字段名拼错。
Workflow 的 Branch
Workflow 也支持条件分支,和 Graph 的 AddBranch 用法类似,但有一个关键区别(workflow.go:416):
Graph 的 Branch 会自动把输入传给选中的节点;Workflow 的 Branch 不传------选中的节点必须自己声明
AddInput。
这和 Workflow 的设计哲学一致:数据怎么流,由节点自己声明,不由分支"顺带"传过去。
go
// Workflow 的 Branch
wf.AddBranch("b1", compose.NewGraphBranch(
func(ctx context.Context, in float64) (string, error) {
if in > 5.0 {
return compose.END, nil
}
return "b2", nil
},
map[string]bool{compose.END: true, "b2": true},
))
选型总结:Graph vs Chain vs Workflow
| 维度 | Chain | Graph | Workflow |
|---|---|---|---|
| 拓扑 | 线性 | 任意(含循环) | 任意(不含循环) |
| 字段映射 | 不支持 | 手动 addEdgeWithMappings |
声明式 AddInput |
| 静态值注入 | 不支持 | 不支持 | SetStaticValue |
| 数据/控制分离 | 不支持 | 不支持 | WithNoDirectDependency |
| 代码量 | 最少 | 中等 | 中等 |
| 适用场景 | 简单管道 | ReAct 循环 | 类型不匹配的复杂流程 |
一张决策图:
markdown
节点间有类型不匹配?
│
├── 没有 → 流程是线性的?
│ │
│ ├── 是 → Chain
│ │
│ └── 否 → 需要循环?
│ │
│ ├── 是 → Graph(Pregel 模式)
│ └── 否 → Graph(DAG 模式)
│
└── 有 → Workflow
│
├── 需要循环?
│ └── 是 → Workflow 做不到,用 Graph + 手写适配层
│
└── 不需要循环 → Workflow(字段映射 + 静态值 + 数据控制分离)
一句话总结:如果 Graph/Chain 的"上一个的输出就是下一个的输入"这个假设成立,用 Graph 或 Chain;如果节点间需要字段对字段映射、需要静态值、需要数据和控制分离,用 Workflow。
完整示例:带分支和静态值的竞价 Workflow
把前面学的东西串起来:
go
package main
import (
"context"
"fmt"
"math/rand"
)
func main() {
ctx := context.Background()
// 竞价函数
bidder := func(ctx context.Context, in bidInput) (float64, error) {
if in.Price >= in.Budget {
return in.Budget, nil
}
return in.Price + rand.Float64()*in.Budget, nil
}
type bidInput struct {
Price float64
Budget float64
}
wf := compose.NewWorkflow[float64, map[string]float64]()
// b1:竞价方 1,预算固定 3.0(静态值)
wf.AddLambdaNode("b1", compose.InvokableLambda(bidder)).
AddInput(compose.START, compose.ToField("Price")).
SetStaticValue([]string{"Budget"}, 3.0) // ← 静态注入预算
// 分支:竞价结果 > 5 则直接结束,否则继续 b2
wf.AddBranch("b1", compose.NewGraphBranch(
func(ctx context.Context, in float64) (string, error) {
if in > 5.0 {
return compose.END, nil
}
return "b2", nil
},
map[string]bool{compose.END: true, "b2": true},
))
// b2:竞价方 2,预算固定 4.0
wf.AddLambdaNode("b2", compose.InvokableLambda(bidder)).
AddDependency("b1"). // ← 只控制:等 b1 执行完
AddInputWithOptions( // ← 只数据:从 START 拿 Price
compose.START,
[]*compose.FieldMapping{compose.ToField("Price")},
compose.WithNoDirectDependency(), // ← 不建直接控制依赖
).
SetStaticValue([]string{"Budget"}, 4.0)
// 收集结果
wf.End().
AddInput("b1", compose.ToField("bidder1")).
AddInput("b2", compose.ToField("bidder2"))
runner, _ := wf.Compile(ctx)
result, _ := runner.Invoke(ctx, 3.0)
fmt.Println(result)
// 输出: map[bidder1:4.23 bidder2:5.67](具体数值随机)
}
这个例子一次展示了 Workflow 的三个核心能力:
- 字段映射 :
ToField("Price")、ToField("bidder1") - 静态值 :
SetStaticValue注入 Budget - 数据/控制分离 :
AddDependency只等不等数据,WithNoDirectDependency只拿数据不等控制
小结
| 概念 | 一句话 | API |
|---|---|---|
| 字段映射 | 节点间按字段名传数据 | MapFields / FromField / ToField / MapFieldPaths |
| 静态值 | 编译时注入常量 | SetStaticValue(FieldPath, value) |
| 纯数据依赖 | 传数据但不阻塞执行 | AddInputWithOptions(..., WithNoDirectDependency()) |
| 纯控制依赖 | 等执行但不传数据 | AddDependency("nodeKey") |
| 流式映射 | chunk 级别的字段提取 | TransformableLambda + 同样的 AddInput |
| Branch | 条件路由,但不自动传数据 | AddBranch + 节点自己 AddInput |
记住三句话:
- Workflow = Graph + 字段映射,底层编译成同一个 Runnable
- 适合"节点间类型不匹配"的场景------不用手写适配层
- 不支持循环------ReAct 还是得用 Graph
本文涉及的源码版本:eino main 分支,示例代码参考 eino-examples/compose/workflow。文中代码已简化以突出核心逻辑,完整实现请参考源码链接。