第 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.ts | task 的并行:串行 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) |
| 并行执行多个 task | await 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 |
三句话总结
- LangGraph 有两套写法:画图(
StateGraph)和写代码(entrypoint+task)。不是替代关系,是形状适配关系。 task的价值在于白捡四样东西:可命名、可并行、可重试、可缓存 —— 这些都是裸函数没有的。- 选写法的顺序是"先看形状,再挑工具":循环式智能体 → 图或
createReactAgent;直线流程 / 可并行子步骤 → 函数式;简单线性 → 留在 LangChain。
动手练习
- 把示例 2 的 4 个 task 改成"3 个并行 + 1 个依赖前 3 个结果",画出依赖关系后再写代码,对比耗时。
- 把示例 4 的
chat改造成"只保留最近 6 条消息"的版本(在save里裁剪历史),观察长对话时getPreviousState读到几条。 - 用
entrypoint重写第 1 章示例 3 的 ReAct 循环,数一数要写多少行 —— 亲手验证"循环不是函数式的强项"这句话。
下一章预告
最后一章,我们把图送到真正使用它的人面前:用 SSE 把 token 流式推给前端、定义前后端之间的事件协议、把"等人审批"这个动作通过 HTTP 传出去再传回来、以及给出一份可以直接复制到 React 项目里的接入片段。还会把官方现成的 UI 生态(Agent Chat UI、生成式 UI 例子)盘一遍。