第 8 章 · 工程化与部署
🎯 本章目标
- 让节点「抗造」:用
retryPolicy自动重试临时失败,用缓存避免重复劳动 - 分清错误类型,走对三条路:重试 / 降级 / 直接失败,并记住一张判定表
- 把图用 Node 自带的
node:http包成一个能对外服务的 HTTP 接口(零额外依赖) - 用 checkpointer +
thread_id让服务记住多轮会话,不同用户互不串味 - 给图装上「可观测性」:接 LangSmith(云追踪)+ 自写
traced()包装器(本地追踪) - 了解
langgraph.json+langgraph-cli本地开发、以及 LangGraph Studio / Platform 部署的全貌
本章代码
📌 代码约定:正文的代码块都保持可直接运行(含 import 与模型初始化)。若某段为聚焦概念的节选,会明确标注「节选」并指向同名的
code/完整文件。所有示例的输出都是真实运行结果,不是编造的示例输出。
| 文件 | 内容 |
|---|---|
code/01-retry-policy.ts | 节点重试:retryPolicy 的指数退避,前两次失败第三次成功 |
code/02-node-caching.ts | 节点缓存:compile({ cache }) + cachePolicy,相同输入免执行 |
code/03-error-recovery.ts | 错误恢复三对照:临时错重试 / 业务错降级 / 死循环兜底 |
code/04-http-server.ts | 用 node:http 把图包成服务,多轮会话 + 自测后自动退出 |
code/05-observability.ts | 可观测性:LangSmith 状态检测 + 本地 traced() 节点追踪 |
一、让图更「抗造」:重试与缓存
前面七章我们一直在「把图搭出来」。从这一章起,问题变成「把它放上生产」。
真实系统里,节点天生会出问题:网络会抖、第三方接口会偶发 502、数据库会死锁。一个只会在「一切顺利」时工作的图,是不够的。这一节先解决两件最普遍的工程问题:
- 失败了怎么办 → 重试(
retryPolicy); - 重复算了怎么办 → 缓存(
cachePolicy+compile({ cache }))。
示例 1:节点重试(retryPolicy)
addNode 的第三个参数可以传一个 retryPolicy。节点抛异常时,框架会自动按「指数退避」的节奏重试:第一次等 initialInterval,之后每次把间隔乘以 backoffFactor,直到成功或达到 maxAttempts。
本示例故意做一个「前两次必失败、第三次一定成功」的节点,打印每次尝试的间隔,用真实耗时证明重试真的发生、且间隔成倍增长。
typescript
/**
* 示例 1:节点重试(retryPolicy)—— 让「偶发失败」的节点自己爬起来
*
* 真实系统里节点是会失败的:网络抖动、第三方接口偶发 502、数据库死锁……
* 这些错误大多只是「临时」的,等一两百毫秒再试一次往往就好了。
*
* LangGraph 把重试能力做进了框架:给 addNode 传一个 retryPolicy,
* 节点抛异常时框架会自动按「指数退避」的节奏重试,直到成功或达到上限。
*
* 本示例故意做一个「前两次必失败、第三次一定成功」的节点,
* 打印每次尝试的序号,以及两次尝试之间的实际等待时间,
* 用真实耗时证明两件事:
* 1) 重试确实发生了;
* 2) 每次重试的等待间隔在成倍增长(这就是指数退避)。
*/
import { StateGraph, START, END, Annotation } from "@langchain/langgraph";
import { banner } from "../../lib/model.js";
// ── 状态:一个占位输入 + 最终结果 ──────────────────────────────
const RetryState = Annotation.Root({
/** 任务名,用来让输入保持稳定(缓存/重试都依赖输入) */
task: Annotation<string>,
/** 重试成功后写回的结果 */
result: Annotation<string>,
});
async function main() {
banner("示例 1:节点重试(retryPolicy)—— 指数退避");
// ── 计数器:记录这个节点总共被调用了第几次 ─────────────────────
let attempts = 0;
/** 上一次「失败」发生的时刻(毫秒时间戳),用来量两次尝试的间隔 */
let lastFailAt = 0;
async function flakyNode(state: typeof RetryState.State) {
attempts += 1;
const now = Date.now();
const gap = lastFailAt === 0 ? 0 : now - lastFailAt;
console.log(
` [节点 flaky] 第 ${attempts} 次尝试(任务:${state.task})` +
(gap > 0 ? `,距上次失败 ${gap}ms` : "")
);
if (attempts < 3) {
// 前两次抛出异常 → 框架读 retryPolicy,决定「等一会儿再试」
lastFailAt = Date.now();
throw new Error(`模拟临时故障:第 ${attempts} 次尝试失败`);
}
// 第三次不再抛异常 → 节点成功,图继续往下走
return { result: `✅ 第 ${attempts} 次尝试成功` };
}
const graph = new StateGraph(RetryState)
.addNode("flaky", flakyNode, {
retryPolicy: {
maxAttempts: 3, // 最多尝试 3 次
initialInterval: 200, // 第一次重试前等 200ms
backoffFactor: 3, // 之后每次间隔 ×3:200ms → 600ms
jitter: false, // 关掉随机抖动,让间隔可预测,方便验证指数退避
},
})
.addEdge(START, "flaky")
.addEdge("flaky", END)
.compile();
console.log('👤 调用图(初始状态 {"task":"下载报表"})\n');
const t0 = Date.now();
const result = await graph.invoke({ task: "下载报表" });
const cost = Date.now() - t0;
console.log(`\n📦 最终状态:${JSON.stringify(result)}`);
console.log(`\n⏱️ 整图耗时:${cost}ms`);
console.log(" 期待两次退避等待:200ms + 600ms = 800ms(其余是执行开销)");
console.log(`\n📊 节点总共被调用 ${attempts} 次:前 2 次失败,第 3 次成功。`);
}
main().catch((e) => {
console.error("❌ 调用失败:", e);
process.exit(1);
});运行:
bash
npx tsx 08-production/code/01-retry-policy.ts真实输出:
========================================================================
示例 1:节点重试(retryPolicy)—— 指数退避
========================================================================
👤 调用图(初始状态 {"task":"下载报表"})
[节点 flaky] 第 1 次尝试(任务:下载报表)
Retrying task "flaky" after 200.00ms (attempt 1) after Error: Error: 模拟临时故障:第 1 次尝试失败
[节点 flaky] 第 2 次尝试(任务:下载报表),距上次失败 203ms
Retrying task "flaky" after 600.00ms (attempt 2) after Error: Error: 模拟临时故障:第 2 次尝试失败
[节点 flaky] 第 3 次尝试(任务:下载报表),距上次失败 603ms
📦 最终状态:{"task":"下载报表","result":"✅ 第 3 次尝试成功"}
⏱️ 整图耗时:828ms
期待两次退避等待:200ms + 600ms = 800ms(其余是执行开销)
📊 节点总共被调用 3 次:前 2 次失败,第 3 次成功。💡 看两个关键数字:第 2 次尝试「距上次失败 203ms」、第 3 次「距上次失败 603ms」。这就是
initialInterval: 200+backoffFactor: 3的效果 —— 等待时间200ms → 600ms,成倍增长,整图耗时也随之落在800ms附近。中间那两行Retrying task "flaky" after 200.00ms ...是框架自动打印的告警(受logWarning控制,默认开),它直接告诉你「第几次重试、等多久」,排查时非常好用。
retryPolicy 的字段(读 .d.ts 确认过)
| 字段 | 含义 | 默认值 |
|---|---|---|
maxAttempts | 最多尝试几次(含首次) | 3 |
initialInterval | 第一次重试前的等待毫秒数 | 500 |
backoffFactor | 每次重试等待时间的倍数(指数退避) | 2 |
maxInterval | 单次等待的上限毫秒数 | 128000 |
jitter | 是否给等待时间加随机抖动(防止重试风暴) | true |
retryOn | 函数 (err) => boolean,返回 true 的错误才重试 | 所有错误 |
logWarning | 重试时是否打印告警日志 | true |
⚠️ 两个常见坑:
jitter默认是true,等待时间会带随机扰动。想让它「可预测、可断言」(就像本示例)就把它设为false。- 重试不是万能药。像「参数不合法」这种业务错,重试一万次也不会变好 —— 那是示例 3 要讲的「降级」。给每个节点的
retryOn加上过滤,只重试真正临时的错误。
示例 2:节点缓存(cachePolicy + compile({ cache }))
有些节点很「贵」:调大模型、查数据库、跑耗时计算。如果同样的输入会反复出现,把结果缓存下来能省下大量时间与成本。
LangGraph 的做法分两步:
compile({ cache: new InMemoryCache() })—— 给整张图配一个缓存存储;addNode("节点名", fn, { cachePolicy: true })—— 声明这个节点允许被缓存。
命中缓存时,框架用「节点的输入」算 key,直接返回上次的结果,节点函数根本不会被调用 —— 这正是本示例要证明的点。
typescript
/**
* 示例 2:节点缓存 —— 相同输入直接复用上次结果
*
* 有些节点很「贵」:调用大模型、查数据库、跑一个耗时的计算……
* 如果同样的输入会反复出现,把结果缓存下来就能省下大量时间与成本。
*
* LangGraph 的做法分两步:
* 1) compile({ cache: new InMemoryCache() }) —— 给整张图配一个缓存存储;
* 2) addNode("节点名", fn, { cachePolicy: true }) —— 声明这个节点允许被缓存。
*
* 缓存命中时,框架用「节点的输入」算出 key,直接返回上次的结果,
* 节点函数根本不会被调用 —— 这是本示例要证明的关键点。
*
* ⚠️ 版本说明:不同 LangGraph.js 版本里缓存 API 的名字略有出入。
* 在本书锁定的版本中,缓存存储 InMemoryCache 从
* @langchain/langgraph-checkpoint 导入,传给 compile({ cache });
* 节点侧用 cachePolicy: true(也可传 { keyFunc, ttl } 精细控制)。
* 若你的版本对不上,请以本示例为准,并查阅对应版本的 .d.ts。
*/
import { StateGraph, START, END, Annotation } from "@langchain/langgraph";
import { InMemoryCache } from "@langchain/langgraph-checkpoint";
import { banner } from "../../lib/model.js";
// ── 状态:输入 n,输出它的平方 ─────────────────────────────────
const CacheState = Annotation.Root({
n: Annotation<number>,
square: Annotation<number>,
});
/** 模拟一次耗时昂贵的操作 */
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function main() {
banner("示例 2:节点缓存(compile({ cache }) + cachePolicy)");
// 记录节点函数「真正被执行」的次数。命中缓存时这个数字不会增长。
let executions = 0;
async function slowSquare(state: typeof CacheState.State) {
executions += 1;
console.log(` [节点 slowSquare] ⏳ 真正执行第 ${executions} 次(耗时计算开始)…`);
await sleep(800); // 假装这是一个很贵的调用
const square = state.n * state.n;
console.log(` [节点 slowSquare] ✅ 计算完成:${state.n}² = ${square}`);
return { square };
}
const graph = new StateGraph(CacheState)
.addNode("slowSquare", slowSquare, { cachePolicy: true }) // ② 声明可缓存
.addEdge(START, "slowSquare")
.addEdge("slowSquare", END)
.compile({ cache: new InMemoryCache() }); // ① 装上缓存存储
// ── 第 1 次调用:n=7,缓存里没有 → 节点真正执行 ─────────────────
console.log("🔹 第 1 次调用(n=7,缓存未命中)");
let t = Date.now();
let r = await graph.invoke({ n: 7 });
console.log(` → 结果 ${r.square},耗时 ${Date.now() - t}ms\n`);
// ── 第 2 次调用:还是 n=7 → 命中缓存 → 节点不执行 ──────────────
console.log("🔹 第 2 次调用(n=7,与上次完全相同,应命中缓存)");
t = Date.now();
r = await graph.invoke({ n: 7 });
console.log(` → 结果 ${r.square},耗时 ${Date.now() - t}ms\n`);
// ── 第 3 次调用:换成 n=9 → 新输入 → 节点再次执行 ──────────────
console.log("🔹 第 3 次调用(n=9,新输入,缓存未命中)");
t = Date.now();
r = await graph.invoke({ n: 9 });
console.log(` → 结果 ${r.square},耗时 ${Date.now() - t}ms\n`);
console.log(`📊 节点函数共真正执行 ${executions} 次(3 次调用中,第 2 次命中了缓存)。`);
console.log(" 结论:同样的输入,第 2 次比第 1 次快了一个数量级。");
}
main().catch((e) => {
console.error("❌ 调用失败:", e);
process.exit(1);
});运行:
bash
npx tsx 08-production/code/02-node-caching.ts真实输出:
========================================================================
示例 2:节点缓存(compile({ cache }) + cachePolicy)
========================================================================
🔹 第 1 次调用(n=7,缓存未命中)
[节点 slowSquare] ⏳ 真正执行第 1 次(耗时计算开始)…
[节点 slowSquare] ✅ 计算完成:7² = 49
→ 结果 49,耗时 824ms
🔹 第 2 次调用(n=7,与上次完全相同,应命中缓存)
→ 结果 49,耗时 4ms
🔹 第 3 次调用(n=9,新输入,缓存未命中)
[节点 slowSquare] ⏳ 真正执行第 2 次(耗时计算开始)…
[节点 slowSquare] ✅ 计算完成:9² = 81
→ 结果 81,耗时 804ms
📊 节点函数共真正执行 2 次(3 次调用中,第 2 次命中了缓存)。
结论:同样的输入,第 2 次比第 1 次快了一个数量级。💡 证据就在两处:
- 第 2 次调用没有任何
[节点 slowSquare]的日志 → 节点函数没被执行;- 耗时从
824ms掉到4ms→ 快了两百倍。这两点合起来才能证明「缓存真的命中了」。如果只测时间,可能有别的解释;只测日志,可能被覆盖。两个一起看,铁证。
⚠️ 缓存 API 的版本注意:本章锁定版本里,缓存对象
InMemoryCache从@langchain/langgraph-checkpoint导入(不是@langchain/langgraph,也不是@langchain/core/caches)。节点侧用cachePolicy: true;想精细控制就传{ keyFunc, ttl }(ttl单位是秒,不填则永不过期)。这是本示例实测跑通的确切写法。
二、错误恢复:三种错,三条路
示例 1 只讲了「重试」。但工程里更重要的判断是:这个错到底该不该重试? 答案是 —— 不同错走不同的路。本示例把三种典型情况摆在一起对照。
示例 3:临时错重试 / 业务错降级 / 死循环兜底
typescript
/**
* 示例 3:错误恢复 —— 三种错,三条不同的路
*
* 工程化的核心问题之一:节点报错了怎么办?答案不是「一律重试」,
* 而是要分清错误类型,走不同的路:
*
* ① 可重试的临时错(网络抖动)→ 交给 retryPolicy 自动重试;
* ② 不可重试的业务错(参数不合法)→ 不重试,交给 errorHandler 优雅降级;
* ③ 死循环触发的 GraphRecursionError → 在 invoke 外层 try/catch 兜住。
*
* 本示例用三张独立的小图分别演示这三条路。
*/
import {
StateGraph,
START,
END,
Annotation,
GraphRecursionError,
type NodeError,
} from "@langchain/langgraph";
import { banner } from "../../lib/model.js";
/** 临时性错误:等一会儿重试就好 */
class TransientError extends Error {}
/** 业务性错误:重试多少次都没用,只能降级或报给用户 */
class BusinessError extends Error {}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// ════════════════════════════════════════════════════════════════
// 场景 ①:可重试的临时错
// ════════════════════════════════════════════════════════════════
const NetState = Annotation.Root({
request: Annotation<string>,
data: Annotation<string>,
});
async function demoTransient() {
console.log("① 可重试的临时错(模拟一次网络抖动)");
let tries = 0;
async function callRemote(state: typeof NetState.State) {
tries += 1;
console.log(` [节点 callRemote] 第 ${tries} 次请求「${state.request}」`);
if (tries < 2) {
throw new TransientError("连接被重置(网络抖动)");
}
return { data: "远端返回的正常数据" };
}
const graph = new StateGraph(NetState)
.addNode("callRemote", callRemote, {
retryPolicy: {
maxAttempts: 3,
initialInterval: 100,
backoffFactor: 2,
jitter: false,
// 只重试「临时错」,其它错误一律不重试
retryOn: (e: unknown) => e instanceof TransientError,
},
})
.addEdge(START, "callRemote")
.addEdge("callRemote", END)
.compile();
const r = await graph.invoke({ request: "GET /report" });
console.log(` → 最终拿到:${r.data}(共尝试 ${tries} 次)\n`);
}
// ════════════════════════════════════════════════════════════════
// 场景 ②:不可重试的业务错 —— 用 errorHandler 优雅降级
// ════════════════════════════════════════════════════════════════
const BizState = Annotation.Root({
amount: Annotation<number>,
result: Annotation<string>,
degraded: Annotation<boolean>,
});
async function demoBusiness() {
console.log("② 不可重试的业务错(金额非法的转账请求)");
let tries = 0;
async function transfer(state: typeof BizState.State) {
tries += 1;
console.log(` [节点 transfer] 校验金额 amount=${state.amount}`);
// 业务规则:金额必须大于 0,否则是这个请求本身的问题,重试没用
if (state.amount <= 0) {
throw new BusinessError(`非法金额:${state.amount}(必须大于 0)`);
}
return { result: `成功转账 ${state.amount} 元` };
}
const graph = new StateGraph(BizState)
.addNode("transfer", transfer, {
retryPolicy: {
maxAttempts: 3,
initialInterval: 50,
jitter: false,
// BusinessError 不属于临时错 → 一次都不重试
retryOn: (e: unknown) => e instanceof TransientError,
},
// 节点重试彻底失败后才会走到这里:优雅降级,而不是让整张图崩掉
errorHandler: (state: typeof BizState.State, error: NodeError) => {
console.log(` [errorHandler] 捕获到 ${error.node} 的失败:${error.error.message}`);
console.log(" [errorHandler] 判定为业务错 → 不重试,转入人工审核(降级)");
return {
result: "⚠️ 转账失败:金额非法,单据已转人工审核",
degraded: true,
} satisfies Partial<typeof BizState.State>;
},
})
.addEdge(START, "transfer")
.addEdge("transfer", END)
.compile();
const r = await graph.invoke({ amount: -100, degraded: false });
console.log(` → 结果:${r.result}(degraded=${r.degraded},transfer 仅尝试 ${tries} 次)\n`);
}
// ════════════════════════════════════════════════════════════════
// 场景 ③:死循环引发的 GraphRecursionError
// ════════════════════════════════════════════════════════════════
const LoopState = Annotation.Root({
n: Annotation<number>,
});
async function demoRecursion() {
console.log("③ 死循环(忘记写退出条件)被框架拦下");
async function tick(state: typeof LoopState.State) {
console.log(` [节点 tick] n=${state.n}`);
// 忘了写退出条件:n 一直加、条件边永远指回自己 → 死循环
return { n: state.n + 1 };
}
const graph = new StateGraph(LoopState)
.addNode("tick", tick)
.addEdge(START, "tick")
// 无条件指回自己,制造一个真正的死循环
.addConditionalEdges("tick", () => "tick", ["tick"])
.compile();
try {
// recursionLimit 数的是「超步次数」,这里故意设得很小
await graph.invoke({ n: 0 }, { recursionLimit: 6 });
console.log(" ❌ 意外:死循环竟然没被拦下");
} catch (e) {
if (e instanceof GraphRecursionError) {
console.log(` ✅ 被拦下:捕获到 ${e.constructor.name}`);
console.log(` 信息:${e.message.split("\n")[0]}`);
console.log(" 处理方式:交给运维告警 / 回退到安全的兜底逻辑。\n");
} else {
throw e;
}
}
}
async function main() {
banner("示例 3:错误恢复 —— 临时错重试 / 业务错降级 / 死循环兜底");
await demoTransient();
await demoBusiness();
await demoRecursion();
}
main().catch((e) => {
console.error("❌ 调用失败:", e);
process.exit(1);
});运行:
bash
npx tsx 08-production/code/03-error-recovery.ts真实输出:
========================================================================
示例 3:错误恢复 —— 临时错重试 / 业务错降级 / 死循环兜底
========================================================================
① 可重试的临时错(模拟一次网络抖动)
[节点 callRemote] 第 1 次请求「GET /report」
Retrying task "callRemote" after 100.00ms (attempt 1) after Error: Error: 连接被重置(网络抖动)
[节点 callRemote] 第 2 次请求「GET /report」
→ 最终拿到:远端返回的正常数据(共尝试 2 次)
② 不可重试的业务错(金额非法的转账请求)
[节点 transfer] 校验金额 amount=-100
[errorHandler] 捕获到 transfer 的失败:非法金额:-100(必须大于 0)
[errorHandler] 判定为业务错 → 不重试,转入人工审核(降级)
→ 结果:⚠️ 转账失败:金额非法,单据已转人工审核(degraded=true,transfer 仅尝试 1 次)
③ 死循环(忘记写退出条件)被框架拦下
[节点 tick] n=0
[节点 tick] n=1
[节点 tick] n=2
[节点 tick] n=3
[节点 tick] n=4
[节点 tick] n=5
✅ 被拦下:捕获到 GraphRecursionError
信息:Recursion limit of 6 reached without hitting a stop condition. You can increase the limit by setting the "recursionLimit" config key.
处理方式:交给运维告警 / 回退到安全的兜底逻辑。对照三段输出,三种结局一目了然:
- ① 抛了
TransientError(临时错)→retryOn放行 → 框架等 100ms 重试 → 第 2 次成功。路子:重试。 - ② 抛了
BusinessError→retryOn拦下(不是临时错)→ 一次都不重试 →errorHandler接手,返回一个「降级结果」,图照常走完(degraded=true)。路子:降级。 - ③ 死循环 → 被
recursionLimit: 6拦下 → 抛GraphRecursionError→ 我们在invoke外层try/catch接住。路子:兜底。
⚠️ 注意 ③ 的输出里
tick只被打印了 6 次(n=0到n=5)—— 因为recursionLimit: 6数的是超步次数,不是「节点执行次数」,这个区别在第 3 章讲过。还有一点:如果临时错始终不好,重试到maxAttempts之后框架会把异常向上抛,绝不会悄悄吞掉它。错误最终必须有人负责 —— 要么被errorHandler降级,要么冒泡到调用方。
「什么错该重试 / 该降级 / 该直接失败」判定表
| 错误类型 | 典型例子 | 该怎么做 | 代码手段 |
|---|---|---|---|
| 临时性(可恢复) | 网络抖动、502/503、连接超时、数据库死锁 | 重试(指数退避 + 抖动) | retryPolicy.retryOn 放行这类错 |
| 限流(可恢复但需退避) | 429 Too Many Requests | 退避后重试,间隔要够大 | retryPolicy,调大 initialInterval |
| 业务/参数错(不可恢复) | 参数非法、余额不足、权限不够 | 不重试,降级或告知用户 | retryOn 拦下 + errorHandler 降级 |
| 编程错(Bug) | 空指针、类型错、JSON 解析失败 | 直接失败,让它暴露出来 | 不设 retryOn 放行、不设 errorHandler;靠日志/告警修代码 |
| 死循环 | 忘记写循环退出条件 | 兜底:捕获 GraphRecursionError | invoke(..., { recursionLimit }) + try/catch |
| 依赖整体不可用 | 下游服务长时间宕机 | 快速失败 + 熔断 | 重试到上限后向上抛,由外层熔断器处理 |
⚠️ 最危险的反模式:给所有错误都配一个「永远重试 + 永远降级不报错」的组合。这样真正的 Bug(比如代码写错了)会被降级逻辑悄悄掩盖,生产里表现为「系统没报错,但结果一直是错的」—— 比直接崩掉更难排查。降级要留痕(像本示例的
degraded: true),该失败的时候就要失败。
💡
errorHandler是节点级的:写在addNode的 options 里,只在这个节点失败时触发,而且只在它的retryPolicy用尽之后才跑。这正好构成「先尽力重试,重试无望再优雅降级」的两级结构。它还能返回Command({ goto })把流程转到专门的「人工处理」节点,本章示例用了最简的「返回一个更新」写法。
三、把图变成一个服务
前面所有示例都是「跑一次就退出」的脚本。真实产品里,图要长期驻留、对外提供接口、被很多人同时调用,且每个用户要有自己的会话。
示例 4:用 node:http 包成 HTTP 服务
用 Node 自带的 node:http(零额外依赖)起服务,提供两个接口:
GET /health健康检查POST /chatbody 为{ message, thread_id }
图用 MemorySaver 做检查点,靠 thread_id 区分会话。
⚠️ 为了让本示例「一次
npx tsx就能跑完并自动退出」,它会自己listen→ 用fetch打几个真实请求 → 打印 →server.close()。它不是需要你手动 Ctrl+C 的常驻服务(那种写法在书里没法验证)。请你注意:「自测后退出」只是演示技巧,真实部署时去掉自测那段、让进程常驻即可。
typescript
/**
* 示例 4:把图包成一个 HTTP 服务(本章最重要的「工程化」示例)
*
* 前面所有示例都是「跑一次就退出」的脚本。真实产品里,图要长期驻留,
* 对外提供接口、被很多人同时调用(每个用户有自己的会话)。
*
* 这个示例用 Node 自带的 node:http(零额外依赖)起一个服务:
* GET /health 健康检查
* POST /chat { message, thread_id } 发一句话,返回图的回复
*
* 图本身用 MemorySaver 做检查点(checkpointer),靠 thread_id 区分会话,
* 于是「同一个 thread_id 的第二轮」能记得第一轮说过的话。
*
* ⚠️ 注意:本示例被设计成「一次 npx tsx 就能跑完并自动退出」,
* 所以它会自己 listen、用 fetch 打几个真实请求、打印结果,
* 最后 server.close()。它不是那种需要你手动 Ctrl+C 的常驻服务。
*/
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import {
StateGraph,
START,
END,
MessagesAnnotation,
MemorySaver,
} from "@langchain/langgraph";
import { HumanMessage } from "@langchain/core/messages";
import { createModel, banner } from "../../lib/model.js";
import { assertChatKey } from "../../lib/env.js";
const PORT = 8787; // 挑一个不常冲突的端口
/** 读取请求体并解析成 JSON(容错:空 body / 非法 JSON 都会抛错,交给调用方处理) */
async function readJson(req: IncomingMessage): Promise<Record<string, unknown>> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(chunk as Buffer);
}
const raw = Buffer.concat(chunks).toString("utf8");
if (!raw) return {};
return JSON.parse(raw) as Record<string, unknown>;
}
/** 统一的 JSON 响应 */
function sendJson(res: ServerResponse, status: number, body: unknown) {
const payload = JSON.stringify(body);
res.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(payload),
});
res.end(payload);
}
async function main() {
banner("示例 4:把图包成一个 HTTP 服务(node:http + MemorySaver)");
assertChatKey();
const model = createModel({ temperature: 0 });
// ── 图:一个「聊天」节点 + MemorySaver 检查点 ───────────────────
async function chatNode(state: typeof MessagesAnnotation.State) {
const res = await model.invoke(state.messages);
return { messages: [res] };
}
const graph = new StateGraph(MessagesAnnotation)
.addNode("chat", chatNode)
.addEdge(START, "chat")
.addEdge("chat", END)
.compile({ checkpointer: new MemorySaver() });
// ── HTTP 处理器 ─────────────────────────────────────────────────
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
const method = req.method ?? "GET";
console.log(` [server] ${method} ${url.pathname}`);
try {
// 路由 1:健康检查
if (method === "GET" && url.pathname === "/health") {
return sendJson(res, 200, { status: "ok", service: "langgraph-chat" });
}
// 路由 2:对话
if (method === "POST" && url.pathname === "/chat") {
let body: Record<string, unknown>;
try {
body = await readJson(req);
} catch {
return sendJson(res, 400, { error: "请求体不是合法的 JSON" });
}
const message = body.message;
const threadId = body.thread_id;
if (typeof message !== "string" || typeof threadId !== "string") {
return sendJson(res, 400, {
error: "缺少字段:需要 { message: string, thread_id: string }",
});
}
const result = await graph.invoke(
{ messages: [new HumanMessage(message)] },
{ configurable: { thread_id: threadId } } // ← 用 thread_id 隔离会话
);
const reply = result.messages[result.messages.length - 1];
return sendJson(res, 200, {
reply: String(reply.content),
thread_id: threadId,
message_count: result.messages.length, // 会话里累计的消息条数
});
}
// 其它路径
return sendJson(res, 404, { error: `未知路径:${method} ${url.pathname}` });
} catch (e) {
console.error(" [server] 处理请求出错:", (e as Error).message);
return sendJson(res, 500, { error: (e as Error).message });
}
});
// ── 启动服务:listen 在 Promise 里,拿到后读实际端口 ─────────────
await new Promise<void>((resolve) => server.listen(PORT, resolve));
const addr = server.address();
const actualPort = typeof addr === "object" && addr ? addr.port : PORT;
const base = `http://127.0.0.1:${actualPort}`;
console.log(`✅ 服务已启动:${base}(后台常驻是真实用法,本示例跑完会自己关掉)\n`);
/** 发一个请求并把「请求 + 响应」都打印出来 */
async function call(
label: string,
method: string,
path: string,
body?: unknown
): Promise<unknown> {
console.log(`\n🔹 ${label}`);
const init: RequestInit =
body === undefined
? { method }
: {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
};
const resp = await fetch(`${base}${path}`, init);
const json = (await resp.json()) as Record<string, unknown>;
console.log(` 请求:${method} ${path} ${body ? JSON.stringify(body) : ""}`);
console.log(` 响应:[HTTP ${resp.status}] ${JSON.stringify(json)}`);
return json;
}
// ── 自测:真实发几个请求 ────────────────────────────────────────
await call("① 健康检查", "GET", "/health");
await call("② 同一会话(thread_id=t1)第一轮:告诉它我的名字", "POST", "/chat", {
message: "你好,我叫蜗牛,请记住我的名字。只回复一句确认即可。",
thread_id: "t1",
});
await call("③ 同一会话(thread_id=t1)第二轮:问它我叫什么", "POST", "/chat", {
message: "我叫什么名字?直接回答。",
thread_id: "t1",
});
await call("④ 换一个会话(thread_id=t2):它应该不知道我叫什么", "POST", "/chat", {
message: "我叫什么名字?直接回答。",
thread_id: "t2",
});
await call("⑤ 非法请求(缺字段)应返回 400", "POST", "/chat", { message: "hi" });
// ── 收尾:关掉服务,进程正常退出 ────────────────────────────────
await new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve()))
);
console.log("\n🛑 服务已关闭,示例结束。");
}
main().catch((e) => {
console.error("❌ 调用失败:", e);
process.exit(1);
});运行:
bash
npx tsx 08-production/code/04-http-server.ts真实输出:
========================================================================
示例 4:把图包成一个 HTTP 服务(node:http + MemorySaver)
========================================================================
✅ 服务已启动:http://127.0.0.1:8787(后台常驻是真实用法,本示例跑完会自己关掉)
🔹 ① 健康检查
[server] GET /health
请求:GET /health
响应:[HTTP 200] {"status":"ok","service":"langgraph-chat"}
🔹 ② 同一会话(thread_id=t1)第一轮:告诉它我的名字
[server] POST /chat
请求:POST /chat {"message":"你好,我叫蜗牛,请记住我的名字。只回复一句确认即可。","thread_id":"t1"}
响应:[HTTP 200] {"reply":"好的,蜗牛,我记住你的名字了。","thread_id":"t1","message_count":2}
🔹 ③ 同一会话(thread_id=t1)第二轮:问它我叫什么
[server] POST /chat
请求:POST /chat {"message":"我叫什么名字?直接回答。","thread_id":"t1"}
响应:[HTTP 200] {"reply":"蜗牛。","thread_id":"t1","message_count":4}
🔹 ④ 换一个会话(thread_id=t2):它应该不知道我叫什么
[server] POST /chat
请求:POST /chat {"message":"我叫什么名字?直接回答。","thread_id":"t2"}
响应:[HTTP 200] {"reply":"我不知道你的名字。","thread_id":"t2","message_count":2}
🔹 ⑤ 非法请求(缺字段)应返回 400
[server] POST /chat
请求:POST /chat {"message":"hi"}
响应:[HTTP 400] {"error":"缺少字段:需要 { message: string, thread_id: string }"}
🛑 服务已关闭,示例结束。💡 重点看 ②③④ 三条:
- ② 和 ③ 用的是同一个
thread_id: "t1"。第 ③ 条问「我叫什么名字」,模型答出了「蜗牛」—— 因为第一轮的话被MemorySaver记住了,message_count也从2涨到了4。- ④ 换成
thread_id: "t2",同样的问题,模型答「我不知道你的名字」,message_count又是2。会话被干净地隔离了。这就是「多用户服务」的核心机制:
thread_id就是会话 ID,checkpointer负责按它存取状态。生产里把thread_id换成真实用户/会话 ID 即可。
⚠️
MemorySaver是「内存」检查点,进程一重启全没。它只适合开发与单机演示。生产要换成持久化的 saver(例如本项目已依赖的@langchain/langgraph-checkpoint-sqlite的SqliteSaver,或官方 Platform 的托管存储)。换法很简单:把compile({ checkpointer: ... })里那个对象换掉,其余代码一行不动。
💡 两个工程细节:① 端口从
server.address()读回来,而不是硬编码 —— 万一你想改成「让系统分配端口」(listen(0)),代码不用改。②/chat对缺失字段、非法 JSON 都返回400而不是500—— 区分「客户端错」和「服务端错」是接口设计的底线。
四、可观测性:看得见,才调得动
图上了线,最怕变黑箱。出问题时你需要知道:哪一步慢?哪一步错?输入输出是什么?这一节给两条互补的路:
- ① 云端追踪:接 LangSmith,几个环境变量搞定,可视化整条调用链;
- ② 本地追踪:自写一个
traced()包装器包住节点,零依赖打印每步耗时与输入输出摘要。
示例 5:LangSmith 状态检测 + 本地节点追踪
typescript
/**
* 示例 5:可观测性 —— 看得见,才调得动
*
* 图一旦上线,最怕的是「黑箱」:出了问题不知道哪一步慢、哪一步错。
* 本示例演示两条互补的路:
*
* ① 接入 LangSmith(官方托管追踪):只靠几个环境变量就开启,
* 开启后 LangGraph 的每一次运行都会自动上报到云端,可视化每一步。
* 本环境默认没有配置 LangSmith,所以这里只「检测 + 给指引」,照常跑完。
*
* ② 不依赖任何外部服务的本地追踪:自己写一个 traced() 包装器包住节点,
* 打印每一步的耗时与输入/输出摘要。零依赖、本地就能用。
*/
import { StateGraph, START, END, Annotation } from "@langchain/langgraph";
import { HumanMessage } from "@langchain/core/messages";
import { createModel, banner } from "../../lib/model.js";
import { assertChatKey } from "../../lib/env.js";
const ObsState = Annotation.Root({
question: Annotation<string>, // 用户的问题
draft: Annotation<string>, // 初稿
polished: Annotation<string>, // 润色稿
});
/** 把任意值压成一行短摘要,供日志使用 */
function summarize(value: unknown): string {
let s: string;
try {
s = typeof value === "string" ? value : JSON.stringify(value);
} catch {
s = String(value);
}
s = s.replace(/\s+/g, " ").trim();
return s.length > 70 ? s.slice(0, 70) + "…" : s;
}
/**
* traced():把一个节点函数包起来,自动打印「开始 / 结束 / 耗时 / 输入输出摘要」。
* 这是「本地追踪」最朴素的形态 —— 一句话就能给每个节点加上观测点。
*/
function traced<State extends Record<string, unknown>>(
name: string,
fn: (state: State) => Promise<Record<string, unknown>> | Record<string, unknown>
): (state: State) => Promise<Record<string, unknown>> {
return async (state: State) => {
const t0 = Date.now();
console.log(` ┌─ [trace] 节点 ${name} 开始 | 输入:${summarize(state)}`);
const out = await fn(state);
console.log(
` └─ [trace] 节点 ${name} 结束 | 耗时 ${Date.now() - t0}ms | 输出:${summarize(out)}`
);
return out;
};
}
/** ① 检测 LangSmith 是否开启(同时兼容新旧两套变量名) */
function reportLangSmithStatus(): boolean {
const tracing =
process.env.LANGCHAIN_TRACING_V2 ?? process.env.LANGSMITH_TRACING ?? "";
const apiKey = process.env.LANGCHAIN_API_KEY ?? process.env.LANGSMITH_API_KEY ?? "";
const project =
process.env.LANGCHAIN_PROJECT ?? process.env.LANGSMITH_PROJECT ?? "(默认 default)";
const enabled = tracing === "true" && apiKey.length > 0;
console.log("📡 LangSmith 追踪状态检查:");
console.log(` LANGCHAIN_TRACING_V2 = ${tracing === "" ? "(未设置)" : tracing}`);
console.log(
` LANGCHAIN_API_KEY : ${apiKey.length > 0 ? "已设置(值不打印)" : "(未设置)"}`
);
console.log(` LANGCHAIN_PROJECT = ${project}`);
console.log(
` → 当前是否开启跟踪:${enabled ? "✅ 已开启" : "❌ 未开启(按未开启继续演示)"}`
);
if (!enabled) {
console.log(" 💡 想开启云端追踪,只需在 .env 里补上这三行(无需改代码):");
console.log(" LANGCHAIN_TRACING_V2=true");
console.log(" LANGCHAIN_API_KEY (填你的 LangSmith 密钥,机密勿外泄)");
console.log(" LANGCHAIN_PROJECT=langgraph-cn-book");
console.log(" 重启后,每次 invoke 都会自动上报到 smith.langchain.com。");
} else {
console.log(" 运行结束后可到 smith.langchain.com 查看本次调用的完整 trace。");
}
return enabled;
}
async function main() {
banner("示例 5:可观测性(LangSmith 检测 + 本地节点追踪)");
assertChatKey();
// ── ① 先报告 LangSmith 状态(不配置也照常继续) ─────────────────
reportLangSmithStatus();
console.log();
const model = createModel({ temperature: 0 });
// ── ② 用 traced() 包住每个节点,得到本地可读的执行轨迹 ──────────
const writeDraft = traced<typeof ObsState.State>("writeDraft", async (state) => {
const res = await model.invoke([
new HumanMessage(`用一句话回答(不要展开):${state.question}`),
]);
return { draft: String(res.content).trim() };
});
const polish = traced<typeof ObsState.State>("polish", async (state) => {
const res = await model.invoke([
new HumanMessage(`把下面这句话改得更简洁,只输出改后的句子:\n${state.draft}`),
]);
return { polished: String(res.content).trim() };
});
const graph = new StateGraph(ObsState)
.addNode("writeDraft", writeDraft)
.addNode("polish", polish)
.addEdge(START, "writeDraft")
.addEdge("writeDraft", "polish")
.addEdge("polish", END)
.compile();
const question = "什么是 LangGraph?";
console.log(`👤 提问:${question}\n`);
const t0 = Date.now();
const result = await graph.invoke({ question });
const total = Date.now() - t0;
console.log(`\n🤖 初稿:${result.draft}`);
console.log(`🤖 润色稿:${result.polished}`);
console.log(`\n⏱️ 整图总耗时:${total}ms`);
console.log(" (上面每个节点的 ┌─/└─ 两行就是本地追踪;");
console.log(" 生产环境把同样的思路换成 LangSmith,就能可视化整条调用链。)");
}
main().catch((e) => {
console.error("❌ 调用失败:", e);
process.exit(1);
});运行:
bash
npx tsx 08-production/code/05-observability.ts真实输出(本环境未配置 LangSmith,所以走的是「检测 + 给指引」分支,程序照常跑完):
========================================================================
示例 5:可观测性(LangSmith 检测 + 本地节点追踪)
========================================================================
📡 LangSmith 追踪状态检查:
LANGCHAIN_TRACING_V2 = (未设置)
LANGCHAIN_API_KEY : (未设置)
LANGCHAIN_PROJECT = (默认 default)
→ 当前是否开启跟踪:❌ 未开启(按未开启继续演示)
💡 想开启云端追踪,只需在 .env 里补上这三行(无需改代码):
LANGCHAIN_TRACING_V2=true
LANGCHAIN_API_KEY (填你的 LangSmith 密钥,机密勿外泄)
LANGCHAIN_PROJECT=langgraph-cn-book
重启后,每次 invoke 都会自动上报到 smith.langchain.com。
👤 提问:什么是 LangGraph?
┌─ [trace] 节点 writeDraft 开始 | 输入:{"question":"什么是 LangGraph?"}
└─ [trace] 节点 writeDraft 结束 | 耗时 1076ms | 输出:{"draft":"LangGraph 是 LangChain 推出的一个用于构建有状态、多步骤(可含循环与分支)LLM 智能体应用的图结构…
┌─ [trace] 节点 polish 开始 | 输入:{"question":"什么是 LangGraph?","draft":"LangGraph 是 LangChain 推出的一个用于构建有…
└─ [trace] 节点 polish 结束 | 耗时 1983ms | 输出:{"polished":"LangGraph 是 LangChain 的图编排框架,用于构建有状态、多步骤(可循环/分支)的 LLM 智能体…
🤖 初稿:LangGraph 是 LangChain 推出的一个用于构建有状态、多步骤(可含循环与分支)LLM 智能体应用的图结构编排框架,用节点和边来组织流程并支持持久化与人工介入。
🤖 润色稿:LangGraph 是 LangChain 的图编排框架,用于构建有状态、多步骤(可循环/分支)的 LLM 智能体应用,以节点和边组织流程,支持持久化与人工介入。
⏱️ 整图总耗时:3079ms
(上面每个节点的 ┌─/└─ 两行就是本地追踪;
生产环境把同样的思路换成 LangSmith,就能可视化整条调用链。)💡 看输出的下半段:每个节点的
┌─ ... 开始 / └─ ... 结束两行,带耗时和输入/输出摘要。writeDraft花了 1076ms、polish花了 1983ms —— 一眼就知道时间花在哪。这就是「本地追踪」,traced()一个高阶函数就给每个节点装上了观测点,零外部依赖。
① 接入 LangSmith 有多简单?
就三个环境变量,代码一行不改:
LANGCHAIN_TRACING_V2=true
LANGCHAIN_API_KEY=<你的 LangSmith 密钥> # 机密,切勿提交到仓库
LANGCHAIN_PROJECT=langgraph-cn-book设好后重启程序,每次 invoke 都会自动上报,你可以到 smith.langchain.com 看到每一步的输入、输出、耗时、token 用量、错误栈。示例 5 里的 reportLangSmithStatus() 就是帮你判断「当前到底开没开」——很多「追踪不生效」的问题,都是变量名写错、值不是 "true"、或忘了配 API_KEY。本示例对「未配置」做了优雅处理:给出指引,然后照常把图跑完。
⚠️ 安全提醒:
LANGCHAIN_API_KEY是密钥,绝不能写进代码、日志或提交到 Git。示例里特意只打印「已设置 / 未设置」,不打印值本身。
💡 进阶:如果不想用
traced()手动包,还可以给invoke传callbacks: [handler],用 LangChain 的BaseCallbackHandler钩子(handleChainStart/End、handleLLMStart/End等)来统一采集日志。对「每个节点都想知道耗时」这个诉求,traced()更直接;对「想统一拦截所有 LLM 调用」,callback 更合适。两者不冲突。
五、本地开发与云端部署(本节为说明,未实跑)
⚠️ 郑重标注:本节所有内容都需要 Docker 或 LangGraph Platform 账号,在本环境无法实跑。下面只做文字讲解,帮助你建立全貌认知,不提供「真实输出」(因为跑不出来就不该编造)。真正要动手时,请以官方文档为准:https://docs.langchain.com/oss/javascript/langgraph/overview
5.1 langgraph.json:一个描述文件
官方工具链(CLI / Studio / Platform)都靠一个 langgraph.json 认识你的图(示意,未在本环境验证):
json
{
"dependencies": ["."],
"graphs": { "chat": "./src/graph.ts:graph" },
"env": ".env"
}dependencies 指依赖位置(通常 ["."]);graphs 是「名字 → 图的导出路径」("./src/graph.ts:graph" 即从该文件导出名为 graph 的编译产物);env 是环境变量文件。
5.2 @langchain/langgraph-cli:本地开发与一键即用
常用命令:langgraph dev(本地开发服务器 + 热重载,并在浏览器打开 Studio)、langgraph build(打成 Docker 镜像)、langgraph up(用 Docker 起完整运行时)、langgraph deploy(部署到云端,需账号)。
⚠️
langgraph up/langgraph deploy分别需要 Docker / 云端账号,本环境都没有,未实跑。
5.3 Studio 与 Platform
LangGraph Studio 是官方图形化调试器:看拓扑、单步执行、检视每步状态、回放 checkpoint、手工改状态后继续 —— 调试复杂图(多智能体、人工介入)时帮助极大。LangGraph Platform 是官方托管运行时:部署后自动把图暴露成一整套 REST API,并内置持久化检查点、任务队列、横向扩容、可观测性 —— 你在示例 4 手写的「HTTP + thread_id + checkpointer」,Platform 替你标准化并产品化了。
💡 部署路线怎么选:本地调试用
langgraph dev+ Studio;上生产要么① 自己用node:http/Express 包一层(像示例 4),要么② 交给 Platform 托管、省掉自己搭持久化与队列的功夫。两条路都合理,看你更要「自控」还是「省事」。
本章小结
本章速查表
| 你要做的事 | 代码 |
|---|---|
| 给节点加重试 | addNode("n", fn, { retryPolicy: { maxAttempts, initialInterval, backoffFactor, jitter, retryOn } }) |
| 只重试某类错误 | retryPolicy: { retryOn: (e) => e instanceof TransientError } |
| 给节点加缓存 | addNode("n", fn, { cachePolicy: true }) + compile({ cache: new InMemoryCache() }) |
| 缓存精细控制 | cachePolicy: { keyFunc, ttl }(ttl 单位秒) |
| 节点降级 | addNode("n", fn, { errorHandler: (state, err) => ({...}) }) |
| 检查点(多轮会话) | compile({ checkpointer: new MemorySaver() }) |
| 指定会话 | graph.invoke(input, { configurable: { thread_id } }) |
| 防死循环 | graph.invoke(input, { recursionLimit: N }) + try/catch GraphRecursionError |
| 起 HTTP 服务 | node:http 的 createServer + server.listen(PORT) + server.close() |
| 本地追踪 | 自写 traced() 包装节点,打印耗时与输入输出摘要 |
| 云追踪 | 环境变量 LANGCHAIN_TRACING_V2 / LANGCHAIN_API_KEY / LANGCHAIN_PROJECT |
| 版本说明 | InMemoryCache 从 @langchain/langgraph-checkpoint 导入 |
三句话总结
- 能自动扛的就别写代码:临时错交给
retryPolicy(指数退避 + 抖动),重复输入交给cachePolicy—— 框架已经把「韧性」做成了声明式的一行配置。 - 错误要分类处理:临时错重试、业务错降级、Bug 直接失败、死循环兜底。最怕的是「一律重试 + 一律静默降级」,那会把 Bug 藏起来。
- 上生产就三件事:包成服务(
node:http)、记住会话(checkpointer+thread_id)、看得见(LangSmith 或本地traced())。示例 4 就是这三件事的最小完整骨架。
动手练习
- 把示例 1 的
backoffFactor改成2、initialInterval改成100,先预测两次退避等待是多少毫秒,再跑一次用真实输出验证你的预测。 - 给示例 2 的
cachePolicy加一个自定义keyFunc,让「输入 n 相同」和「输入 n 是同一个偶数的不同表示」……(提示:keyFunc接收节点输入数组,返回字符串 key;试着让n=2和n=2.0命中同一条缓存)。 - 改示例 4:给
/chat增加一个可选的system字段(系统提示词),并在节点里把它插到消息开头。再想一个问题:如果两个请求用同一个thread_id但不同system,行为会怎样?(提示:状态是累积的,换了 system 也盖不住历史消息)。
下一章预告
到这里,全书正文就结束了。从「链」到「图」,从单节点到多智能体,从能跑到能上线 —— 你已经走完了 LangGraph.js 的主干。接下来请翻到 附录:术语表(GLOSSARY),它把全书出现过的核心概念按字母/主题重新梳理了一遍,适合当随手查阅的「词典」。愿你搭出的每一张图,都既聪明、又可靠。