Skip to content

第 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、返回字段增量对象"
RunnableBranchaddConditionalEdges("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 }))🔧 需要改造重试策略配在节点上,不是配在整条链上
RunnableWithMessageHistorycheckpointer + thread_id🔧 需要改造记忆从"链的外挂"变成"图的持久化能力"
整条链(编排整体)StateGraph + compile()🔧 需要改造编排从"串联"升级为"状态机"

💡 这张表的规律:带 ✅ 的几乎都是"单步"的东西(一次 prompt、一次解析、一个函数)—— 它们只是换了个位置(从链的一环变成节点里的一行)。带 🔧 的几乎都是"连接/编排"的东西(分支、传值、并行、重试、记忆、Agent)—— 它们需要换一套表达方式。

迁移的本质:单步照搬,编排重写。

推荐迁移流程 ​

① 先不动老链,把它整体挂成一个节点 → 跑通(示例 3 的挂法)
② 找出"需要循环/中断/并行/记忆"的那一处,把对应部分拆成多个节点
③ 把原本靠 RunnablePassthrough 夹带的字段,逐个搬到 Annotation.Root 状态里
④ 把 RunnableBranch 改写成 addConditionalEdges
⑤ 需要跨请求记忆时,挂上 checkpointer 并给调用方传 thread_id
⑥ 全程用 graph.getGraphAsync().drawMermaid() 检查结构对不对

八、本章小结 ​

本章速查表

你要做的事LCELLangGraph
串起多步a.pipe(b).pipe(c)addNode + addEdge
条件分支RunnableBranch.from([...])addConditionalEdges("from", 路由, [目标])
原样传值RunnablePassthrough.assign({...})直接声明状态字段、节点读 state
循环❌ 做不到条件边指回自己
暂停等人❌ 做不到interrupt() + checkpointer
跨请求记忆外挂 RunnableWithMessageHistorycheckpointer + thread_id
并行再汇总.batch() / RunnableParallelSTART 连多节点、再汇合到同一节点
把老链塞进图——.addNode("名", 老链) 或节点内 老链.invoke()
看结构chain.getGraph().drawMermaid()(同步)await graph.getGraphAsync(),再 .drawMermaid()

五句话总结

  1. 迁移 ≠ 重写。老链一行不用改,把它当节点挂进图就行 —— 这是最省力、也最常见的路径。
  2. 单步照搬,编排重写。prompt / parser / 普通函数换位置即可;分支、传值、循环、记忆要换表达方式。
  3. 该迁的四个信号:需要循环、需要中断等人、需要跨请求记忆、需要并行再汇总 —— 任意一条就够了。
  4. 该留的信号:直线、无状态、无中断、无循环 —— 全中的话,留在 LangChain,别为"整齐"过度设计。
  5. 图和链是配合关系:链负责"单步怎么调模型",图负责"多步之间怎么连"。先问需求,再选工具。

动手练习

  1. 把示例 1 的链,按示例 3 的挂法 B直接挂成一个节点(让链自己返回 { reply } 对象),跑通。
  2. 给示例 3 的图加一个"循环":如果草稿里出现「抱歉」,就回到 classify 重新处理一次(提示:改条件边让它能指回 classify,并加 round 计数防死循环)。
  3. 把示例 4 的 interrupt 演示改成"人回复 reject",观察 finalText 变成「已退回」。
  4. 拿你自己项目里任意一条 3 步以上的 LCEL 链,先跑 chain.getGraph().drawMermaid() 看结构 —— 如果是一条直线,恭喜,它不用迁。

下一章预告

图搭好了、老链也挂进来了,但"跑起来对不对"不能靠眼睛看。第 12 章讲测试与调试:怎么给节点写单元测试、怎么用 stream 观察每一步中间状态、怎么定位"图走到一半不动了"这类问题。


← 上一章:应用结构 | 返回总目录 | 下一章:测试与调试 →

内容基于 LangGraph.js 官方文档与示例(langchain-ai/langgraphjs,MIT License)整理并重排为中文学习路径