目录
[1. 从 Chain 开始:最简单的链式编排](#1. 从 Chain 开始:最简单的链式编排)
[2. Graph:把流程从一条线扩展成一张图](#2. Graph:把流程从一条线扩展成一张图)
[3. Graph + ChatModel:让不同分支生成不同 Prompt](#3. Graph + ChatModel:让不同分支生成不同 Prompt)
[4. State:在节点之间共享临时状态](#4. State:在节点之间共享临时状态)
[5. Callback:观察每个节点的执行过程](#5. Callback:观察每个节点的执行过程)
[6. 嵌套 Graph:把复杂流程封装成一个节点](#6. 嵌套 Graph:把复杂流程封装成一个节点)
[7. Tool:让模型拥有外部能力](#7. Tool:让模型拥有外部能力)
[8. ReAct Agent:让模型自己决定是否调用工具](#8. ReAct Agent:让模型自己决定是否调用工具)
[9. ToolsNode:把工具作为图节点](#9. ToolsNode:把工具作为图节点)
[10. Multi-Agent:把任务交给不同专家](#10. Multi-Agent:把任务交给不同专家)
[11. 从固定流程到智能体自动决策](#11. 从固定流程到智能体自动决策)
[12. 总结](#12. 总结)
前言
上一篇文章主要围绕 RAG 展开,从 ChatModel、Prompt、Embedding、Indexer、Retriever、Transformer 这些组件入手,理解了一个最小 RAG 系统是怎么把外部知识交给大模型使用的。
继续往后学习 Eino 时,会发现只会调用模型还不够。真实的人工智能应用通常不是一个简单的"输入问题 -> 模型回答"流程,而是需要把多个步骤组织起来:有些任务要按顺序执行,有些任务要根据输入走不同分支,有些任务要在节点之间传递状态,有些任务还需要调用外部工具。
这时就会接触到 Eino 里的 Chain、Graph、Tool、ReAct Agent,以及更进一步的 Multi-Agent。
1. 从 Chain 开始:最简单的链式编排
如果说直接调用 ChatModel 是单点能力,那么 Chain 解决的是多个步骤按顺序执行的问题。
先看一个最简单的链式编排流程:
Go
chain := compose.NewChain[string, *schema.Message]()
chain.AppendLambda(lambda).AppendChatModel(model)
r, err := chain.Compile(ctx)
answer, err := r.Invoke(ctx, "你好,请问乱星海怎么走")
这里可以把 Chain 理解成一条流水线:
用户输入 -> Lambda 处理 -> ChatModel 生成回答 -> 输出结果
其中 Lambda 是一个自定义处理节点:
Go
lambda := compose.InvokableLambda(func(ctx context.Context, input string) (output []*schema.Message, err error) {
content := input + "回答结尾加上 desuwa"
output = []*schema.Message{
{
Role: schema.User,
Content: content,
},
}
return output, nil
})
它的作用是把原始字符串转换成模型需要的 \[\]*schema.Message。这一步很关键,因为模型并不是直接吃普通字符串,而是吃消息结构。Lambda 就像一个适配层,把上游输入转换成下游节点需要的格式。
所以 Chain 适合处理这种固定顺序的任务:
Go
输入清洗 -> Prompt 构造 -> 模型调用 -> 结果处理
它的特点是简单、直观、顺序明确。但如果流程里出现"根据不同输入走不同分支",单纯用 Chain 就不太方便了,这时就需要 Graph。
2. Graph:把流程从一条线扩展成一张图
Graph 可以把多个节点组织成图结构。相比 Chain,它最大的特点是支持分支。先看一个没有接入模型的图式编排示例,只用多个 Lambda 节点模拟流程:
Go
graph := compose.NewGraph[string, string]()
err = graph.AddLambdaNode("lambda0", lambda0)
err = graph.AddLambdaNode("lambda1", lambda1)
err = graph.AddLambdaNode("lambda2", lambda2)
err = graph.AddLambdaNode("lambda3", lambda3)
然后通过 AddEdge 连接固定路径:
Go
err = graph.AddEdge(compose.START, "lambda0")
err = graph.AddEdge("lambda1", compose.END)
err = graph.AddEdge("lambda2", compose.END)
err = graph.AddEdge("lambda3", compose.END)
然后是添加分支 AddBranch:
Go
err = graph.AddBranch("lambda0", compose.NewGraphBranch(
func(ctx context.Context, in string) (endNode string, err error) {
if in == "cat" {
return "lambda1", nil
}
if in == "dog" {
return "lambda2", nil
}
if in == "device" {
return "lambda3", nil
}
return compose.END, nil
},
map[string]bool{
"lambda1": true,
"lambda2": true,
"lambda3": true,
compose.END: true,
},
))
这段逻辑的意思是:lambda0 执行完之后,不是固定进入某一个节点,而是根据输出内容决定下一个节点。
可以把它理解成:
Go
START
↓
lambda0
↓
根据结果选择分支
├─ lambda1 -> END
├─ lambda2 -> END
└─ lambda3 -> END
如果任务只是固定顺序,Chain 就够了。如果任务里存在条件判断、分支选择、多个处理路径,那么 Graph 更合适。
3. Graph + ChatModel:让不同分支生成不同 Prompt
进一步看一个接入模型节点的图式编排示例。
整体流程是:
Go
START
↓
lambda
↓
根据 role 分支
├─ tsundere -> model -> END
└─ cute -> model -> END
代码里先创建图:
Go
graph := compose.NewGraph[map[string]string, *schema.Message]()
输入是 mapstringstring,输出是 *schema.Message。
第一个 lambda 节点负责判断用户传入的角色:
Go
lambda := compose.InvokableLambda(func(ctx context.Context, input map[string]string) (output map[string]string, err error) {
if input["role"] == "tsundere" {
return map[string]string{
"role": "傲娇",
"content": input["content"],
}, nil
}
if input["role"] == "cute" {
return map[string]string{
"role": "可爱",
"content": input["content"],
}, nil
}
return map[string]string{
"role": "user",
"content": input["content"],
}, nil
})
后面的 tsundere 和 cute 节点分别构造不同的 SystemMessage 和 UserMessage:
Go
TsundereLambda := compose.InvokableLambda(func(ctx context.Context, input map[string]string) (output []*schema.Message, err error) {
return []*schema.Message{
{
Role: schema.System,
Content: "你是一个高冷傲娇的大小姐,每次都会用傲娇的语气回答问题",
},
{
Role: schema.User,
Content: input["content"],
},
}, nil
})
这其实就是把 Prompt 生成逻辑拆成了不同节点。如果是 role=tsundere,就进入傲娇 Prompt 节点;如果是 role=cute,就进入可爱 Prompt 节点。最后两个分支都进入同一个模型节点:
Go
err = graph.AddChatModelNode("model", model)
err = graph.AddEdge("tsundere", "model")
err = graph.AddEdge("cute", "model")
err = graph.AddEdge("model", compose.END)
这时 Graph 就不只是普通流程图了,而是一个可以组织模型调用逻辑的编排器。
4. State:在节点之间共享临时状态
当流程变复杂后,节点之间可能需要共享一些中间信息。
比如第一个节点分析出一些上下文,后面的节点需要继续使用。如果每个节点都通过输入输出硬传,代码会很乱。Eino 提供了本地状态来解决这个问题。
先定义状态结构:
Go
type State struct {
History map[string]any
}
func genFunc(ctx context.Context) *State {
return &State{
History: make(map[string]any),
}
}
创建 Graph 时传入状态生成函数:
Go
g := compose.NewGraph[map[string]string, *schema.Message](
compose.WithGenLocalState(genFunc),
)
节点内部可以通过 compose.ProcessState 修改状态:
Go
_ = compose.ProcessState[*State](ctx, func(_ context.Context, state *State) error {
state.History["tsundere_action"] = "我喜欢你"
state.History["cute_action"] = "摸摸头"
return nil
})
后续节点再读取这个状态:
Go
_ = compose.ProcessState[*State](ctx, func(_ context.Context, state *State) error {
input["content"] = input["content"] + state.History["tsundere_action"].(string)
return nil
})
除了在节点内部处理状态,也可以通过 WithStatePreHandler 在节点执行前处理输入:
Go
cutePreHandler := func(ctx context.Context, input map[string]string, state *State) (map[string]string, error) {
input["content"] = input["content"] + state.History["cute_action"].(string)
return input, nil
}
err = g.AddLambdaNode("cute", CuteLambda, compose.WithStatePreHandler(cutePreHandler))
这样做的好处是:状态逻辑和节点主体逻辑可以拆开。节点本身只关心如何处理输入,前置处理器负责把共享状态合并进去。
5. Callback:观察每个节点的执行过程
流程一多,排查问题就会变难。
比如一个 Graph 里有多个节点、多个分支、模型调用和工具调用,如果结果不对,需要知道到底是哪一步出了问题。
这时 Callback 就很有用。
可以通过 callbacks.NewHandlerBuilder 创建回调:
Go
func genCallback() callbacks.Handler {
handler := callbacks.NewHandlerBuilder().
OnStartFn(func(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
fmt.Printf("当前 %s 节点输入: %s\n", info.Component, input)
return ctx
}).
OnEndFn(func(ctx context.Context, info *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context {
fmt.Printf("当前 %s 节点输出: %s\n", info.Component, output)
return ctx
}).
Build()
return handler
}
执行 Graph 时挂上回调:
Go
answer, err := r.Invoke(ctx, input, compose.WithCallbacks(genCallback()))
这样每个节点开始执行和执行结束时,都会打印对应的输入输出。
Callback 不改变业务结果,它更像是观察器。适合做调试节点输入输出、记录链路日志、统计执行耗时、捕获错误信息、观察流式输出过程。
在智能体场景里,Callback 尤其重要。因为智能体会自己决定是否调用工具、调用哪个工具、工具结果如何继续交给模型。如果没有 Callback,很难看清中间发生了什么。
6. 嵌套 Graph:把复杂流程封装成一个节点
当一个 Graph 已经能完成一段独立流程时,可以把它作为另一个 Graph 的节点使用。
先构造内部 Graph:
Go
func GenOrcGraphWithGraphM(ctx context.Context) *compose.Graph[map[string]string, *schema.Message] {
insideGraph := compose.NewGraph[map[string]string, *schema.Message](
compose.WithGenLocalState(genFunc),
)
// 内部节点注册和边连接
return insideGraph
}
然后在外部 Graph 中加入内部 Graph:
Go
insideGraph := GenOrcGraphWithGraphM(ctx)
outsideGraph := compose.NewGraph[map[string]string, string]()
err := outsideGraph.AddGraphNode("inside", insideGraph)
外部流程大概是:
Go
START
↓
outlambda1
↓
inside Graph
↓
write
↓
END
内部 Graph 负责生成模型回答,外部 Graph 负责继续处理结果,比如写入文件:
Go
writelambda := compose.InvokableLambda(func(ctx context.Context, input *schema.Message) (output string, err error) {
file, err := os.OpenFile("orc_graph_withgraph.md", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return "", err
}
defer file.Close()
if _, err = file.WriteString(input.Content + "\n---\n"); err != nil {
return "", err
}
return "已经写入文件,请前往文件中查看", nil
})
这其实是一种封装思路。当某段流程足够稳定时,可以把它封装成一个内部 Graph。外部 Graph 不需要关心里面有多少节点,只需要把它当成一个普通节点使用。
7. Tool:让模型拥有外部能力
前面的 Chain 和 Graph 主要是在组织模型调用流程,但模型本身仍然只是在生成文本。
如果希望模型能查询数据、执行计算、访问浏览器、调用业务接口,就需要 Tool。
下面是一个根据游戏名称返回官网地址的工具:
Go
type Game struct {
Name string `json:"name"`
Url string `json:"url"`
}
type InputParams struct {
Name string `json:"name" jsonschema:"description=the name of game"`
}
工具真正执行的函数是:
Go
func GetGame(_ context.Context, params *InputParams) (string, error) {
gameSet := []Game{
{Name: "原神", Url: "https://ys.mihoyo.com/tool"},
{Name: "鸣潮", Url: "https://mc.kurogames.com/tool"},
{Name: "明日方舟", Url: "https://ak.hypergryph.com/tool"},
}
for _, game := range gameSet {
if game.Name == params.Name {
return game.Url, nil
}
}
return "", nil
}
然后通过 utils.NewTool 包装成 Eino 可以识别的工具:
Go
func CreateTool() tool.InvokableTool {
getGameTool := utils.NewTool(&schema.ToolInfo{
Name: "get_game",
Desc: "get a game url by name",
ParamsOneOf: schema.NewParamsOneOfByParams(
map[string]*schema.ParameterInfo{
"name": {
Type: schema.String,
Desc: "game's name",
Required: true,
},
},
),
}, GetGame)
return getGameTool
}
一个 Tool 至少要描述三件事:工具名是什么、工具能做什么、工具需要哪些参数。
模型并不会直接读 Go 语言函数源码,它是根据 ToolInfo 理解工具能力的。所以工具描述写得越清楚,模型越容易正确选择和调用。
还可以接入 browseruse.NewBrowserUseTool 这类浏览器工具:
Go
but, err := browseruse.NewBrowserUseTool(ctx, &browseruse.Config{})
result, err := but.Execute(&browseruse.Param{
Action: browseruse.ActionGoToURL,
URL: &url,
})
这类工具可以让模型或程序操作浏览器,不再局限于纯文本生成。
8. ReAct Agent:让模型自己决定是否调用工具
有了 Tool 之后,还需要一个机制让模型决定什么时候用工具、用哪个工具、参数是什么。
这就是 ReAct Agent 要解决的问题。
在 ReAct Agent 示例中,先创建模型:
Go
arkModel, err := ark.NewChatModel(ctx, &ark.ChatModelConfig{
APIKey: arkAPIKey,
Model: arkModelName,
})
然后准备工具:
Go
addtool := GetAddTool()
subtool := GetSubTool()
analyzetool := GetAnalyzeTool()
这里的工具包括:
-
add:计算两数相加
-
sub:计算两数相减
-
analyze:分析题目难度
创建 ReAct Agent:
Go
raAgent, err := react.NewAgent(ctx, &react.AgentConfig{
ToolCallingModel: arkModel,
ToolsConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{addtool, subtool, analyzetool},
ExecuteSequentially: false,
},
StreamToolCallChecker: toolCallChecker,
})
这里有几个关键点。
第一,ToolCallingModel 是支持工具调用的模型。
第二,ToolsConfig 把工具列表交给智能体。
第三,ExecuteSequentially: false 表示工具可以并发执行。
第四,StreamToolCallChecker 用来检查流式输出里是否包含工具调用。
代码中的 toolCallChecker 会持续读取流式消息:
Go
toolCallChecker := func(ctx context.Context, sr *schema.StreamReader[*schema.Message]) (bool, error) {
defer sr.Close()
for {
msg, err := sr.Recv()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return false, err
}
if len(msg.ToolCalls) > 0 {
return true, nil
}
}
return false, nil
}
如果消息里包含 ToolCalls,说明模型准备调用工具。
执行时直接调用智能体的流式接口:
Go
sr, err := raAgent.Stream(
ctx,
chatMsg,
agent.WithComposeOptions(compose.WithCallbacks(&loggerCallback{})),
)
然后不断读取输出:
Go
finalContent := ""
for {
msg, err := sr.Recv()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return
}
finalContent += msg.Content
fmt.Println(msg.Content)
}
这个示例里的用户问题是:
请同时告诉我 183+192-90 这道题的难易程度和答案
这类问题里既有计算,又有难度分析。模型可以根据任务自己选择调用加法工具、减法工具和分析工具。
这就是智能体和普通 ChatModel 的区别:普通 ChatModel 直接生成答案,而 ReAct Agent 可以思考是否需要工具,并根据工具结果继续生成答案。
9. ToolsNode:把工具作为图节点
除了 ReAct Agent 自动调用工具,也可以把工具放进 Graph 节点里。
也可以通过 compose.NewToolNode 创建工具节点:
Go
func newToolsNode(ctx context.Context) (tsn *compose.ToolsNode, err error) {
config := &compose.ToolsNodeConfig{}
toolIns11 := GetAddTool()
toolIns12 := GetSubTool()
toolIns13 := GetAnalyzeTool()
config.Tools = []tool.BaseTool{toolIns11, toolIns12, toolIns13}
tsn, err = compose.NewToolNode(ctx, config)
return tsn, err
}
这种方式更偏向显式编排:工具调用作为图里的一个节点存在。ReAct Agent 更偏向自动决策:模型自己判断是否调用工具。
两种方式并不冲突。如果工具调用路径固定,可以用 ToolsNode;如果工具调用取决于模型判断,可以用 ReAct Agent。
10. Multi-Agent:把任务交给不同专家
当工具和智能体继续变多后,可以进一步拆成多个专家智能体。
多智能体示例里,核心结构是 Host + Specialist。
Host 负责接收任务并分发:
Go
return &host.Host{
ToolCallingModel: chatModel,
SystemPrompt: "你可以同时计算加法和减法",
}, nil
加法专家只绑定加法工具:
Go
addtool := GetAddTool()
raAgent, err := react.NewAgent(ctx, &react.AgentConfig{
ToolCallingModel: chatModel,
ToolsConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{addtool},
},
})
然后包装成 Specialist:
Go
return &host.Specialist{
AgentMeta: host.AgentMeta{
Name: "add_specialist",
IntendedUse: "两数相加并返回结果",
},
Invokable: func(ctx context.Context, input []*schema.Message, opts ...agent.AgentOption) (output *schema.Message, err error) {
return raAgent.Generate(ctx, input, opts...)
},
}, nil
减法专家也是类似,只是绑定 SubTool。
最后创建 Multi-Agent:
Go
hostMA, err := host.NewMultiAgent(ctx, &host.MultiAgentConfig{
Host: *h,
Specialists: []*host.Specialist{
adder,
suber,
},
Summarizer: &host.Summarizer{
ChatModel: h.ToolCallingModel,
SystemPrompt: "请总结一下各个专家的回答",
},
})
这个结构可以理解成:
Go
用户问题
↓
Host 判断任务
↓
分发给 add_specialist / sub_specialist
↓
专家 Agent 调用自己的工具
↓
Summarizer 汇总结果
↓
最终回答
代码里还通过 OnHandOff 观察任务交接:
Go
func (l *logCallback) OnHandOff(ctx context.Context, info *host.HandOffInfo) context.Context {
fmt.Println("\nHandOff to", info.ToAgentName, "with argument", info.Argument)
return ctx
}
Multi-Agent 适合任务边界比较清楚的场景。比如一个专家负责查资料,一个专家负责计算,一个专家负责写总结,一个专家负责校验答案。
相比把所有工具都塞给一个智能体,多智能体的好处是职责更清晰。但它也会带来更多编排成本,所以简单任务没必要一上来就拆成多智能体。
11. 从固定流程到智能体自动决策
把这些示例串起来看,其实可以看到一条很清晰的学习路径。
第一阶段是 Chain:固定顺序执行,适合简单流水线。
第二阶段是 Graph:根据条件选择不同路径,适合有分支、有节点复用、有复杂流程控制的任务。
第三阶段是 State 和 Callback:让流程具备状态和可观察性,适合调试复杂链路,也适合记录运行过程。
第四阶段是 Tool:让模型拥有外部能力。模型不再只是生成文本,而是可以调用函数、访问浏览器、查询业务系统。
第五阶段是 ReAct Agent:让模型自己决定工具调用。这一步开始,应用不再完全依赖人工写死流程,而是让模型参与决策。
第六阶段是 Multi-Agent:把复杂任务拆给多个专家智能体。每个专家负责一类能力,Host 负责调度,Summarizer 负责汇总。
12. 总结
通过这些 Eino 示例,可以把几个核心概念简单总结为:
Chain 解决顺序编排问题。它像一条流水线,适合输入处理、Prompt 构造、模型调用这种固定流程。
Graph 解决复杂流程编排问题。它可以注册多个节点,通过边和分支控制执行路径。
State 解决节点间共享临时数据的问题。它让复杂流程不必把所有中间信息都塞进输入输出里。
Callback 解决可观察性问题。它可以帮助我们看到每个节点的输入输出,以及智能体执行过程中的中间行为。
Tool 解决模型能力边界问题。模型本身不会真的计算、查库、打开浏览器,但工具可以把这些外部能力接进来。
ReAct Agent 解决自动决策问题。它让模型根据任务判断是否需要工具,以及应该调用哪个工具。
Multi-Agent 解决复杂任务分工问题。不同专家智能体处理不同类型的任务,Host 负责协调。
Eino 的学习重点也逐渐从单个组件转向了编排:
Go
先能调用模型
再能组织 Prompt
再能接入知识库
再能编排流程
再能接入工具
最后让 Agent 自己完成一部分决策