Day 10 / 共 20 天 · 第 2 周 编排引擎(收官)
Workflow 字段级编排
第三种编排,也是最精细的:不再"整个输出喂给下一个",而是"A 的某个字段 → B 的某个字段"。今天讲字段映射,并收官第 2 周。
📍 你在整门课的位置 · 第 2 周 编排引擎(共 4 周 · 20 天)
D6 流式范式→
D7 Chain 链→
D8 Graph 图→
D9 编译执行→
D10 Workflow
L01
Workflow 解决什么问题
🤔 痛点:下游只要上游的一个字段,Chain 却把整个结构体塞过来
上游"用户查询"节点吐一个
{Name, Age, City, VIP} 大结构体,可下游"问候语"节点只要 Name 一个字段。用 Chain/Graph,你只能把整个结构体传过去,然后在下游节点里手写代码拆出 Name——每个下游都得写一遍这种拆包胶水,还容易和上游结构耦合死。💡 本质:Workflow 让你按"字段"接线,而不是按"整个值"接线
Graph 传的是"整个输出→整个输入",Workflow 能精确声明"上游的
City 字段 → 我的 city 字段"。框架用反射在节点交界处自动搬字段,你不用写拆包/组包代码。代价:它必须是 DAG(不能循环),因为字段依赖是编译时静态确定的。📮 用「快递单」来理解本讲(后面统一用这个类比)
Chain/Graph 传值就像把整个包裹连同快递单一起转交:下游拿到整箱东西,还得自己翻找要的那件。
Workflow 的字段映射就像只抄快递单上的"收件人"一栏填到新单子的"客户名"栏:要哪格填哪格,精准搬运,不搬整张单。
控制/数据分离就像"签收要 A 签字,但货从 B 仓库发":谁批准(控制)和货从哪来(数据)可以是两个人(见 L05)。
Workflow 的字段映射就像只抄快递单上的"收件人"一栏填到新单子的"客户名"栏:要哪格填哪格,精准搬运,不搬整张单。
控制/数据分离就像"签收要 A 签字,但货从 B 仓库发":谁批准(控制)和货从哪来(数据)可以是两个人(见 L05)。
Chain/Graph 传值是"整个输出 → 整个输入"。但真实场景常常是:上游产出一个结构体,下游只需要它其中一个字段;或者下游的输入要拼自好几个上游的不同字段。Workflow 就是为"字段级接线"而生。
举个例子
节点 A(用户信息查询)输出
{Name, Age, City, VIP等级}。下游节点 B(生成问候语)只要 Name,节点 C(推荐商品)只要 City 和 VIP等级。用 Chain 你得写胶水代码手动拆字段;用 Workflow 你直接声明"A.Name → B.input"、"A.City → C.input.city",框架自动搬运。Workflow = 声明式的"字段接线板"。L02
字段映射一张图
节点A 输出
Name
Age
City
VIP
接线 →
节点B 输入
UserName ← A.Name
节点C 输入
City ← A.City
Level ← A.VIP
字段级接线:只搬需要的字段(字段名可不同),Age 没人映射就丢弃(MapFields,field_mapping.go:85)
注意:字段名可以不一样(
A.Name → B.UserName),Workflow 负责按你声明的映射把值搬过去。Age 没人要,就丢弃。这种精细控制是 Chain/Graph 给不了的。L03
真实 API 用法
workflow.go 的用法——加节点后用 AddInput 声明字段映射:
wf := compose.NewWorkflow[InType, OutType]()
wf.AddChatModelNode("model", cm)
wf.AddLambdaNode("format", fmtLambda).
AddInput("model", compose.MapFields("Content", "Text"))
// ↑从哪个节点取 ↑ model输出的Content字段 → format输入的Text字段
wf.End().AddInput("format") // format 的输出作为整个工作流的输出
r, _ := wf.Compile(ctx)
读法:
AddInput(上游节点key, 字段映射...) 声明"我这个节点的输入,从哪个上游的哪些字段来"。MapFields("Content","Text") = 把上游的 Content 字段值放进我的 Text 字段。不写字段映射(如 AddInput("format"))就是整体传递。📝 最小例子:一个字段怎么被搬过去
model 节点输出
你声明了
于是 format 节点拿到的输入是
你没写任何
{Content:"你好呀", Role:"assistant"}。你声明了
AddInput("model", MapFields("Content","Text"))——只要 Content。于是 format 节点拿到的输入是
{Text:"你好呀"}(Content 的值被搬进 Text 字段,Role 被丢弃)。你没写任何
out.Text = in.Content 的赋值代码——框架用反射按字段名替你搬了。几种映射助手:
MapFields(from, to) 字段到字段;ToField(to) 整个上游输出放进我的某字段;FromField(from) 取上游某字段作为我的整体输入。组合起来能表达几乎任意的"接线"。L04
FieldMapping 源码:怎么搬字段
每条字段映射是一个 FieldMapping 结构(field_mapping.go),记录"从哪个字段来、到哪个字段去"。编译时它变成一个真正搬数据的函数(反射按字段名取值/赋值):
// 概念示意
type FieldMapping struct {
fromNodeKey string
from string // 上游字段名(空=整体)
to string // 下游字段名(空=整体)
}
// 运行时:用反射从上游输出取 from 字段值,塞进下游输入的 to 字段
读法:Workflow 用 Go 的反射(reflect)在运行时按字段名搬值。编译期会检查字段是否存在、类型是否兼容——又是"错误前移":字段名写错,Compile 就报错。
反射的代价:反射比直接赋值慢一点,但字段映射只在节点交界处发生(不是热循环),且省去了你手写大量拆包/组包胶水代码——这个取舍很划算。字段名和类型检查放编译期,运行期只做搬运。
💥 没有这个机制会出什么事故?
假设没有字段映射,你只能整体传值。上游"用户查询"改了结构体:把字段
City 改名成 Location。结果所有下游手写的 in.City 全部编译报错或取到空值——你得挨个改一堆节点里的拆包代码。更糟的是,如果字段名写成字符串埋在逻辑里,可能编译不报错、运行时静默取到零值,Bug 藏到线上。Workflow 把映射集中声明在 AddInput 处,且编译期就检查字段存不存在、类型配不配——字段名写错,Compile 当场报错,不会拖到线上(Day 09 讲的"错误前移"再次兑现)。把一次运行的字段搬运,落成单步走查表(上游 A 输出 {Name:"小明", Age:18, City:"北京", VIP:"金"}):
| 步骤 | 此刻发生什么 | 目标节点收到的输入 |
|---|---|---|
| 1 | A 执行完,输出整个结构体放进 channel | — |
| 2 | 按 B.AddInput(A, MapFields("Name","UserName")) 搬 Name | B 收到 {UserName:"小明"} |
| 3 | 按 C.AddInput(A, MapFields("City","city"),MapFields("VIP","level")) 搬两字段 | C 收到 {city:"北京", level:"金"} |
| 4 | Age 没有任何映射声明 | 被丢弃,谁都收不到 |
L05
控制与数据分离(Day 08 L05 的兑现)
Day 08 提到"控制边 vs 数据边可以分开"。Workflow 正是用它的地方:AddInput 声明"数据从哪来"(数据边),AddDependency 声明"等谁执行完"(控制边),两者可以是不同节点。
什么场景需要分开?
比如:"节点 D 必须等审计节点 Audit 跑完才能执行(合规要求),但 D 的输入数据来自节点 C"。这时
D.AddInput("C")(数据来自 C)+ D.AddDependency("Audit")(但要等 Audit)。控制和数据指向不同节点——这种精细依赖只有 Workflow 能优雅表达。这就是 Day 08 埋的伏笔。L06
为什么 Workflow 必须是 DAG(不能循环)
Workflow 用 DAG 引擎(Day 09),不允许环。原因:字段映射是"静态接线"——编译时就确定"每个字段从哪来"。如果有环,就会出现"A 的字段依赖 B、B 的字段又依赖 A"的死结,无法确定求值顺序。
一句话:需要循环(Agent)→ 用 Graph(Pregel);需要精细字段接线的一次性流水线(数据处理、RAG 管道)→ 用 Workflow(DAG)。DAG 的 AllPredecessor 触发也正好契合"等所有依赖字段到齐才计算"。
⚠️ 小白常误以为:Workflow 比 Graph 高级,所以更强、什么都能干。其实:它俩是"各有所长"——Workflow 强在字段级接线,但不能循环;Graph 强在能循环、任意拓扑,但只能整体传值。做 Agent(要循环)必须 Graph,Workflow 干不了。没有谁更高级,只有谁更合适。
L07
三种编排如何选(决策速查)
// 固定顺序、无循环、整体传值 → Chain(最省事)
chain.AppendChatTemplate(t).AppendChatModel(m)
// 有循环 / 复杂分支汇聚 / Agent → Graph(最灵活,Pregel)
g.AddEdge("tools","chat"); g.AddBranch("chat", ...) // 回头边=循环
// 精细字段接线、一次性 DAG 流水线 → Workflow(最精细,DAG)
wf.AddLambdaNode("f",l).AddInput("model", MapFields("Content","Text"))
新手建议
先无脑用 Chain,写简单流程够了。等你要做 Agent(模型↔工具循环)自然会转向 Graph——事实上 Eino 的 ADK/ReAct(下周)内部就是用 Graph 搭的。Workflow 是进阶,等你有"多字段精细接线"的数据流水线需求再用。三者底层同一引擎,学会一个迁移到另一个很快。
L08
第 2 周收官 + 动手
🎓 第 2 周(Day 06-10)你已掌握 Eino 引擎
- Day 06 Runnable:统一插头 + 4 流式范式自动适配
- Day 07 Chain:自动连边的直线流水线
- Day 08 Graph:手动连边、可循环的图(Agent 基础)
- Day 09 编译执行:Compile 五步、Pregel/DAG、channel、super-step、State
- Day 10 Workflow:字段级接线的 DAG
你现在理解了 Eino"怎么把一堆组件编排成能跑的流程"的完整机制。下周进入 ADK 智能体开发——用这些引擎搭真正的 Agent。
✋ 动手
sed -n '1,80p' compose/workflow.go | head -50 # Workflow API
sed -n '1,60p' compose/field_mapping.go # FieldMapping
grep -n 'AddInput\|AddDependency\|MapFields' compose/*.go | head
下周预告 · Day 11:进入 ADK(Agent Development Kit)。Day 11 讲 Agent 抽象——Agent 接口、AgentInput/AgentEvent、Runner,以及 Agent 和上面编排引擎的关系。