创建日期:2026-09-14 | 最近更新:2026-09-14 本文所有「执行轨迹」都是本机真实运行
@langchain/langgraph1.4.15 抓到的(streamMode: updates / values)。图解用 mermaid 渲染。小提示:搜
langgraphic是找不到的,正确名字是 LangGraph(@langchain/langgraph)——这可能也是你之前「看不懂」的一部分原因:资料没找对。
LangGraph 图解:一张图跑起来时,到底发生了什么
上一篇讲了「State / Node / Edge 三件套」,但概念堆在一起容易糊。这篇换个方式:先把图长什么样画出来,再把它跑起来时每一步的状态变化逐帧摊开,最后把最常见的那几个「没搞懂」逐条回答。
1. 先记住一句话
LangGraph 就是「一个带状态的循环执行器」:把若干函数(节点)用线(边)连起来,数据装在一个状态对象里在节点间传递;边决定「下一个跑谁」,条件边可以让它掉头回去再跑(这就是循环)。
- 节点:一个普通函数,
(state) => 部分state; - 边:控制流,表示「A 跑完跑 B」;
- 状态:一个对象,节点读它、也可以只返回要改的部分;
- START / END:虚拟起止点。
2. 最小例子:图长这样
START → inc → double → END,其中 inc 让 n 加一、double 让 n 翻倍。
代码对应的就是这三行连线:
import { StateGraph, Annotation, START, END } from '@langchain/langgraph';
// ① 状态长什么样(n 是数字,默认 0;reducer 决定“新值怎么盖旧值”)
const S = Annotation.Root({ n: Annotation({ reducer: (a, b) => b, default: () => 0 }) });
// ② 建图:加节点 + 连边
const g = new StateGraph(S)
.addNode('inc', (s) => ({ n: s.n + 1 })) // 注意:返回的是“增量”,不是完整状态
.addNode('double', (s) => ({ n: s.n * 2 }))
.addEdge(START, 'inc')
.addEdge('inc', 'double')
.addEdge('double', END)
.compile();
3. 把它跑起来:逐帧看状态
调 g.invoke({ n: 5 }) 时,框架内部是这样的(不是伪代码,是它的真实行为):
用官方流式接口把每一帧打出来(本机真实输出):
// streamMode: 'updates' —— 每步打印“节点返回的增量”
for await (const chunk of await g.stream({ n: 5 }, { streamMode: 'updates' })) {
console.log(JSON.stringify(chunk));
}
{"inc":{"n":6}} ← inc 节点返回 {n:6}
{"double":{"n":12}} ← double 节点返回 {n:12}
// streamMode: 'values' —— 每步打印“合并之后的完整状态”
for await (const chunk of await g.stream({ n: 5 }, { streamMode: 'values' })) {
console.log(JSON.stringify(chunk));
}
{"n":5} ← 输入
{"n":6} ← inc 合并后
{"n":12} ← double 合并后(= 最终结果)
看懂这两段,你就懂 LangGraph 了 80%:节点只交「差额」,状态合并由 reducer 完成,
values是每步的完整快照。最终await g.invoke(...)返回的就是最后的{"n":12}。
4. 循环:加一条「回边」而已
Agent 需要循环(调工具 → 再问模型 → 再调工具…)。在 LangGraph 里,循环不是新语法,只是让边指回前面:
代码就多了个 addConditionalEdges:
const L = new StateGraph(Annotation.Root({
n: Annotation({ reducer: (a, b) => b, default: () => 0 }),
log: Annotation({ reducer: (a, b) => a.concat(b), default: () => [] }), // ← 追加型
}))
.addNode('inc', (s) => ({ n: s.n + 1, log: [`inc:${s.n + 1}`] }))
.addNode('done', (s) => ({ log: [`done@${s.n}`] }))
.addEdge(START, 'inc')
.addConditionalEdges('inc', (s) => (s.n >= 3 ? 'done' : 'inc')) // ★ 返回“下一个节点名”
.addEdge('done', END)
.compile();
真实执行轨迹(streamMode: 'updates'):
{"inc":{"n":1,"log":["inc:1"]}}
{"inc":{"n":2,"log":["inc:2"]}}
{"inc":{"n":3,"log":["inc:3"]}} ← 同一个节点被跑了 3 次(循环!)
{"done":{"log":["done@3"]}}
两个关键点:
- 条件边的函数返回的是「下一个节点名」(字符串),不是布尔值——
s.n>=3 ? 'done' : 'inc'就是「够了去收尾,否则回去再来」; log用了追加 reducer,所以三条记录都在(inc:1/2/3);如果它也用(a,b)=>b,就只剩最后一条了。
⚠️ 循环必须有出口,否则会撞上
recursion_limit(默认 25 步)报错——和你手写while时加「最大轮数」是一个道理。
5. 记忆:状态存在图外面(按 thread_id)
没有记忆时,每次 invoke 都是全新的状态。加上 checkpointer,状态就按 thread_id 存起来,下次带着同一个 id 进来,状态接着上次:
真实输出:
第1轮: "好的,小林,我记住了。有什么需要随时说。"
第2轮: "你叫小林。"
累计消息条数: 4
要点:「记忆」不在图里,在图外面(checkpointer 里,按 thread_id 分桶);图本身只是「跑一次」的逻辑。MemorySaver 是内存实现,生产换持久化后端。
6. 逐条回答「你可能没搞懂的地方」
| 疑问 | 答案 |
|---|---|
| 节点返回什么? | 只返回要改的字段(增量),如 {n: 6};不用返回完整状态 |
| 谁负责合并? | reducer:你定义 (旧值, 新值) => 结果。默认语义是「覆盖」,要追加就 a.concat(b) |
| 边是数据流吗? | 不是,是控制流。数据在状态里,边只说「下一个跑谁」 |
| 条件边返回什么? | 下一个节点的名字(字符串),不是 true/false |
| START/END 是什么? | 虚拟节点,标出入口与出口 |
| 为什么叫 Graph 不叫 Chain? | 因为能回头(循环)、能分叉(条件边);链只能一路向前 |
| 图跑完状态去哪了? | 没 checkpointer 就丢了;有的话按 thread_id 存下来 |
invoke 和 stream 什么关系? | invoke = 跑完给最终状态;stream = 把中间每一步暴露给你(做进度/调试) |
| 一个节点会跑很多次吗? | 会(循环),所以节点函数要幂等/可重复执行,别在里面积累副作用 |
7. 把它和你手写的 Agent 循环对上
一手一图,是完全对应的:while 变成「回边」,messages 变量变成「带 reducer 的状态」,break 变成「条件边指向 END」。所以 createReactAgent 并不神秘——它就是这套图的预置版本。
8. 自测(答得上来就真懂了)
inc节点返回{n: 6}而不是完整状态,谁把它合并进去?用什么规则?streamMode: 'updates'和'values'打出来的东西差在哪?- 条件边的函数为什么返回字符串?
- 循环图里,同一个节点被执行了 3 次——这对节点函数的编写提了什么要求?
- 「记忆」存在图的里面还是外面?靠什么区分不同会话?
- 为什么链(Chain)做不了 Agent,而图可以?
关联
- 前置:LangGraph 入门:状态图与记忆 | 实战:ReAct Agent
- 手写对照:frontend-agent 系列