第 11 章 · 从 LangChain 迁移到 LangGraph
🎯 本章目标
- 认清现实:你已经写过的 LCEL 链,一行都不用推倒重来
- 看懂「链」和「图」在结构上的真实差异(把两边导出的 Mermaid 并排比一比)
- 掌握最省力的迁移路径:把现成的
Runnable直接当节点挂进图 - 分清该迁的四个信号(循环 / 中断 / 记忆 / 并行),每条亲手跑一遍
- 也分清不该迁的信号:直线、无状态、无中断的简单链,留在 LangChain 更好
- 拿到一张「LCEL 概念 → LangGraph 对应物」的迁移映射表,对着改就行
本章代码
📌 代码约定:正文代码块都可直接运行(含 import 与模型初始化)。所有输出都是真实运行结果,不是编造。
关于依赖:本项目只装了
@langchain/core、@langchain/openai、@langchain/langgraph,没有langchain/@langchain/classic。所以本章「迁移前」的 LCEL 代码全部用@langchain/core的基础件拼装 —— 它们与langchain包同名导出同源,只是导入路径更底层。
| 文件 | 内容 |
|---|---|
code/01-lcel-baseline.ts | 迁移前:一条真实的 LCEL 链(prompt → model → parser → branch) |
code/02-same-as-graph.ts | 同一需求改写成 StateGraph,并排打印两边结构 |
code/03-chain-as-node.ts | 最省力:把现成的链当节点挂进图(老代码 + 新图混合) |
code/04-when-to-migrate.ts | 该迁的四个信号:循环 / 中断 / 记忆 / 并行 |
code/05-when-not-to-migrate.ts | 别迁的信号:LCEL 3 行 vs 图 15 行,诚实取舍 |
一、为什么专门有一章讲「迁移」
上一本书《LangChain.js 中文入门》里,你一定写过这样的代码:
typescript
const chain = prompt.pipe(model).pipe(new StringOutputParser());
const answer = await chain.invoke({ question });现在你想用 LangGraph 的图来编排,第一个问题通常是:「我这一堆链是不是要全部重写?」
答案是:不用。 恰恰相反 —— LangGraph 的节点本来就接受 Runnable。你可以在图里直接调用老链,一行都不用改。
本章解决三件事:看清楚差异(示例 1、2)、怎么改最省力(示例 3)、要不要改(示例 4、5)。
💡 一句话原则:LangGraph 不排斥 LangChain。链负责「单步怎么调模型」,图负责「多步之间怎么连」。二者是配合关系,不是替代关系。
二、迁移前的样子:一条真实的 LCEL 链
需求:电商客服系统,把用户工单先分类(技术 / 账单 / 其他),再按分类分流,最后整理成答复。这正是链最擅长的形态:输入确定、步骤固定、一条线走到底。
示例 1:一条 LCEL 链
typescript
/**
* 示例 1:迁移前 —— 一条 LCEL 链(客服工单分流)
* 需求:把用户工单分类(技术/账单/其他),再按分类分流,最后整理成答复。
* 形态:prompt → model → StringOutputParser → RunnableBranch → 分支整理。
* 特点:一行 pipe 到底、步骤固定、无循环、无状态。
*
* 本项目只装了 @langchain/core(没装 langchain / @langchain/classic),
* 所以下面全部用 @langchain/core 的基础件拼装 —— 与 langchain 包同名导出同源。
*/
import {
RunnableSequence,
RunnableLambda,
RunnableBranch,
RunnablePassthrough,
} from "@langchain/core/runnables";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { createModel, banner } from "../../lib/model.js";
import { assertChatKey } from "../../lib/env.js";
// 链里流动的两个字段:原始工单 + 模型分类结果
interface RoutedState {
ticket: string;
classified: string;
}
// 把多行文本压成一行,方便塞进最终答复
const oneLine = (s: string) => s.replace(/\s*\n\s*/g, " | ").trim();
async function main() {
banner("示例 1:迁移前 —— 一条 LCEL 链(客服工单分流)");
assertChatKey();
const model = createModel({ temperature: 0 });
const parser = new StringOutputParser();
// 第 1 步:让模型给工单分类
const classifyPrompt = ChatPromptTemplate.fromMessages([
[
"system",
`你是电商客服的工单分类助手。把用户工单归入【技术】【账单】【其他】三类之一。
严格按下面两行的格式输出,不要任何多余内容:
分类:<技术 或 账单 或 其他>
处理:<一句话处理建议>`,
],
["human", "工单内容:{ticket}"],
]);
const classifyChain = classifyPrompt.pipe(model).pipe(parser);
// 第 2 步:根据分类走不同分支(每个分支都是一个 RunnableLambda)
const techBranch = RunnableLambda.from(
(s: RoutedState) =>
`【分流 → 技术支持组】\n原始工单:${s.ticket}\n模型判断:${oneLine(
s.classified
)}\n处理结果:已转交技术支持组,预计 2 小时内响应。`
);
const billingBranch = RunnableLambda.from(
(s: RoutedState) =>
`【分流 → 财务账单组】\n原始工单:${s.ticket}\n模型判断:${oneLine(
s.classified
)}\n处理结果:已转交财务账单组,将在 1 个工作日内核对扣款。`
);
const otherBranch = RunnableLambda.from(
(s: RoutedState) =>
`【分流 → 人工客服】\n原始工单:${s.ticket}\n模型判断:${oneLine(
s.classified
)}\n处理结果:已转交人工客服,请留意客服来电。`
);
// RunnableBranch:按顺序判断条件,命中即执行对应分支,都不命中走最后默认分支
const route = RunnableBranch.from([
[(s: RoutedState) => s.classified.includes("技术"), techBranch],
[(s: RoutedState) => s.classified.includes("账单"), billingBranch],
otherBranch,
]);
// 把整条链拼起来:RunnablePassthrough.assign 保留 ticket,同时挂上 classified
const chain = RunnableSequence.from([
RunnablePassthrough.assign({ classified: classifyChain }),
route,
]);
// 真实跑几条工单
const tickets = [
"上个月我被重复扣了两次会员费,麻烦查一下并退款。",
"App 每次点开都闪退,卸载重装也没用。",
"请问你们的线下门店周末营业吗?",
];
for (const ticket of tickets) {
console.log("─".repeat(70));
console.log(`👤 工单:${ticket}\n`);
console.log(`📨 处理结果:\n${await chain.invoke({ ticket })}\n`);
}
// 看一眼这条链的「结构」:它确实只是一条直线
console.log("─".repeat(70));
console.log("🔎 这条链内部由哪些步骤组成(chain.steps):");
const steps = (chain as unknown as { steps: Array<{ getName(): string }> }).steps;
steps.forEach((s, i) => console.log(` ${i + 1}. ${s.getName()}`));
console.log("\n👉 链是一条「直线」——没有回头路、没有分叉后重新汇合、没有暂停。");
}
main().catch((e) => {
console.error("❌ 调用失败:", e);
process.exit(1);
});运行:
bash
npx tsx 11-from-langchain/code/01-lcel-baseline.ts真实输出:
========================================================================
示例 1:迁移前 —— 一条 LCEL 链(客服工单分流)
========================================================================
──────────────────────────────────────────────────────────────────────
👤 工单:上个月我被重复扣了两次会员费,麻烦查一下并退款。
📨 处理结果:
【分流 → 财务账单组】
原始工单:上个月我被重复扣了两次会员费,麻烦查一下并退款。
模型判断:分类:账单 | 处理:请用户提供订单号与扣款时间,核对是否重复扣费,确认后按流程提交退款并回复处理时限。
处理结果:已转交财务账单组,将在 1 个工作日内核对扣款。
──────────────────────────────────────────────────────────────────────
👤 工单:App 每次点开都闪退,卸载重装也没用。
📨 处理结果:
【分流 → 技术支持组】
原始工单:App 每次点开都闪退,卸载重装也没用。
模型判断:分类:技术 | 处理:建议用户提供手机型号与系统版本,转技术团队排查闪退日志并跟进修复。
处理结果:已转交技术支持组,预计 2 小时内响应。
──────────────────────────────────────────────────────────────────────
👤 工单:请问你们的线下门店周末营业吗?
📨 处理结果:
【分流 → 人工客服】
原始工单:请问你们的线下门店周末营业吗?
模型判断:分类:其他 | 处理:告知线下门店周末营业时间,建议提供具体门店营业信息或引导用户查询门店详情页面。
处理结果:已转交人工客服,请留意客服来电。
──────────────────────────────────────────────────────────────────────
🔎 这条链内部由哪些步骤组成(chain.steps):
1. RunnableAssign
2. RunnableBranch
👉 链是一条「直线」——没有回头路、没有分叉后重新汇合、没有暂停。用到了哪些「基础件」
| 基础件 | 作用 | 从哪来 |
|---|---|---|
ChatPromptTemplate | 把 {ticket} 填进提示词,产出 messages | @langchain/core/prompts |
ChatOpenAI | 调用大模型 | @langchain/openai(本示例封装为 createModel) |
StringOutputParser | 把模型返回的 AIMessage 取成字符串 | @langchain/core/output_parsers |
RunnableLambda | 把普通函数变成长链里的一环 | @langchain/core/runnables |
RunnableBranch | 按条件选分支(链里唯一的"分叉") | @langchain/core/runnables |
RunnablePassthrough | 原样带上输入字段(assign 加字段) | @langchain/core/runnables |
RunnableSequence / .pipe() | 把各步串起来 | @langchain/core/runnables |
⚠️ 坑 1(v1 导入路径变更):v0.x 时代这些基础件大多从
"langchain"导入;到 v1,langchain包变成了"上层封装",基础件统一挪到了@langchain/core的子路径。照着老教程写import { RunnableSequence } from "langchain"而项目没装langchain,会直接报模块找不到。用@langchain/core/runnables就对了。
注意最后两行:整条链在框架眼里只有两个"步骤",首尾相连成一条线。没有"回到上一步"、没有"等一会儿"、没有"记住上一次" —— 这三个"没有",就是后面要迁移的全部理由。
三、同一需求改成图,并把结构并排比一比
把完全一样的需求改写成 StateGraph,然后把两边的结构都导出成 Mermaid 并排看。
示例 2:同一需求 + 结构对比
typescript
/**
* 示例 2:同一需求改写为 StateGraph,并把两边的「结构」并排打印出来
* ① 链导出结构:chain.getGraph().drawMermaid()(core v1 里都是同步的)
* ② 图导出结构:await graph.getGraphAsync(),再 .drawMermaid()
* ③ 把两段 Mermaid 并排一比,就知道「链 vs 图」差在哪
*/
import {
RunnableSequence,
RunnableLambda,
RunnableBranch,
RunnablePassthrough,
} from "@langchain/core/runnables";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { StateGraph, START, END, Annotation } from "@langchain/langgraph";
import { createModel, banner } from "../../lib/model.js";
import { assertChatKey } from "../../lib/env.js";
const oneLine = (s: string) => s.replace(/\s*\n\s*/g, " | ").trim();
// 两版共享的模型与提示词
const model = createModel({ temperature: 0 });
const parser = new StringOutputParser();
const classifyPrompt = ChatPromptTemplate.fromMessages([
[
"system",
`你是电商客服的工单分类助手。把用户工单归入【技术】【账单】【其他】三类之一。
严格按下面两行的格式输出:
分类:<技术 或 账单 或 其他>
处理:<一句话处理建议>`,
],
["human", "工单内容:{ticket}"],
]);
interface RoutedState {
ticket: string;
classified: string;
}
// ══════════════════════════════════════════════════════════════════
// 第一部分:迁移前那条 LCEL 链(同示例 1)
// ══════════════════════════════════════════════════════════════════
function buildLcelChain() {
const classifyChain = classifyPrompt.pipe(model).pipe(parser);
const mk = (label: string) =>
RunnableLambda.from((s: RoutedState) => `【${label}】${oneLine(s.classified)}`);
const route = RunnableBranch.from([
[(s: RoutedState) => s.classified.includes("技术"), mk("技术支持组")],
[(s: RoutedState) => s.classified.includes("账单"), mk("财务账单组")],
mk("人工客服"),
]);
return RunnableSequence.from([
RunnablePassthrough.assign({ classified: classifyChain }),
route,
]);
}
// ══════════════════════════════════════════════════════════════════
// 第二部分:同一需求,改成 StateGraph
// ══════════════════════════════════════════════════════════════════
const TicketState = Annotation.Root({
ticket: Annotation<string>,
classified: Annotation<string>,
reply: Annotation<string>,
});
// 条件边:读 classified,决定下一步去哪个节点
function routeByCategory(
state: typeof TicketState.State
): "tech" | "billing" | "other" {
if (state.classified.includes("技术")) return "tech";
if (state.classified.includes("账单")) return "billing";
return "other";
}
function buildGraph() {
// 节点 1:分类(节点内部照旧用 prompt → model → parser)
async function classifyNode(state: typeof TicketState.State) {
const messages = await classifyPrompt.formatMessages({ ticket: state.ticket });
return { classified: await parser.invoke(await model.invoke(messages)) };
}
const makeBranch =
(label: string) => (state: typeof TicketState.State) => ({
reply: `【${label}】${oneLine(state.classified)}`,
});
return new StateGraph(TicketState)
.addNode("classify", classifyNode)
.addNode("tech", makeBranch("技术支持组"))
.addNode("billing", makeBranch("财务账单组"))
.addNode("other", makeBranch("人工客服"))
.addEdge(START, "classify")
.addConditionalEdges("classify", routeByCategory, ["tech", "billing", "other"])
.addEdge("tech", END)
.addEdge("billing", END)
.addEdge("other", END)
.compile();
}
async function main() {
banner("示例 2:同一需求改写成 StateGraph + 结构对比");
assertChatKey();
const chain = buildLcelChain();
const graph = buildGraph();
// ① 链的结构:getGraph() 与 drawMermaid() 在 core v1 里都是「同步」的
const chainDrawable = chain.getGraph();
const chainMermaid = chainDrawable.drawMermaid();
// ② 图的结构:用 getGraphAsync()(v1 推荐;老的 getGraph 已 deprecated)
const graphDrawable = await graph.getGraphAsync();
const graphMermaid = graphDrawable.drawMermaid();
console.log("░".repeat(34) + " LCEL 链的结构 " + "░".repeat(34) + "\n");
console.log(chainMermaid);
console.log("\n" + "░".repeat(33) + " StateGraph 的结构 " + "░".repeat(34) + "\n");
console.log(graphMermaid);
// ③ 定量对比
const chainNodes = Object.values(chainDrawable.nodes);
const graphNodes = Object.values(graphDrawable.nodes);
console.log("─".repeat(70));
console.log("📊 定量对比:");
console.log(` LCEL 链 节点数 = ${chainNodes.length},边数 = ${chainDrawable.edges.length}`);
console.log(` StateGraph 节点数 = ${graphNodes.length},边数 = ${graphDrawable.edges.length}`);
console.log(" LCEL 链的节点:");
for (const n of chainNodes) console.log(` • ${n.name}`);
console.log(" StateGraph 的节点:");
for (const n of graphNodes) console.log(` • ${n.name}`);
// ④ 图里的条件边(虚线)
const condEdges = graphDrawable.edges.filter((e) => e.conditional);
console.log(`\n🔀 StateGraph 里的条件边(conditional=true)共 ${condEdges.length} 条:`);
for (const e of condEdges) console.log(` • ${e.source} ⇢ ${e.target}`);
console.log("\n👉 结论:链导出的图是一条没有分叉的直线;图的导出里能看到真正的分支。");
console.log(
" 但如果你的流程本来就是直线,这两张图会长得几乎一样 —— 那也就没必要迁移(见示例 5)。"
);
}
main().catch((e) => {
console.error("❌ 调用失败:", e);
process.exit(1);
});运行:
bash
npx tsx 11-from-langchain/code/02-same-as-graph.ts真实输出:
========================================================================
示例 2:同一需求改写成 StateGraph + 结构对比
========================================================================
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ LCEL 链的结构 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
%%{init: {'flowchart': {'curve': 'linear'}}}%%
graph TD;
RunnableAssignInput([RunnableAssignInput]):::first
Assign(Assign)
Branch(Branch)
RunnableBranchOutput([RunnableBranchOutput]):::last
RunnableAssignInput --> Assign;
Branch --> RunnableBranchOutput;
Assign --> Branch;
classDef default fill:#f2f0ff,line-height:1.2;
classDef first fill-opacity:0;
classDef last fill:#bfb6fc;
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ StateGraph 的结构 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
%%{init: {'flowchart': {'curve': 'linear'}}}%%
graph TD;
__start__([<p>__start__</p>]):::first
classify(classify)
tech(tech)
billing(billing)
other(other)
__end__([<p>__end__</p>]):::last
__start__ --> classify;
billing --> __end__;
other --> __end__;
tech --> __end__;
classify -.-> tech;
classify -.-> billing;
classify -.-> other;
classDef default fill:#f2f0ff,line-height:1.2;
classDef first fill-opacity:0;
classDef last fill:#bfb6fc;
──────────────────────────────────────────────────────────────────────
📊 定量对比:
LCEL 链 节点数 = 4,边数 = 3
StateGraph 节点数 = 6,边数 = 7
LCEL 链的节点:
• RunnableAssignInput
• Assign
• Branch
• RunnableBranchOutput
StateGraph 的节点:
• __start__
• classify
• tech
• billing
• other
• __end__
🔀 StateGraph 里的条件边(conditional=true)共 3 条:
• classify ⇢ tech
• classify ⇢ billing
• classify ⇢ other
👉 结论:链导出的图是一条没有分叉的直线;图的导出里能看到真正的分支。
但如果你的流程本来就是直线,这两张图会长得几乎一样 —— 那也就没必要迁移(见示例 5)。怎么看这两段 Mermaid
链这边:它确实也是一张"图"(LangChain 内部就叫 Graph),但形状是 RunnableAssignInput → Assign → Branch → RunnableBranchOutput —— 一条直线,而且没有条件边的虚线(RunnableBranch 在链里被压成了一个节点,内部逻辑在图上根本没展开)。
图这边信息丰富多了:
__start__ → classify (实线 = 固定边 addEdge,必走)
classify ⇢ tech / billing / other (虚线 = 条件边 addConditionalEdges,看情况走)
tech / billing / other → __end__ (分叉后重新汇合)⚠️ 坑 2(两边导出 Mermaid 的 API 差异):
LCEL 链 LangGraph 图 拿可绘制对象 chain.getGraph()(同步)await graph.getGraphAsync()(异步,v1 推荐)出 Mermaid .drawMermaid()(同步返回字符串).drawMermaid()(同步返回字符串)出 PNG .drawMermaidPng().drawMermaidPng()(都会联网调 mermaid.ink)
chain.getGraph()在@langchain/core的Runnable上,是同步方法。而图的getGraphAsync()是异步的(老的getGraph()已 deprecated)。别照抄await chain.getGraph()——await非 Promise 虽不报错,但会误导读者。
一句话总结差异
| 维度 | LCEL 链 | StateGraph |
|---|---|---|
| 形状 | 一条直线 | 有分叉、可循环、可汇合 |
| 分支 | 只是 RunnableBranch 节点内部的事(图上不展开) | 显式条件边,图上看得见 |
| 状态 | 靠 RunnablePassthrough.assign 在步骤间"夹带"字段 | 一张显式的 Annotation.Root 状态表 |
| 循环 / 暂停 / 记忆 | 都不可能 | 循环、interrupt、checkpointer 都能上 |
也就是说:示例 1 的需求本身并不需要图。它在链里表达得很自然;改成图之后代码反而更长(这正是示例 5 的取舍)。那什么时候必须上图?先看示例 3 的"最省力迁移",再看示例 4 的四个信号。
四、最省力的迁移:把现成的链当节点用
这是本章最实用的一节。假设示例 1 那条链已写在生产里、跑得好好的,现在你想给它加"编排"(循环、人工审核)。做法是:保留老链,把它们当作节点挂进图。
示例 3:一半老代码,一半新图
typescript
/**
* 示例 3:本章最实用的一节 —— 把现成的链当「节点」挂进图
* 老链原封不动,只让「图」负责编排。两种挂法:
* 挂法 A:节点函数里调用 chain.invoke() —— 最清晰,能顺手改字段名
* 挂法 B:把 Runnable 直接传给 .addNode() —— 最省字,前提是它返回一个对象
*/
import { RunnableLambda } from "@langchain/core/runnables";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { StateGraph, START, END, Annotation } from "@langchain/langgraph";
import { createModel, banner } from "../../lib/model.js";
import { assertChatKey } from "../../lib/env.js";
// ══════════════════════════════════════════════════════════════════
// ① 老资产:两条 LCEL 链,原封不动搬过来(一行都不用改)
// ══════════════════════════════════════════════════════════════════
function buildLegacyChains() {
const model = createModel({ temperature: 0 });
const parser = new StringOutputParser();
// 老链 1:分类(输入 { ticket },输出一段文字)
const classifyChain = ChatPromptTemplate.fromMessages([
[
"system",
`你是电商客服的工单分类助手。把用户工单归入【技术】【账单】【其他】三类之一。
只输出一行,格式为:分类:<技术 或 账单 或 其他>`,
],
["human", "工单内容:{ticket}"],
])
.pipe(model)
.pipe(parser);
// 老链 2:生成回复草稿(输入 { ticket, group, classified },输出一段文字)
const draftChain = ChatPromptTemplate.fromMessages([
["system", "你是{group}的客服专员,请针对用户工单写一句得体的回复草稿,不超过 60 字。"],
["human", "工单:{ticket}\n系统判断:{classified}"],
])
.pipe(model)
.pipe(parser);
return { classifyChain, draftChain };
}
// ══════════════════════════════════════════════════════════════════
// ② 新图:负责「编排」
// ══════════════════════════════════════════════════════════════════
const TicketState = Annotation.Root({
ticket: Annotation<string>,
classified: Annotation<string>,
reply: Annotation<string>,
});
function routeByCategory(
state: typeof TicketState.State
): "tech" | "billing" | "other" {
if (state.classified.includes("技术")) return "tech";
if (state.classified.includes("账单")) return "billing";
return "other";
}
async function main() {
banner("示例 3:把现成的链当节点用(一半老代码,一半新图)");
assertChatKey();
const { classifyChain, draftChain } = buildLegacyChains();
const GROUPS: Record<string, string> = {
tech: "技术支持组",
billing: "财务账单组",
other: "人工客服",
};
// 一个「直接挂」的节点:本身是 Runnable,输入状态对象、输出状态更新对象
const makeDraftNode = (group: string) =>
RunnableLambda.from(async (state: typeof TicketState.State) => {
const reply = await draftChain.invoke({
ticket: state.ticket,
group,
classified: state.classified,
});
return { reply };
});
const graph = new StateGraph(TicketState)
// 挂法 A:节点函数里调用 chain.invoke()(顺便对齐字段名)
.addNode("classify", async (state) => ({
classified: await classifyChain.invoke({ ticket: state.ticket }),
}))
// 挂法 B:直接把 Runnable 当节点挂进去
.addNode("tech", makeDraftNode(GROUPS.tech))
.addNode("billing", makeDraftNode(GROUPS.billing))
.addNode("other", makeDraftNode(GROUPS.other))
.addEdge(START, "classify")
.addConditionalEdges("classify", routeByCategory, ["tech", "billing", "other"])
.addEdge("tech", END)
.addEdge("billing", END)
.addEdge("other", END)
.compile();
const tickets = [
"上个月我被重复扣了两次会员费,麻烦查一下并退款。",
"App 每次点开都闪退,卸载重装也没用。",
"请问你们的线下门店周末营业吗?",
];
for (const ticket of tickets) {
console.log("─".repeat(70));
console.log(`👤 工单:${ticket}\n`);
const result = await graph.invoke({ ticket });
console.log(` 分类:${result.classified.replace(/\n/g, " ")}`);
console.log(` 草稿:${result.reply}\n`);
}
const drawable = await graph.getGraphAsync();
console.log("─".repeat(70));
console.log("🔎 图里的节点(每个节点背后可能就是一条老链):");
for (const n of Object.values(drawable.nodes)) console.log(` • ${n.name}`);
console.log(
"\n👉 什么时候这招就够了?当图只负责编排、想让老链被分支/循环/中断包起来时,\n" +
" 把 invoke() 塞进节点函数即可,老链一行不用改 —— 这就是最省力的迁移路径。"
);
}
main().catch((e) => {
console.error("❌ 调用失败:", e);
process.exit(1);
});运行:
bash
npx tsx 11-from-langchain/code/03-chain-as-node.ts真实输出:
========================================================================
示例 3:把现成的链当节点用(一半老代码,一半新图)
========================================================================
──────────────────────────────────────────────────────────────────────
👤 工单:上个月我被重复扣了两次会员费,麻烦查一下并退款。
分类:分类:账单
草稿:您好,已收到您重复扣费的反馈,我们正在核查该笔会员费账单,确认后将尽快为您办理退款,感谢您的耐心等待。
──────────────────────────────────────────────────────────────────────
👤 工单:App 每次点开都闪退,卸载重装也没用。
分类:分类:技术
草稿:您好,很抱歉给您带来不便!已为您转接技术团队排查,请提供手机型号、系统版本及App版本,方便尽快定位问题。
──────────────────────────────────────────────────────────────────────
👤 工单:请问你们的线下门店周末营业吗?
分类:分类:其他
草稿:您好,门店周末营业时间因地区而异,请告知您所在城市及具体门店,我帮您核实,谢谢。
──────────────────────────────────────────────────────────────────────
🔎 图里的节点(每个节点背后可能就是一条老链):
• __start__
• classify
• tech
• billing
• other
• __end__
👉 什么时候这招就够了?当图只负责编排、想让老链被分支/循环/中断包起来时,
把 invoke() 塞进节点函数即可,老链一行不用改 —— 这就是最省力的迁移路径。两种挂法怎么选
挂法 A —— 节点函数里 chain.invoke():老链要 { ticket },而状态里可能还有别的字段,输入输出对不上。包一层顺便把"字段名对齐"做掉,最清晰。
挂法 B —— 直接把 Runnable 当节点:.addNode(name, xxx) 里 xxx 只要是 RunnableLike 就行,Runnable 天然就是 RunnableLike,所以能把一个链直接交给 addNode。
⚠️ 挂法 B 的前提:这个链的返回值必须是对象(状态更新)。上面
makeDraftNode返回{ reply },能直接挂。若老链返回的是字符串(如prompt.pipe(model).pipe(new StringOutputParser())的原始输出),直接挂会报错 —— 框架拿到字符串,不知道该放进状态里哪个字段。此时要么用挂法 A 包一层return { reply: await chain.invoke(...) },要么用RunnableLambda.from(async (s) => ({ reply: await chain.invoke(...) }))把它"收口"成返回对象的 Runnable。
「这招就够了」的边界
适合:让老链进入更大的图里被分支 / 循环 / 中断包起来;老链内部逻辑稳定、重写没收益;想渐进式迁移(一次换一个节点)。
不够用:当老链本身需要"精细控制中间状态"(在链的中间某步读写状态、中断在链中间、并行跑链内部子步骤)时,得把链拆开逐步改成图里的多个节点。判断标准:
你要在链的"内部"动手,就拆开;只是把链当成一个整体块,就挂上去。
五、该迁的信号:链做不到、图必须上的四件事
下面四件事,一条 LCEL 链一件都做不到。任何一件出现在需求里,就是"该迁移"的铁证。
示例 4:四个信号
typescript
/**
* 示例 4:链「做不到」、图「必须上」的四件事(每件一个最小可跑演示)
* ① 循环:模型自己决定「要不要再来一轮」
* ② 中断:interrupt,图暂停等人答复再继续
* ③ 记忆:checkpointer + thread_id,第二次请求记得第一次
* ④ 并行:两条分支同时跑,跑完再汇合到一个节点
* 这四件事,一条 LCEL 链一件都做不到 —— 这就是「该迁移」的信号。
*/
import {
StateGraph,
START,
END,
Annotation,
MessagesAnnotation,
MemorySaver,
Command,
interrupt,
isInterrupted,
} from "@langchain/langgraph";
import { HumanMessage, SystemMessage, type BaseMessage } from "@langchain/core/messages";
import { createModel, banner } from "../../lib/model.js";
import { assertChatKey } from "../../lib/env.js";
const section = (t: string) => console.log("\n" + "█".repeat(70) + `\n${t}\n` + "█".repeat(70) + "\n");
// ══════════════════════════════════════════════════════════════════
// ① 循环:模型自己决定要不要再来一轮
// ══════════════════════════════════════════════════════════════════
const RefineState = Annotation.Root({
slogan: Annotation<string>,
draft: Annotation<string>,
round: Annotation<number>,
done: Annotation<boolean>,
});
async function demoLoop() {
section("① 循环 —— 模型自己决定「要不要再来一轮」(链做不到)");
const model = createModel({ temperature: 0 });
const MAX_ROUNDS = 3; // 安全阀:万一模型一直说 MORE,也不会死循环
async function refineNode(state: typeof RefineState.State) {
const round = state.round + 1;
console.log(` [节点 refine] 第 ${round} 轮润色…`);
const res = await model.invoke([
new SystemMessage(
`你是文案。把一句产品口号改得更短、更有力。
第一行必须**只**输出下面两个标签之一(不要别的字符):
STATUS: MORE —— 你觉得还可以再改一轮
STATUS: DONE —— 你觉得已经够好了
第二行开始,输出你的当前版本。`
),
new HumanMessage(`原句:${state.slogan}\n当前版本:${state.draft || "(还没有,请写初稿)"}`),
]);
const text = String(res.content).trim();
const more = text.includes("STATUS: MORE");
console.log(` [节点 refine] 模型说:${more ? "MORE(再来一轮)" : "DONE(够了)"}`);
return { draft: text, round, done: !more };
}
function shouldContinue(state: typeof RefineState.State): "refine" | typeof END {
if (state.done || state.round >= MAX_ROUNDS) return END;
return "refine"; // ← 回到自己,形成循环:链永远做不到的「回头路」
}
const graph = new StateGraph(RefineState)
.addNode("refine", refineNode)
.addEdge(START, "refine")
.addConditionalEdges("refine", shouldContinue, ["refine", END])
.compile();
const result = await graph.invoke(
{ slogan: "我们的云盘,又快又安全", draft: "", round: 0, done: false },
{ recursionLimit: 10 }
);
console.log(`\n 📦 共循环 ${result.round} 轮`);
console.log(` 📝 最终版本:\n${result.draft.split("\n").slice(1).join("\n")}`);
}
// ══════════════════════════════════════════════════════════════════
// ② 中途停下等人:interrupt
// ══════════════════════════════════════════════════════════════════
const ReviewState = Annotation.Root({
topic: Annotation<string>,
draft: Annotation<string>,
decision: Annotation<string>,
finalText: Annotation<string>,
});
async function demoInterrupt() {
section("② 中途停下等人 —— interrupt:图暂停 → 人答复 → 继续(链做不到)");
function humanReview(state: typeof ReviewState.State) {
console.log(" [节点 review] 调用 interrupt,图在此暂停…");
// 第一次跑到这里会「抛信号」暂停;恢复后重跑,返回人类给的 resume 值
const answer = interrupt({
kind: "approval_request",
question: "这份公告可以发布吗?回复 approve 或 reject。",
draft: state.draft,
});
console.log(` [节点 review] 收到人类答复:${String(answer)}`);
return { decision: String(answer) };
}
// ★ interrupt 必须挂 checkpointer 才能真正「暂停 → 续跑」
const graph = new StateGraph(ReviewState)
.addNode("writeDraft", (s: typeof ReviewState.State) => ({
draft: `关于「${s.topic}」的公告草稿:系统将于今晚 23:00 停机维护约 30 分钟。`,
}))
.addNode("review", humanReview)
.addNode("finalize", (s: typeof ReviewState.State) => ({
finalText: s.decision.toLowerCase().includes("approve")
? `✅ 已发布:${s.draft}`
: `❌ 已退回:${s.draft}`,
}))
.addEdge(START, "writeDraft")
.addEdge("writeDraft", "review")
.addEdge("review", "finalize")
.addEdge("finalize", END)
.compile({ checkpointer: new MemorySaver() });
const config = { configurable: { thread_id: "review-demo" } };
console.log(" ▶ 第 1 次 invoke(图应停在中途)");
const paused = await graph.invoke({ topic: "系统维护通知" }, config);
if (isInterrupted(paused)) {
console.log(` ⏸ 图已暂停,interrupt 的 value:`);
console.log(` ${JSON.stringify(paused.__interrupt__[0].value)}`);
}
console.log("\n ▶ 人来答复,用 Command({ resume: 'approve' }) 继续");
const resumed = await graph.invoke(new Command({ resume: "approve" }), config);
console.log(` 📦 最终:${resumed.finalText}`);
}
// ══════════════════════════════════════════════════════════════════
// ③ 断点续跑 / 跨请求记忆:checkpointer + thread_id
// ══════════════════════════════════════════════════════════════════
async function demoMemory() {
section("③ 跨请求记忆 —— checkpointer + thread_id:第二次请求记得第一次(链做不到)");
const model = createModel({ temperature: 0 });
const graph = new StateGraph(MessagesAnnotation)
.addNode("chat", async (state: typeof MessagesAnnotation.State) => {
const res = await model.invoke([
new SystemMessage("你是简洁的中文助手,回答不超过 20 字。"),
...state.messages,
]);
return { messages: [res] };
})
.addEdge(START, "chat")
.addEdge("chat", END)
.compile({ checkpointer: new MemorySaver() });
// 模拟同一用户的两条 HTTP 请求,用同一个 thread_id 串起同一段对话
const config = { configurable: { thread_id: "user-42" } };
console.log(" ▶ 第 1 次请求:`我叫蜗牛,请记住。`");
const r1 = await graph.invoke({ messages: [new HumanMessage("我叫蜗牛,请记住。")] }, config);
console.log(` 🤖 ${String((r1.messages.at(-1) as BaseMessage).content)}`);
console.log("\n ▶ 第 2 次请求(全新的一次 invoke):`我叫什么名字?`");
const r2 = await graph.invoke({ messages: [new HumanMessage("我叫什么名字?")] }, config);
console.log(` 🤖 ${String((r2.messages.at(-1) as BaseMessage).content)}`);
console.log(
`\n ✅ 第 2 次只传了一句新消息,但图从 checkpointer 里取回了历史(共 ${r2.messages.length} 条),所以它记得你叫蜗牛。`
);
console.log(" 💡 链是无状态的:同样两次调用,第二次它会回答「我不知道」。");
}
// ══════════════════════════════════════════════════════════════════
// ④ 并行分支再汇总
// ══════════════════════════════════════════════════════════════════
const AnalysisState = Annotation.Root({
topic: Annotation<string>,
pros: Annotation<string>,
cons: Annotation<string>,
summary: Annotation<string>,
});
async function demoParallel() {
section("④ 并行分支再汇总 —— 两条分支同时跑,跑完汇合(链做不到)");
const model = createModel({ temperature: 0 });
const ask = async (q: string) => String((await model.invoke([new HumanMessage(q)])).content).trim();
async function prosNode(state: typeof AnalysisState.State) {
const t0 = Date.now();
console.log(` [节点 pros] 开始(t=${t0 % 100000})`);
const pros = await ask(`用一句话说出「${state.topic}」的一个优点。不超过 25 字。`);
console.log(` [节点 pros] 结束(耗时 ${Date.now() - t0}ms)`);
return { pros };
}
async function consNode(state: typeof AnalysisState.State) {
const t0 = Date.now();
console.log(` [节点 cons] 开始(t=${t0 % 100000})`);
const cons = await ask(`用一句话说出「${state.topic}」的一个缺点。不超过 25 字。`);
console.log(` [节点 cons] 结束(耗时 ${Date.now() - t0}ms)`);
return { cons };
}
// START 同时指向 prosNode / consNode → 并行;两条边都指向 summarize → 等齐再跑
// ⚠️ 节点名不能和状态字段名重名,所以节点用 prosNode/summarize
const graph = new StateGraph(AnalysisState)
.addNode("prosNode", prosNode)
.addNode("consNode", consNode)
.addNode("summarize", (s: typeof AnalysisState.State) => ({
summary: `「${s.topic}」\n 优点:${s.pros}\n 缺点:${s.cons}`,
}))
.addEdge(START, "prosNode")
.addEdge(START, "consNode")
.addEdge("prosNode", "summarize")
.addEdge("consNode", "summarize")
.addEdge("summarize", END)
.compile();
const result = await graph.invoke({ topic: "远程办公", pros: "", cons: "", summary: "" });
console.log(`\n 📦 汇总结果:\n${result.summary}`);
console.log("\n 💡 看上面的时间戳:pros 和 cons 的「开始」几乎同时 —— 它们是并行跑的。");
}
async function main() {
banner("示例 4:该迁移的四个信号(循环 / 中断 / 记忆 / 并行)");
assertChatKey();
await demoLoop();
await demoInterrupt();
await demoMemory();
await demoParallel();
console.log("\n" + "=".repeat(72));
console.log(" 以上四件事,任何一条出现在你的需求里,就该从链迁到图。");
console.log("=".repeat(72));
}
main().catch((e) => {
console.error("❌ 调用失败:", e);
process.exit(1);
});运行:
bash
npx tsx 11-from-langchain/code/04-when-to-migrate.ts真实输出:
========================================================================
示例 4:该迁移的四个信号(循环 / 中断 / 记忆 / 并行)
========================================================================
██████████████████████████████████████████████████████████████████████
① 循环 —— 模型自己决定「要不要再来一轮」(链做不到)
██████████████████████████████████████████████████████████████████████
[节点 refine] 第 1 轮润色…
[节点 refine] 模型说:MORE(再来一轮)
[节点 refine] 第 2 轮润色…
[节点 refine] 模型说:DONE(够了)
📦 共循环 2 轮
📝 最终版本:
云盘:快、安全
██████████████████████████████████████████████████████████████████████
② 中途停下等人 —— interrupt:图暂停 → 人答复 → 继续(链做不到)
██████████████████████████████████████████████████████████████████████
▶ 第 1 次 invoke(图应停在中途)
[节点 review] 调用 interrupt,图在此暂停…
⏸ 图已暂停,interrupt 的 value:
{"kind":"approval_request","question":"这份公告可以发布吗?回复 approve 或 reject。","draft":"关于「系统维护通知」的公告草稿:系统将于今晚 23:00 停机维护约 30 分钟。"}
▶ 人来答复,用 Command({ resume: 'approve' }) 继续
[节点 review] 调用 interrupt,图在此暂停…
[节点 review] 收到人类答复:approve
📦 最终:✅ 已发布:关于「系统维护通知」的公告草稿:系统将于今晚 23:00 停机维护约 30 分钟。
██████████████████████████████████████████████████████████████████████
③ 跨请求记忆 —— checkpointer + thread_id:第二次请求记得第一次(链做不到)
██████████████████████████████████████████████████████████████████████
▶ 第 1 次请求:`我叫蜗牛,请记住。`
🤖 好的,蜗牛,记住了。
▶ 第 2 次请求(全新的一次 invoke):`我叫什么名字?`
🤖 你叫蜗牛。
✅ 第 2 次只传了一句新消息,但图从 checkpointer 里取回了历史(共 4 条),所以它记得你叫蜗牛。
💡 链是无状态的:同样两次调用,第二次它会回答「我不知道」。
██████████████████████████████████████████████████████████████████████
④ 并行分支再汇总 —— 两条分支同时跑,跑完汇合(链做不到)
██████████████████████████████████████████████████████████████████████
[节点 cons] 开始(t=14232)
[节点 pros] 开始(t=14232)
[节点 pros] 结束(耗时 1388ms)
[节点 cons] 结束(耗时 1404ms)
📦 汇总结果:
「远程办公」
优点:远程办公能省下通勤时间,让人更自由地安排生活。
缺点:远程办公容易模糊工作与生活的界限,让人难以真正下班。
💡 看上面的时间戳:pros 和 cons 的「开始」几乎同时 —— 它们是并行跑的。
========================================================================
以上四件事,任何一条出现在你的需求里,就该从链迁到图。
========================================================================逐条拆解
① 循环:关键在 addConditionalEdges("refine", shouldContinue, ["refine", END]) —— 条件边的目标里出现了节点自己,形成循环。链结构上做不到(一串 pipe 没有"回头"概念)。
⚠️
MAX_ROUNDS是"安全阀":模型可能一直说MORE。图有recursionLimit兜底,业务上更该自己设上限。💡 跑出来的轮数会变:这次运行第 1 轮
MORE、第 2 轮DONE(共 2 轮);换一次可能 1 轮就结束。这恰恰是重点 —— "几轮"由模型自己决定,不是你写死的(写死次数的链只能for固定 N 次,做不到"问模型")。
② 中断:writeDraft → review(interrupt) → [暂停] → 人回复 → finalize → END。interrupt(payload) 让图在当前节点暂停并交回 payload;调用方拿到 __interrupt__ 后,用 new Command({ resume: 答复 }) 再次 invoke,图从断点续跑。链跑完就结束,没有"暂停/续跑"接口。
⚠️ 硬性前提:
interrupt必须配合checkpointer(示例用了MemorySaver),否则无法暂停后恢复。
③ 记忆:两次独立 invoke 共用同一个 thread_id: "user-42"。第二次只传了一句新消息,但图从 checkpointer 里取回历史(共 4 条),所以它记得用户叫蜗牛。链路无状态:同样两次调用,第二次它会一脸茫然。
④ 并行:START 同时连到两个节点 → 并行执行(看两个"开始"的时间戳几乎一样);两条边都指向 summarize → 等两条都跑完才执行。链是单线的,做不到"同时跑、再汇合"。
⚠️ 坑 3(节点名不能和状态字段重名):我一开始把节点命名为
pros,而状态里也正好有字段pros,直接报错:Error: pros is already being used as a state attribute (a.k.a. a channel), cannot also be used as a node name.改成
prosNode/summarize即可。状态字段和节点名,取名字时记得错开。
六、别迁的信号:诚实取舍
不是所有链都该迁。如果流程本来就是直线、无状态、无中断,迁到 LangGraph 只是给链套了个壳 —— 代码变长,收益为零。
示例 5:同一个简单任务,两种写法的代码量对照
typescript
/**
* 示例 5:何时「别」迁移 —— 诚实取舍
* 同一个简单任务(中译英):LCEL 版 vs 图版,并排打印源码、数行数、都跑一遍。
* 结论:用不到「编排」的简单链,留在 LangChain 更好。
*/
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { StateGraph, START, END, Annotation } from "@langchain/langgraph";
import { createModel, banner } from "../../lib/model.js";
import { assertChatKey } from "../../lib/env.js";
// 两版共享的准备工作(不计入行数统计)
const model = createModel({ temperature: 0 });
const prompt = ChatPromptTemplate.fromMessages([
["system", "把用户给的中文翻译成英文,只输出译文,不要解释。"],
["human", "{text}"],
]);
// 图版还需要一份「状态形状」的定义(链不需要)
const TranslateState = Annotation.Root({
text: Annotation<string>,
result: Annotation<string>,
});
// 两个版本「真正要写的代码」(用于并排展示 + 数行数)
const LCEL_SNIPPET = `const chain = prompt.pipe(model).pipe(new StringOutputParser());
const out = await chain.invoke({ text });
return out;`;
const GRAPH_SNIPPET = `const TranslateState = Annotation.Root({
text: Annotation<string>,
result: Annotation<string>,
});
const graph = new StateGraph(TranslateState)
.addNode("translate", async (state) => {
const messages = await prompt.formatMessages({ text: state.text });
const res = await model.invoke(messages);
return { result: String(res.content) };
})
.addEdge(START, "translate")
.addEdge("translate", END)
.compile();
const out = await graph.invoke({ text });
return out.result;`;
// 真正执行的函数:内容与上面的片段一致
async function lcelVersion(text: string): Promise<string> {
const chain = prompt.pipe(model).pipe(new StringOutputParser());
const out = await chain.invoke({ text });
return out;
}
async function graphVersion(text: string): Promise<string> {
const graph = new StateGraph(TranslateState)
.addNode("translate", async (state) => {
const messages = await prompt.formatMessages({ text: state.text });
const res = await model.invoke(messages);
return { result: String(res.content) };
})
.addEdge(START, "translate")
.addEdge("translate", END)
.compile();
const out = await graph.invoke({ text });
return out.result;
}
async function main() {
banner("示例 5:何时别迁移 —— LCEL vs 图,代码量对照");
assertChatKey();
// 并排打印两份源码(行号、行数都是真实的)
const lcelSrc = LCEL_SNIPPET.split("\n");
const graphSrc = GRAPH_SNIPPET.split("\n");
const col = 52;
const pad = (s: string) => s.padEnd(col - 1);
console.log("┌" + "─".repeat(col) + "┬" + "─".repeat(col) + "┐");
console.log("│" + pad(" LCEL 版(推荐)") + "│" + pad(" 图版(这里属于过度设计)") + "│");
console.log("├" + "─".repeat(col) + "┼" + "─".repeat(col) + "┤");
for (let i = 0; i < Math.max(lcelSrc.length, graphSrc.length); i++) {
const left = `${String(i + 1).padStart(2)} │ ${lcelSrc[i] ?? ""}`;
const right = `${String(i + 1).padStart(2)} │ ${graphSrc[i] ?? ""}`;
console.log("│" + pad(left.slice(0, col - 1)) + "│" + pad(right.slice(0, col - 1)) + "│");
}
console.log("└" + "─".repeat(col) + "┴" + "─".repeat(col) + "┘");
console.log(`\n📏 代码行数:LCEL 版 = ${lcelSrc.length} 行,图版 = ${graphSrc.length} 行`);
console.log(` 多出来的 ${graphSrc.length - lcelSrc.length} 行,这次没换来任何东西。`);
// 都跑一遍:结果一样,但复杂度差一大截
const text = "让每一次对话都更聪明。";
console.log(`\n👤 输入:${text}`);
const a = (await lcelVersion(text)).trim();
const b = (await graphVersion(text)).trim();
console.log(`🔗 LCEL 版结果:${a}`);
console.log(`🕸 图版结果 :${b}`);
console.log(a === b ? "✅ 两者输出一致 —— 这个任务上,图没有任何额外价值。" : "⚠️ 措辞略有不同但功能等价。");
// 判定清单
console.log("\n" + "=".repeat(72));
console.log(" 迁移判定清单:出现「任意一条」,才从链迁到图");
console.log("=".repeat(72));
const signals: Array<[string, string]> = [
["🔁 需要循环", "流程要「回头」:迭代重试、工具循环、直到满足条件才停"],
["⏸ 需要中断等人", "跑一半要暂停,等人工审批 / 补充信息 / 修改后再继续"],
["💾 需要状态持久化", "跨请求记住上下文,或要能断点续跑、回到历史某一步"],
["🔀 需要并行 + 汇总", "多条分支同时跑,最后合并成一个结果"],
["🤝 需要多智能体协作", "多个角色(主管 / 工人 / 专家)互相交接"],
["📊 需要流程可视化", "流程复杂到必须画出来、并在图上看到真实结构"],
];
for (const [k, v] of signals) console.log(` ${k.padEnd(22, " ")} → ${v}`);
console.log("\n 反过来:满足下面全部条件,就「别迁」,留在 LangChain:");
for (const s of [
"✅ 流程是一条直线(A → B → C),没有分叉后重新汇合",
"✅ 无状态(每次调用互不相关,不需要记住上一轮)",
"✅ 不需要中途暂停等人",
"✅ 不需要循环、不需要并行",
])
console.log(` ${s}`);
console.log("\n👉 一句话:图是「编排复杂流程」的工具。用不到编排,它就只是给链套了个壳。");
}
main().catch((e) => {
console.error("❌ 调用失败:", e);
process.exit(1);
});运行:
bash
npx tsx 11-from-langchain/code/05-when-not-to-migrate.ts真实输出:
========================================================================
示例 5:何时别迁移 —— LCEL vs 图,代码量对照
========================================================================
┌────────────────────────────────────────────────────┬────────────────────────────────────────────────────┐
│ LCEL 版(推荐) │ 图版(这里属于过度设计) │
├────────────────────────────────────────────────────┼────────────────────────────────────────────────────┤
│ 1 │ const chain = prompt.pipe(model).pipe(new Stri│ 1 │ const TranslateState = Annotation.Root({ │
│ 2 │ const out = await chain.invoke({ text }); │ 2 │ text: Annotation<string>, │
│ 3 │ return out; │ 3 │ result: Annotation<string>, │
│ 4 │ │ 4 │ }); │
│ 5 │ │ 5 │ const graph = new StateGraph(TranslateState) │
│ 6 │ │ 6 │ .addNode("translate", async (state) => { │
│ 7 │ │ 7 │ const messages = await prompt.formatMessag│
│ 8 │ │ 8 │ const res = await model.invoke(messages); │
│ 9 │ │ 9 │ return { result: String(res.content) }; │
│10 │ │10 │ }) │
│11 │ │11 │ .addEdge(START, "translate") │
│12 │ │12 │ .addEdge("translate", END) │
│13 │ │13 │ .compile(); │
│14 │ │14 │ const out = await graph.invoke({ text }); │
│15 │ │15 │ return out.result; │
└────────────────────────────────────────────────────┴────────────────────────────────────────────────────┘
📏 代码行数:LCEL 版 = 3 行,图版 = 15 行
多出来的 12 行,这次没换来任何东西。
👤 输入:让每一次对话都更聪明。
🔗 LCEL 版结果:Make every conversation smarter.
🕸 图版结果 :Make every conversation smarter.
✅ 两者输出一致 —— 这个任务上,图没有任何额外价值。
========================================================================
迁移判定清单:出现「任意一条」,才从链迁到图
========================================================================
🔁 需要循环 → 流程要「回头」:迭代重试、工具循环、直到满足条件才停
⏸ 需要中断等人 → 跑一半要暂停,等人工审批 / 补充信息 / 修改后再继续
💾 需要状态持久化 → 跨请求记住上下文,或要能断点续跑、回到历史某一步
🔀 需要并行 + 汇总 → 多条分支同时跑,最后合并成一个结果
🤝 需要多智能体协作 → 多个角色(主管 / 工人 / 专家)互相交接
📊 需要流程可视化 → 流程复杂到必须画出来、并在图上看到真实结构
反过来:满足下面全部条件,就「别迁」,留在 LangChain:
✅ 流程是一条直线(A → B → C),没有分叉后重新汇合
✅ 无状态(每次调用互不相关,不需要记住上一轮)
✅ 不需要中途暂停等人
✅ 不需要循环、不需要并行
👉 一句话:图是「编排复杂流程」的工具。用不到编排,它就只是给链套了个壳。💡 关于示例 5 里那两个"代码字符串":为什么把两版代码写成
LCEL_SNIPPET/GRAPH_SNIPPET两个模板字符串,而不是函数.toString()数行数?因为tsx转译时会把函数体压成一行,toString()拿到的不是源码原貌,行数没意义。写成模板字符串,行号行数才真实可控。
判定清单(贴在工位旁边)
| 出现任意一条 → 该迁到图 | 满足全部 → 别迁,留在链 |
|---|---|
| 🔁 需要循环(迭代 / 工具循环 / 直到满足条件) | ✅ 流程是一条直线,无分叉后汇合 |
| ⏸ 需要中断等人(审批 / 补充信息 / 修改) | ✅ 无状态(每次调用互不相关) |
| 💾 需要状态持久化(跨请求记忆 / 断点续跑) | ✅ 不需要中途暂停 |
| 🔀 需要并行 + 汇总 | ✅ 不需要循环、不需要并行 |
| 🤝 需要多智能体协作 | |
| 📊 流程复杂到必须可视化 |
⚠️ 给"统一用图"的冲动泼盆冷水:为了架构"整齐",把本来 3 行的简单链硬塞进 15 行的图 —— 这不是进步,是负债。先问需求,再选工具。
七、迁移映射表(LCEL 概念 → LangGraph 对应物)
| LCEL 概念 | LangGraph 对应物 | 能否直接对应 | 说明 |
|---|---|---|---|
RunnableSequence / .pipe() | 节点 + 固定边 addEdge("a","b") | ✅ 能直接对应 | 每一步变成一个节点,串联顺序用 addEdge 表达 |
RunnableLambda | 节点函数 addNode("名", fn) | ✅ 能直接对应 | 签名从"收任意输入"变成"收 state、返回字段增量对象" |
RunnableBranch | addConditionalEdges("from", 路由函数, [候选节点]) | 🔧 需要改造 | 条件与分支要拆开:路由函数只返回"下一个节点名" |
RunnablePassthrough(.assign) | 状态字段(Annotation.Root)+ 节点直接读 state.xxx | 🔧 需要改造 | 链靠"夹带"传值,图靠显式状态表;assign 加的字段 = 状态里多一个字段 |
ChatPromptTemplate | 节点内直接用 await prompt.formatMessages(state) | ✅ 能直接对应 | 放进节点函数内部,按需格式化 |
StringOutputParser | 节点内解析,或直接取 AIMessage.content | ✅ 能直接对应 | 消息通常走 MessagesAnnotation;parser.invoke(res) 也行 |
AgentExecutor(v0 经典 Agent) | createReactAgent() / 手写 ReAct 图 | 🔧 需要改造 | v1 里 createReactAgent 在 @langchain/langgraph/prebuilt;更上层是 createAgent() |
.batch()(并行批量) | 并行节点(多条边指向多个节点)/ Send API | 🔧 需要改造 | 图的并行是"拓扑级"的:START 连多个节点即并行;动态 map-reduce 用 Send |
.stream() | graph.stream()(多种 streamMode) | ✅ 能直接对应 | 语义一致,模式更丰富(第 6 章) |
.withRetry() | 节点级 retryPolicy(addNode(name, fn, { retryPolicy })) | 🔧 需要改造 | 重试策略配在节点上,不是配在整条链上 |
RunnableWithMessageHistory | checkpointer + thread_id | 🔧 需要改造 | 记忆从"链的外挂"变成"图的持久化能力" |
| 整条链(编排整体) | StateGraph + compile() | 🔧 需要改造 | 编排从"串联"升级为"状态机" |
💡 这张表的规律:带 ✅ 的几乎都是"单步"的东西(一次 prompt、一次解析、一个函数)—— 它们只是换了个位置(从链的一环变成节点里的一行)。带 🔧 的几乎都是"连接/编排"的东西(分支、传值、并行、重试、记忆、Agent)—— 它们需要换一套表达方式。
迁移的本质:单步照搬,编排重写。
推荐迁移流程
① 先不动老链,把它整体挂成一个节点 → 跑通(示例 3 的挂法)
② 找出"需要循环/中断/并行/记忆"的那一处,把对应部分拆成多个节点
③ 把原本靠 RunnablePassthrough 夹带的字段,逐个搬到 Annotation.Root 状态里
④ 把 RunnableBranch 改写成 addConditionalEdges
⑤ 需要跨请求记忆时,挂上 checkpointer 并给调用方传 thread_id
⑥ 全程用 graph.getGraphAsync().drawMermaid() 检查结构对不对八、本章小结
本章速查表
| 你要做的事 | LCEL | LangGraph |
|---|---|---|
| 串起多步 | a.pipe(b).pipe(c) | addNode + addEdge |
| 条件分支 | RunnableBranch.from([...]) | addConditionalEdges("from", 路由, [目标]) |
| 原样传值 | RunnablePassthrough.assign({...}) | 直接声明状态字段、节点读 state |
| 循环 | ❌ 做不到 | 条件边指回自己 |
| 暂停等人 | ❌ 做不到 | interrupt() + checkpointer |
| 跨请求记忆 | 外挂 RunnableWithMessageHistory | checkpointer + thread_id |
| 并行再汇总 | .batch() / RunnableParallel | START 连多节点、再汇合到同一节点 |
| 把老链塞进图 | —— | .addNode("名", 老链) 或节点内 老链.invoke() |
| 看结构 | chain.getGraph().drawMermaid()(同步) | await graph.getGraphAsync(),再 .drawMermaid() |
五句话总结
- 迁移 ≠ 重写。老链一行不用改,把它当节点挂进图就行 —— 这是最省力、也最常见的路径。
- 单步照搬,编排重写。prompt / parser / 普通函数换位置即可;分支、传值、循环、记忆要换表达方式。
- 该迁的四个信号:需要循环、需要中断等人、需要跨请求记忆、需要并行再汇总 —— 任意一条就够了。
- 该留的信号:直线、无状态、无中断、无循环 —— 全中的话,留在 LangChain,别为"整齐"过度设计。
- 图和链是配合关系:链负责"单步怎么调模型",图负责"多步之间怎么连"。先问需求,再选工具。
动手练习
- 把示例 1 的链,按示例 3 的挂法 B直接挂成一个节点(让链自己返回
{ reply }对象),跑通。 - 给示例 3 的图加一个"循环":如果草稿里出现「抱歉」,就回到
classify重新处理一次(提示:改条件边让它能指回classify,并加round计数防死循环)。 - 把示例 4 的
interrupt演示改成"人回复reject",观察finalText变成「已退回」。 - 拿你自己项目里任意一条 3 步以上的 LCEL 链,先跑
chain.getGraph().drawMermaid()看结构 —— 如果是一条直线,恭喜,它不用迁。
下一章预告
图搭好了、老链也挂进来了,但"跑起来对不对"不能靠眼睛看。第 12 章讲测试与调试:怎么给节点写单元测试、怎么用 stream 观察每一步中间状态、怎么定位"图走到一半不动了"这类问题。