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)。

Chain/Graph 传值是"整个输出 → 整个输入"。但真实场景常常是:上游产出一个结构体,下游只需要它其中一个字段;或者下游的输入要拼自好几个上游的不同字段。Workflow 就是为"字段级接线"而生。

举个例子 节点 A(用户信息查询)输出 {Name, Age, City, VIP等级}。下游节点 B(生成问候语)只要 Name,节点 C(推荐商品)只要 CityVIP等级。用 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
节点A 输出 Name Age(没人要) City VIP 节点B 输入 UserName 节点C 输入 city level Name → UserName City → city VIP → level
字段级接线:只搬需要的字段(字段名可不同),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 节点输出 {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:"金"}):

步骤此刻发生什么目标节点收到的输入
1A 执行完,输出整个结构体放进 channel
2B.AddInput(A, MapFields("Name","UserName")) 搬 NameB 收到 {UserName:"小明"}
3C.AddInput(A, MapFields("City","city"),MapFields("VIP","level")) 搬两字段C 收到 {city:"北京", level:"金"}
4Age 没有任何映射声明被丢弃,谁都收不到
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 和上面编排引擎的关系。
← Day 09 编译执行 Day 11 · ADK Agent 抽象 →