跳到主要内容

创建日期:2026-09-14 | 最近更新:2026-09-14 本文所有「执行轨迹」都是本机真实运行 @langchain/langgraph 1.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"]}}

两个关键点:

  1. 条件边的函数返回的是「下一个节点名」(字符串),不是布尔值——s.n>=3 ? 'done' : 'inc' 就是「够了去收尾,否则回去再来」;
  2. 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 存下来
invokestream 什么关系?invoke = 跑完给最终状态;stream = 把中间每一步暴露给你(做进度/调试)
一个节点会跑很多次吗?会(循环),所以节点函数要幂等/可重复执行,别在里面积累副作用

7. 把它和你手写的 Agent 循环对上

一手一图,是完全对应的while 变成「回边」,messages 变量变成「带 reducer 的状态」,break 变成「条件边指向 END」。所以 createReactAgent 并不神秘——它就是这套图的预置版本。

8. 自测(答得上来就真懂了)

  1. inc 节点返回 {n: 6} 而不是完整状态,谁把它合并进去?用什么规则?
  2. streamMode: 'updates''values' 打出来的东西差在哪?
  3. 条件边的函数为什么返回字符串?
  4. 循环图里,同一个节点被执行了 3 次——这对节点函数的编写提了什么要求?
  5. 「记忆」存在图的里面还是外面?靠什么区分不同会话?
  6. 为什么链(Chain)做不了 Agent,而图可以?

关联