LangGraph.js 从零上手:把 Agent 工作流从一条线变成一张网

单 Agent 的 system prompt 越堆越长------所有工具的说明、所有功能的提示词全塞进去,可真正执行某个功能时,用到的可能只有一小部分。

拆成多 Agent 是解法。但拆完之后问题来了:谁来编排这些 Agent? 一条链走到底的线性编排不够用了,需要的是「网状」。

这篇用 5 个能直接跑的 demo,把 LangGraph.js 从最基础的图模型一路讲到中断与恢复------那是 Agent 敢碰真实业务的入场券。


一、先回答:为什么需要多 Agent

1.1 单 Agent 的 system prompt 会越来越臃肿

单 Agent 架构下,所有工具的说明、每个功能的 prompt 都堆在 system prompt 里。但实际执行某个功能时,只需要其中一部分,剩下全是陪跑。

陪跑的代价不只是 token 更贵。更要命的是后半句:

无关信息会干扰模型思考,让它效率更低、更容易出错。

拆成多 Agent 之后,每个 Agent 只保留自己需要的那部分 prompt。虽然调用 LLM 的次数变多了,但每次消耗的 token 更少,而且没有冗余信息干扰,准确率更高

理解这件事有个好用的公式:

ini 复制代码
Agent = LLM(大脑) + Harness(tool + mcp + rag + skill + ...)
  • 单 Agent:只有一个大脑,所有事都得一步步想、一步步调工具;
  • 多 Agent:多个大脑,可以并行思考。主 Agent 下发任务,子 Agent 并行处理完再返回。

而且每个大脑还能选适合自己的模型------便宜的干粗活,贵的干细活;Agent 本身也可以按需加载、动态加载。

1.2 拆成多 Agent 的三个理由

① 决策准确率更高,token 消耗更低

每个 Agent 只带必要的最少 prompt,没有冗余信息干扰。调用 LLM 的次数多了,但总量更省。

② 并行思考和任务处理

主管把任务拆开分派下去,子 Agent 同时干活,整体效率更高。

③ 多角色互相讨论,纠错能力更强

这是最容易被低估的一条。典型例子:写代码的 Agent 负责实现,测试 Agent 负责写测试用例(TDD),验证 Agent 负责检查代码是否符合预期,最后告诉主 Agent「通过了」。这种互相 review 的结构,纠错能力远强于单个模型自己检查自己。AutoGen 那套「法庭」式辩论也是同一个思路。

1.3 从 LangChain 到 LangGraph

LangChain LangGraph
定位 基础模块 + 线性工作流编排 网状工作流编排
提供什么 LLM API、Document Loaders、Splitter、Embedding、Vector Store、Output Parser、Memory... 工作节点 + 节点之间的组织方式
编排形态 线性的 网状的
能做什么 简单 Agent 复杂的多 Agent 协作

两者共享底层基础设施(LLM、工具、记忆都是同一套),区别在编排能力:LangChain 的链是一条线走到底,LangGraph 的图可以分支、可以循环、可以并行。

所以这条演进路线可以概括成一句话:简单 Agent → 复杂多 Agent 协作,编排方式从线性走向网状。


二、图模型的四要素:第一个能跑的工作流

LangGraph 的图模型非常简洁,就四样东西:

要素 是什么 在代码里
开始节点 入口,携带初始状态 START
工作节点 一个职责,读写状态 addNode("名字", 函数)
连接节点,决定往哪走 addEdge / addConditionalEdges
最终状态 结束,输出结果 END

直接把最小可运行的图写出来(src/basic-graph.mjs):

js 复制代码
import { Annotation, END, START, StateGraph } from '@langchain/langgraph';

// ① 声明状态:数据长什么样
const StateAnnotation = Annotation.Root({
  text: Annotation({
    // _prev 是来到当前节点之前的状态,next 是当前节点返回的状态
    // reducer 决定状态怎么变
    reducer: (_prev, next) => next,
    default: () => "",   // 默认值
  })
});

// ② 定义节点:函数就是节点,返回值是下一个节点的状态
const step1 = (state) => ({ text: `${state.text} -> step1` });
const step2 = (state) => ({ text: `${state.text} -> step2` });

// ③ 拼成图
const graph = new StateGraph(StateAnnotation)
  .addNode("step1", step1)
  .addNode("step2", step2)
  .addEdge(START, "step1")
  .addEdge("step1", "step2")
  .addEdge("step2", END)
  .compile();

// ④ 跑
console.log(await graph.invoke({ text: "hello" }));

输出:

rust 复制代码
{ text: 'hello -> step1 -> step2' }

四段式------声明状态 → 定义节点 → 连边 → invoke,后面所有复杂的图都是在这上面加东西。

2.1 状态:reducer 决定怎么变,default 决定从哪开始

js 复制代码
text: Annotation({
  reducer: (_prev, next) => next,
  default: () => "",
})
  • reducer :状态的合并规则。(_prev, next) => next 表示用新值直接覆盖;想累加就写成 (prev, next) => prev + next(这个名字确实取自 JS 数组的 reduce------把一串更新「消消乐」成一个最终状态)。
  • default :初始值。注意它必须是个工厂函数 ,写成 default: "" 会直接报错。

2.2 节点就是函数

step1step2 都是普通函数,收 state、返回要更新的字段(不用返回完整 state,只需要返回你想改的那部分)。这个设计让节点之间的耦合非常低------加一个节点,你只要关心它读什么、写什么。

2.3 顺手把图画出来

LangGraph 内置了可视化能力,drawMermaid() 会吐出 Mermaid 文本:

js 复制代码
const drawable = await graph.getGraphAsync();
console.log(drawable.drawMermaid({ withStyles: true }));
%%{init: {'flowchart': {'curve': 'linear'}}}%% graph TD; __start__([<p>__start__</p>]):::first step1(step1) step2(step2) __end__([<p>__end__</p>]):::last __start__ --> step1; step1 --> step2; step2 --> __end__; classDef default fill:#f2f0ff,line-height:1.2; classDef first fill-opacity:0; classDef last fill:#bfb6fc;

这个小功能在调复杂图的时候非常救命------节点一多,光靠读代码很难确认连线有没有连错,画出来一眼就看到了


三、网状的两大能力:分支与循环

「网状」不是随便说说,它落在两个具体能力上。

3.1 分支:addConditionalEdges

用一个路由节点决定下一步走哪(src/conditional-routing.mjs):

js 复制代码
const router = (state) => {
  const isMath = /[+\-*/]/.test(state.query);
  // 下一步怎么走?是数学问题走 math,否则走 chat
  return { route: isMath ? "math" : "chat" };
};

const mathNode = (state) => {
  try {
    return { answer: String(eval(state.query)) };   // "1+2" -> 3 -> "3"
  } catch {
    return { answer: "表达式无法计算" };
  }
};

const chatNode = (state) => ({ answer: `你说的是:${state.query}` });

const graph = new StateGraph(StateAnnotation)
  .addNode("router", router)
  .addNode("math", mathNode)
  .addNode("chat", chatNode)
  .addEdge(START, "router")
  // 条件跳转:按 state.route 的值决定走哪个分支
  .addConditionalEdges("router", (state) => state.route, {
    math: "math",
    chat: "chat"
  })
  .addEdge("math", END)
  .addEdge("chat", END)
  .compile();

跑两次,走的是两条不同的路:

css 复制代码
result: { query: '你好', route: 'chat', answer: '你说的是:你好' }
result: { query: '1+2', route: 'math', answer: '3' }

画出来能直接看到那个分叉:

%%{init: {'flowchart': {'curve': 'linear'}}}%% graph TD; __start__([<p>__start__</p>]):::first router(router) math(math) chat(chat) __end__([<p>__end__</p>]):::last __start__ --> router; chat --> __end__; math --> __end__; router -.-> math; router -.-> chat; classDef default fill:#f2f0ff,line-height:1.2; classDef first fill-opacity:0; classDef last fill:#bfb6fc;

addConditionalEdges 三个参数要看清:

js 复制代码
.addConditionalEdges(
  "router",              // 从哪个节点出发
  (state) => state.route, // 返回一个「key」
  { math: "math", chat: "chat" }  // key → 真实节点名的映射表
)

第二个参数返回的不是节点名,而是一个 key,真正的映射交给第三个参数。多一层看似啰嗦,实则方便------路由函数不用知道节点叫什么名字,判断逻辑和拓扑结构解耦了

3.2 循环:把条件边指回自己

把条件边的目标指回上游节点,就形成了重试(src/loop-retry.mjs):

js 复制代码
const attempt = (state) => {
  const tries = state.tries + 1;
  const ok = tries >= 3;                 // 第 3 次才成功
  return {
    tries,
    ok,
    message: ok ? `第${tries}次成功` : `第${tries}次失败`
  };
};

const graph = new StateGraph(StateAnnotation)
  .addNode("attempt", attempt)
  .addEdge(START, "attempt")
  .addConditionalEdges("attempt", (state) => state.ok ? "done" : "retry", {
    retry: "attempt",   // ← 指回自己,这就是循环
    done: END
  })
  .compile();

console.log(await graph.invoke({ tries: 0 }));
yaml 复制代码
result: { tries: 3, ok: true, message: '第3次成功' }
%%{init: {'flowchart': {'curve': 'linear'}}}%% graph TD; __start__([<p>__start__</p>]):::first attempt(attempt) __end__([<p>__end__</p>]):::last __start__ --> attempt; attempt -. &nbsp;done&nbsp; .-> __end__; attempt -. &nbsp;retry&nbsp; .-> attempt; classDef default fill:#f2f0ff,line-height:1.2; classDef first fill-opacity:0; classDef last fill:#bfb6fc;

那条 attempt -. retry .-> attempt 的自环,就是 LangGraph 相比 LangChain 最本质的差别:链可以无限长,但永远回不了头;图能回头。

Agent 场景里这条自环太常用了------工具调用失败要重试、模型输出不合格要重新生成、代码跑不过测试要改到过为止,全都是循环。

3.3 顺手一个坑:eval()

数学节点里用了 eval()------它会把传入的字符串当作 JS 代码执行,返回执行结果。eval("1+2") 就是 3,在 demo 里省事得很。

生产环境千万别对用户输入用 eval(),那等于把任意代码执行权直接交出去。本文为了 demo 简洁保留了它,正式代码请换成安全的表达式解析器。


四、状态持久化:checkpointer 与 thread_id

到这里图能跑、能分支、能循环了,但还有一个致命问题:每次 invoke 都是全新的开始,状态活在内存里,跑完就没。

这直接堵死了后面要说的一切------Agent 执行中断了、失败了、要暂停等授权,进程一停状态全没,恢复时只能从头再来。

LangGraph 的解法是给图配一个 checkpointer(存档器) 。最简单的实现是 MemorySaversrc/checkpointer-memory.mjs):

js 复制代码
const graph = new StateGraph(StateAnnotation)
  .addNode("recordVisit", recordVisit)
  .addEdge(START, "recordVisit")
  .addEdge("recordVisit", END)

const checkpointer = new MemorySaver();
const app = graph.compile({ checkpointer });   // ← 编译时挂上

// 两个用户,两条独立线程
const user1 = { configurable: { thread_id: "用户-小张" } };
const user2 = { configurable: { thread_id: "用户-小李" } };

console.log(await app.invoke({}, user1));   // 第1次
console.log(await app.invoke({}, user1));   // 第2次
console.log(await app.invoke({}, user1));   // 第3次
console.log(await app.invoke({}, user2));   // 另一个人,从第1次开始

真实输出:

css 复制代码
{ visitCount: 1, message: '这是你在本会话里第1次进入。' }
{ visitCount: 2, message: '这是你在本会话里2次进入' }
{ visitCount: 3, message: '这是你在本会话里3次进入' }
{ visitCount: 1, message: '这是你在本会话里第1次进入。' }

小张连着进三次,计数一路累加;小李是另一份独立记录,从 1 开始。

关键在 thread_id------它就是「会话/线程」的身份证:

js 复制代码
const config = { configurable: { thread_id: "会话ID" } };

一句话记住这两个概念的分工:checkpointer 决定状态存在哪,thread_id 决定状态属于谁。

同一个 thread_id 的状态会续上,不同 thread_id 互相隔离------这就是多用户、多会话的基础。


五、中断与恢复:让 Agent 停下来等你确认

5.1 什么场景需要「停下来」

scss 复制代码
用户:帮我给张三转 100 块
Agent 内心:识别到转账意图 → 准备执行 transfer("张三", 100)

这一步能直接执行吗?不能。 转账不可逆,模型理解错一个字代价就是真金白银。所以在真正扣款前,必须有人点头。

场景 为什么要中断
转账、下单、删除数据 不可逆操作,要人确认
调用付费 API、跑大规模任务 成本高,要授权
修改生产环境配置 风险高,要审批
触发敏感工具 合规要求,要人工介入

这些场景的共同点是:流程不是一口气跑完的,中间必须停下来等一个外部输入,而这个输入可能要等很久------几秒、几小时、甚至几天。

这就是 Harness 这一层最难做对的部分:

暂停时,当前执行到哪、状态是什么,必须被完整保存下来;恢复时,要能从暂停的那一点继续,而不是从头再来。

这也是为什么第四节要先讲持久化------没有 checkpointer,就没有中断恢复

5.2 图长什么样

完整例子在 src/graph-interrupt.mjs

sql 复制代码
START → showTransfer(展示待办)→ waitConfirm(等待确认)→ END
%%{init: {'flowchart': {'curve': 'linear'}}}%% graph TD; __start__([<p>__start__</p>]):::first showTransfer(showTransfer) waitConfirm(waitConfirm) __end__([<p>__end__</p>]):::last __start__ --> showTransfer; showTransfer --> waitConfirm; waitConfirm --> __end__; classDef default fill:#f2f0ff,line-height:1.2; classDef first fill-opacity:0; classDef last fill:#bfb6fc;

两个节点各司其职:

js 复制代码
// 节点一:准备待确认的动作
const showTransfer = () => ({
  actionSummary: "向张三转账 $100"
});

// 节点二:在这里中断,等用户输入
const waitConfirm = (state) => {
  const text = interrupt({                          // ← 中断点
    hint: "终端里输入[确认]或者备注后回车,图才会继续",
    actionSummary: state.actionSummary
  });
  return { userInput: String(text) };                // ← 恢复后拿到输入
};

const graph = new StateGraph(StateAnnotation)
  .addNode("showTransfer", showTransfer)
  .addNode("waitConfirm", waitConfirm)
  .addEdge(START, "showTransfer")
  .addEdge("showTransfer", "waitConfirm")
  .addEdge("waitConfirm", END)
  .compile({ checkpointer: new MemorySaver() });     // ← 必须挂 checkpointer

5.3 interrupt() 做了三件事

第一,它让图停下来。 调用 interrupt() 的节点立刻挂起,图不再往下走。注意------这不是报错,是正常的暂停 ,别用 try/catch 去接它。

第二,它把信息带出去。 传给 interrupt() 的参数会作为中断的载荷返回给调用方:

js 复制代码
const config = { configurable: { thread_id: "interrupt-demo" } };
const paused = await graph.invoke({}, config);
console.log("待你确认", paused.__interrupt__?.[0]?.value);
css 复制代码
待你确认 { hint: '终端里输入[确认]或者备注后回车,图才会继续', actionSummary: '向张三转账 $100' }

返回结果里带着一个 __interrupt__ 数组,[0].value 就是你传进去的那份数据。前端就是靠它渲染出「确认弹窗 / 卡片」的。

第三,它会记住自己停在哪。 这正是 compile() 必须挂 checkpointer 的原因:

js 复制代码
.compile()                                    // ❌ 运行直接抛 GraphValueError: No checkpointer set
.compile({ checkpointer: new MemorySaver() }) // ✅

5.4 interrupt() 的返回值

这是最容易忽略的一点。再看 waitConfirm

js 复制代码
const text = interrupt({ ... });
return { userInput: String(text) };

interrupt() 有返回值 ,返回值就是恢复时传进来的那份数据 。也就是说,同一个节点会经历两个阶段:第一次执行到 interrupt() 停下来;恢复后这个节点会从头重新执行 ,这次 interrupt() 直接返回你给的输入,代码继续往下走。

这个设计很巧妙:

你不需要额外写「恢复之后该做什么」的逻辑,把恢复后的处理直接写在 interrupt() 后面就行。

5.5 恢复:Command({ resume })

用户输入完之后,用 Command 把它送回图里:

js 复制代码
const rl = createInterface({ input: process.stdin, output: process.stdout });
const line = (await rl.question("> ")).trim();
await rl.close();

const done = await graph.invoke(new Command({ resume: line }), config);
console.log("done:", done);
说明
Command 命令对象,告诉图「这次不是新的一轮,而是带着指令回来」
resume 恢复时携带的数据,会成为 interrupt() 的返回值
config 必须是同一个 thread_id

完整跑一遍:

css 复制代码
待你确认 { hint: '终端里输入[确认]或者备注后回车,图才会继续', actionSummary: '向张三转账 $100' }
> 你输入了 确认
done: { actionSummary: '向张三转账 $100', userInput: '确认' }

图从中断点接上了,用户的输入被写进了状态。这就是一个「Agent 停下来问人,人回答完它接着干」的完整闭环。


六、再往前一步:把状态存到数据库

MemorySaver 有个明显的局限:它存在进程内存里。进程一重启,所有会话状态全丢------对「等用户明天来确认」这种场景完全不可接受。

生产环境要换成持久化的 checkpointer:

存储 适用
MemorySaver 本地开发、demo、单元测试
SQLite 单机部署、中小规模,文件型数据库
Redis 多实例部署、需要高性能读写和过期策略

换起来很简单------中断恢复的业务代码一行都不用改,只换 compile() 里那个 checkpointer 实例

这也再次印证了那个分层:

图描述「做什么」,checkpointer 决定「状态存在哪」。 两者解耦,所以能从内存一路平滑升级到数据库。


七、踩坑清单(都是真跑出来的)

这一节是我在写 demo 时实际踩到、并且逐个验证过的,比前面任何一段都值得细看。

7.1 defaultValue 是静默失效的

状态默认值要写 default不是 defaultValue

js 复制代码
tries: Annotation({
  reducer: (_prev, next) => next,
  defaultValue: 0,      // ❌ 不报错,但被静默忽略
  default: () => 0,     // ✅ 而且必须是工厂函数
})

坑就坑在它不报错defaultValue 会被直接无视,state.triesundefined------然后 undefined + 1 = NaN,整个判断逻辑悄悄失灵。

顺带一提,default 写成 default: 0(不是函数)会直接抛 initialValueFactory is not a function。安全那种写法反而不会。

7.2 循环图没有终止条件 → GraphRecursionError

上面那个静默失效的 defaultValue 在循环图里后果被放大。因为 tries 永远是 NaNok 永远是 false,图就会一直 retry 下去。这时候你会看到:

vbnet 复制代码
GraphRecursionError: Recursion limit of 25 reached without hitting a stop condition.
You can increase the limit by setting the "recursionLimit" config key.

LangGraph 默认的递归上限是 25 步,是个保护机制,防止图跑飞。所以:

  • 每写一个循环,先确认它的终止条件真的会成立
  • 循环次数确实需要超过 25 时,用 recursionLimit 配置调大:
js 复制代码
await graph.invoke({ ... }, { recursionLimit: 100 });

7.3 恢复时 thread_id 不一致:不是从头重跑,而是静默拿不到存档

很多文章(包括我第一版的理解)会说「thread_id 不一致 = 新会话 = 从头执行」。实测下来不是这样

css 复制代码
中断 thread_id = "A",用 thread_id = "B" 去 resume
→ 返回 { actionSummary: '', userInput: '' }

showTransfer 没有 重新执行,图也没有报错,你传进去的 resume 数据被静默丢弃了,拿回来的是一个空状态(如果那条线程本来有存档,就是它上次的最终状态)。

这比「从头重跑」更危险------从头重跑你一眼就看得出来,静默失败你根本发现不了。所以实践里一定这么写:

js 复制代码
const config = { configurable: { thread_id: sessionId } };  // 抽成变量,两处共用

7.4 被中断的节点会从头重新执行

这个我在 demo 里专门打了日志验证:

css 复制代码
第1次 invoke:
  [waitConfirm 开始执行, 副作用发生了]
 -> { __interrupt__: [ ... ] }

resume:
  [waitConfirm 开始执行, 副作用发生了]   ← 又来了一次
  [interrupt 返回了, 继续往下走]
 -> { log: '|after-interrupt', userInput: '确认' }

「副作用发生了」打印了两次------被中断的节点确实整个重跑了一遍 ,包括 interrupt() 之前的代码。

所以有一条铁律:

interrupt() 之前的代码必须能安全地重复执行。 别在里面写扣款、发消息、写库这类有副作用的操作------第一次执行到 interrupt() 时虽然会挂起,但恢复时这段代码会重新跑。

正确的做法是把副作用放到 interrupt() 之后,或者放到一个独立的、不会重跑的节点里。

7.5 中断不是异常

interrupt()正常的流程控制 ,不是抛错。别用 try/catch 去接它,也别当成失败处理------它抛出来的 __interrupt__ 是设计好的返回值,不是错误。

7.6 eval() 别用在用户输入上

前面提过,这里再强调一次:demo 里用它算数学题很省事,但用户输入 + eval() = 任意代码执行漏洞,正式代码请换成安全的表达式解析器。


八、总结

把这篇串成一条线:

  1. 复杂 Agent 走向多 Agent,因为单 Agent 的 system prompt 里塞了太多用不上的信息,既费 token 又干扰判断;拆开之后每个 Agent 只带必要的 prompt,更准也更省。多 Agent 需要网状编排,于是从 LangChain 走向了 LangGraph。
  2. LangGraph 的图就四要素:开始节点、工作节点、边、最终状态。四段式写法是「声明状态 → 定义节点 → 连边 → invoke」。
  3. 状态靠 Annotation 声明reducer 决定怎么合并(记住是 default 不是 defaultValue),default 给初始值且必须是工厂函数。
  4. 网状的两大能力addConditionalEdges 做分支,条件边指回自己就是循环------这是 LangGraph 相比 LangChain 最本质的差别。
  5. 持久化的分工checkpointer 决定状态存在哪,thread_id 决定状态属于谁。
  6. 中断用 interrupt() :图挂起,载荷通过 __interrupt__ 带出去给前端渲染确认卡片;恢复用 new Command({ resume }) ,同一个 thread_id,图就从停下的地方接着跑。
  7. interrupt() 的返回值就是 resume 传进来的数据,恢复后的处理逻辑直接写在它后面即可------但要注意被中断的节点会整个重跑,前面的代码不能有副作用。
  8. MemorySaver 只适合开发,生产要换 SQLite / Redis;好在业务代码不用动,只换 checkpointer 实例。

最后记一句

一个能自动跑的 Agent 已经不容易了,但真正让它能用于生产的,是「该停的时候停得住,该继续的时候接得上」。

中断与恢复不是锦上添花的功能,而是 Agent 敢碰真实业务的入场券。


本文的 5 个 demo 都在 langgraph-test/src 下(basic-graph.mjsconditional-routing.mjsloop-retry.mjscheckpointer-memory.mjsgraph-interrupt.mjs),装好依赖后 node src/xxx.mjs 直接就能跑。

如果你也在做 Agent 落地,希望这篇帮你把「编排 → 状态 → 持久化 → 中断恢复」这条链路理清楚。有问题欢迎评论区交流 👋

相关推荐
默_笙1 小时前
⚓ AI 的"官方答题卡":withStructuredOutput 与结构化输出的终局之战
前端·javascript
我家猫叫佩奇2 小时前
🦭 厌倦了千篇一律的线性图标?Naive Icons 正式开源
前端·javascript·css
梦诺5 小时前
vue3 keepAlive+记录滚动条
前端·javascript·vue.js
天若有情6736 小时前
开源轻量双语工具|一键批量查询 NPM 包历史下载量,支持按作者批量统计
javascript·npm·github pages·开源工具·netlify·前端开源·npm克隆量
福兮说7 小时前
纯前端把图片压缩到指定体积:canvas.toBlob 配合二分查找
前端·javascript·canvas·图片处理
志尊宝7 小时前
Vue3 零基础每日笔记(018):按键与鼠标修饰符——回车搜索、Ctrl+S 保存这样写
javascript·vue.js·笔记
可乐鸡翅yeah_8 小时前
HLS流媒体首屏起播慢深度优化,从分片、索引、播放器全链路调优
开发语言·javascript·ecmascript·m3u8·m3u8在线
开开心心就好9 小时前
低年级识字练笔顺工具,电脑和安卓端都能用
前端·javascript·网络·安全·scala·erlang·语音识别
杨利杰YJlio9 小时前
ITSK PE 26U5 测试版解读:组件完善、VMD 修复与服务器支持边界
前端·javascript·后端