【从 0 到 1 动手造 Agent】(原理篇)02、给循环装上护栏:把流程画成一张图

137 阅读15分钟

上一章我们把 Agent 的 417 行拆到了骨头,结论惊人地简单:一个 while 循环,加一个交接。

但那个循环太自由了。这一章,我们给它装一个笼子。


一、先说一个厨房的问题

回想一下上一章搭出来的那个厨房。

它只有一位大厨、一口锅,流程是这样的:大厨做一道菜 → 看一眼结果 → 再决定下一道做什么。 自由,灵活,什么都能做。

但如果这是一家真餐厅呢?

  • 谁负责配菜?谁负责炒?谁负责装盘?——没有分工,全凭大厨一个人现想。
  • 万一他陷入了「尝一口 → 再加盐 → 再尝一口」的死循环怎么办?——没有流程约束,只能靠他自己醒悟。
  • 万一他中途晕倒了,这一桌菜怎么办?——没有记录,别人接手完全不知道做到哪了。
  • 万一他记错了,把已经放过的调料又放一遍?——没人拦着。

一句话:这个厨房没有流程,只有一位天才。

上一章末尾那张「Swarm 缺了什么」的表,浓缩成四个字就是——失控。

flowchart LR
    A[自由的循环] --> B[拓扑藏在代码里<br/>流程长什么样,不读代码不知道]
    A --> C[路由是隐式的<br/>交接与否全看模型这次回没回工具调用]
    A --> D[状态无约束<br/>任何函数能写坏任何字段]
    A --> E[只有数字刹车<br/>唯一的保险是一个计数器]

    style A fill:#FFF4E5,stroke:#FB8C00,color:#1A1A1A
    style B fill:#F1F3F4,stroke:#9AA0A6,color:#1A1A1A
    style C fill:#F1F3F4,stroke:#9AA0A6,color:#1A1A1A
    style D fill:#F1F3F4,stroke:#9AA0A6,color:#1A1A1A
    style E fill:#F1F3F4,stroke:#9AA0A6,color:#1A1A1A

四条短板指向同一个词:失控。循环自由地转,模型想交接就交接、想写状态就写状态,唯一的保险是一个计数器——这与其说是编排,不如说是听天由命。

LangGraph 的核心主张,就是把上面四条全部翻过来:

把「控制流」从代码里拎出来,变成一张显式的图。

确定性不是对模型的约束,而是对流程的约束:模型依然可以自由地说出想说的话,但「这句话之后走哪条路」,由图和状态说了算。用确定性的图,兜住不确定性的模型——这就是「笼子」的全部含义。

用厨房的话说:不是换掉大厨,而是给后厨立一套传菜流程。

一句话概括这一章要做的事:Swarm 把图写死在循环里,我们这一章要把循环写进图里。


二、换一种读源码的方法

先说明一个方法论上的差别。

上一章之所以能逐行注解源码,是因为 Swarm 小:核心三个文件 417 行,通读一遍就完了。

但 LangGraph 不一样。它是一个生产级框架:核心包本身大几千行,外面还挂着一整套生态。逐行读源码既不现实,也读不出重点——大框架 90% 的代码是工程设施,真正决定它「长什么样」的,只有一小撮设计思想。

所以这一章换一种做法,分三步:

  1. 概念蒸馏 —— 从公开的设计文档里,抽出它最核心的心智模型;
  2. 用伪代码重建 —— 只用那套概念,用伪代码重建一个极简的「状态图」引擎;
  3. 反推边界 —— 做完之后,把「我们刻意没做的部分」列出来。那部分,才是完整的 LangGraph。

三、心智模型:一张传菜单,四个工位,四种积木

LangGraph 的全部核心,可以用一张图说清。

先看厨房版本:后厨墙上挂着一张传菜单。所有工位都往同一张单子上写,写完传给下一个工位。

flowchart LR
    START([开单]) --> N1[配菜工位]
    N1 --> N2[炒锅工位]
    N2 --> D{看单子决定<br/>下一步去哪}
    D -->|还要摆盘| N3[装盘工位]
    D -->|直接出餐| END([出餐])
    N3 --> END

    style START fill:#E6F4EA,stroke:#34A853,color:#1A1A1A
    style END fill:#E6F4EA,stroke:#34A853,color:#1A1A1A
    style D fill:#FFF4E5,stroke:#FB8C00,color:#1A1A1A
    style N1 fill:#E8F0FE,stroke:#4285F4,color:#1A1A1A
    style N2 fill:#E8F0FE,stroke:#4285F4,color:#1A1A1A
    style N3 fill:#E8F0FE,stroke:#4285F4,color:#1A1A1A

拆开来看,就是四种积木。

3.1 State:整张图只有一张传菜单

整张图从头到尾只有一个共享状态。 每个工位读的是这张单子的【全量】,写的只是自己关心的【局部更新】。

这一条是一切设计的基石。有了这个「唯一的真相源」:

  • 工位之间不需要互相认识——配菜工位不知道炒锅工位是谁,它们只通过这张单子通信;
  • 所有中间结果都有一个确定的家,不会散落在各个局部变量里;
  • 为后面的「落盘 / 重放 / 断点续跑」提供了天然落点:这张单子本身就是快照。

3.2 Node:读全量、写局部的纯函数

工位是整张图里唯一的执行单元,它的契约就一行:

接口 工位:
    函数 执行(整张单子) -> 我想改的几行

用起来是这样:

函数 炒锅工位(单子):
    返回 { 菜品清单: ["宫保鸡丁"], 当前火候: "大火" }
    # 只写自己负责的两行,其它行一概不管

注意「纯函数」三个字的分量:工位读到的永远是一份快照,写的是「我想改成这样」的声明,而不是直接动手改那张单子。至于怎么改、能不能改,那是引擎和合并规则的事。

这个约定带来了三个好处:可以并行(多个工位同时看单子互不干扰)、可以重放(单子一样,结果就一样)、可以测试(工位是纯函数,喂进去什么就出来什么)。

3.3 Reducer:决定「这一行怎么写」

这是整个设计里最有意思、也最常让新手卡住的地方。

问题很朴素:好几个工位都往同一行上写(比如都往「菜品清单」里加菜),该怎么处理?

直接覆盖?那前面工位写的菜就丢了。

答案是:每一行挂一个合并策略,定义「旧值 + 新更新 → 新值」。

策略效果厨房里对应什么
后写覆盖(默认)新值顶掉旧值「当前火候」——大厨说改成小火,就改小火
列表追加往后接着写「菜品清单」——配菜加了凉菜,炒锅再加热菜,都留着
数值累加数字相加「已出餐数」——每出一个就 +1

关键在于:合并策略是「每一行各自独立」的。 同一张单子上,「菜品清单」是追加、「当前火候」是覆盖、「已出餐数」是累加,三种策略可以和平共处。

这就是「LangGraph 的消息历史为什么自动累积」的底层原理:每个工位只吐出一段增量,引擎按合并规则把增量拼回共享状态。 你永远不会因为某个工位忘了拷贝旧消息,就把历史冲掉了。

3.4 边:普通边与条件边

边决定流程走向,有两种:

  • 普通边:干完 A 之后,固定去 B。线性流程就靠它。
  • 条件边:干完 A 之后,调用一个读单子、返回下一站名字的函数。
图.加条件边(从="炒锅工位", 规则=函数(单子):
    如果 单子.还需要摆盘: 返回 "装盘工位"
    否则:                返回 "出餐"
)

「下一步去哪」变成了一个可审计的函数,而不是散落在 if-else 里的隐式逻辑。 这一条直接回答了第一节说的「路由即数据」。

另外还有两个哨兵:开单 和 出餐。它们不是真实的工位,只是引擎的起点和终点标记。

3.5 一个循环:引擎的全部心脏

四种积木组装起来之后,执行引擎本身还是那个朴素到可笑的循环——LangGraph 官方管它叫 Pregel 调度(名字致敬 Google 的并行图计算框架 Pregel):

函数 执行图(图, 起始状态):

    当前工位 = 开单
    单子     = 起始状态

    循环 只要 当前工位 != 出餐:

        更新 = 图.工位[当前工位].执行(单子)     # ① 执行:读全量,吐局部更新
        单子 = 按规则合并(单子, 更新)           # ② 合并:每一行用各自的策略
        当前工位 = 图.下一站(当前工位, 单子)     # ③ 寻路:普通边 or 条件边

    返回 单子

▍引擎循环的三步,就是全部心脏:

① 执行 —— 工位读整张单子,吐回一段「局部更新」; ② 合并 —— 引擎按每一行自己的合并策略,把增量拼回单子; ③ 寻路 —— 走普通边去固定的下一站,或走条件边让函数读单子定下一站。

理解了这三步,就理解了 90% 的 LangGraph。 剩下 10% 是「怎么描述图」的 API 语法糖,以及一堆后面会提到的工程设施。

请注意——这个循环和上一章 Swarm 的循环,是同一副骨架。 下一节就把这件事讲透。


四、Swarm 的循环,就是这张图的一个特例

这是本章最关键的一节。把两章的「心脏」摆在一起:

Swarm 的循环(上一章):

循环 只要 还没超预算 且 有活跃的 Agent:
    回复 = 问大厨(活跃Agent.守则, 历史, 活跃Agent.厨具)
    历史 += 回复
    如果 回复.没喊工具: 跳出
    结果 = 执行(回复.喊的工具)
    历史 += 结果.消息
    状态 += 结果.状态更新
    如果 结果.要转交: 活跃Agent = 结果.新Agent

这一章的循环:

循环 只要 当前工位 != 出餐:
    更新     = 工位[当前工位].执行(单子)      # 执行
    单子     = 按规则合并(单子, 更新)          # 合并
    当前工位 = 下一站(当前工位, 单子)          # 寻路

看出来了吗?

Swarm 是「只有一种工位形态、且拓扑写死」的一张图。

上一章那个循环里所有在运行中被换掉的部分,在这一章里都被拆成了显式的积木:

在 Swarm 里(隐含的)在这一章里(显式化之后)说明
活跃Agent 当前是谁当前工位都是「现在轮到谁」
while 循环写死的走向边 / 条件边拓扑从代码搬进了数据
工具返回 Agent → 交接条件边返回下一个工位名路由从隐式变成显式函数
共享状态自由字典声明式的 State从「约定俗成」变成「写明白」
历史手动追加追加型合并策略消息累积从「手写」变成「自动」
最大轮数刹车终点条件 + 步数保险丝结束条件从「计数」变成「结构」
工位 = 调 LLM(唯一形态)任意工位(LLM / 工具 / 纯函数)从单一形态变成任意形态

这张映射表不是比喻,是字面成立的。

上一章那个「客服转西班牙语」的交接,在这一章里就是一张四节点的图:

图 = 新建图()
    .加数据行("工具日志", 合并策略=追加)
    .加工位("大厨",  函数(单子): ... 假装是 LLM,决定要不要用厨具 ...)
    .加工位("厨具",  函数(单子): ... 执行工具,结果写回单子 ...)
    .加边(开单, "大厨")
    .加条件边("大厨", 函数(单子):
        如果 单子.需要工具: 返回 "厨具"
        否则:              返回 出餐)
    .加边("厨具", "大厨")          # ★ 回边:让循环转起来

跑起来是这样的:

sequenceDiagram
    participant G as 引擎
    participant A as 大厨工位
    participant T as 厨具工位

    G->>A: 第 1 轮:执行「大厨」
    Note over A: 单子里有「天气」<br/>且还没查过 → 决定用厨具
    A-->>G: 更新 { 需要工具: 是 }
    G->>T: 条件边判定 → 去「厨具」
    Note over T: 执行天气 API
    T-->>G: 更新 { 工具日志: 追加一条 }
    G->>A: 回边 → 又回到「大厨」
    Note over A: 单子里已经有天气结果了 → 直接收尾
    A-->>G: 更新 { 需要工具: 否, 最终答案: ... }
    G->>G: 条件边判定 → 出餐

注意「大厨」这个工位走了两趟。 第一趟它决定用厨具,第二趟它看到结果已经齐了,直接收尾。第二趟它只更新了「需要工具」和「最终答案」两行,没有再碰「工具日志」那一行——各个工位各改各的行,这正是「读全量、写局部」这个约定的直接后果。

上一章用循环加 if 隐式表达的东西,到这里全部变成了声明:

  • 大厨 → 厨具 → 大厨 这个循环,是回边画出来的,不是代码写死的;
  • 「要不要用厨具」,是条件边读单子决定的,不是 if 判断的。

Swarm 是这张图的一个子集。 理解了这张映射表,你就同时理解了这两个框架——以及所有号称「编排 Agent」的框架。


五、笼子到底锁住了什么

现在回到系列的核心命题。我们必须把这个隐喻讲清楚,否则容易产生两个方向的误解:

  • 误解 A:笼子 = 限制大模型。 ✗ 错。笼子从不限制模型能说什么、能决定什么。
  • 误解 B:笼子 = 多余的重型工程。 ✗ 错。笼子锁住的,恰好是上一章失控的地方。

笼子锁住的是三样东西:

flowchart TB
    CAGE[笼子锁住的三样东西]

    CAGE --> L1[1 锁住拓扑]
    CAGE --> L2[2 锁住路由]
    CAGE --> L3[3 锁住写入]

    L1 --> L1a[流程长什么样<br/>是声明出来的数据<br/>可以序列化,可视化,审查]
    L2 --> L2a[下一步去哪<br/>是一个能单独测试的函数<br/>但函数内部仍可以是 LLM]
    L3 --> L3a[每一行怎么写<br/>由合并策略规定<br/>不再是谁都能改坏]

    style CAGE fill:#E8F0FE,stroke:#4285F4,color:#1A1A1A
    style L1 fill:#FFF4E5,stroke:#FB8C00,color:#1A1A1A
    style L2 fill:#FFF4E5,stroke:#FB8C00,color:#1A1A1A
    style L3 fill:#FFF4E5,stroke:#FB8C00,color:#1A1A1A

① 锁住拓扑——流程长什么样,是声明出来的。

上一章:想加一个工位,得改 while 循环里的逻辑。这一章:加一个工位、再连一条边。图的形状变成了数据,不再是控制流,于是它可以被序列化、被可视化、被审查。生产系统要的「可审计」,第一步就是这个。

② 锁住路由——「下一步去哪」变成显式函数。

条件边必须读单子、返回一个合法的工位名。路由逻辑不再散落在 if 深处,而是一个能单独测试、单独替换的函数。

注意,这个函数内部仍然可以是 LLM。 「让模型判断用户是在抱怨还是夸奖,返回『投诉线』还是『表扬线』」——这就是一条合法的条件边。笼子锁的是「路由这件事有明确的出口」,不锁「出口由谁决定」。

③ 锁住写入——状态不再被随意覆盖。

自由字典时代,任何一个函数都能写坏任何一个字段;现在,每一行怎么写由合并策略说了算。共享状态的「写」,从放任变成了规则。


六、刻意没做的部分

用伪代码重建完核心之后,一个事实变得非常清晰:

核心原理就三件事加一个循环。剩下的一切,都是工程设施。

下面这些是真实 LangGraph 有、而我们故意没做的部分。每一行的背后,都对应着上一章那个真实的痛点:

没做的设施真实 LangGraph 里是什么解决什么痛点
检查点(持久化)把状态存进内存 / Postgres,支持读取与修改会话只在内存,进程一挂全丢
时间旅行基于检查点的历史回放,改过去再重跑长任务失败只能从头再来
人工介入挂起图,人工审核后恢复高危操作无法卡人工
并行步一步内节点并行、步间同步上一章发现的「模型开了并行、引擎却在串行」
单写限制同一步只允许写同一个字段一次并发写状态互相踩踏
原子性一步内任一节点失败,该步所有更新整体回滚半路崩溃留下脏状态
节点级重试 / 超时每个节点可配重试策略一次网络抖动毁掉整轮
流式更新 / 子图 / 递归熔断增量输出、子图复用、递归深度限制响应不实时、图不可复用、递归失控

这张表和上一章那份「缺失清单」几乎是逐行对应的。这不是巧合——LangGraph 就是照着这些痛点长出来的。

下面这条线值得单独拎出来讲透:

真实 LangGraph 九成的代码,都在做一件事——让「一次执行」变成「一笔可靠的事务」。

为什么会这样?因为 Agent 和普通程序最本质的区别,是它每执行一步都在和真实世界交互:调了工具、付了钱、改了数据。

普通函数失败了可以就地重试;Agent 失败了,世界已经被它动过一遍了。于是「怎么让一次长达几百步、跨小时、随时可能被打断的执行不至于半路全毁」,就成了工程的核心命题。

而答案,恰恰是数据库早就给出过的答案。把 LangGraph 的设施清单和 ACID 逐条对照:

ACID在 Agent 引擎里的对应
原子性 A一步内任一工位失败,该步所有更新整体回滚。对象从「一行数据」换成了「一轮 Agent 决策」,语义完全相同
一致性 C合并规则保证状态永远合法:清单永远是列表、计数永远是数字,写不进非法值
隔离性 I「单写限制」+「步间同步」就是并发控制,防止两个工位同时踩同一行
持久性 D检查点把状态落盘,进程死了、机器重启了,拉一下就能从断点继续跑

工业级 Agent 引擎的本质,就是给 AI 加上数据库级别的 ACID 事务保障。 模型负责生成「下一步做什么」的不确定性,框架负责让这段执行「要么完成,要么干净地回到起点」。

用伪代码重建的核心证明了「图的骨架」可以有多简单;而这九成代码的存在,则证明了另一件事——简单的骨架加上可靠的护城河,才是生产环境的全部真相。


七、小结

Swarm 用 417 行证明了骨架是确定性的;这一章则告诉我们,这个确定性可以显式到什么程度。

这一章我们用伪代码重建的那个引擎,核心只有三行:执行、合并、寻路。而完整框架里那九成的代码,全都是为了让这三行在真实世界里不出事。

回到厨房的比喻:

  • 上一章的厨房里只有一位天才大厨,想怎么干怎么干;
  • 这一章的厨房墙上挂了一张传菜单,有固定的工位、有明确的传菜路线、有「这一步该谁干」的规矩。

大厨的手艺一点没变——变的只是他干活的那个场子。

核心结论:确定性不是对模型的约束,而是对流程的约束。模型永远在边界内自由,图永远在边界外确定。

但还有一个问题没解决。这个厨房的记性,依然只有「一道菜」那么长——传菜单再规范,也架不住单子越写越长,最后写不下了。

下一章,我们给 Agent 装一块真正的记忆。

本章要点回顾

  1. 状态图引擎 = 一张共享状态 + 若干工位 + 合并规则 + 条件边;
  2. 引擎循环三步:执行 → 合并 → 寻路,和 Swarm 是同一副骨架;
  3. Swarm 是「只有一种工位形态、拓扑写死」的特例;
  4. 笼子锁的是拓扑、路由、写入,不锁模型。