Skip to content

第 13 章 · 函数式 API(task / entrypoint) ​

🎯 本章目标 ​

  • 知道 LangGraph 其实有两套写法:画图(StateGraph)和写代码(entrypoint + task)
  • 会用 entrypoint 把一个普通 async 函数变成可运行、可流式、可持久化的"图"
  • 会用 task 把可复用的子步骤抽出来,并让它们并行跑
  • 会在同一个项目里混用两套写法:简单处写函数、复杂处画图
  • 会用 getPreviousState + entrypoint.final 做函数式写法的多轮记忆
  • 拿到一张三套 API 的决策表:StateGraph / entrypoint+task / createReactAgent 到底怎么选

本章代码

📌 代码约定:正文的代码块都保持可直接运行(含 import 与模型初始化)。若某段为聚焦概念的节选,会明确标注「节选」并指向同名的 code/ 完整文件。所有示例的输出都是真实运行结果,不是编造的示例输出。

文件内容
code/01-entrypoint-basics.ts同一功能,StateGraph 与 entrypoint+task 并排对照
code/02-task-parallel.tstask 的并行:串行 4 秒 vs 并行 1 秒
code/03-entrypoint-in-graph.ts两套写法混用的三种方式
code/04-entrypoint-state.ts函数式写法的状态与多轮记忆
code/05-choose-api.ts三套 API 选型:同一需求三种实现

一、LangGraph 其实有两套写法 ​

前面十二章我们一直在画图:声明状态、注册节点、连边、compile()。

但 LangGraph 还有第二套写法 —— 函数式 API。它不画图,直接写代码:

画图(StateGraph)                     写代码(entrypoint + task)

  START                                  const app = entrypoint(async (input) => {
    │                                      const a = await step1(input);   // task
    ▼                                      const b = await step2(a);       // task
  [normalize]                              return `结果:${b}`;
    │                                    });
    ▼
  [sayHello]                             ← 就这么两行,已经是一个
    │                                       「可运行、可流式、可持久化」的图
    ▼
   END

为什么要有第二套? 因为不是所有流程都值得画成图。

如果一件事就是"取数据 → 调模型 → 拼结果"这种直线,画图会写出一堆样板代码(声明状态、注册节点、连边、编译);而只要写个 async 函数就完事了。

两套写法的对应关系,是本章最该记住的一张表:

画图(StateGraph)写代码(entrypoint)
状态字段函数的入参 / 返回值
节点函数函数里的一次 await 调用
边代码的先后顺序
可复用的节点task(还能并行、重试、缓存)
compile()不用,entrypoint 返回的本身就是可运行对象
checkpointer一模一样,选项里传 checkpointer

💡 一句话:图擅长「结构」,函数式擅长「自由」。 需要看得见、改得动、能插入校验的复杂编排,画图;直线流程或者大量可并行的小步骤,写函数。


二、entrypoint:把一个函数变成图 ​

entrypoint 接收一个 async 函数,返回一个和编译后的图一样的可运行对象 —— 一样有 .invoke()、.stream(),一样能接 checkpointer。

示例 1:同一个功能,两种写法并排 ​

需求刻意选得很简单(规整姓名 → 打招呼,两步直线),这样最能看清两套写法的差异。

typescript
/**
 * 示例 1:同一个功能,两种写法并排对照 —— StateGraph(画图) vs entrypoint+task(写代码)
 *
 * 需求:把用户输入的姓名,先「规整」(去空白、修空格),再「打招呼」。
 *       一个只有两步的直线流程 —— 这正是最能看清两套写法差异的场景。
 *
 * 本文件把两种写法放进同一个文件,最后各自运行一次,并自动统计「定义流程」的代码行数。
 *
 *   画图(StateGraph):先声明状态形状 → 注册节点 → 连边 → 编译
 *   写代码(entrypoint):把整件事写成一个 async 函数,可复用的子步骤用 task 抽出
 *
 * 运行:npx tsx 13-functional-api/code/01-entrypoint-basics.ts
 */
import { readFileSync } from "node:fs";
import {
  StateGraph,
  START,
  END,
  Annotation,
  entrypoint,
  task,
} from "@langchain/langgraph";
import { banner } from "../../lib/model.js";

// ══════════════════════════════════════════════════════════════════════
// 写法 A:画图(StateGraph)—— 先决定「状态长什么样」,再摆节点、连边
// #region graph
const GreetState = Annotation.Root({
  name: Annotation<string>, // 用户输入
  greeting: Annotation<string>, // 最终结果
});

function normalize(state: typeof GreetState.State) {
  return { name: state.name.trim().replace(/\s+/g, " ") };
}

function sayHello(state: typeof GreetState.State) {
  return { greeting: `你好,${state.name}!欢迎学习 LangGraph。` };
}

const graph = new StateGraph(GreetState)
  .addNode("normalize", normalize)
  .addNode("sayHello", sayHello)
  .addEdge(START, "normalize")
  .addEdge("normalize", "sayHello")
  .addEdge("sayHello", END)
  .compile();
// #endregion graph

// ══════════════════════════════════════════════════════════════════════
// 写法 B:写代码(entrypoint + task)—— 整件事就是一个 async 函数
// #region functional
const normalizeTask = task("normalize", async (name: string) => {
  return name.trim().replace(/\s+/g, " ");
});

const sayHelloTask = task("sayHello", async (name: string) => {
  return `你好,${name}!欢迎学习 LangGraph。`;
});

const workflow = entrypoint("greet", async (name: string) => {
  const clean = await normalizeTask(name);
  const greeting = await sayHelloTask(clean);
  return greeting;
});
// #endregion functional

/** 统计源码里两个标记之间「非空、非纯注释」的行数 */
function countRegion(source: string, region: string): number {
  const lines = source.split("\n");
  const start = lines.findIndex((l) => l.includes(`#region ${region}`));
  const end = lines.findIndex((l) => l.includes(`#endregion ${region}`));
  return lines
    .slice(start + 1, end)
    .filter((l) => l.trim() !== "" && !l.trim().startsWith("//"))
    .length;
}

async function main() {
  banner("示例 1:StateGraph vs entrypoint+task(同一功能,两种写法)");

  const rawInput = "  蜗 牛  ";

  // ── 写法 A 运行 ──────────────────────────────────────────────
  console.log("【写法 A:StateGraph】");
  console.log(`   初始状态:${JSON.stringify({ name: rawInput })}`);
  const a = await graph.invoke({ name: rawInput, greeting: "" });
  console.log(`   状态流转 → ${JSON.stringify(a)}`);
  console.log(`   结果:${a.greeting}\n`);

  // ── 写法 B 运行 ──────────────────────────────────────────────
  console.log("【写法 B:entrypoint + task】");
  console.log(`   输入:${JSON.stringify(rawInput)}`);
  const b = await workflow.invoke(rawInput);
  console.log(`   输出:${JSON.stringify(b)}\n`);

  // ── 结构差异 ────────────────────────────────────────────────
  const source = readFileSync(new URL(import.meta.url), "utf-8");
  const graphLines = countRegion(source, "graph");
  const funcLines = countRegion(source, "functional");

  console.log("📊 两套写法的结构差异:");
  console.log(`   StateGraph 定义流程:${graphLines} 行(状态声明 + 2 个节点 + 3 条边 + compile)`);
  console.log(`   entrypoint 定义流程:${funcLines} 行(2 个 task + 1 个 entrypoint)`);
  console.log("");
  console.log("🔑 关键对应关系:");
  console.log("   StateGraph 的「状态字段」   ←→  entrypoint 的「函数入参 / 返回值」");
  console.log("   StateGraph 的「节点函数」   ←→  entrypoint 里的一次函数调用");
  console.log("   StateGraph 的「边」         ←→  entrypoint 里的普通 await 顺序");
  console.log("   StateGraph 的「可复用节点」 ←→  task(可被多次调用、可并行)");
  console.log("   StateGraph 的「compile()」  ←→  entrypoint 返回的本身就是一个可运行对象");
  console.log("");
  console.log("✅ 两者输出完全一致:", a.greeting === b);
}

main().catch((e) => {
  console.error("❌ 运行失败:", e);
  process.exit(1);
});

运行:

bash
npx tsx 13-functional-api/code/01-entrypoint-basics.ts

真实输出:

========================================================================
  示例 1:StateGraph vs entrypoint+task(同一功能,两种写法)
========================================================================

【写法 A:StateGraph】
   初始状态:{"name":"  蜗 牛  "}
   状态流转 → {"name":"蜗 牛","greeting":"你好,蜗 牛!欢迎学习 LangGraph。"}
   结果:你好,蜗 牛!欢迎学习 LangGraph。

【写法 B:entrypoint + task】
   输入:"  蜗 牛  "
   输出:"你好,蜗 牛!欢迎学习 LangGraph。"

📊 两套写法的结构差异:
   StateGraph 定义流程:17 行(状态声明 + 2 个节点 + 3 条边 + compile)
   entrypoint 定义流程:11 行(2 个 task + 1 个 entrypoint)

🔑 关键对应关系:
   StateGraph 的「状态字段」   ←→  entrypoint 的「函数入参 / 返回值」
   StateGraph 的「节点函数」   ←→  entrypoint 里的一次函数调用
   StateGraph 的「边」         ←→  entrypoint 里的普通 await 顺序
   StateGraph 的「可复用节点」 ←→  task(可被多次调用、可并行)
   StateGraph 的「compile()」  ←→  entrypoint 返回的本身就是一个可运行对象

✅ 两者输出完全一致: true

这个示例还做了一件有意思的事:它在运行时读自己的源码,自动统计"定义流程"的行数(用 #region 标记圈定范围)。所以那两个数字(17 行 / 11 行)不是手写的,是当场数出来的。

结果很清楚:同样的功能,entrypoint 写法的流程定义代码少了三分之一。

💡 注意 entrypoint 包装的函数最多只能接收两个参数:(input, config)。这是它与图的接口约定 —— input 相当于图的初始状态,config 里带着 configurable、signal 这些运行时上下文。


三、task:可复用、可并行的子步骤 ​

task 是函数式 API 里最有价值的一个零件。它把一段逻辑包装成"任务",于是这段逻辑就白捡了四样能力:

能力说明
可命名出现在流式事件和追踪里,便于调试
可并行调用它返回的是 Promise,Promise.all 就能并发
可重试选项里传 retry
可缓存选项里传 cachePolicy
可超时选项里传 timeout

这些都是裸函数没有的 —— 这是 task 存在的全部理由。

示例 2:4 个步骤,串行 vs 并行 ​

typescript
/**
 * 示例 2:task 的并行 —— 多个 task 并发执行再汇总
 *
 * 这里有一个反直觉但极其重要的点:
 *   task 函数返回的是 Promise。只要你不 await,它们就已经在跑了。
 *   所以「并发」在函数式 API 里不需要任何特殊语法 —— 就是 JS 原生的 Promise.all。
 *
 * 本示例跑两遍同一件事(4 个各耗时 1 秒的步骤):
 *   • 串行:一个一个 await       → 约 4 秒
 *   • 并行:Promise.all 一起等    → 约 1 秒
 * 用时间戳把差距钉死。
 *
 * 运行:npx tsx 13-functional-api/code/02-task-parallel.ts
 */
import { entrypoint, task } from "@langchain/langgraph";
import { banner } from "../../lib/model.js";

/** 一个"慢"任务:睡 1 秒后返回,模拟一次网络 / 模型调用 */
const sleepTask = task("sleep1s", async (label: string) => {
  await new Promise((r) => setTimeout(r, 1000));
  console.log(`      ✓ 完成:${label}(${new Date().toISOString().slice(11, 23)})`);
  return `${label} 的结果`;
});

// ── 写法一:串行 await ────────────────────────────────────────────
const serialFlow = entrypoint("serial", async (labels: string[]) => {
  const started = Date.now();
  const out: string[] = [];
  for (const l of labels) {
    out.push(await sleepTask(l)); // 一个跑完再跑下一个
  }
  return { out, elapsed: Date.now() - started };
});

// ── 写法二:并行 Promise.all ──────────────────────────────────────
const parallelFlow = entrypoint("parallel", async (labels: string[]) => {
  const started = Date.now();
  // 先一次性发出所有调用(不 await),再统一等待
  const promises = labels.map((l) => sleepTask(l));
  const out = await Promise.all(promises);
  return { out, elapsed: Date.now() - started };
});

const LABELS = ["A", "B", "C", "D"];

async function main() {
  banner("示例 2:task 的并行(串行 vs Promise.all)");

  console.log(`🧩 4 个步骤,每个 sleep 1 秒:${LABELS.join(", ")}\n`);

  console.log("【串行】一个 await 接一个 await …");
  const t0 = Date.now();
  const s = await serialFlow.invoke(LABELS);
  const sWall = Date.now() - t0;
  console.log(`   结果:${JSON.stringify(s.out)}`);
  console.log(`   ⏱️  总耗时:${s.elapsed} ms(墙钟实测 ${sWall} ms)\n`);

  console.log("【并行】Promise.all 一起等 …");
  const t1 = Date.now();
  const p = await parallelFlow.invoke(LABELS);
  const pWall = Date.now() - t1;
  console.log(`   结果:${JSON.stringify(p.out)}`);
  console.log(`   ⏱️  总耗时:${p.elapsed} ms(墙钟实测 ${pWall} ms)\n`);

  console.log("📊 对照结论:");
  console.log(`   串行 ≈ ${(s.elapsed / 1000).toFixed(2)} 秒(4 × 1s)`);
  console.log(`   并行 ≈ ${(p.elapsed / 1000).toFixed(2)} 秒(max(1s))`);
  console.log(`   提速约 ${(s.elapsed / p.elapsed).toFixed(1)} 倍`);
  console.log("");
  console.log("💡 为什么 task 能这么简单就并行?");
  console.log("   因为 task(...) 返回的是 Proxy 函数,调用它=发出一个 Promise。");
  console.log("   被 task 包装过的函数还额外获得了:可命名、可重试(retry)、可缓存(cache)、");
  console.log("   可超时(timeout) —— 这些是裸函数没有的。");
}

main().catch((e) => {
  console.error("❌ 运行失败:", e);
  process.exit(1);
});

运行:

bash
npx tsx 13-functional-api/code/02-task-parallel.ts

真实输出:

========================================================================
  示例 2:task 的并行(串行 vs Promise.all)
========================================================================

🧩 4 个步骤,每个 sleep 1 秒:A, B, C, D

【串行】一个 await 接一个 await …
      ✓ 完成:A(04:11:06.809)
      ✓ 完成:B(04:11:07.814)
      ✓ 完成:C(04:11:08.818)
      ✓ 完成:D(04:11:09.822)
   结果:["A 的结果","B 的结果","C 的结果","D 的结果"]
   ⏱️  总耗时:4016 ms(墙钟实测 4039 ms)

【并行】Promise.all 一起等 …
      ✓ 完成:A(04:11:10.846)
      ✓ 完成:B(04:11:10.847)
      ✓ 完成:C(04:11:10.847)
      ✓ 完成:D(04:11:10.847)
   结果:["A 的结果","B 的结果","C 的结果","D 的结果"]
   ⏱️  总耗时:1006 ms(墙钟实测 1009 ms)

📊 对照结论:
   串行 ≈ 4.02 秒(4 × 1s)
   并行 ≈ 1.01 秒(max(1s))
   提速约 4.0 倍

💡 为什么 task 能这么简单就并行?
   因为 task(...) 返回的是 Proxy 函数,调用它=发出一个 Promise。
   被 task 包装过的函数还额外获得了:可命名、可重试(retry)、可缓存(cache)、
   可超时(timeout) —— 这些是裸函数没有的。

看时间戳那一列:串行模式下四个步骤每隔 1 秒完成一个;并行模式下四个步骤在同一毫秒完成(其实是在同一批里跑完)。

串行 ≈ 4.02 秒(4 × 1s)
并行 ≈ 1.01 秒(max(1s))
提速约 4.0 倍

为什么 task 能这么简单地并行? 因为 task(...) 返回的是一个 Proxy 函数,调用它等于发出一个 Promise,而不是立刻执行完。所以你可以像写普通异步代码一样,收集好一批调用再 Promise.all。

⚠️ 一个真实的坑:task 只能在 entrypoint 或 StateGraph 节点内部调用。在图的上下文之外直接调 task 包装的函数会报错 —— 因为它需要从运行时上下文里拿执行信息。


四、函数式与图的混用 ​

官方并不希望你在两套写法里二选一。真实项目里更常见的是混着用。

示例 3:三种混用方式 ​

typescript
/**
 * 示例 3:函数式写法和图写法混用 —— 官方推荐的"渐进式"用法
 *
 * 官方的态度很明确:**别二选一**。
 *   • 简单的地方 → 写函数(entrypoint / task),代码短、好读、好测
 *   • 复杂的地方 → 画图(StateGraph),能看清分支、循环、并行
 *   • 两者可以互相嵌套:图里调函数、函数里画图、task 当节点、entrypoint 当节点
 *
 * 本示例用一张"邀请函流水线"把三种混用方式都演一遍:
 *   A. 在图节点里调用 task          (借用图的编排,复用 task 的能力)
 *   B. 在图节点里调用 entrypoint     (把一小段函数式逻辑当黑盒用)
 *   C. 把 entrypoint 直接当节点挂进图(最彻底的混用)
 *
 * ⚠️ 坑:节点名(addNode 的第一个参数)不能和状态字段重名,
 *        否则图的类型推导和运行都会出问题。本示例刻意让它们不同名。
 *
 * 运行:npx tsx 13-functional-api/code/03-entrypoint-in-graph.ts
 */
import {
  StateGraph,
  START,
  END,
  Annotation,
  entrypoint,
  task,
} from "@langchain/langgraph";
import { banner } from "../../lib/model.js";

// ── 状态:4 个字段(注意节点名会刻意避开它们)────────────────────
const InviteState = Annotation.Root({
  topic: Annotation<string>, // 主题
  draft: Annotation<string>, // 中文草稿
  english: Annotation<string>, // 英文版
  signed: Annotation<string>, // 加签名后的最终稿
});

// ── A. 一个 task:生成草稿(可复用、可命名、可重试)──────────────
const makeDraftTask = task("makeDraft", async (topic: string) => {
  console.log(`   [task makeDraft] 为主题「${topic}」生成草稿`);
  return `诚邀您参加「${topic}」。`;
});

// ── B. 一个 entrypoint:翻译(一小段独立的函数式逻辑)────────────
const translateEntry = entrypoint("translate", async (text: string) => {
  console.log(`   [entrypoint translate] 翻译:${text}`);
  const dict: Record<string, string> = {
    诚邀您参加: "You are cordially invited to",
    学习研讨: "study seminar",
  };
  return Object.entries(dict).reduce(
    (acc, [zh, en]) => acc.replace(zh, en),
    text
  );
});

// ── C. 一个 entrypoint:加签名 —— 这次它要直接当图的节点 ─────────
//    当 entrypoint 作为节点时:入参是【整个 state】,返回值必须是【状态增量对象】
const signEntry = entrypoint(
  "sign",
  async (state: typeof InviteState.State) => {
    console.log(`   [entrypoint sign] 作为节点,收到整个状态:${JSON.stringify(state)}`);
    return { signed: `${state.english}\n\n—— 蜗牛 敬上` };
  }
);

const graph = new StateGraph(InviteState)
  // A 种混用:节点里调用 task
  .addNode("makeDraft", async (state) => {
    const draft = await makeDraftTask(state.topic);
    return { draft };
  })
  // B 种混用:节点里调用另一个 entrypoint(.invoke 黑盒使用)
  .addNode("doTranslate", async (state) => {
    const english = await translateEntry.invoke(state.draft);
    return { english };
  })
  // C 种混用:entrypoint 直接当节点
  .addNode("addSign", signEntry)
  .addEdge(START, "makeDraft")
  .addEdge("makeDraft", "doTranslate")
  .addEdge("doTranslate", "addSign")
  .addEdge("addSign", END)
  .compile();

async function main() {
  banner("示例 3:函数式写法与图写法混用");

  const input = { topic: "LangGraph 学习研讨", draft: "", english: "", signed: "" };
  console.log(`👤 输入主题:${input.topic}\n`);

  const result = await graph.invoke(input);

  console.log("\n📦 最终状态:");
  console.log(`   草稿:${result.draft}`);
  console.log(`   英文:${result.english}`);
  console.log(`   最终:\n${result.signed.split("\n").map((l) => "        " + l).join("\n")}`);

  console.log("\n🔑 三种混用方式小结:");
  console.log("   A. 图节点里 await task(...)          → 让 task 参与图的编排");
  console.log("   B. 图节点里 await entrypoint.invoke  → 把函数式逻辑当黑盒");
  console.log("   C. addNode(name, entrypoint)         → entrypoint 直接当节点");
  console.log("");
  console.log("⚠️  C 的关键差异:当 entrypoint 当节点时,");
  console.log("       • 它收到的是【整个 state 对象】,不是某个标量字段");
  console.log("       • 它必须返回【状态增量对象】,例如 { signed: ... },不能直接返回字符串");
  console.log("");
  console.log("🎯 官方建议:简单处写函数、复杂处画图,二者嵌套使用,不必二选一。");
}

main().catch((e) => {
  console.error("❌ 运行失败:", e);
  process.exit(1);
});

运行:

bash
npx tsx 13-functional-api/code/03-entrypoint-in-graph.ts

真实输出:

========================================================================
  示例 3:函数式写法与图写法混用
========================================================================

👤 输入主题:LangGraph 学习研讨

   [task makeDraft] 为主题「LangGraph 学习研讨」生成草稿
   [entrypoint translate] 翻译:诚邀您参加「LangGraph 学习研讨」。
   [entrypoint sign] 作为节点,收到整个状态:{"topic":"LangGraph 学习研讨","draft":"诚邀您参加「LangGraph 学习研讨」。","english":"You are cordially invited to「LangGraph study seminar」。","signed":""}

📦 最终状态:
   草稿:诚邀您参加「LangGraph 学习研讨」。
   英文:You are cordially invited to「LangGraph study seminar」。
   最终:
        You are cordially invited to「LangGraph study seminar」。
        
        —— 蜗牛 敬上

🔑 三种混用方式小结:
   A. 图节点里 await task(...)          → 让 task 参与图的编排
   B. 图节点里 await entrypoint.invoke  → 把函数式逻辑当黑盒
   C. addNode(name, entrypoint)         → entrypoint 直接当节点

⚠️  C 的关键差异:当 entrypoint 当节点时,
       • 它收到的是【整个 state 对象】,不是某个标量字段
       • 它必须返回【状态增量对象】,例如 { signed: ... },不能直接返回字符串

🎯 官方建议:简单处写函数、复杂处画图,二者嵌套使用,不必二选一。

三种混用方式:

A. 图节点里 await task(...)         → 让 task 参与图的编排
B. 图节点里 await entrypoint.invoke  → 把函数式逻辑当黑盒调用
C. addNode(name, entrypoint)        → entrypoint 直接当节点

⚠️ C 方式有个必须知道的行为差异:当 entrypoint 被直接当节点挂进图时 ——

  • 它收到的是整个 state 对象,不是某个标量字段
  • 它必须返回状态增量对象(例如 { signed: "—— 蜗牛 敬上" }),不能直接返回字符串

否则会报 InvalidUpdateError: Expected node "xxx" to return an object or an array containing at least one Command object, received string。

这是本章最容易踩的一脚。示例 3 的输出里特地把这条打印出来了。

官方建议:简单处写函数、复杂处画图,二者嵌套使用,不必二选一。


五、状态与多轮记忆 ​

函数式写法的记忆机制和图的完全一样(都是 checkpointer + thread_id),但读写状态的方式更直接:

图的写法函数式写法
Annotation.Root 声明状态函数入参 + 返回值
reducer 合并并发更新你在函数里自己维护(如 [...history, ...])
checkpointer + thread_id完全一样
读上一步状态getPreviousState<T>()
——entrypoint.final({ value, save })

最后那个 entrypoint.final 是函数式 API 独有的能力:把「返回给调用方的值」和「存进 checkpoint、下一轮可读的值」分开。

示例 4:一个记得住名字的聊天机器人 ​

typescript
/**
 * 示例 4:函数式写法的"状态"与持久化 —— getPreviousState + checkpointer + thread_id
 *
 * 图的记忆靠 checkpointer + thread_id。函数式写法的记忆机制完全一样,
 * 只是读"上一轮状态"的方式更直接:调用 getPreviousState<T>() 即可拿到上一次
 * 由 entrypoint.final({ save }) 存进 checkpoint 的值。
 *
 * 三个关键点:
 *   1. entrypoint 选项里传 checkpointer(这里是内存版 MemorySaver)
 *   2. 调用时通过 config.configurable.thread_id 指定"房间号"
 *   3. 用 entrypoint.final({ value, save }) 把「返回给调用方的值」和
 *      「存进 checkpoint、下轮可读的值」分开 —— 这是函数式 API 独有的能力
 *
 * 运行:npx tsx 13-functional-api/code/04-entrypoint-state.ts
 */
import {
  entrypoint,
  getPreviousState,
  MemorySaver,
} from "@langchain/langgraph";
import {
  HumanMessage,
  type BaseMessage,
} from "@langchain/core/messages";
import { createModel, banner } from "../../lib/model.js";
import { assertChatKey } from "../../lib/env.js";

async function main() {
  banner("示例 4:函数式写法的状态与持久化(多轮记忆)");
  assertChatKey();

  const model = createModel({ temperature: 0 });

  // ── 一个记得住历史的聊天 entrypoint ─────────────────────────────
  //    save 存整段消息历史,value 只把"本轮回答"返回给调用方。
  const chat = entrypoint(
    { name: "chat", checkpointer: new MemorySaver() },
    async (userText: string) => {
      // ① 读上一轮存下的状态(首次调用是 undefined)
      const previous = getPreviousState<BaseMessage[]>();
      const history: BaseMessage[] = previous ?? [];
      console.log(
        `   [chat] 读到上一轮历史:${history.length} 条(${history
          .map((m) => m.getType())
          .join(", ") || "空"})`
      );

      // ② 拼接本轮消息,交给模型
      const messages = [...history, new HumanMessage(userText)];
      const response = await model.invoke(messages);

      // ③ value 给调用方;save 存进 checkpoint,供下一轮 getPreviousState 读取
      return entrypoint.final<string, BaseMessage[]>({
        value: String(response.content),
        save: [...messages, response],
      });
    }
  );

  /** 用同一个 entrypoint、不同 thread_id,跑一轮对话 */
  async function ask(threadId: string, text: string): Promise<string> {
    return chat.invoke(text, { configurable: { thread_id: threadId } });
  }

  // ── 线程 A:连续两轮,看它记不记得名字 ──────────────────────────
  console.log("🧵 线程 A(thread_id = \"thread-A\")");
  const a1 = "你好,我叫蜗牛,请记住我的名字。";
  console.log(`👤 ${a1}`);
  console.log(`🤖 ${await ask("thread-A", a1)}\n`);

  const a2 = "我刚才说我叫什么名字?只回答名字。";
  console.log(`👤 ${a2}`);
  console.log(`🤖 ${await ask("thread-A", a2)}\n`);

  // ── 线程 B:换个 thread_id,同样的图,完全失忆 ──────────────────
  console.log("🧵 线程 B(thread_id = \"thread-B\",全新会话)");
  console.log(`👤 ${a2}`);
  console.log(`🤖 ${await ask("thread-B", a2)}\n`);

  console.log("📌 结论:");
  console.log("   线程 A 记得「蜗牛」—— 因为 getPreviousState 读到了上一轮 save 的消息历史;");
  console.log("   线程 B 不知道 —— thread_id 不同,checkpoint 是完全独立的一份。");
  console.log("");
  console.log("🔑 函数式 API 的「状态」等价物:");
  console.log("   图的 State      ←→  entrypoint 的入参 + 返回值");
  console.log("   图的 reducer    ←→  你在函数里自己维护(例如 [...history, ...])");
  console.log("   图的 checkpointer ←→  entrypoint 选项里的 checkpointer(一模一样)");
  console.log("   读上一步状态     ←→  getPreviousState<T>()");
  console.log("   只存不返回       ←→  entrypoint.final({ value, save })");
}

main().catch((e) => {
  console.error("❌ 运行失败:", e);
  process.exit(1);
});

运行:

bash
npx tsx 13-functional-api/code/04-entrypoint-state.ts

真实输出:

========================================================================
  示例 4:函数式写法的状态与持久化(多轮记忆)
========================================================================

🧵 线程 A(thread_id = "thread-A")
👤 你好,我叫蜗牛,请记住我的名字。
   [chat] 读到上一轮历史:0 条(空)
🤖 你好,蜗牛!很高兴认识你。我已经记住了,在这个对话里我会叫你“蜗牛”。🐌

👤 我刚才说我叫什么名字?只回答名字。
   [chat] 读到上一轮历史:2 条(human, ai)
🤖 蜗牛

🧵 线程 B(thread_id = "thread-B",全新会话)
👤 我刚才说我叫什么名字?只回答名字。
   [chat] 读到上一轮历史:0 条(空)
🤖 你没有说名字。

📌 结论:
   线程 A 记得「蜗牛」—— 因为 getPreviousState 读到了上一轮 save 的消息历史;
   线程 B 不知道 —— thread_id 不同,checkpoint 是完全独立的一份。

🔑 函数式 API 的「状态」等价物:
   图的 State      ←→  entrypoint 的入参 + 返回值
   图的 reducer    ←→  你在函数里自己维护(例如 [...history, ...])
   图的 checkpointer ←→  entrypoint 选项里的 checkpointer(一模一样)
   读上一步状态     ←→  getPreviousState<T>()
   只存不返回       ←→  entrypoint.final({ value, save })

对照很清楚:

  • 线程 A 第二轮问"我叫什么",它答"蜗牛"—— 因为 getPreviousState 读到了上一轮 save 进去的完整消息历史
  • 线程 B 是全新 thread_id,问同一句话,它答"你没有说名字"

💡 value 与 save 的分工值得记住:save 存整段历史(下轮要读的),value 只把"本轮回答"返回给调用方。如果两者不分,调用方拿到的就是一堆历史消息而不是答案 —— 这在做 HTTP 接口时尤其重要。


六、三套 API 怎么选 ​

到这一步,你已经见过 LangGraph 的三套写法了。同一个需求写三遍,差别一目了然。

示例 5:同一需求,三种实现 ​

需求:算 (1234567 × 89) + (98765 × 4321),必须用计算器工具、不许心算。

typescript
/**
 * 示例 5:三套 API 怎么选 —— 同一个需求,三种写法并排
 *
 * 需求(三份实现完全一致):一个"会算数"的助手。
 *   用户用自然语言问一道数学题 → 助手决定调用 calculator 工具 → 回答结果。
 *
 * 三种写法:
 *   ① StateGraph         —— 手工画 ReAct 图(agent 节点 + tools 节点 + 条件边)
 *   ② entrypoint + task  —— 把 ReAct 循环写成一个 while 循环,工具调用是 task
 *   ③ createReactAgent   —— 官方预置,一行搞定
 *
 * 程序会在运行时读自己的源码,统计三份实现"定义流程"的实际代码行数,再给出决策表。
 *
 * 运行:npx tsx 13-functional-api/code/05-choose-api.ts
 */
import { readFileSync } from "node:fs";
import {
  StateGraph,
  START,
  END,
  MessagesAnnotation,
  entrypoint,
  task,
} from "@langchain/langgraph";
import { ToolNode, createReactAgent } from "@langchain/langgraph/prebuilt";
import {
  AIMessage,
  HumanMessage,
  SystemMessage,
  ToolMessage,
  type BaseMessage,
} from "@langchain/core/messages";
import { tool } from "@langchain/core/tools";
import * as z from "zod";
import { evaluate } from "mathjs";
import { createModel, banner } from "../../lib/model.js";
import { assertChatKey } from "../../lib/env.js";

// ── 共用的工具与模型 ──────────────────────────────────────────────
const calculator = tool(
  async ({ expression }) => {
    console.log(`      🔧 calculator("${expression}")`);
    return String(evaluate(expression));
  },
  {
    name: "calculator",
    description: "执行数学计算。当需要计算数学表达式时使用。",
    schema: z.object({
      expression: z.string().describe("要计算的数学表达式,例如 '25 * 8'"),
    }),
  }
);

const tools = [calculator];
const baseModel = createModel({ temperature: 0 });

// 三份实现共用同一段系统提示,强制模型走「调工具」而不是心算,
// 这样三种写法在同一个问题上都会真正触发 tool 调用,便于对照。
const SYSTEM_PROMPT =
  "你是计算助手。任何涉及数字计算的问题,都必须调用 calculator 工具来获得结果,严禁心算或自行推导。";

// ══════════════════════════════════════════════════════════════════
// ① StateGraph:手工画 ReAct 图
// #region graph
const modelWithTools = baseModel.bindTools(tools);

const reactGraph = new StateGraph(MessagesAnnotation)
  .addNode("agent", async (state) => {
    const response = await modelWithTools.invoke([
      new SystemMessage(SYSTEM_PROMPT),
      ...state.messages,
    ]);
    return { messages: [response] };
  })
  .addNode("tools", new ToolNode(tools))
  .addEdge(START, "agent")
  .addEdge("tools", "agent")
  .addConditionalEdges(
    "agent",
    (state) => {
      const last = state.messages[state.messages.length - 1] as AIMessage;
      return last.tool_calls?.length ? "tools" : END;
    },
    ["tools", END]
  )
  .compile();
// #endregion graph

// ══════════════════════════════════════════════════════════════════
// ② entrypoint + task:把 ReAct 循环写成普通 while 循环
// #region functional
const callModelTask = task("callModel", async (messages: BaseMessage[]) => {
  return modelWithTools.invoke(messages);
});

const runToolTask = task(
  "runTool",
  async (name: string, args: Record<string, unknown>) => {
    if (name === "calculator") {
      console.log(`      🔧 calculator("${String(args.expression)}")  ← 由 task runTool 执行`);
      return String(evaluate(String(args.expression)));
    }
    return `未知工具:${name}`;
  }
);

const reactFlow = entrypoint("reactAgent", async (question: string) => {
  const messages: BaseMessage[] = [
    new SystemMessage(SYSTEM_PROMPT),
    new HumanMessage(question),
  ];
  for (let step = 0; step < 6; step++) {
    const ai = (await callModelTask(messages)) as AIMessage;
    messages.push(ai);
    const calls = ai.tool_calls ?? [];
    console.log(
      `      第 ${step + 1} 轮:模型返回 ${calls.length > 0 ? `要调用 ${calls.map((c) => c.name).join(", ")}` : "最终回答"}`
    );
    if (calls.length === 0) return String(ai.content);
    for (const call of calls) {
      const output = await runToolTask(
        call.name,
        (call.args ?? {}) as Record<string, unknown>
      );
      messages.push(
        new ToolMessage({
          content: output,
          tool_call_id: call.id ?? "",
          name: call.name,
        })
      );
    }
  }
  return "(达到最大轮数,未得出最终答案)";
});
// #endregion functional

// ══════════════════════════════════════════════════════════════════
// ③ createReactAgent:官方预置
// #region prebuilt
const agent = createReactAgent({ llm: baseModel, tools, prompt: SYSTEM_PROMPT });
// #endregion prebuilt

/** 统计源码里某 region 内"非空、非纯注释"的行数 */
function countRegion(source: string, region: string): number {
  const lines = source.split("\n");
  const start = lines.findIndex((l) => l.includes(`#region ${region}`));
  const end = lines.findIndex((l) => l.includes(`#endregion ${region}`));
  return lines
    .slice(start + 1, end)
    .filter((l) => l.trim() !== "" && !l.trim().startsWith("//") && !l.trim().startsWith("*"))
    .length;
}

async function main() {
  banner("示例 5:三套 API 怎么选(同一需求,三种写法)");
  assertChatKey();

  const question = "计算 (1234567 × 89) + (98765 × 4321) 等于多少?请使用计算器工具,不要心算。";
  console.log(`👤 提问:${question}\n`);

  // ── ① StateGraph ────────────────────────────────────────────
  console.log("【① StateGraph 手工图】");
  const r1 = await reactGraph.invoke({ messages: [new HumanMessage(question)] });
  const a1 = r1.messages[r1.messages.length - 1].content;
  console.log(`🤖 ${a1}\n`);

  // ── ② entrypoint + task ─────────────────────────────────────
  console.log("【② entrypoint + task 函数式】");
  const a2 = await reactFlow.invoke(question);
  console.log(`🤖 ${a2}\n`);

  // ── ③ createReactAgent ──────────────────────────────────────
  console.log("【③ createReactAgent 预置】");
  const r3 = await agent.invoke({ messages: [new HumanMessage(question)] });
  const a3 = r3.messages[r3.messages.length - 1].content;
  console.log(`🤖 ${a3}\n`);

  // ── 代码行数统计 ────────────────────────────────────────────
  const source = readFileSync(new URL(import.meta.url), "utf-8");
  const g = countRegion(source, "graph");
  const f = countRegion(source, "functional");
  const p = countRegion(source, "prebuilt");

  console.log("📊 「定义流程」的代码行数(不含共用工具/模型):");
  console.log(`   ① StateGraph        :${g} 行`);
  console.log(`   ② entrypoint + task  :${f} 行`);
  console.log(`   ③ createReactAgent   :${p} 行`);
  console.log("");
  console.log("📐 结构差异:");
  console.log("   ① StateGraph       :显式画出节点与边,结构一目了然,可可视化、可插入校验节点");
  console.log("   ② entrypoint+task  :把循环写成 while,工具调用抽成 task(可并行/重试)");
  console.log("   ③ createReactAgent :固定 ReAct 结构,一行搞定,但改不动内部结构");
  console.log("");
  console.log("🧭 决策表:");
  console.log("   ┌────────────────────────────────────────────┬──────────────────────┐");
  console.log("   │ 你的需求                                    │ 推荐写法              │");
  console.log("   ├────────────────────────────────────────────┼──────────────────────┤");
  console.log("   │ 标准 ReAct 智能体、无需定制                  │ createReactAgent      │");
  console.log("   │ 需要在模型/工具前后插入校验、审批、改写        │ StateGraph            │");
  console.log("   │ 分支繁多、需要循环/并行、要看得见结构          │ StateGraph            │");
  console.log("   │ 直线流程、步骤固定、想代码短                  │ entrypoint + task     │");
  console.log("   │ 大量可并行子步骤(如批量调用)                │ entrypoint + Promise.all(task) │");
  console.log("   │ 简单逻辑 + 局部复杂:混合                      │ entrypoint 里嵌 StateGraph │");
  console.log("   │ 无状态、纯直线、无中断                        │ 干脆留在 LangChain(链) │");
  console.log("   └────────────────────────────────────────────┴──────────────────────┘");
  console.log("");
  console.log("🎯 一句话:图擅长「结构」,函数式擅长「自由」。");
  console.log("   先拿 StateGraph 想清楚结构,局部再用 entrypoint/task 让代码变短变清爽。");
}

main().catch((e) => {
  console.error("❌ 运行失败:", e);
  process.exit(1);
});

运行:

bash
npx tsx 13-functional-api/code/05-choose-api.ts

真实输出:

========================================================================
  示例 5:三套 API 怎么选(同一需求,三种写法)
========================================================================

👤 提问:计算 (1234567 × 89) + (98765 × 4321) 等于多少?请使用计算器工具,不要心算。

【① StateGraph 手工图】
      🔧 calculator("1234567 * 89")
      🔧 calculator("98765 * 4321")
      🔧 calculator("109876463 + 426763565")
🤖 计算完成:

- 1234567 × 89 = 109876463
- 98765 × 4321 = 426763565
- 两者之和:109876463 + 426763565 = **536640028**

所以 (1234567 × 89) + (98765 × 4321) = **536,640,028**

【② entrypoint + task 函数式】
      第 1 轮:模型返回 要调用 calculator
      🔧 calculator("(1234567 * 89) + (98765 * 4321)")  ← 由 task runTool 执行
      第 2 轮:模型返回 最终回答
🤖 计算结果为:

**(1234567 × 89) + (98765 × 4321) = 536640028**

其中:
- 1234567 × 89 = 109876463
- 98765 × 4321 = 426763565

两部分相加得到 **536640028**。

【③ createReactAgent 预置】
      🔧 calculator("1234567 * 89")
      🔧 calculator("98765 * 4321")
      🔧 calculator("109876463 + 426763565")
🤖 计算结果如下:

- 1234567 × 89 = 109876463
- 98765 × 4321 = 426763565
- 两者相加:109876463 + 426763565 = **536640028**

所以,(1234567 × 89) + (98765 × 4321) = **536640028**。

📊 「定义流程」的代码行数(不含共用工具/模型):
   ① StateGraph        :21 行
   ② entrypoint + task  :42 行
   ③ createReactAgent   :1 行

📐 结构差异:
   ① StateGraph       :显式画出节点与边,结构一目了然,可可视化、可插入校验节点
   ② entrypoint+task  :把循环写成 while,工具调用抽成 task(可并行/重试)
   ③ createReactAgent :固定 ReAct 结构,一行搞定,但改不动内部结构

🧭 决策表:
   ┌────────────────────────────────────────────┬──────────────────────┐
   │ 你的需求                                    │ 推荐写法              │
   ├────────────────────────────────────────────┼──────────────────────┤
   │ 标准 ReAct 智能体、无需定制                  │ createReactAgent      │
   │ 需要在模型/工具前后插入校验、审批、改写        │ StateGraph            │
   │ 分支繁多、需要循环/并行、要看得见结构          │ StateGraph            │
   │ 直线流程、步骤固定、想代码短                  │ entrypoint + task     │
   │ 大量可并行子步骤(如批量调用)                │ entrypoint + Promise.all(task) │
   │ 简单逻辑 + 局部复杂:混合                      │ entrypoint 里嵌 StateGraph │
   │ 无状态、纯直线、无中断                        │ 干脆留在 LangChain(链) │
   └────────────────────────────────────────────┴──────────────────────┘

🎯 一句话:图擅长「结构」,函数式擅长「自由」。
   先拿 StateGraph 想清楚结构,局部再用 entrypoint/task 让代码变短变清爽。

三种写法都算对了,但「定义流程」的代码行数差得很远:

① StateGraph        :21 行
② entrypoint + task :42 行
③ createReactAgent  : 1 行

行数不等于好坏。 三者的定位完全不同:

① StateGraph       :显式画出节点与边,结构一目了然,可可视化、可插入校验节点
② entrypoint+task  :把循环写成 while,工具调用抽成 task(可并行/重试)
③ createReactAgent :固定 ReAct 结构,一行搞定,但改不动内部结构

⚠️ 注意 ② 的行数(42 行)比 ① (21 行)多。这不是笔误:做 ReAct 循环这种"图天生擅长"的事,手写函数式反而更啰嗦 —— 你得自己写 while、自己判断 tool_calls、自己拼消息。函数式 API 的优势场景是直线流程和可并行子步骤,不是循环式智能体。

选写法的第一条原则是:先想清楚你的流程是什么形状,再挑工具。

决策表

你的需求推荐写法
标准 ReAct 智能体、无需定制createReactAgent
需要在模型/工具前后插入校验、审批、改写StateGraph
分支繁多、需要循环/并行、要看得见结构StateGraph
直线流程、步骤固定、想代码短entrypoint + task
大量可并行子步骤(如批量调用)entrypoint + Promise.all(task)
简单逻辑 + 局部复杂:混合entrypoint 里嵌 StateGraph
无状态、纯直线、无中断干脆留在 LangChain(链)

最后一行是诚实的建议:不是所有东西都值得用 LangGraph。如果一件事就是"输入 → 调模型 → 输出",用链就好 —— 这一点第 11 章专门讲过。


七、本章小结 ​

本章速查表

你要做的事代码
定义一个函数式"图"entrypoint(async (input, config) => { ... })
定义可复用的子步骤task(async (...) => { ... }),或 task("名字", fn)
并行执行多个 taskawait Promise.all([t1(), t2(), t3()])
给 task 加重试/缓存/超时task({ retry, cachePolicy, timeout }, fn)
把 entrypoint 当节点挂进图.addNode("名字", 已定义的 entrypoint)(收到整个 state,必须返回状态增量)
读上一轮状态getPreviousState<T>()
分开"返回值"与"存档值"entrypoint.final({ value, save })
加持久化entrypoint({ name, checkpointer }, fn) + 调用时传 thread_id

三句话总结

  1. LangGraph 有两套写法:画图(StateGraph)和写代码(entrypoint + task)。不是替代关系,是形状适配关系。
  2. task 的价值在于白捡四样东西:可命名、可并行、可重试、可缓存 —— 这些都是裸函数没有的。
  3. 选写法的顺序是"先看形状,再挑工具":循环式智能体 → 图或 createReactAgent;直线流程 / 可并行子步骤 → 函数式;简单线性 → 留在 LangChain。

动手练习

  1. 把示例 2 的 4 个 task 改成"3 个并行 + 1 个依赖前 3 个结果",画出依赖关系后再写代码,对比耗时。
  2. 把示例 4 的 chat 改造成"只保留最近 6 条消息"的版本(在 save 里裁剪历史),观察长对话时 getPreviousState 读到几条。
  3. 用 entrypoint 重写第 1 章示例 3 的 ReAct 循环,数一数要写多少行 —— 亲手验证"循环不是函数式的强项"这句话。

下一章预告

最后一章,我们把图送到真正使用它的人面前:用 SSE 把 token 流式推给前端、定义前后端之间的事件协议、把"等人审批"这个动作通过 HTTP 传出去再传回来、以及给出一份可以直接复制到 React 项目里的接入片段。还会把官方现成的 UI 生态(Agent Chat UI、生成式 UI 例子)盘一遍。


← 上一章:测试与调试 | 返回总目录 | 下一章:前端集成 →

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