Eino 从链式编排到智能体:Chain、Graph、Tool 与 ReAct 实战

目录

前言

[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 自己完成一部分决策