Workflow 编排:字段映射、数据流分离

系列「企业级 AI Agent 实现拆解」补充篇。前两篇讲了 GraphChain:Graph 能画任意流程,Chain 是线性管道的语法糖。但它们有一个共同的隐含假设------上一个节点的输出类型,正好是下一个节点的输入类型。现实世界不总是这样。这一篇讲 Workflow,专门解决"类型不匹配"问题的第三个编排工具。

读完这篇你会知道:

  • Workflow 解决什么问题(用一个具体例子讲清楚)
  • 字段映射的 4 个 API:FromField / ToField / MapFields / MapFieldPaths
  • 数据流和控制流分离:WithNoDirectDependencyAddDependency
  • 静态值注入:SetStaticValue
  • 流式场景下的字段映射
  • Workflow vs Graph vs Chain 的选型总结

先看一个 Graph 做起来很别扭的场景

假设你要搭一个"文字统计"流程:

  1. 输入一条消息(包含正文和搜索词)
  2. 统计正文中搜索词出现的次数
  3. 统计推理内容中搜索词出现的次数
  4. 两个结果合并输出

输入类型长这样:

go 复制代码
type message struct {
    *schema.Message
    SubStr string   // 要搜索的子串
}

统计函数的输入类型长这样:

go 复制代码
type counter struct {
    FullStr string  // 被搜索的完整字符串
    SubStr  string  // 搜索词
}

问题来了:messagecounter。Graph 的边默认要求"上一个的输出 = 下一个的输入",但这里 message.Content 要映射到 counter.FullStrmessage.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

核心数据结构是 FieldMappingfield_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 节点合并两个结果

没有手写任何适配层。MapFieldsMapFieldPaths 替你做了字段提取和赋值。


数据流和控制流分离

普通依赖(AddInput)同时做两件事:

  1. 控制依赖:等前驱节点执行完,当前节点才开始
  2. 数据依赖:把前驱节点的输出传给当前节点

有时候你只想传数据,不想建立直接的控制依赖。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"))

校验逻辑:

  1. 沿着 FieldPath 逐层检查类型------结构体用 FieldByName,map 用 key 类型检查
  2. 遇到 interface{} 类型无法静态检查的,延迟到运行时校验
  3. 检查来源字段类型能否赋值给目标字段类型

编译期校验是 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 的三个核心能力:

  1. 字段映射ToField("Price")ToField("bidder1")
  2. 静态值SetStaticValue 注入 Budget
  3. 数据/控制分离AddDependency 只等不等数据,WithNoDirectDependency 只拿数据不等控制

小结

概念 一句话 API
字段映射 节点间按字段名传数据 MapFields / FromField / ToField / MapFieldPaths
静态值 编译时注入常量 SetStaticValue(FieldPath, value)
纯数据依赖 传数据但不阻塞执行 AddInputWithOptions(..., WithNoDirectDependency())
纯控制依赖 等执行但不传数据 AddDependency("nodeKey")
流式映射 chunk 级别的字段提取 TransformableLambda + 同样的 AddInput
Branch 条件路由,但不自动传数据 AddBranch + 节点自己 AddInput

记住三句话

  1. Workflow = Graph + 字段映射,底层编译成同一个 Runnable
  2. 适合"节点间类型不匹配"的场景------不用手写适配层
  3. 不支持循环------ReAct 还是得用 Graph

本文涉及的源码版本:eino main 分支,示例代码参考 eino-examples/compose/workflow。文中代码已简化以突出核心逻辑,完整实现请参考源码链接。

相关推荐
倾颜1 小时前
从手写 Runner 到 LangGraph:受控 Agent 接入 LangGraph
前端·后端·langchain
wuhen_n2 小时前
从零到一!前端搭建本地轻量化 RAG 问答系统
前端·langchain·ai编程
Solis程序员5 小时前
LangChain从入门到精通(2)
langchain
kishu_iOS&AI6 小时前
LLM —— LangChain
人工智能·langchain
老梁agent7 小时前
Agent 返回 JSON 而不是闲聊:LangChain4j 结构化输出实战
物联网·langchain
打小就很皮...8 小时前
基于 Python + LangChain + React 实现智能发票识别与验真系统实战
前端·react.js·langchain·ocr·发票识别
李燚9 小时前
Chain 编排:线性流、并行、Passthrough
agent·chain·workflow·graph·ai-agent
颜酱21 小时前
让 Agent 不再失忆:LangChain 短期记忆实战
langchain·agent
装不满的克莱因瓶1 天前
了解 LangChain 中的 LLM 与 ChatModel 的差异
人工智能·python·ai·langchain·llm·agent·chatmodel