AI 大模型落地系列|Eino 编排进阶篇:一文讲透编排(Chain 与 Graph)

声明:本文数据源于官方文档与官方示例,重点参考 Chain/Graph 编排介绍编排的设计理念eino-examples/compose

一文讲透 Chain 与 Graph 的设计价值、运行方式与工程落地

    • [1. 为什么很多项目最后都会走到编排](#1. 为什么很多项目最后都会走到编排)
    • [2. 先把 Graph 看懂:它编排的不是函数,而是运行关系](#2. 先把 Graph 看懂:它编排的不是函数,而是运行关系)
    • [3. Graph 最值得重视的,不连线成图,而是划清边界](#3. Graph 最值得重视的,不连线成图,而是划清边界)
      • [3.1 先定类型,再谈连接](#3.1 先定类型,再谈连接)
      • [3.2 `WithOutputKey / WithInputKey` 不是小技巧,而是汇聚场景的正道](#3.2 WithOutputKey / WithInputKey 不是小技巧,而是汇聚场景的正道)
      • [3.3 外部变量只读,不是洁癖,是并发和流式场景的底线](#3.3 外部变量只读,不是洁癖,是并发和流式场景的底线)
      • [3.4 `Runnable` 统一了运行姿势](#3.4 Runnable 统一了运行姿势)
    • [4. ToolCallAgent 这种场景,为什么更适合挂进 Graph](#4. ToolCallAgent 这种场景,为什么更适合挂进 Graph)
    • [5. Graph with state,重点不是"能存数据",而是"数据放在哪一层"](#5. Graph with state,重点不是“能存数据”,而是“数据放在哪一层”)
    • [6. Chain 为什么是更顺手的入口,而不是另一套框架](#6. Chain 为什么是更顺手的入口,而不是另一套框架)
    • [7. 什么时候用 Chain,什么时候直接上 Graph](#7. 什么时候用 Chain,什么时候直接上 Graph)
    • [8. 编排中容易跳进去的 5 个坑](#8. 编排中容易跳进去的 5 个坑)
      • [8.1 把 `map[string]any` 当万能胶](#8.1 把 map[string]any 当万能胶)
      • [8.2 只写 `Invoke`,从来不看 `Stream / Transform`](#8.2 只写 Invoke,从来不看 Stream / Transform)
      • [8.3 在节点里直接改外部引用类型](#8.3 在节点里直接改外部引用类型)
      • [8.4 把 `state` 当"什么都能放"的储物箱](#8.4 把 state 当“什么都能放”的储物箱)
      • [8.5 把 `Chain` 当成 `Agent`](#8.5 把 Chain 当成 Agent)
    • [9. 总结](#9. 总结)
    • 参考资料

很多人第一次看 Eino 的 Chain / Graph,第一反应都差不多:

不就是把 promptmodeltool 接一下吗?

这件事自己写几个函数也能干,为什么还要单独学一套编排?

如果你只是跑个 demo,这个想法没什么问题。

但只要链路一变长,问题马上就会冒出来:

  • 上一个节点到底给下一个节点传了什么,靠 any 还是靠猜?
  • 同一条链路既要支持完整输出,又要支持流式输出,代码是不是得写两套?
  • 工具调用、分支执行、状态共享,到底写在业务里,还是写在框架里?
  • 多个节点汇聚到一个节点时,数据怎么合并,谁来兜底?

这些问题如果都散在业务代码里,系统也能跑。

但通常跑不久就会开始难改、难查、难扩。

所以这篇博客,我要讲的是:

Chain / Graph 解决的不是"怎么把几个组件连起来",而是"复杂执行链路怎样以稳定、可检查、可扩展的方式跑起来"。

本文会按三条线往下讲:

  • 为啥要用
  • 工程视角怎么拆
  • 代码场景怎么落

接下来我会先引入 Graph ,补充 Chain 的用法。

1. 为什么很多项目最后都会走到编排

如果你的程序只有一步,比如"把一条用户消息送进模型,然后拿回回答",那当然不需要太重的编排。

问题在于,大多数真实项目不会永远停在这一步。

你很快就会碰到下面这些事:

  • 先做 prompt 组装,再调用模型
  • 模型命中了工具调用,还要执行工具,再把结果接回消息链路
  • 有的节点要走同步,有的节点要走流式
  • 某些运行过程需要保留状态,供后续节点继续判断
  • 某些节点前面有多个上游,后面还有多个分支

这时你会发现,麻烦不在于"多写几个函数"。

麻烦在于,这些函数之间其实已经存在明确的运行关系。

它们不是散点逻辑,而是一张执行图。

从工程角度看,Chain / Graph 真正想做的是 4 件事:

第一,节点之间的输入输出边界。

上游吐出来的值,下游到底能不能接,不能靠上线以后才发现。

Eino 的思路是:尽量在 Compile 阶段就把这件事说明白。

第二,执行关系。

谁先跑,谁后跑,谁分支,谁汇聚,谁结束,这些都不该藏在几十行 if/else 和回调里。

第三,运行时范式。

同一条编排链,不应该"同步是一套写法,流式又是一套写法"。

Eino 最后编译出来的是统一的 Runnable,可以 Invoke、可以 Stream、也可以 Transform

第四,工程扩展点。

状态、回调、工具调用、嵌套图,这些东西都不是 demo 里最抢眼的功能,但它们才决定你这套链路后面能不能活得久。

所以你如果要问:

为什么很多后端工程师一开始觉得 Chain / Graph 有点"重",做一阵子又会反过来觉得它有必要?

答案很简单:

因为系统一复杂,你迟早都要面对"编排"这件事。

区别只在于,你是显式地把它交给框架,还是隐式地把它塞进业务代码。

2. 先把 Graph 看懂:它编排的不是函数,而是运行关系

很多人第一次看 Graph,关注点全在"怎么连节点"。

这只看到了表面。

Graph 真正重要的,不是你能不能写出 AddEdge

而是它把"节点"和"节点之间的运行关系"明确成了一张图。

看一个最小闭环:

go 复制代码
ctx := context.Background()

// 创建一个图:输入为 map[string]any,输出为 *schema.Message
g := compose.NewGraph[map[string]any, *schema.Message]()

// 定义提示模板,接收 location 变量并生成用户消息
tpl := prompt.FromMessages(
    schema.FString,
    schema.UserMessage("what's the weather in {location}?"),
)

// 添加节点:prompt 负责渲染模板,model 负责调用聊天模型
_ = g.AddChatTemplateNode("prompt", tpl)
_ = g.AddChatModelNode("model", &mockChatModel{})

// 连接执行链路:START -> prompt -> model -> END
_ = g.AddEdge(compose.START, "prompt")
_ = g.AddEdge("prompt", "model")
_ = g.AddEdge("model", compose.END)

// 编译图,生成可运行对象
r, err := g.Compile(ctx)
if err != nil {
    panic(err)
}

// 同步调用:一次性拿到完整结果
out, err := r.Invoke(ctx, map[string]any{"location": "beijing"})
if err != nil {
    panic(err)
}
fmt.Println(out.Content)

// 流式调用:逐块接收模型输出
stream, err := r.Stream(ctx, map[string]any{"location": "beijing"})
if err != nil {
    panic(err)
}
defer stream.Close()

for {
    // 持续读取流式返回的内容块
    chunk, err := stream.Recv()
    if errors.Is(err, io.EOF) {
        break // 流结束
    }
    if err != nil {
        panic(err)
    }
    fmt.Println(chunk.Content)
}
  • 这段代码解决了什么工程问题:它把 prompt -> model -> end 这条执行链路显式表达了出来,并在 Compile 之后收口成一个统一可运行对象。
  • 不用编排时,这段逻辑会散到哪里:模板格式化、模型调用、同步输出、流式输出这些逻辑通常会散在 controller、service、helper 甚至 goroutine 里,最后没人说得清"这条链路本来长什么样"。

这段代码最值得盯住的,不是天气问题,也不是 mockChatModel

而是 5 个关键词:

1. compose.NewGraph[I, O]

图的输入、输出类型在一开始就定下来了。

这和"全程都传 map[string]any,最后再断言类型"的思路不一样。

2. START / END

图不是一堆松散节点。

它有明确入口,也有明确终点。

3. Node

ChatTemplateChatModelToolsNodeLambda、甚至另一个 Graph,都可以是节点。

也就是说,Graph 编排的不是某一种固定组件,而是"逻辑节点"。

4. Edge

边不是装饰。

边定义的是下一个要跑谁。

这意味着"关系"本身成了第一等公民。

5. Compile

这一步最容易被低估。

很多人会想:我都已经把节点和边加完了,为什么还要多一次编译?

因为 Compile 干的不是"形式化地收个尾"。

它是在把你刚才搭出来的图,转成一个真正可运行、可检查的 Runnable

说白一点:

Graph 不是"边写边跑"的胶水脚本,它更像是先把执行拓扑搭清楚,再生成运行体。

这也是为什么 Compile 不是多余一步。

它把"图长什么样"和"图怎么跑"切开了。

前者是结构定义,后者是运行时。

3. Graph 最值得重视的,不连线成图,而是划清边界

如果只把 Graph 理解成"可以把节点画成一张图",那还是太浅。

它真正珍贵的地方,在于把一条复杂链路里最容易失控的边界收住了。

3.1 先定类型,再谈连接

官方在"编排的设计理念"里反复强调一个词:类型对齐

这不是文档里的漂亮话。

这其实是在回答一个很现实的问题:

上一个节点的输出,凭什么就能当下一个节点的输入?

如果你的方案是"先都塞成 any 再说",那后面每个节点都得自己做类型断言。

如果你的方案是"统一都传 map[string]any",那心智负担也只是换了个地方。

Eino 走的是另一条路:

  • 节点尽量保持开发者预期中的具体类型
  • Compile 阶段检查上下游能不能对齐
  • 必要时通过 WithOutputKeyWithInputKey 做受控转换

这套设计对 Go 工程师很友好。

因为你脑子里想的,不再是"这团 any 里面可能装了什么"。

而是"这个节点吐出来的东西,下一个节点有没有资格接"。

这就像搭积木。

尺寸对上了,才能接上。

3.2 WithOutputKey / WithInputKey 不是小技巧,而是汇聚场景的正道

很多人把 WithOutputKeyWithInputKey 当成"偶尔拿来修一下类型"的小技巧。

其实不是。

它们真正重要的地方,在于多上游汇聚时,你必须正面回答两个问题:

  • 多个上游输出怎么合并?
  • 下游到底从哪一个 key 取值?

比如上游输出的是 string,但多个节点最终要汇聚到一个 map[string]any 节点,这时可以用 compose.WithOutputKey("query") 把它包成 map。

反过来,如果上游已经是 map[string]any,而下游只想拿其中一个字段,则用 compose.WithInputKey("query") 明确取值。

这件事看起来只是类型转换。

本质上是在避免"汇聚以后到底该读哪份数据"变成隐式约定。

3.3 外部变量只读,不是洁癖,是并发和流式场景的底线

这是我觉得很多人最容易忽略、但又最工程化的一条原则。

官方明确提到,图里节点之间的数据流转,本质上是变量赋值,不是深拷贝。

所以当输入是 mapslice、指针这类引用类型时,如果你在节点内部直接修改它,就可能把副作用带到外面。

这在分支、扇出、流式场景里尤其危险。

因为你以为自己只是"顺手改一下"。

实际上你改的可能是整个运行过程共享着的那份值。

所以 Eino 的建议很明确:

Node、Branch、Handler 内部默认不要修改输入;真要改,先自己 Copy。

这不是框架保守。

这是运行时系统必须守住的底线。

3.4 Runnable 统一了运行姿势

Graph 一旦 Compile 完,拿到的是 Runnable

这件事很关键。

因为这说明编排产物最终不是"某个特殊 Graph 对象"。

而是一个统一运行入口。

它至少有三种常用姿势:

  • Invoke:完整输入,完整输出
  • Stream:完整输入,流式输出
  • Transform:流式输入,流式输出

这意味着你不需要为了"换成流式"就重新发明一条执行链。

框架会在运行时帮你补齐缺失的流式范式。

这比业务层自己维护两套流程稳定得多。

4. ToolCallAgent 这种场景,为什么更适合挂进 Graph

如果说最能体现 Graph 工程价值的场景,我认为不是天气 demo。

而是 ToolCallAgent

因为这个场景刚好包含了三层边界:

  • Prompt 怎么组
  • 模型怎么做工具决策
  • 工具结果怎么重新回到消息链路

看一个裁剪后的主链:

go 复制代码
chatTpl := prompt.FromMessages(
    schema.FString,
    schema.SystemMessage("你是一名房产经纪人,结合用户信息推荐房产。"),
    schema.MessagesPlaceholder("message_histories", true),
    schema.UserMessage("{user_query}"),
)

chatModel, _ := openai.NewChatModel(ctx, modelConf)

userInfoTool := utils.NewTool(
    &schema.ToolInfo{
        Name: "user_info",
        Desc: "根据用户姓名和邮箱查询公司、职位、薪酬",
        ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
            "name":  {Type: "string", Desc: "用户姓名"},
            "email": {Type: "string", Desc: "用户邮箱"},
        }),
    },
    func(ctx context.Context, input *userInfoRequest) (*userInfoResponse, error) {
        return &userInfoResponse{
            Name:     input.Name,
            Email:    input.Email,
            Company:  "Bytedance",
            Position: "CEO",
            Salary:   "9999",
        }, nil
    },
)

info, _ := userInfoTool.Info(ctx)
_ = chatModel.BindForcedTools([]*schema.ToolInfo{info})

toolsNode, _ := compose.NewToolNode(ctx, &compose.ToolsNodeConfig{
    Tools: []tool.BaseTool{userInfoTool},
})

g := compose.NewGraph[map[string]any, []*schema.Message]()
_ = g.AddChatTemplateNode("template", chatTpl)
_ = g.AddChatModelNode("chat_model", chatModel)
_ = g.AddToolsNode("tools", toolsNode)

_ = g.AddEdge(compose.START, "template")
_ = g.AddEdge("template", "chat_model")
_ = g.AddEdge("chat_model", "tools")
_ = g.AddEdge("tools", compose.END)

r, _ := g.Compile(ctx)
out, _ := r.Invoke(ctx, map[string]any{
    "message_histories": []*schema.Message{},
    "user_query":        "我叫 zhangsan,邮箱是 zhangsan@bytedance.com,帮我推荐一处房产",
})
  • 这段代码解决了什么工程问题:它把"提示词准备 -> 模型决策 -> 工具执行 -> 结果回链路"压成了一条明确执行链,而不是让工具调用散在业务流程里。
  • 不用编排时,这段逻辑会散到哪里:prompt 组装、模型调用、ToolCall 解析、工具分发、工具结果封装、下一轮消息拼接,最后大概率会混在同一个 service 方法里。

这里最重要的一句话是:

ChatModel 负责决定调谁,Graph 负责把整条执行链跑通,ToolsNode 负责把已经做出的调用真正执行掉。

这个边界一旦看清,很多误解都会消失。

比如:

  • ToolsNode 不是决策器
  • Tool 不是流程控制器
  • Graph 不是为了让代码更"好看"才存在

它存在的原因很现实:

如果你手写这条链,早期也能跑。

但一旦你要加第二个工具、要记录 callback、要改成流式、要嵌套别的节点,代码会迅速变成"谁都能改,谁都不敢动"的样子。

Graph 的价值就在这儿。

它不是替你写业务。

它是替你把执行边界立住。

5. Graph with state,重点不是"能存数据",而是"数据放在哪一层"

很多人一看到 state,直觉会很兴奋:

那我是不是终于有地方塞各种临时变量了?

如果你这样理解,后面很容易把 state 用偏。

Graph with state 的重点,不是"图里可以放全局变量"。

而是"这次运行过程中,需要有一份只属于这次运行的上下文"。

看一个精简后的例子:

go 复制代码
// 定义一个运行时状态结构体,用于在整个流程中共享数据
type runState struct {
    Steps []string // 记录每一步执行的日志
}

// 创建一个 Graph(有向图)
// 输入类型:string
// 输出类型:string
g := compose.NewGraph[string, string](
    // 为每次执行生成一个"局部状态"(每次 run 独立)
    compose.WithGenLocalState(func(ctx context.Context) *runState {
        return &runState{}
    }),
)

// =====================
// 节点1:prepare
// =====================
_ = g.AddLambdaNode(
    "prepare", // 节点名称

    // 节点核心逻辑:把输入转成大写
    compose.InvokableLambda(func(ctx context.Context, in string) (string, error) {
        return strings.ToUpper(in), nil
    }),

    // 前置处理(在核心逻辑执行之前)
    compose.WithStatePreHandler(func(ctx context.Context, in string, state *runState) (string, error) {
        // 记录输入
        state.Steps = append(state.Steps, "input:"+in)
        return in, nil
    }),

    // 后置处理(在核心逻辑执行之后)
    compose.WithStatePostHandler(func(ctx context.Context, out string, state *runState) (string, error) {
        // 记录处理结果
        state.Steps = append(state.Steps, "prepare:"+out)
        return out, nil
    }),
)

// =====================
// 节点2:finish
// =====================
_ = g.AddLambdaNode(
    "finish",

    // 核心逻辑
    compose.InvokableLambda(func(ctx context.Context, in string) (string, error) {
        var history string

        // 从 context 中取出全局 state
        err := compose.ProcessState[*runState](ctx, func(_ context.Context, state *runState) error {
            // 把历史步骤拼接成字符串
            history = strings.Join(state.Steps, " -> ")

            // 记录当前步骤
            state.Steps = append(state.Steps, "finish:"+in)
            return nil
        })
        if err != nil {
            return "", err
        }

        // 返回完整执行链路
        return history + " -> finish:" + in, nil
    }),
)

// =====================
// 定义执行流程(有向边)
// =====================

// 起点 -> prepare
_ = g.AddEdge(compose.START, "prepare")

// prepare -> finish
_ = g.AddEdge("prepare", "finish")

// finish -> 终点
_ = g.AddEdge("finish", compose.END)
  • 这段代码解决了什么工程问题:它把"单次运行上下文"显式挂在图上,而不是让节点通过包变量、共享 map 或上下文外的全局对象偷偷交换信息。
  • 不用编排时,这段逻辑会散到哪里:某些人会把状态塞到闭包里,有些人会塞进全局 map,还有些人会把它挂到业务 struct 上,最后状态边界和生命周期一起失控。

这段代码里有 3 个点要分开看。

1. WithGenLocalState

它定义的是:每次运行这张图时,怎么生成一份新的状态。

注意,是"每次运行一份新的"。

不是"整个应用启动以后共用一份"。

2. WithStatePreHandler / WithStatePostHandler

它们是节点外侧的钩子。

你可以理解成:

  • 节点真正执行前,先看一下输入和状态
  • 节点真正执行后,再看一下输出和状态

这很适合做运行过程中的记录、补充、调整。

3. ProcessState

这是节点内部读写状态的入口。

当节点本身需要根据历史状态做判断时,就该走这里,而不是绕出去摸别的共享变量。

所以 state 的正确打开方式,不是"我终于有个地方可以乱塞东西"。

而是:

这次运行里,哪些上下文确实属于图本身,而且后续节点还要继续用?

如果不满足这个条件,就别放。

比如数据库连接、全局配置、跨请求缓存,这些都不该进这里。

它们不是"单次运行上下文"。

6. Chain 为什么是更顺手的入口,而不是另一套框架

官方文档里有一句话我很认同:

Chain 可以视为 Graph 的简化封装。

这句话很重要。

因为很多人学到这里,会产生两个相反的误解:

  • 要么觉得 Chain 太简单,像玩具
  • 要么觉得 ChainGraph 是两套并列框架

这两个理解都不对。

Chain 的本质,是把"线性链路"写得更顺。

看一个被我压缩过的例子:

go 复制代码
// 并行节点:同时准备模板需要的两个变量 role 和 input
parallel := compose.NewParallel().
	// 产出变量 role
	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
	})).
	// 产出变量 input
	AddLambda("input", compose.InvokableLambda(func(ctx context.Context, kvs map[string]any) (string, error) {
		return "你的叫声是怎样的?", nil // 固定用户问题
	}))

// 子链:把 role/input 渲染进提示词,再交给聊天模型生成消息
rolePlayer := compose.NewChain[map[string]any, *schema.Message]()
rolePlayer.
	AppendChatTemplate(prompt.FromMessages(
		schema.FString,
		schema.SystemMessage("You are a {role}."), // 系统设定
		schema.UserMessage("{input}"),             // 用户输入
	)).
	AppendChatModel(cm) // 调用模型

// 主链:输入 map[string]any,最终输出 string
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
	})).

	// 分支:根据 branchCond 决定是否执行 b1 / b2 这条支路
	AppendBranch(
		compose.NewChainBranch(branchCond).
			AddLambda("b1", b1).
			AddLambda("b2", b2),
	).

	// 透传:把当前上下文继续往后传,避免前面结果被截断
	AppendPassthrough().

	// 并行生成 role 和 input,合并成一个 map 供后续模板使用
	AppendParallel(parallel).

	// 执行子链 rolePlayer:模板渲染 + 大模型调用
	AppendGraph(rolePlayer).

	// 把模型返回的 *schema.Message 提取成纯文本
	AppendLambda(compose.InvokableLambda(func(ctx context.Context, m *schema.Message) (string, error) {
		return m.Content, nil
	}))

// 编译整个链,得到可执行 Runner
r, _ := chain.Compile(ctx)

// 执行链:这里传入一个空 map 作为初始输入
output, _ := r.Invoke(ctx, map[string]any{})
  • 这段代码解决了什么工程问题:它把一条以线性推进为主的执行链写得更紧凑,同时保留了分支、并行和图嵌套能力。
  • 不用编排时,这段逻辑会散到哪里:每一步都要手工传值、手工判断分支、手工等待并行结果、手工把子流程接回来,最后"主链"本身会淹没在细节里。

这段代码说明了两件事。

第一,Chain 并不弱。

它不是只有 AppendChatTemplateAppendChatModel 这种最简单的串联。

它还能接 branch、接 parallel、接另一个 graph

第二,Chain 仍然是线性心智模型。

你写的时候,脑子里想的是"先做 A,再做 B,再做 C"。

这比直接上图更顺手。

所以很多场景下,Chain 应该是你的第一选择。

尤其是:

  • 处理链路天然线性
  • 中间节点之间没有太多复杂汇聚
  • 你更想快速表达主路径

但如果你已经明显开始关心:

  • 节点关系是不是要显式画出来
  • 哪些节点是多上游汇聚
  • 哪些节点是复杂分支
  • 哪些地方要更强的状态控制

那就别再硬拿 Chain 扛所有场景了。

7. 什么时候用 Chain,什么时候直接上 Graph

这件事不复杂。

我直接给结论。

更适合 Chain 的场景

当你的流程整体上是一条主线 时,更适合用 Chain

也就是你在写流程时,脑子里想的是:

  • 先做什么
  • 再做什么
  • 最后输出什么

哪怕中间有一点分支、并行,也只是局部补充,整体仍然是顺序执行的流水线

这种场景下,用 Chain 会更自然,代码也更容易快速搭起来。

更适合 Graph 的场景

当你的流程不再是一条简单直线,而更像一张流程图 时,更适合用 Graph

比如:

  • 一个节点会分叉到多个下游
  • 多个节点的结果要汇聚到同一个节点
  • 分支、汇合、依赖关系比较复杂
  • 你希望明确控制"谁连接谁"

这时候你关注的重点已经不只是"步骤顺序",而是节点之间的连接关系 ,那么 Graph 会更清晰。

一个简单好记的理解方式

Chain

更像是在写一条流水线

text 复制代码
输入 -> 步骤1 -> 步骤2 -> 步骤3 -> 输出

适合大多数"顺着往下执行"的流程。

Graph

更像是在画一张流程图

text 复制代码
       -> 节点B ->
节点A              -> 节点D
       -> 节点C ->

适合有明显分叉、汇合、复杂依赖的场景。

实际开发中的建议

如果一个流程:

  • 主路径很清晰
  • 只是偶尔插入分支或并行
  • 你更关心整体执行顺序

那优先用 Chain

如果一个流程:

  • 节点关系复杂
  • 分叉和汇合较多
  • 你已经开始用"流程图"的方式去思考

那就更适合用 Graph

8. 编排中容易跳进去的 5 个坑

8.1 把 map[string]any 当万能胶

map[string]any 不是不能用。

但如果你从头到尾都靠它传值,最后还是会回到"每个节点都在猜 key、猜类型"的老路上。

它更适合:

  • 明确的汇聚场景
  • 经由 WithOutputKeyWithInputKey 做受控转换

而不是变成整条链路的默认协议。

8.2 只写 Invoke,从来不看 Stream / Transform

很多 demo 只写 Invoke,这是可以理解的。

但你如果做的是实际产品链路,迟早会遇到流式输出。

更进一步,某些节点本身就要吃流、吐流,这时你就得理解 Transform

如果你从设计阶段就把这件事忽略了,后面通常要补一套平行逻辑。

8.3 在节点里直接改外部引用类型

这是最隐蔽的坑之一。

尤其是 mapslice、指针。

你以为自己只是改了当前节点的输入,实际上可能改的是整个运行过程共享的那份值。

这类 bug 一旦叠上分支、并发、流式,排起来会非常难受。

8.4 把 state 当"什么都能放"的储物箱

state 不是跨请求缓存。

不是全局依赖容器。

也不是你懒得设计边界时的逃生门。

它只该放这次运行过程中确实需要被后续节点继续消费的上下文。

8.5 把 Chain 当成 Agent

Chain 可以承接很多 agent 的执行步骤。

但它本身不是 agent 概念本身。

如果你把这两个层级混在一起,后面讨论 tool calling、event、runner、workflow agent 时,脑子会越来越乱。

Chain / Graph 解决的是编排。
Agent 解决的是更上层的智能体运行抽象。

这两个层级要分开。

9. 总结

很多人学 Chain / Graph 时,最容易走偏的一点,就是把它当成"更高级一点的流程写法"。

这个理解不算错,但远远不够。

它真正值钱的地方在于:

  • 把节点和关系显式化
  • 把上下游边界定清楚
  • 把同步、流式、状态、工具调用纳入统一运行时
  • 把复杂链路从业务胶水里拆出来

Graph 适合你把复杂关系讲清楚。
Chain 适合你把主路径写顺。

这两层一旦看懂,后面的 WorkflowAgentGraphTool,你会顺很多。

参考资料

  1. Chain/Graph 编排介绍
  2. 编排的设计理念
  3. eino-examples/compose
相关推荐
红云梦4 小时前
简历投了 100 份没回音?我给面试平台加了个“简历雷达“
人工智能·面试·职场和发展
嘉伟咯4 小时前
动手做一个AIAgent - 简易框架搭建
人工智能·agent
嘉伟咯4 小时前
动手做一个AIAgent - RAG基础
人工智能·agent
AI-Ming4 小时前
程序员转行学习 AI 大模型: 踩坑记录:服务器内存不够,程序被killed
服务器·人工智能·python·gpt·深度学习·学习·agi
2601_955363154 小时前
技术赋能B端拓客:号码核验行业的痛点破解与高质量发展之路,氪迹科技法人股东核验系统,阶梯式价格
大数据·人工智能
洞见前行4 小时前
AI 当逆向工程师:Claude Code 自主分析 APK 和 so 文件,解决 Unity 插件化启动崩溃
android·人工智能
龙腾AI白云4 小时前
如何利用知识图谱实现推理和计算
人工智能·深度学习·语言模型·自然语言处理·数据分析
阿部多瑞 ABU4 小时前
文明文化悖论
前端·人工智能·ai写作
2501_945318494 小时前
零基础学习AI的选型指南:CAIE认证与编程型AI认证如何取舍
人工智能·学习