系列「企业级 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):
- 往内部 Graph 里加一个节点
- 自动从上一个节点连一条边到新节点
所以你不需要调 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):
- 至少 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(或 AddChatModel、AddGraph 等)注册每条路线。
源码的限制(chain.go:358):
- 至少 2 条路线:只有一个选项的分支没有意义
- 条件函数返回的 key 必须是注册过的:返回一个不存在的 key 会报错
- 分支后只有一个上游节点:如果前面是 Parallel(有多个上游),不能直接加分支
Passthrough:什么也不做的"中转站"
在上面的例子中,你看到了 AppendPassthrough()。它是一个什么也不做的节点------输入什么,原样输出什么。
为什么需要这种"什么都不做"的节点?因为它能合并多条路径。
看这个场景:ChainBranch 产生两条路径(cat_path 和 dog_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):
- 补 END 边 :
addEndIfNeeded()自动给最后一个节点连END - 委托给 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 |
记住三句话:
- Chain = Graph 的语法糖------底层编译成同一个东西
- 能用 Chain 写清楚的就用 Chain,代码量通常是 Graph 的一半
- 遇到"循环"或"复杂拓扑",Chain 搞不定了,再上 Graph
下一篇我们看 Workflow------Chain 和 Graph 的"第三个兄弟",专门解决"这个节点输出 A 类型,下个节点要 B 类型"的问题。
本文涉及的源码版本:eino main 分支。文中代码已简化以突出核心逻辑,完整实现请参考源码链接。