上一章我们把 Swarm 的 417 行拆到了骨头,结论是惊人的简单:一个
while循环 + 一个交接。 但文章结尾留了个问题------给这个自由的循环,怎么加上"确定性的笼子"?LangGraph 是目前最主流的答案,也是本篇的主角。
用确定性的图,兜住不确定性的模型。 模型负责不确定地"说什么",图负责确定地"怎么走"。
1. 和上一章不一样的做法
先说明一个重要的方法论差异。
上一章(Swarm)之所以能逐行注解源码,是因为它小:核心三个文件 417 行,clone 下来通读一遍就完了。
但 LangGraph 不一样。它是一个生产级框架:核心包 langgraph 本身大几千行,外面还挂着一整套生态(langgraph-checkpoint、langgraph-sdk、LangSmith、各种 saver......)。逐行读源码不现实,也读不出重点------大框架 90% 的代码是工程设施,真正决定它"长什么样"的,只有一小撮设计思想。
所以这一章换一种做法,分三步:
- 概念蒸馏 ------ 从公开的设计文档里抽出它最核心的心智模型;
- 最小复刻 ------ 只用那套概念,用 Groovy / Java 各重建一个约 150 行的"状态图"引擎;
- 反推边界 ------ 做完之后,把"我们刻意没做的部分"列出来。那部分,才是完整的 LangGraph。
2. LangGraph 在回答什么问题
先回顾一下 Swarm 留给我们的短板。上一章那张"缺了什么"的表,浓缩成四条:
- 拓扑藏在代码里 :
while循环的走向是写死的,agent → tool → agent...,流程长什么样,不读代码不知道; - 路由是隐式的 :交接与否,取决于模型这次回没回
tool_calls、工具返没返回Agent------没有一处能"一眼看出这个会话会走向哪里"; - 状态无约束 :
context_variables是一个自由字典,任何函数都能写任何 key,写坏了没有机制兜底; - 只有数字刹车 :唯一防失控的是
max_turns一个计数,结构上没有任何保障。
四条短板,指向同一个词:失控。循环自由地转着,模型想交接就交接、想写状态就写状态,唯一的保险是一个计数器------这与其说是编排,不如说是"听天由命"。
LangGraph 的核心主张,就是把上面四条全部翻过来:
把"控制流"从代码里拎出来,变成一张显式的图。
确定性不是对模型的约束,而是对流程 的约束:模型依然可以自由地说出想说的话,但"这句话之后走哪条路",由图和 State 说了算。用确定性的图,兜住不确定性的模型------这就是"铁笼"的全部含义。
- 拓扑显式:节点 + 边,一张图就是流程的全部,一眼看完;
- 路由即数据:条件边把"下一步去哪"变成一个返回节点名的函数,可审计、可测试;
- 状态有 schema:State 声明有哪些 key,reducer 声明每个 key 该怎么写;
- 一切可中断、可落盘、可重放:执行过程是数据,不是不可逆的副作用。
一句话概括:Swarm 把图写死在循环里,LangGraph 把循环写进图里。
3. 心智模型:一张图,一个状态,四种积木
LangGraph 的全部核心,可以用一张图说清------这也正是 Agent 状态管理的本质:

拆开来看,就是四种积木。
3.1 State:一张图只有一个共享状态
整张图从头到尾只有一个 State 对象。 每个节点读的是这个对象的【全量】,写的只是自己关心的【局部更新 partial update】。
这一条是 LangGraph 一切设计的基石。有了"唯一的真相源":
- 节点之间不需要互相认识------node1 不知道 node2 是谁,它们只通过 State 通信;
- 所有中间结果都有一个确定的家,不会散落在各种局部变量里;
- 为后面的"落盘 / 重放 / 断点续跑"提供了天然的落点:State 就是快照。
3.2 Node:读全量、写局部的纯函数
节点是整张图里唯一的执行单元,它的契约就一行:
groovy
interface Node {
Map<String, Object> apply(State state) // 入参:全量 State → 出参:局部更新
}
对应到 Python 就是一个普通函数:
python
def node1(state):
return {"counter": state["counter"] + 1} # 只改 counter,其它 key 一概不管
注意"纯函数"三个字的分量:节点读到的永远是一个快照,写的是"我想改成这样"的声明,而不是直接动手改共享对象。至于怎么改、谁能改,那是引擎和 reducer 的事。这个约定让节点可以并行、可以重放、可以测试。
3.3 Reducer:决定"怎么写"
这是 LangGraph 最有意思的设计,也常是新手第一处看不懂的地方。
问题很朴素:多个节点都写同一个 key(比如都往 messages 里加消息),该怎么处理? 直接覆盖?那前面节点写的消息就丢了。
LangGraph 的答案是:每个 key 挂一个合并策略(reducer),定义"旧值 + 新更新 → 新值"。 在 Python 里,这个策略写在类型注解上:
python
from typing import Annotated
from operator import add
class State(TypedDict):
messages: Annotated[list, add] # 追加:每个节点吐的消息全拼起来
counter: int # 不加 Annotated → 后写覆盖(LastValueChannel)
Groovy / Java 没有类型注解这套机制,所以把策略显式挂到图上:
groovy
graph.channel('messages', Reducers.&append) // Groovy:方法指针
graph.channel("messages", Reducers::append) // Java:方法引用
三种策略的关系,用一张表列清楚:
| 策略 | Python | Groovy / Java | 效果 |
|---|---|---|---|
| 后写覆盖 | 不加 Annotated |
不调 channel(默认) | LastValueChannel,新值顶旧值 |
| 列表追加 | Annotated[list, add] |
Reducers.append |
消息越走越长 |
| 数值累加 | Annotated[int, add] |
Reducers.addNumbers |
计数器累加 |
reducer 是"每个 key 各自独立"的 ------同一个 State 里,messages 追加、score 累加、summary 覆盖,三种策略可以和平共处。
这就是"LangGraph 消息历史为什么自动累积"的底层原理:每个节点都只是吐出一段增量,框架按 reducer 把增量拼回共享 State。 你永远不会因为某个节点忘了拷贝旧消息,就把历史冲掉。
3.4 边:普通边与条件边
边决定流程走向,有两种:
- 普通边 :执行完
from后固定去to。线性流程就靠它。 - 条件边 :执行完
from后,调用一个读 State、返回目标节点名的函数。
groovy
.addConditionalEdges('agent') { State st ->
st.data.needsTool ? 'tools' : Graph.END // 路由:读状态,决定下一步
}
"下一步去哪"是一个可审计的函数,而不是散落在 if-else 里的逻辑。 这一条直接回答了第 2 节说的"路由即数据"。
另有 START / END 两个哨兵 ------它们不是真实节点,是引擎的起点和终点标记。真实 LangGraph 里是 START、END 两个常量;我们的复刻里叫 Graph.START = '__START__' / Graph.END = '__END__'。
3.5 一个循环:invoke / Pregel
四种积木组装起来之后,执行引擎本身还是那个朴素到可笑的循环 ------LangGraph 官方把它叫 Pregel 调度(命名致敬 Google 的 Pregel 并行图计算框架):
python
# Python LangGraph(概念版)
current = START
while current != END:
update = nodes[current](state) # ① 节点:读全量,吐局部更新
state = merge(state, update) # ② 合并:按 reducer 写回 State
current = next(current, state) # ③ 寻路:普通边 / 条件边
return state
复刻版的 Graph.invoke() 逐行对应:
groovy
// Groovy:三步循环,一字不差
def current = Graph.START
while (current != Graph.END) {
def update = nodes[current].apply(state) // ① 执行节点:读全量 State
applyUpdate(state, update) // ② 按 reducer 合并增量
current = nextNode(current, state) // ③ 普通边 or 条件边
}
java
// Java:同一条骨架
while (!END.equals(current)) {
Map<String,Object> update = nodes.get(current).apply(state); // ① 执行节点
applyUpdate(state, update); // ② reducer 合并
current = next(state, current); // ③ 寻路
}
▍引擎循环的三步,就是 LangGraph 的全部心脏:
① 执行 ------ 节点读全量 State,吐回一段"局部更新"; ② 合并 ------ 引擎按每个 key 自己的 reducer,把增量拼回 State; ③ 寻路 ------ 走普通边去固定下一站,或走条件边让函数读 State 定下一站。
理解了这三步,就理解了 90% 的 LangGraph。 剩下 10% 是"怎么描述图"的 API 语法糖,以及一堆我们后面会提到的工程设施。
3.6 语言层的顿悟:从 Python 的魔法,到 Java/Groovy 的显式
注意 3.3 小节里那个容易被忽略的细节:Python 版的状态 schema,策略是藏在类型注解里 的。LangGraph 在运行时偷偷调用 get_type_hints() 去读这段注解,把 add 挖出来、自动挂到 messages 这个 channel 上;@tool、@checkpointer 装饰器同理------约定全藏在语法糖里。这就是 Python 版看起来那么"省字"的根本原因。
但到了 Groovy / Java,这一套走不通 。强类型语言没有等价于 TypedDict 的运行时反射可以安全依赖,于是"追加"这个策略必须亲手挂到图上 ------就是上面那两行 graph.channel(...)。
元编程把设计藏在语言机制里,读代码的人要追进框架源码才看得见;显式调用把设计摊在明面上,
graph怎么组装,一眼读完。Python 的优雅是"少写一行",Groovy/Java 的优雅是"没有藏起来的行"。
请注意,这个循环和 Swarm 的循环是同一副骨架。接下来的第 4 节,就把这件事讲透。
4. Swarm 的循环,就是这张图的一个特例
这是本篇文章最关键的一节。把两章的"心脏"摆在一起:
Swarm 的循环(上一章):
python
while 没超预算 and active_agent:
回复 = 调模型(active_agent)
历史 += 回复
if 回复里没有 tool_calls: break
结果 = 执行工具(回复.tool_calls)
状态 += 结果.状态更新
if 结果.agent: active_agent = 结果.agent # ★ 交接
LangGraph 的循环(这一章):
python
while current != END:
update = nodes[current](state) # 执行当前节点
state = merge(state, update) # 合并状态
current = next(current, state) # 走到下一个节点
看出来了吗?Swarm 是"只有一个节点形状、拓扑写死"的 LangGraph。 上一章那个循环里所有在运行中被换掉的部分,在 LangGraph 里都被拆成了显式的积木:
| Swarm(隐含在图里) | LangGraph(显式化之后) | 说明 |
|---|---|---|
active_agent 当前是谁 |
当前节点 current |
都是"现在轮到谁" |
while 循环写死的走向 |
edges / 条件边 |
拓扑从代码搬进数据 |
工具返回 Agent → 交接 |
条件边返回下一个节点名 | 路由从隐式 tool_call 变成显式函数 |
context_variables 自由字典 |
State(声明式 schema) |
共享状态从"约定俗成"变成"类型声明" |
history 手动 append |
messages + append reducer |
消息累积从"手写"变成"reducer 自动合并" |
max_turns 数字刹车 |
END 条件 + 步数保险丝 |
结束条件从"计数"变成"结构" |
| 节点 = 调 LLM(唯一形态) | 任意 Node(LLM / 工具 / 纯函数) | 节点形态从单一变成任意 |
那张映射表不是比喻,是字面成立的。上一章那个"客服转西班牙语"的交接,在 LangGraph 里就是一张四节点的图:
groovy
def graph = new Graph()
.channel('toolLog', Reducers.&append) // 工具日志累加
.addNode('agent') { State st -> /* 假装是 LLM:决策要不要调工具 */ }
.addNode('tools') { State st -> /* 执行工具,结果写回 State */ }
.addEdge(Graph.START, 'agent')
.addConditionalEdges('agent') { State st -> // 交接 / 回答,一条条件边
st.data.needsTool ? 'tools' : Graph.END
}
.addEdge('tools', 'agent') // ★ 回边:让循环转起来
跑出来(复刻的真实输出):
ini
执行路径: [__START__, agent, tools, agent, __END__] ← 注意 agent 走了两趟
最终 State: {toolLog=[🌤 天气API返回:晴 26°C], needsTool=false,
lastAnswer=最终回答『今天天气如何?』, toolTurns=1}
同一张图,Java 版长这样。条件边 + 回边,跟 Groovy 一字不差------只把 st.data.x 换成 st.get("x")、闭包换成 lambda:
java
Graph graph = new Graph()
.channel("toolLog", Reducers::append) // 工具日志累加
.addNode("agent", st -> {
String req = (String) st.get("request");
int turns = (st.get("toolTurns") == null) ? 0 : (int) st.get("toolTurns");
if (req.contains("天气") && turns == 0) { // 第一次:决定调工具
return Graph.updates("needsTool", true, "toolTurns", turns + 1);
}
return Graph.updates("needsTool", false,
"lastAnswer", "最终回答『" + req + "』"); // 信息够了:直接收尾
})
.addNode("tools", st -> Graph.updates("toolLog",
List.of("🌤 天气API返回:晴 26°C"))) // 执行假天气 API
.addEdge(Graph.START, "agent")
.addConditionalEdges("agent", st -> (Boolean) st.get("needsTool")
? "tools" : Graph.END) // 条件边:交接 or 回答
.addEdge("tools", "agent"); // ★ 回边:让循环转起来
运行 ./run.sh langgraph.examples.AgentLoop,Java 引擎会把每一步走向打成 trace------这就是第 3.5 节那个三步循环的"录像带":
ini
== 执行 AgentLoop ==
[graph] __START__ 执行完(更新了 ∅)→ 下一步 agent
[agent] 需要查『今天天气如何?』,决定调用工具
[graph] agent 执行完(更新了 [needsTool, toolTurns])→ 下一步 tools
[tools] 执行假天气 API
[graph] tools 执行完(更新了 [toolLog])→ 下一步 agent
[agent] 已有足够信息,直接回答
[graph] agent 执行完(更新了 [needsTool, lastAnswer])→ 下一步 __END__
最终 State: {toolLog=[🌤 天气API返回:晴 26°C], request=今天天气如何?,
toolTurns=1, needsTool=false, lastAnswer=最终回答『今天天气如何?』}
执行路径: [__START__, agent, tools, agent, __END__]
每一行
[graph]都是一轮"① 执行 → ② 合并 → ③ 寻路"。 注意agent走了两趟,第二趟只更新[needsTool, lastAnswer]、不再碰toolLog------节点各改各的 key,这正是"读全量、写局部"纯函数约定的直接后果。
上一章 Swarm 用循环 + if 隐式表达的东西,这里全部变成了声明 :agent 去 tools 再回 agent,循环是"回边"画出来的,不是代码写死的;needsTool 要不要调工具,是条件边读 State 决定的。
Swarm 是 LangGraph 的"只有一种节点形态 + 写死拓扑"的子集。 理解了这张映射表,你就同时理解了两个框架------以及所有号称"编排 Agent"的框架。
5. 最小复刻:150 行搭起笼子
概念讲完了,现在动手。整套复刻(Groovy / Java 各一份)只有四个类,加起来约 150 行核心代码。没有一行是 AI 相关的------全是集合、Map、循环。
5.1 三个契约
State (共享状态):一个持有 data 的轻量包装,读写都走 key。
groovy
class State {
Map<String, Object> data
State(Map seed = null) { data = new LinkedHashMap<>(seed ?: [:]) }
Object get(String key) { data[key] }
void set(String key, Object value) { data[key] = value }
}
Node(节点):函数式接口,闭包 / lambda 即节点。
groovy
interface Node {
Map<String, Object> apply(State state) // 入参全量 → 出参局部更新
}
Reducers (写策略):一组 (旧值, 新更新) -> 合并结果 的静态函数:
groovy
class Reducers {
static Object append(Object current, Object update) {
def base = current instanceof List ? current : (current == null ? [] : [current])
return (update instanceof List) ? base + update : base + [update]
}
static Object addNumbers(Object current, Object update) { // 数值累加
def c = current instanceof Number ? current : 0
def u = update instanceof Number ? update : 0
return c + u
}
}
Java 版的三个契约形状几乎一样------这套心智模型本来就不分语言:
java
// Node:函数式接口 = 节点,契约与 Groovy 一字不差
@FunctionalInterface
public interface Node {
Map<String, Object> apply(State state); // 读全量 State → 吐局部更新
}
// State:data 是公开字段,直接对应 Python 的 state dict
public final class State {
public final Map<String, Object> data = new LinkedHashMap<>();
public State(Map<String, Object> seed) { if (seed != null) data.putAll(seed); }
public Object get(String key) { return data.get(key); }
public void set(String key, Object value) { data.put(key, value); }
}
// Reducers.append:签名同为 (current, update) -> merged,实现换成 ArrayList
public static Object append(Object current, Object update) {
List<Object> base = new ArrayList<>();
if (current instanceof List<?> c) base.addAll(c);
else if (current != null) base.add(current);
if (update instanceof List<?> u) base.addAll(u);
else if (update != null) base.add(update);
return base;
}
Java 版多了一个小助手 Graph.updates(...):Map.of 不允许 null 值,用它拼"局部更新"更省事,也更像 Groovy 的 map 字面量。
5.2 组装 + 执行:Graph
Graph 负责两件事:组装 (注册节点 / 边 / reducer)和执行 (invoke 那个三步循环)。组装是链式 API,跑一个最简流程长这样:
groovy
def graph = new Graph()
.channel('messages', Reducers.&append) // messages 追加
.addNode('node1') { State st ->
[counter: st.data.counter + 1, messages: [[role:'assistant', content:'node1 说:你好']]]
}
.addNode('node2') { State st ->
[counter: st.data.counter + 10, messages: [[role:'assistant', content:'node2 说:加 10']]]
}
.addEdge(Graph.START, 'node1')
.addEdge('node1', 'node2')
.addEdge('node2', Graph.END)
def result = graph.invoke([messages: [], counter: 0])
// result.counter == 11
// result.messages*.content == ['node1 说:你好', 'node2 说:加 10'] ← 追加,不是覆盖
真实输出:
ini
执行路径: [__START__, node1, node2, __END__]
最终 State: {messages=[{role=assistant, content=node1 说:你好}, ...], counter=11}
同样的流程,Java 版长这样。组装方式逐行对应,只是把 st.data.x 换成 st.get("x")、闭包换成 lambda(msg(...) 是示例里的小助手,= Map.of("role","assistant","content", content)):
java
Graph graph = new Graph()
.channel("messages", Reducers::append) // = Annotated[list, add]
.addNode("node1", st -> Graph.updates(
"counter", (int) st.get("counter") + 1,
"messages", List.of(msg("node1 说:你好"))))
.addNode("node2", st -> Graph.updates(
"counter", (int) st.get("counter") + 10,
"messages", List.of(msg("node2 说:加 10"))))
.addEdge(Graph.START, "node1")
.addEdge("node1", "node2")
.addEdge("node2", Graph.END);
Map<String, Object> out = graph.invoke(Map.of("messages", List.of(), "counter", 0));
运行 ./run.sh langgraph.examples.BasicFlow。前面展示的 Groovy 输出是"剪辑版"(只留路径和终态),这里用 Java 版把引擎的完整 trace 亮出来------每一步执行了谁、更新了哪些 key,一览无余,断言也一并打了:
ini
== 执行 BasicFlow ==
[graph] __START__ 执行完(更新了 ∅)→ 下一步 node1
[graph] node1 执行完(更新了 [counter, messages])→ 下一步 node2
[graph] node2 执行完(更新了 [counter, messages])→ 下一步 __END__
最终 State: {counter=11, messages=[{content=node1 说:你好, role=assistant}, {content=node2 说:加 10, role=assistant}]}
执行路径: [__START__, node1, node2, __END__]
✅ counter == 11(0 → node1 写成 1 → node2 读到再 +10)
✅ messages 追加而非覆盖,两段消息都保留
✅ 执行路径 = START → node1 → node2 → END
✅ BasicFlow 全部断言通过
这里有几个值得琢磨的实现细节:
▍三个实现细节,三个设计决定:
① START 是虚拟哨兵,跳过节点执行直接寻路。 引擎里
START没有对应的节点函数,invoke遇到它就直接跳到edges[START]指向的第一个真实节点。整个引擎只有这一处特判。② 缺省的合并策略是"后写覆盖"。
applyUpdate里,没挂 reducer 的 key 直接state.data[key] = value。这正是LastValueChannel的语义,也是上面counter能一路从0 → 1 → 11的原因。③ 一个 100 步的保险丝。 条件边要是永远把自己带回,引擎就会陷入死循环。复刻版和真实 LangGraph 一样,都有一个步数上限:
groovywhile (current != Graph.END) { if (++guard > 100) throw new IllegalStateException("超过 100 步仍未到 END,疑似死循环。") // ... }测试里专门验证了这一条:条件边永远返回自己 → 100 步后抛
IllegalStateException。上一章 Swarm 靠max_turns数字刹车,这一章靠的是结构性的步数熔断------这就是"结束条件从计数变成结构"的含义。
5.3 三个示例、三个问题
整套复刻配了三个示例,每个讲透一件事,Groovy / Java 各一份,运行命令一一对应:
| 示例 | Groovy | Java |
|---|---|---|
| 消息累加 | ./run.sh examples/01_basic_flow.groovy |
./run.sh langgraph.examples.BasicFlow |
| 三种写策略 | ./run.sh examples/02_reducers.groovy |
./run.sh langgraph.examples.ReducersExample |
| Agent 循环 | ./run.sh examples/03_agent_loop.groovy |
./run.sh langgraph.examples.AgentLoop |
第一问:消息为什么自动累积(append vs 覆盖)。
两个节点都往 messages 追加、都改 counter。结果:messages 越长越长,counter 一路被覆盖。同一个 State,不同的 key,不同的命运 ------全由各自的 reducer 决定。这是 LangGraph 消息历史自动累积的最直接演示。上一节的 BasicFlow.java 就是这一课。
第二问:同一个 State 混用三种写策略。
三个节点写同一批 key:messages 追加、score 累加、summary 覆盖。跑 Java 版 ./run.sh langgraph.examples.ReducersExample,输出:
ini
最终 State: {messages=[a, b, c, d], summary=第三版, score=6}
执行路径: [__START__, n1, n2, n3, __END__]
✅ append:4 条消息全部保留
✅ addNumbers:0 + 1 + 2 + 3 = 6
✅ 默认覆盖:summary 只剩最后写的 '第三版'
✅ ReducersExample 全部断言通过
a、b、c、d 全保留(追加),1+2+3=6(累加),summary 只有最后写的 第三版(覆盖)。reducer 是 per-key 的,一个 State 可以同时具备三种"人格"。 这就是 LangGraph 灵活性的来源。
第三问:Agent 循环 = 条件边 + 回边。
就是第 4 节那张图。agent 节点假装是 LLM,第一次看到"天气"决定调工具,工具结果写回 State 后再回 agent,第二次 agent 判定"已有足够信息"才走向 END。执行路径 agent → tools → agent → END 里,agent 走了两趟。
这些要传达的是:ReAct 的"思考 → 行动 → 观察 → 再思考"循环,在 LangGraph 里不是魔法,就是一条从 tools 回到 agent 的回边。 上一章的 Swarm 把它焊死在循环里,这里的 LangGraph 让你自己画。
除了三个示例,Java 版还配了一份断言测试 TestLangGraph.java,把四个核心行为钉死在代码里:① 顺序执行 + State 逐节点传递;② reducer 合并(追加 / 累加 / 默认覆盖);③ 条件路由;④ 防死循环保险丝。运行 ./run.sh langgraph.test.TestLangGraph,第 ④ 项会先刷出一屏 [graph] loop 执行完(条件边永远把自己带回,走到 100 步),然后干净收场:
== TestLangGraph 开始 ==
✅ ① 顺序执行 + 路径正确
✅ ② reducer 合并策略
✅ ③ 条件路由
✅ ④ 防死循环保险丝
✅ TestLangGraph 全部测试通过
Groovy 版同样有一份 TestLangGraph.groovy,./run.sh test/TestLangGraph.groovy 跑出一样的四个 ✅。
6. 笼子到底"锁"住了什么
现在回到系列的核心命题------确定性铁笼。我们必须把这个隐喻讲清楚,否则容易产生两个方向的误解:
- 误解 A:笼子 = 限制大模型。✗ 错。笼子从不限制模型能说什么、能决定什么。
- 误解 B:笼子 = 多余的重型工程。✗ 错。笼子锁住的,恰好是上一章 Swarm 失控的地方。
看这一章的复刻,笼子锁住的是三样东西:
① 锁住拓扑------流程长什么样,是声明出来的。
上一章:想加一个节点,得改 while 循环里的逻辑。这一章:加一个 addNode、再连一条边。图的形状是数据,不是控制流,于是它可以被序列化、被可视化、被审查。生产系统要的"可审计",第一步就是这个。
② 锁住路由------"下一步去哪"变成显式函数。
条件边必须读 State 返回一个合法节点名。路由逻辑不再散落在 if 深处,而是一个能单独测试、单独替换的函数。注意,这个函数内部仍然可以是 LLM ------"让模型判断用户语气是抱怨还是夸奖,返回 complaint 还是 praise"就是一条合法条件边。笼子锁的是"路由这件事有明确的出口",不锁"出口由谁决定"。
③ 锁住写入------状态不再被随意覆盖。
context_variables 时代,任何一个函数都能写坏任何一个 key;messages 时代,历史只能靠 reducer 追加。共享状态的"写"从放任变成了规则。这直接解决了上一章那张"缺的东西"表里的"无状态机约束"。
于是我们终于可以对上开篇的宣言:
用确定性的图(Graph)画好边界,用确定性的节点(Node)限制行为,用确定性的状态机(FSM)硬性约束状态转移。
7. 刻意没做的部分(完整的 LangGraph)
做完 150 行的复刻,一个事实变得非常清晰:
核心原理就三件事 + 一个循环。剩下的一切,都是工程设施。
下面这些,是真实 LangGraph 有、而我们故意没做的部分。每一行的背后,都对应一个 Swarm 时代的真实痛点:
| 没做的设施 | 真实 LangGraph 里是什么 | 解决 Swarm 的什么痛点 |
|---|---|---|
| Checkpointer(持久化) | MemorySaver / PostgresSaver + thread_id,get_state / update_state / get_state_history |
会话状态只在内存、进程一挂全丢 |
| 时间旅行 | 基于 Checkpointer 的历史回放,update_state 改过去再重跑 |
长任务中途失败只能从头再来 |
| Human-in-the-loop | interrupt() 挂起图,人工审核后 Command(resume=...) 恢复 |
转账、删库这类高危操作无法卡人工 |
| 并行 super-step | 真实 Pregel 是 BSP:每步 Plan → Execute → Update,一步内节点并行、步间同步 | 上一章发现的"模型开了并行,引擎串行执行" |
| 单写限制 | LastValue 每步只允许一次写入,并行写同名 key 直接抛 InvalidUpdateError |
并发写状态互相踩踏 |
| 原子性 | 一步内任一节点失败,该步所有更新整体回滚(像数据库事务) | 半路崩溃留下脏状态 |
| 节点级 retry / timeout | 每个节点可配 retry_policy |
一次网络抖动毁掉整轮 |
| 流式更新 / subgraph / recursion limit | 增量输出、子图复用、递归深度熔断 | 响应不够实时、图不可复用、递归失控 |
这张表和上一章 Swarm 的"缺失清单"几乎是逐行对应 的。这不是巧合------LangGraph 就是照着这些痛点长出来的。
下面这条线,值得单独拎出来讲透:真实 LangGraph 九成的代码,都在做一件事------让"一次执行"变成"一笔可靠的事务"。
为什么会这样?因为 Agent 与普通程序最本质的区别,是它每执行一步都在和真实世界交互:调了工具、付了钱、改了数据。普通函数失败了可以就地重试;Agent 失败了,世界已经被它动过一遍------于是"怎么让一次长达几百步、跨小时、随时可能被打断的执行不至于半路全毁",就成了工程的核心命题。
而答案,恰恰是数据库早就给出过的答案。把 LangGraph 的设施清单和 ACID 逐条对照:
- A(原子性)------"一步内任一节点失败,该步所有更新整体回滚"。对象从"一行数据"换成了"一轮 Agent 决策",语义完全相同;
- C(一致性) ------reducer 保证 State 永远是合法的:
messages永远是列表、counter永远是数字,写不进非法值; - I(隔离性)------"单写限制" + "super-step 同步",就是并发控制,防止两个节点同时踩同一个 key;
- D(持久性) ------Checkpointer 把 State 落盘,进程死了、机器重启了,
thread_id一拉,从断点继续跑。
工业级 Agent 引擎的本质,就是给 AI 加上数据库级别的 ACID 事务保障。 模型负责生成"下一步做什么"的不确定性,框架负责让这段执行"要么完成、要么干净地回到起点"。150 行的核心证明了"图的骨架"可以有多简单;而这九成代码的存在,则证明了另一件事------简单的骨架 + 可靠的护城河,才是生产环境的全部真相。
读到这里,请回头再想一遍第 1 节的做法:我们用 150 行复刻了核心,这些设施一个没碰。但正因为核心是确定的、State 是唯一的、路径是可记录的,Checkpointer 只需要"把 State 存下来",HIL 只需要"在节点前挂起",并行只需要"把一步内可达的节点分给多个线程" ------它们都是在这个小核心上加装出来的,而不是重写。
8. 小结
Swarm 用 417 行证明了骨架是确定性的;LangGraph 用一张图告诉我们,这个确定性可以显式到什么程度 。而 150 行的最小复刻则证明了另一件事:你不需要读完整套生产级源码,也能亲手造出核心。
用确定性的图,兜住不确定性的模型------这句话到这里不再是口号,而是两章代码各自落地过一遍的结论。模型永远在边界内自由,图永远在边界外确定。