Chain 编排:线性流、并行、Passthrough

系列「企业级 AI Agent 实现拆解」补充篇 E4。Graph 编排:不只是 ReAct 的通用 DAGhttps://mp.weixin.qq.com/s/9rewe8ZV06b4tj0snnVuYw) 讲了 Graph 能画任意流程------节点、边、分支随便连。Graph 很强大,但大部分时候你的流程没那么复杂。翻译 → 摘要 → 评分,这种"一节一节往下走"的管道,用 Graph 写要手动连每条边,太啰嗦。Chain 就是为此而生的------线性流程的语法糖

读完这篇你会知道:

  • Chain 和 Graph 的关系(Chain 底层就是 Graph)
  • 用链式调用(Builder 模式)5 行代码拼出一个管道
  • Parallel 怎么在一个 Chain 里插入并行节点
  • ChainBranch 怎么加条件分支
  • Passthrough 是什么、什么时候用
  • 一张选型决策图:Graph / Chain / Workflow 什么时候用哪个

Chain 是什么:Graph 的"线性版"

先看一个最简单的对比。同样是"模板 → LLM → 后处理"三步管道:

go 复制代码
// ── 用 Graph 写:7 行,手动连每条边 ──
g := compose.NewGraph[string, string]()
g.AddChatTemplateNode("step1", tmpl)         // 加节点
g.AddChatModelNode("step2", chatModel)        // 加节点
g.AddLambdaNode("step3", postProcess)         // 加节点
g.AddEdge(compose.START, "step1")             // 连边
g.AddEdge("step1", "step2")                   // 连边
g.AddEdge("step2", "step3")                   // 连边
g.AddEdge("step3", compose.END)              // 连边

// ── 用 Chain 写:4 行,自动连边 ──
chain := compose.NewChain[string, string]()
chain.
    AppendChatTemplate(tmpl).                  // 步骤 1
    AppendChatModel(chatModel).                // 步骤 2
    AppendLambda(postProcess)                  // 步骤 3

同样的效果,Chain 少写一半代码。原因很简单:Chain 默认"上一个的输出就是下一个的输入",不需要你手动连边

源码里 Chain 的结构体只有一个核心字段(compose/chain.go:72):

go 复制代码
type Chain[I, O any] struct {
    gg *Graph[I, O]   // ← 内部就包了一个 Graph
    // ...
}

NewChain 创建时,内部就 NewGraph 了一个 Graph(chain.go:37)。每次 AppendXxx 都是在往这个内部 Graph 里加节点并自动连边 。编译时调的也是同一个 gg.Compile()

你可以把 Chain 想象成"高铁买票窗口"------你只需要说"北京→上海→杭州",售票员帮你把每段行程连好。Graph 则是"自己开车"------每段路、每个路口都要自己导航。


链式调用:Builder 模式

Chain 的 API 设计是经典的 Builder 模式 ------每个 AppendXxx 方法返回 Chain 自己,所以可以一路点下去:

go 复制代码
// [compose/chain.go](https://github.com/cloudwego/eino/blob/main/compose/chain.go) --- 完整示例
chain := compose.NewChain[string, string]()

chain.
    AppendLambda(compose.InvokableLambda(
        func(ctx context.Context, input string) (string, error) {
            return "预处理: " + input, nil
        },
    )).
    AppendChatTemplate(tmpl).     // 填模板
    AppendChatModel(chatModel).   // 调 LLM
    AppendLambda(compose.InvokableLambda(
        func(ctx context.Context, msg *schema.Message) (string, error) {
            return msg.Content, nil  // 提取文本
        },
    ))

// 编译 → 运行
r, _ := chain.Compile(ctx)
output, _ := r.Invoke(ctx, "帮我翻译成英文")

这个 Chain 的数据流是:

复制代码
"帮我翻译成英文"
       │
       ▼
  ┌─────────┐     ┌─────────────┐     ┌─────────┐     ┌──────────┐
  │ Lambda  │ ──▶ │ ChatTemplate │ ──▶ │ LLM     │ ──▶ │ Lambda   │
  │ 预处理   │     │ 填变量        │     │ 生成回答 │     │ 提取文本  │
  └─────────┘     └─────────────┘     └─────────┘     └──────────┘
                                                          │
                                                          ▼
                                                     "Help me..."

每个 AppendXxx 做了两件事(chain.go:560):

  1. 往内部 Graph 里加一个节点
  2. 自动从上一个节点连一条边到新节点

所以你不需要调 AddEdge------Chain 帮你做了。


Parallel:在一个 Chain 里并行

线性管道是好,但有些步骤需要同时跑多个任务。比如:

  • 同时用两个 LLM 生成回答,选更好的那个
  • 同时提取"角色"和"用户输入"两个变量,一起喂给模板

Chain 用 AppendParallel 实现并行(compose/chain_parallel.go):

go 复制代码
// 来自 eino-examples 真实代码(简化)
parallel := compose.NewParallel()
parallel.
    AddLambda("role", compose.InvokableLambda(
        func(ctx context.Context, kvs map[string]any) (string, error) {
            role, _ := kvs["role"].(string)
            if role == "" { role = "bird" }
            return role, nil
        },
    )).
    AddLambda("input", compose.InvokableLambda(
        func(ctx context.Context, kvs map[string]any) (string, error) {
            return "你的叫声是怎样的?", nil
        },
    ))

chain := compose.NewChain[map[string]any, string]()
chain.
    AppendLambda(preprocess).           // 步骤 1:预处理
    AppendBranch(branch).              // 步骤 2:条件分支(选角色)
    AppendPassthrough().               // 步骤 3:透传(合并分支)
    AppendParallel(parallel).          // 步骤 4:并行提取 role + input
    AppendGraph(rolePlayerChain).      // 步骤 5:用 role + input 调 LLM
    AppendLambda(extractContent)       // 步骤 6:提取回答文本

Parallel 的数据流是这样的:

复制代码
map[string]any{"role": "cat"}
       │
       ├──▶ role  ──▶ "cat"         ┐
       │                            ├──▶ map[string]any{"role":"cat","input":"..."}
       └──▶ input ──▶ "你的叫声..." ┘

两个 Lambda 同时执行 ,结果打包成 map[string]any,key 就是 AddLambda 时传的第一个参数("role""input")。下游节点接收这个 map,从中取出需要的值。

源码里的限制(chain_parallel.go:235):

  1. 至少 2 个节点:只有一个节点不算并行
  2. outputKey 不能重复 :两个节点不能都用 "result" 作 key

ChainBranch:在 Chain 里加条件分支

线性 + 并行还不够。有时候你需要"根据条件走不同的路"------比如随机选一个角色:

go 复制代码
// [compose/chain_branch.go](https://github.com/cloudwego/eino/blob/main/compose/chain_branch.go)
branch := compose.NewChainBranch(
    func(ctx context.Context, input map[string]any) (string, error) {
        if rand.Intn(2) == 1 {
            return "cat_path", nil    // → 走猫路线
        }
        return "dog_path", nil        // → 走狗路线
    },
)
branch.
    AddLambda("cat_path", compose.InvokableLambda(
        func(ctx context.Context, kvs map[string]any) (map[string]any, error) {
            kvs["role"] = "cat"
            return kvs, nil
        },
    )).
    AddLambda("dog_path", compose.InvokableLambda(
        func(ctx context.Context, kvs map[string]any) (map[string]any, error) {
            kvs["role"] = "dog"
            return kvs, nil
        },
    ))

chain.AppendBranch(branch)

NewChainBranch 接收一个条件函数,返回值是分支的 key("cat_path""dog_path")。分支里用 AddLambda(或 AddChatModelAddGraph 等)注册每条路线。

源码的限制(chain.go:358):

  1. 至少 2 条路线:只有一个选项的分支没有意义
  2. 条件函数返回的 key 必须是注册过的:返回一个不存在的 key 会报错
  3. 分支后只有一个上游节点:如果前面是 Parallel(有多个上游),不能直接加分支

Passthrough:什么也不做的"中转站"

在上面的例子中,你看到了 AppendPassthrough()。它是一个什么也不做的节点------输入什么,原样输出什么。

为什么需要这种"什么都不做"的节点?因为它能合并多条路径

看这个场景:ChainBranch 产生两条路径(cat_pathdog_path),下游需要把两条路径的结果汇到一起。但在 Chain 里,每个 Append 的上游必须是唯一的 ------如果你直接在分支后面加一个节点,Chain 不知道该连 cat_path 还是 dog_path

Passthrough 解决了这个问题:

复制代码
             ┌─ cat_path ─┐
preprocess ──┤            ├─ Passthrough ── Parallel ── ...
             └─ dog_path ─┘

Passthrough 是一个"虚拟汇聚点"------两条分支都连到它,它再连到下游。它本身不做任何处理,只是把上游的结果原样传递下去。

你可以把 Passthrough 想象成高铁的"换乘站"------从北京来的车和从上海来的车都到这个站,乘客统一换乘下一趟车。换乘站本身不改变乘客的目的地,只是提供一个汇聚点。


完整实战:一个"角色扮演"Chain

把前面的所有概念串起来,这是一个完整的可运行示例(基于 eino-examples/compose/chain/main.go 改写):

go 复制代码
func BuildRolePlayChain(ctx context.Context, chatModel model.BaseChatModel) (
    compose.Runnable[map[string]any, string], error,
) {
    // ① 分支:随机选猫或狗
    branch := compose.NewChainBranch(
        func(ctx context.Context, kvs map[string]any) (string, error) {
            if rand.Intn(2) == 1 {
                return "cat", nil
            }
            return "dog", nil
        },
    )
    branch.
        AddLambda("cat", compose.InvokableLambda(
            func(ctx context.Context, kvs map[string]any) (map[string]any, error) {
                kvs["role"] = "cat"
                return kvs, nil
            },
        )).
        AddLambda("dog", compose.InvokableLambda(
            func(ctx context.Context, kvs map[string]any) (map[string]any, error) {
                kvs["role"] = "dog"
                return kvs, nil
            },
        ))

    // ② 并行:同时提取 role 和 input
    parallel := compose.NewParallel()
    parallel.
        AddLambda("role", compose.InvokableLambda(
            func(ctx context.Context, kvs map[string]any) (string, error) {
                role, _ := kvs["role"].(string)
                if role == "" { role = "bird" }
                return role, nil
            },
        )).
        AddLambda("input", compose.InvokableLambda(
            func(ctx context.Context, kvs map[string]any) (string, error) {
                return "你的叫声是怎样的?", nil
            },
        ))

    // ③ LLM 子链:模板 → 模型
    rolePlayerChain := compose.NewChain[map[string]any, *schema.Message]()
    rolePlayerChain.
        AppendChatTemplate(prompt.FromMessages(schema.FString,
            schema.SystemMessage("You are a {role}."),
            schema.UserMessage("{input}"),
        )).
        AppendChatModel(chatModel)

    // ④ 组装主链
    chain := compose.NewChain[map[string]any, string]()
    chain.
        AppendLambda(compose.InvokableLambda(   // 预处理
            func(ctx context.Context, kvs map[string]any) (map[string]any, error) {
                return kvs, nil
            },
        )).
        AppendBranch(branch).                  // 随机选角色
        AppendPassthrough().                   // 合并分支
        AppendParallel(parallel).              // 并行提取 role + input
        AppendGraph(rolePlayerChain).          // 调 LLM
        AppendLambda(compose.InvokableLambda(  // 提取回答
            func(ctx context.Context, msg *schema.Message) (string, error) {
                return msg.Content, nil
            },
        ))

    return chain.Compile(ctx, compose.WithGraphName("RolePlayChain"))
}

数据流全景:

复制代码
map[string]any{}
     │
     ▼
┌──────────┐
│ Lambda   │  预处理(透传)
└────┬─────┘
     │
  ┌──┴──┐
  │     │
  ▼     ▼
 cat   dog                ← ChainBranch:随机选角色
  │     │
  └──┬──┘
     ▼
┌──────────┐
│Passthrough│              ← 合并分支
└────┬─────┘
     │
  ┌──┴──┐
  │     │
  ▼     ▼
 role  input              ← Parallel:并行提取
  │     │
  └──┬──┘
     ▼
┌──────────┐
│ LLM 子链  │              → 模板填充 → 调 ChatModel
└────┬─────┘
     ▼
┌──────────┐
│ Lambda   │              → 提取回答文本
└────┬─────┘
     ▼
  "喵~" 或 "汪!"

Chain 编译时的内部转换

当你调 chain.Compile() 时,Chain 内部做了这些事(chain.go:88):

  1. 补 END 边addEndIfNeeded() 自动给最后一个节点连 END
  2. 委托给 Graph.Compile :调用内部 gg.Compile(),走的就是 E3 讲过的编译流程------类型检查 + 拓扑校验 + 生成 Runnable

Chain 的节点命名规则(chain.go:544):

复制代码
普通节点:  node_0, node_1, node_2, ...
并行节点:  node_3_parallel_0, node_3_parallel_1
分支节点:  node_4_branch_cat, node_4_branch_dog

这些名字在调试和可视化时会用到。如果你调 compose.WithGraphName("MyChain"),生成的 Mermaid 图会更清晰。


选型决策:Graph vs Chain vs Workflow

三个编排工具,一张图说清楚怎么选:

复制代码
你的流程长什么样?
       │
       ├── A→B→C→D 纯线性,没有分支
       │       │
       │       └─▶ Chain(最简单)
       │
       ├── 有分支或条件路由
       │       │
       │       ├── 需要循环(如 ReAct)
       │       │       │
       │       │       └─▶ Graph(Pregel 模式)
       │       │
       │       ├── 不需要循环,但有多种路径
       │       │       │
       │       │       ├── 路径 ≤ 5 条,链式写法更直观
       │       │       │       └─▶ Chain + ChainBranch
       │       │       │
       │       │       └── 路径多或嵌套复杂
       │       │               └─▶ Graph(DAG 模式)
       │       │
       │       └── 需要并行
       │               │
       │               ├── 并行后汇聚到同一节点
       │               │       └─▶ Chain + Parallel
       │               │
       │               └── 并行后走不同路径
       │                       └─▶ Graph
       │
       └── 节点间类型不匹配,需要字段映射
               │
               └─▶ Workflow(下篇讲)

一句话总结

  • Chain:线性流程的首选,API 简洁,内部自动连边
  • Graph:任意拓扑的通用方案,灵活但要手动管理边
  • Workflow:字段映射 + 控制流分离,解决类型不匹配

小结

概念 一句话 API 源码位置
Chain 线性管道,自动连边 NewChain().AppendXxx() chain.go
Parallel 在 Chain 里并行跑多个节点 NewParallel().AddXxx() chain_parallel.go
ChainBranch 在 Chain 里加条件分支 NewChainBranch(cond).AddXxx() chain_branch.go
Passthrough 什么也不做的中转站 AppendPassthrough() chain.go:533

记住三句话

  1. Chain = Graph 的语法糖------底层编译成同一个东西
  2. 能用 Chain 写清楚的就用 Chain,代码量通常是 Graph 的一半
  3. 遇到"循环"或"复杂拓扑",Chain 搞不定了,再上 Graph

下一篇我们看 Workflow------Chain 和 Graph 的"第三个兄弟",专门解决"这个节点输出 A 类型,下个节点要 B 类型"的问题。


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

相关推荐
花伤情犹在2 小时前
Hermes 清理飞书会话操作指南
linux·sqlite·飞书·agent·hermes
要开心吖ZSH2 小时前
AI医疗分诊与健康咨询助手agent开发——(2)让AI输出可控:结构化分诊与安全规则
java·ai·agent·健康医疗·spring ai
唐某人丶10 小时前
模型越来越强,我们还需要 Agent 工程吗?—— 从价值重估到 Harness 实践
前端·agent·ai编程
冬奇Lab14 小时前
Agent 系列(18):成本与性能优化——省钱且更快
人工智能·llm·agent
颜酱14 小时前
让 Agent 不再失忆:LangChain 短期记忆实战
langchain·agent
吴佳浩14 小时前
Hermes vs OpenClaw:基于源码的 Agent Loop 全面分析
人工智能·llm·agent
江夏尧15 小时前
Peri Code 的工具分层——LLM 面对 50 个工具时会停止调用工具
agent
装不满的克莱因瓶17 小时前
了解 LangChain 中的 LLM 与 ChatModel 的差异
人工智能·python·ai·langchain·llm·agent·chatmodel
黑马师兄17 小时前
RAG混合检索深度解析:让AI真正找到你要的内容
java·人工智能·ai·agent·rag·ai-native