Skip to content

第 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

⚠️ 两个常见坑:

  1. jitter 默认是 true,等待时间会带随机扰动。想让它「可预测、可断言」(就像本示例)就把它设为 false。
  2. 重试不是万能药。像「参数不合法」这种业务错,重试一万次也不会变好 —— 那是示例 3 要讲的「降级」。给每个节点的 retryOn 加上过滤,只重试真正临时的错误。

示例 2:节点缓存(cachePolicy + compile({ cache })) ​

有些节点很「贵」:调大模型、查数据库、跑耗时计算。如果同样的输入会反复出现,把结果缓存下来能省下大量时间与成本。

LangGraph 的做法分两步:

  1. compile({ cache: new InMemoryCache() }) —— 给整张图配一个缓存存储;
  2. 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;靠日志/告警修代码
死循环忘记写循环退出条件兜底:捕获 GraphRecursionErrorinvoke(..., { recursionLimit }) + try/catch
依赖整体不可用下游服务长时间宕机快速失败 + 熔断重试到上限后向上抛,由外层熔断器处理

⚠️ 最危险的反模式:给所有错误都配一个「永远重试 + 永远降级不报错」的组合。这样真正的 Bug(比如代码写错了)会被降级逻辑悄悄掩盖,生产里表现为「系统没报错,但结果一直是错的」—— 比直接崩掉更难排查。降级要留痕(像本示例的 degraded: true),该失败的时候就要失败。

💡 errorHandler 是节点级的:写在 addNode 的 options 里,只在这个节点失败时触发,而且只在它的 retryPolicy 用尽之后才跑。这正好构成「先尽力重试,重试无望再优雅降级」的两级结构。它还能返回 Command({ goto }) 把流程转到专门的「人工处理」节点,本章示例用了最简的「返回一个更新」写法。


三、把图变成一个服务 ​

前面所有示例都是「跑一次就退出」的脚本。真实产品里,图要长期驻留、对外提供接口、被很多人同时调用,且每个用户要有自己的会话。

示例 4:用 node:http 包成 HTTP 服务 ​

用 Node 自带的 node:http(零额外依赖)起服务,提供两个接口:

  • GET /health 健康检查
  • POST /chat body 为 { 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 导入

三句话总结

  1. 能自动扛的就别写代码:临时错交给 retryPolicy(指数退避 + 抖动),重复输入交给 cachePolicy —— 框架已经把「韧性」做成了声明式的一行配置。
  2. 错误要分类处理:临时错重试、业务错降级、Bug 直接失败、死循环兜底。最怕的是「一律重试 + 一律静默降级」,那会把 Bug 藏起来。
  3. 上生产就三件事:包成服务(node:http)、记住会话(checkpointer + thread_id)、看得见(LangSmith 或本地 traced())。示例 4 就是这三件事的最小完整骨架。

动手练习

  1. 把示例 1 的 backoffFactor 改成 2、initialInterval 改成 100,先预测两次退避等待是多少毫秒,再跑一次用真实输出验证你的预测。
  2. 给示例 2 的 cachePolicy 加一个自定义 keyFunc,让「输入 n 相同」和「输入 n 是同一个偶数的不同表示」……(提示:keyFunc 接收节点输入数组,返回字符串 key;试着让 n=2 和 n=2.0 命中同一条缓存)。
  3. 改示例 4:给 /chat 增加一个可选的 system 字段(系统提示词),并在节点里把它插到消息开头。再想一个问题:如果两个请求用同一个 thread_id 但不同 system,行为会怎样?(提示:状态是累积的,换了 system 也盖不住历史消息)。

下一章预告

到这里,全书正文就结束了。从「链」到「图」,从单节点到多智能体,从能跑到能上线 —— 你已经走完了 LangGraph.js 的主干。接下来请翻到 附录:术语表(GLOSSARY),它把全书出现过的核心概念按字母/主题重新梳理了一遍,适合当随手查阅的「词典」。愿你搭出的每一张图,都既聪明、又可靠。


← 上一章:多智能体 | 返回总目录 | 下一章:编排设计模式 →

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