Skip to content

第 12 章 · 测试与调试 ​

🎯 本章目标 ​

  • 想清楚「一张由节点和边拼出来的图」到底该怎么测,建立三层测试金字塔的心智模型
  • 学会给节点写单测:因为它就是 (state) => update 的普通函数,饿了喂假 state 就够,完全不碰网络
  • 学会用假模型测整张图:不花钱、结果确定、还能断言「工具到底有没有被调用」
  • 学会集成测试该断言什么:真调一次模型,但只断言结构(消息条数 / 是否出现工具消息 / 关键数字),绝不断言完整措辞
  • 拿到一整套调试工具箱:节点入口打日志、stream 逐步看、getStateHistory 回放、drawMermaid 看图、调小 recursionLimit 炸死循环
  • 收下一份报错速查手册:十几条常见错误,每条都亲手触发过,给出「触发条件 → 根因 → 修法」

本章代码

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

文件内容
code/01-node-unit-test.ts节点级单测:把节点当纯函数测,node:test + node:assert,完全离线
code/02-fake-model-test.ts假模型测整图:自写最小 BaseChatModel,断言消息轨迹与「工具是否被调用」
code/03-integration-test.ts集成测试:真调模型,断言结构(条数 / 工具消息 / 关键数字)而非措辞
code/04-debug-toolbox.ts调试工具箱:入口日志 / stream / getStateHistory / drawMermaid / 小 recursionLimit
code/05-error-catalog.ts报错速查手册:十几条错误逐条真实触发,附「触发 / 根因 / 修法」

📚 官方参考:https://docs.langchain.com/oss/javascript/langgraph/test


一、为什么要给图写测试 ​

前面十一章,我们一直在「把图搭起来」。搭到一定规模,你会发现一个新问题:图一旦复杂,靠肉眼是看不出错的。

考虑下面这些 bug,它们都能让程序「跑起来但结果不对」,而且报错信息往往指不到点上:

症状真实原因(常常是)
一个字段莫名其妙只剩一个值同一超步里两个节点都写了它,但忘了加 reducer(第 2 章)
某个节点「从来没被走到」条件边的第三个参数漏列了它,或 Command.goto 目标没登记 ends
程序挂死、CPU 拉满循环少了退出条件,条件边永远指回自己
编译就报一堆莫名其妙的错节点名和状态字段重名了
恢复中断后行为诡异自己手拼的 config 丢了 checkpoint_ns

这些问题的共同点是:它们属于「编排逻辑」,而编排逻辑恰恰是最值得测、也最容易被忽略的部分。

三层测试金字塔 ​

给图写测试,不是「都上真模型跑一遍」那么简单 —— 那样又贵又慢又不稳。正确的做法是分层:

        ▲  少、慢、贵、最接近真实
        │   ③ 集成测试:真调模型,断言「结构」
        │      —— 消息条数 / 有没有工具消息 / 关键数字对不对
        │   ② 假模型测整图:不联网、确定、验证编排
        │      —— 条件边走得对不对、工具被没被调用、消息顺序
        ▼  多、快、免费、离真实最远
           ① 节点单测:把节点当纯函数测
              —— 喂假 state,断言返回值
层级测什么花不花钱结果确定吗跑多快
① 节点单测单个节点的输入输出否是毫秒级
② 假模型测图整张图的编排(边 / 循环 / 工具调用)否是毫秒级
③ 集成测试真模型 + 真工具端到端是否(所以要断言结构)秒级

底层多写、顶层少写。下面四节,我们逐层拆开。

💡 一句话:节点是纯函数 → 值得单测;图是编排 → 用假模型测;真模型只在最顶层露面,且只断言结构。


二、节点单测:把节点当纯函数 ​

为什么节点能单测? 因为 LangGraph 的节点本质上就是一个普通函数:

typescript
(state) => update

输入是一份状态,输出是一个「要更新的字段」对象。没有隐藏状态、没有全局变量、不需要真的把图跑起来,更不需要联网。于是我们可以像测任何纯函数一样测它:喂一个自己构造的假 state,断言返回值。

运行环境用 Node 自带的 node:test + node:assert(零第三方依赖)。npx tsx <file> 直接就能跑,全部通过退出码为 0、有失败则非零 —— 天然适合塞进 CI。

示例 1:节点级单测 ​

完整代码(code/01-node-unit-test.ts):

typescript
/**
 * 示例 1:节点级单测 —— 把节点当纯函数测,完全不碰网络
 *
 * 为什么节点能单测?因为 LangGraph 的节点本质上就是一个普通函数:
 *
 *     (state) => update
 *
 * 输入是一份(可以自己构造的)状态,输出是一个「要更新的字段」对象。
 * 没有隐藏状态、没有全局变量、不需要真的把图跑起来,更不需要联网。
 * 于是我们可以像测任何纯函数一样测它:喂一个构造好的假 state,断言返回值。
 *
 * 本示例用 Node 自带的 node:test + node:assert(零第三方依赖)。
 * 运行方式:npx tsx 12-testing-and-debugging/code/01-node-unit-test.ts
 */
import { test } from "node:test";
import assert from "node:assert/strict";
import { StateGraph, START, END, Annotation } from "@langchain/langgraph";
import { banner } from "../../lib/model.js";

// ══════════════════════════════════════════════════════════════════
//  被测对象:一个「下单算钱」的图,节点都是纯函数
// ══════════════════════════════════════════════════════════════════

const OrderState = Annotation.Root({
  /** 订单原始金额 */
  amount: Annotation<number>,
  /** 是否 VIP */
  isVip: Annotation<boolean>,
  /** 折扣率(0.2 表示打八折) */
  discount: Annotation<number>,
  /** 折后应付金额 */
  payable: Annotation<number>,
});

/** 节点 1:根据会员等级和金额算折扣 —— 纯函数,读 state、返回增量 */
function computeDiscount(state: typeof OrderState.State) {
  if (!state.isVip) return { discount: 0 };
  return { discount: state.amount >= 100 ? 0.2 : 0.1 };
}

/** 节点 2:算应付金额 —— 纯函数,把浮点误差四舍五入到分 */
function computePayable(state: typeof OrderState.State) {
  const payable = Math.round(state.amount * (1 - state.discount) * 100) / 100;
  return { payable };
}

// ══════════════════════════════════════════════════════════════════
//  单测:直接喂构造好的 state,断言返回值(与图、与网络都无关)
// ══════════════════════════════════════════════════════════════════

banner("示例 1:节点级单测(不碰网络)");

test("computeDiscount:VIP 且金额 ≥ 100 → 打八折(0.2)", () => {
  const update = computeDiscount({ amount: 200, isVip: true, discount: 0, payable: 0 });
  assert.deepEqual(update, { discount: 0.2 });
});

test("computeDiscount:VIP 但金额 < 100 → 打九折(0.1)", () => {
  const update = computeDiscount({ amount: 50, isVip: true, discount: 0, payable: 0 });
  assert.deepEqual(update, { discount: 0.1 });
});

test("computeDiscount:非 VIP → 不打折(0)", () => {
  const update = computeDiscount({ amount: 999, isVip: false, discount: 0, payable: 0 });
  assert.deepEqual(update, { discount: 0 });
});

test("computePayable:200 元打八折 → 应付 160", () => {
  const update = computePayable({ amount: 200, isVip: true, discount: 0.2, payable: 0 });
  assert.equal(update.payable, 160);
});

test("computePayable:99.99 元打九折 → 应付 89.99(四舍五入到分)", () => {
  const update = computePayable({ amount: 99.99, isVip: true, discount: 0.1, payable: 0 });
  assert.equal(update.payable, 89.99);
});

test("节点返回值只包含它想改的字段(增量语义)", () => {
  const update = computeDiscount({ amount: 200, isVip: true, discount: 0, payable: 0 });
  // 只返回 discount,不该顺手把 amount / isVip 等无关字段也吐回来
  assert.deepEqual(Object.keys(update), ["discount"]);
});

// ══════════════════════════════════════════════════════════════════
//  把节点组装成图,跑一次确认「接线」也是通的(依旧不联网)
// ══════════════════════════════════════════════════════════════════

const graph = new StateGraph(OrderState)
  .addNode("computeDiscount", computeDiscount)
  .addNode("computePayable", computePayable)
  .addEdge(START, "computeDiscount")
  .addEdge("computeDiscount", "computePayable")
  .addEdge("computePayable", END)
  .compile();

test("整图:VIP 订单 250 元 → 应付 200", async () => {
  const result = await graph.invoke({ amount: 250, isVip: true, discount: 0, payable: 0 });
  assert.equal(result.discount, 0.2);
  assert.equal(result.payable, 200);
});

test("整图:普通订单 250 元 → 应付 250(无折扣)", async () => {
  const result = await graph.invoke({ amount: 250, isVip: false, discount: 0, payable: 0 });
  assert.equal(result.discount, 0);
  assert.equal(result.payable, 250);
});

运行:

bash
npx tsx 12-testing-and-debugging/code/01-node-unit-test.ts

真实输出:

========================================================================
  示例 1:节点级单测(不碰网络)
========================================================================

✔ computeDiscount:VIP 且金额 ≥ 100 → 打八折(0.2) (1.429583ms)
✔ computeDiscount:VIP 但金额 < 100 → 打九折(0.1) (0.084208ms)
✔ computeDiscount:非 VIP → 不打折(0) (0.058959ms)
✔ computePayable:200 元打八折 → 应付 160 (0.067084ms)
✔ computePayable:99.99 元打九折 → 应付 89.99(四舍五入到分) (0.050334ms)
✔ 节点返回值只包含它想改的字段(增量语义) (0.053375ms)
✔ 整图:VIP 订单 250 元 → 应付 200 (11.7555ms)
✔ 整图:普通订单 250 元 → 应付 250(无折扣) (1.907208ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 31.766834

💡 看点:整份输出里没有一次网络调用,duration_ms 只有 31 毫秒。每行 ✔ 是一条用例,末尾 pass 8 / fail 0 是统计。全部通过时进程退出码为 0;只要有一条失败,退出码就是 1 —— 这样 CI 才能拦住回归。

⚠️ 为什么不用 node --test 而用 npx tsx? 我们的源码是 TypeScript。npx tsx <file> 会先转译再交给 Node 跑,node:test 的用例照常执行、退出码也照常生效。这也是为什么本节开头说「先试 npx tsx,稳就用它」。


三、假模型测整图 ​

节点单测很好,但它测不到「编排」:条件边走得对不对?工具节点有没有被触发?消息顺序对不对?要测这些,得把整张图跑起来。

但直接上图跑真模型又贵又不稳。办法是拿一个假模型冒充真模型。

假模型怎么造? ​

LangChain 的 BaseChatModel 提供了可扩展的最小接口,我们只需要实现两个东西:

  • _llmType() —— 返回一个字符串标识;
  • _generate(messages, options, runManager) —— 真正产生回复,返回 { generations: [{ text, message }] }。

把这层实现成「按预设脚本依次回放 AIMessage」,就有了一个完全可控的假模型。它接进图和真模型一模一样(model.invoke(...)、.bindTools(...) 都能用),所以图的编排逻辑原封不动地跑了,只是不再联网。

示例 2:用假模型测整张图 ​

完整代码(code/02-fake-model-test.ts):

typescript
/**
 * 示例 2:用「假模型」测整张图 —— 不花钱、结果确定、还能验证工具到底被调用没有
 *
 * 真模型有两个测试上的硬伤:① 每次调用都要花钱;② 输出天然有随机性,
 * 今天断言得过的文案明天可能就变了。解决办法是拿一个「假模型」冒充真模型。
 *
 * 假模型只需实现 LangChain 的 BaseChatModel 最小接口(主要是 _generate /
 * _llmType),按预设脚本依次吐出 AIMessage。它接进图的方式和真模型一模一样,
 * 于是整张图的编排逻辑(条件边走得对不对、工具节点有没有被触发、消息顺序对不对)
 * 都能被确定性地断言。
 *
 * 本示例构建一个迷你 ReAct 智能体:agent 节点 + tools 节点 + 条件边。
 */
import { test } from "node:test";
import assert from "node:assert/strict";
import { StateGraph, START, END, MessagesAnnotation } from "@langchain/langgraph";
import { ToolNode } from "@langchain/langgraph/prebuilt";
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
import type { ChatResult } from "@langchain/core/outputs";
import type { CallbackManagerForLLMRun } from "@langchain/core/callbacks/manager";
import {
  AIMessage,
  HumanMessage,
  ToolMessage,
  type BaseMessage,
} from "@langchain/core/messages";
import { tool } from "@langchain/core/tools";
import * as z from "zod";
import { banner } from "../../lib/model.js";

// ══════════════════════════════════════════════════════════════════
//  ① 假模型:一个按脚本回放的 ChatModel
// ══════════════════════════════════════════════════════════════════

class FakeChatModel extends BaseChatModel {
  /** 预设好的回放脚本:每次调用吐一条 */
  private script: AIMessage[];
  /** 被调用了多少次 —— 方便断言「模型到底问了没有」 */
  calls = 0;
  /** 每轮收到的消息条数,方便断言上下文 */
  seenMessageCounts: number[] = [];

  constructor(script: AIMessage[]) {
    super({});
    this.script = script;
  }

  _llmType(): string {
    return "fake";
  }

  async _generate(
    messages: BaseMessage[],
    _options: this["ParsedCallOptions"],
    _runManager?: CallbackManagerForLLMRun
  ): Promise<ChatResult> {
    this.seenMessageCounts.push(messages.length);
    const msg = this.script[Math.min(this.calls, this.script.length - 1)];
    this.calls += 1;
    return { generations: [{ text: String(msg.content), message: msg }] };
  }
}

// ══════════════════════════════════════════════════════════════════
//  ② 工具:带调用计数的假计算器(用来验证「工具到底有没有被执行」)
// ══════════════════════════════════════════════════════════════════

let toolCallsSeen: string[] = [];
const calculator = tool(
  async ({ expression }) => {
    toolCallsSeen.push(expression);
    return String(eval(expression)); // 测试里的假工具,简单求值即可
  },
  {
    name: "calculator",
    description: "计算数学表达式",
    schema: z.object({ expression: z.string().describe("数学表达式") }),
  }
);

// ══════════════════════════════════════════════════════════════════
//  ③ 一张迷你 ReAct 图(agent → tools → agent → …)
// ══════════════════════════════════════════════════════════════════

/** 构建图;传入一个「假模型」,脚本固定:先调工具,再给最终答案 */
function buildGraph(model: FakeChatModel) {
  const graph = new StateGraph(MessagesAnnotation)
    .addNode("agent", async (state: typeof MessagesAnnotation.State) => {
      const res = await model.invoke(state.messages);
      return { messages: [res] };
    })
    .addNode("tools", new ToolNode([calculator]))
    .addEdge(START, "agent")
    .addEdge("tools", "agent")
    .addConditionalEdges(
      "agent",
      (state: typeof MessagesAnnotation.State) => {
        const last = state.messages[state.messages.length - 1] as AIMessage;
        return last.tool_calls?.length ? "tools" : END;
      },
      ["tools", END]
    );
  return graph.compile();
}

/** 固定的模型脚本:第 1 轮请求调用 calculator,第 2 轮给出最终答案 */
function fixedScript(): AIMessage[] {
  return [
    new AIMessage({
      content: "",
      tool_calls: [
        { name: "calculator", args: { expression: "6*7" }, id: "call_1", type: "tool_call" },
      ],
    }),
    new AIMessage("计算完成,结果是 42。"),
  ];
}

banner("示例 2:用假模型测整张图");

// ══════════════════════════════════════════════════════════════════
//  ④ 断言:图的输出、消息轨迹、工具是否真被调用
// ══════════════════════════════════════════════════════════════════

test("整图输出:最后一条是 AI,且内容等于脚本里的最终答案", async () => {
  toolCallsSeen = [];
  const model = new FakeChatModel(fixedScript());
  const graph = buildGraph(model);

  const result = await graph.invoke({ messages: [new HumanMessage("6 乘以 7 等于几?")] });
  const last = result.messages[result.messages.length - 1] as AIMessage;

  assert.equal(last.getType(), "ai");
  assert.equal(String(last.content), "计算完成,结果是 42。");
});

test("消息轨迹:human → ai(工具调用) → tool → ai(最终答案) 共 4 条", async () => {
  toolCallsSeen = [];
  const model = new FakeChatModel(fixedScript());
  const graph = buildGraph(model);

  const result = await graph.invoke({ messages: [new HumanMessage("6 乘以 7 等于几?")] });
  const kinds = result.messages.map((m) => m.getType());

  assert.deepEqual(kinds, ["human", "ai", "tool", "ai"]);
  assert.equal(result.messages.length, 4);

  // 工具消息里的内容,正是工具返回值
  const toolMsg = result.messages[2] as ToolMessage;
  assert.equal(String(toolMsg.content), "42");
});

test("关键点:工具确实被执行了(假模型给的 tool_call 真被 ToolNode 跑了)", async () => {
  toolCallsSeen = [];
  const model = new FakeChatModel(fixedScript());
  const graph = buildGraph(model);

  await graph.invoke({ messages: [new HumanMessage("6 乘以 7 等于几?")] });

  // 这一条是「假模型测试」最独有的价值:
  // 真模型有时自己心算,根本不调工具;我们用固定脚本保证「一定会调」,
  // 于是可以断言「工具真的被调到、参数也对」。
  assert.deepEqual(toolCallsSeen, ["6*7"]);
});

test("假模型被调用了 2 次(第一轮要工具,第二轮给答案)", async () => {
  toolCallsSeen = [];
  const model = new FakeChatModel(fixedScript());
  const graph = buildGraph(model);

  await graph.invoke({ messages: [new HumanMessage("6 乘以 7 等于几?")] });

  assert.equal(model.calls, 2);
  // 第二轮模型看到的是 3 条消息(human + ai + tool)
  assert.deepEqual(model.seenMessageCounts, [1, 3]);
});

test("换个脚本:模型直接给答案(不调工具)→ 图只有 2 条消息", async () => {
  toolCallsSeen = [];
  const model = new FakeChatModel([new AIMessage("6×7=42,不需要工具。")]);
  const graph = buildGraph(model);

  const result = await graph.invoke({ messages: [new HumanMessage("6 乘以 7 等于几?")] });

  assert.deepEqual(
    result.messages.map((m) => m.getType()),
    ["human", "ai"]
  );
  assert.deepEqual(toolCallsSeen, []); // 工具一次都没被调用
});

运行:

bash
npx tsx 12-testing-and-debugging/code/02-fake-model-test.ts

真实输出:

========================================================================
  示例 2:用假模型测整张图
========================================================================

✔ 整图输出:最后一条是 AI,且内容等于脚本里的最终答案 (14.929833ms)
✔ 消息轨迹:human → ai(工具调用) → tool → ai(最终答案) 共 4 条 (3.108667ms)
✔ 关键点:工具确实被执行了(假模型给的 tool_call 真被 ToolNode 跑了) (2.369584ms)
✔ 假模型被调用了 2 次(第一轮要工具,第二轮给答案) (2.006375ms)
✔ 换个脚本:模型直接给答案(不调工具)→ 图只有 2 条消息 (1.270375ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 38.192125

三个好处,一个比一个重要:

  1. 不花 API 钱 —— 整个文件零网络请求,跑一万遍也是 0 成本。
  2. 结果确定 —— 脚本说第一轮回 tool_call、第二轮回最终答案,就一定这样。没有随机性,断言不会「今天过明天红」。
  3. 能测「工具到底被调用了没有」 —— 这是假模型最独有的价值。真模型很可能自己心算(尤其简单算术),你根本没法稳定断言「工具被调了」。用固定脚本,我们强制模型发出 calculator("6*7"),于是可以断言 toolCallsSeen === ["6*7"]。很多「智能体绕过了工具直接瞎编」的 bug,只有这种测法才抓得住。

💡 seenMessageCounts 那个断言很有用:它证明第二轮的模型确实看到了 [human, ai, tool] 三条消息(也就是工具结果真的被回灌给了模型)。如果哪天有人把 tools → agent 这条边删了,这个断言会立刻变红。

⚠️ 假模型的边界:它测的是「编排」,不是「模型好不好用」。假模型不会告诉你提示词写得好不好、真模型会不会自作主张。所以假模型测试要写很多,但不能只有它 —— 还得有顶层那一点点集成测试。


四、集成测试该断言什么 ​

假模型测完编排,还需要少量集成测试:真的把图跑一遍。它的价值在于「提示词 + 真模型 + 真工具」这个组合在真实世界里到底行不行 —— 这是假模型永远测不到的。

但集成测试有一条铁律:

⚠️ 绝不能断言完整文本。 模型输出的措辞天然有随机性。你写 assert.equal(content, "结果是 3000"),明天它可能回「3000,就是它!」,你的测试就红了 —— 而这种红是假警报,只会让你开始无视测试。

正确做法是断言那些稳定不变的结构性事实:

该断言(稳定)不该断言(随机)
消息条数和类型序列完整回答文本
是否出现过 tool 消息语气、标点、换行
最后一条是不是 AI 消息具体措辞
内容里是否含关键数字「第一句是不是这么开头」

示例 3:真调模型,断言结构 ​

完整代码(code/03-integration-test.ts):

typescript
/**
 * 示例 3:集成测试 —— 真调一次模型,但「断言结构,不断言措辞」
 *
 * 假模型能测编排逻辑,但它测不出「真模型面对真提示词会怎么走」。
 * 所以还需要少量的集成测试:真的跑一次图。
 *
 * 关键纪律:真模型同一句话每次写的都可能不一样,所以**绝不能**断言
 * 「content === '结果是 3000'」这种完整文本 —— 明天就红了。正确做法是
 * 断言那些稳定不变的结构性事实:
 *   · 消息条数与类型序列(human → ai → tool → ai …)
 *   · 是否出现过 tool 消息(模型真的调工具了吗)
 *   · 最后一条是不是 AI 消息
 *   · 内容里是否包含关键数字(工具算出来的结果应当被复述出来)
 *
 * 本示例用「125 × 8」这种确定的算术题,让「关键数字」可预期。
 */
import { test, before } from "node:test";
import assert from "node:assert/strict";
import { StateGraph, START, END, MessagesAnnotation } from "@langchain/langgraph";
import { ToolNode } from "@langchain/langgraph/prebuilt";
import { AIMessage, HumanMessage, ToolMessage } 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";

// ══════════════════════════════════════════════════════════════════
//  建一张与线上一致的 ReAct 图(这里用的是真模型)
// ══════════════════════════════════════════════════════════════════

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

function buildRealGraph() {
  const model = createModel({ temperature: 0 }).bindTools([calculator]);
  return new StateGraph(MessagesAnnotation)
    .addNode("agent", async (state: typeof MessagesAnnotation.State) => {
      const res = await model.invoke(state.messages);
      return { messages: [res] };
    })
    .addNode("tools", new ToolNode([calculator]))
    .addEdge(START, "agent")
    .addEdge("tools", "agent")
    .addConditionalEdges(
      "agent",
      (state: typeof MessagesAnnotation.State) => {
        const last = state.messages[state.messages.length - 1] as AIMessage;
        return last.tool_calls?.length ? "tools" : END;
      },
      ["tools", END]
    )
    .compile();
}

banner("示例 3:集成测试(真模型,断言结构)");

// ── 只真正跑一次模型(省钱),用 before 钩子把结果缓存给所有用例 ──
let messages: Awaited<ReturnType<ReturnType<typeof buildRealGraph>["invoke"]>>["messages"] = [];

before(async () => {
  assertChatKey();
  const graph = buildRealGraph();
  const question = "125 乘以 8 等于多少?请用 calculator 工具计算,不要心算。";
  console.log(`👤 提问:${question}\n`);
  const result = await graph.invoke({ messages: [new HumanMessage(question)] });
  messages = result.messages;

  console.log("📜 真实消息轨迹:");
  result.messages.forEach((m, i) => {
    const calls = (m as AIMessage).tool_calls;
    if (calls?.length) {
      console.log(
        `   ${i}. [${m.getType()}] 请求调用 ${calls
          .map((c) => `${c.name}(${JSON.stringify(c.args)})`)
          .join(", ")}`
      );
    } else {
      console.log(`   ${i}. [${m.getType()}] ${String(m.content).slice(0, 120)}`);
    }
  });
  console.log("");
});

test("结构:第一条是 human 消息", () => {
  assert.equal(messages[0].getType(), "human");
});

test("结构:最后一条是 AI 消息", () => {
  assert.equal(messages[messages.length - 1].getType(), "ai");
});

test("结构:过程中出现过 tool 消息(模型确实调用了工具)", () => {
  const toolMsgs = messages.filter((m) => m.getType() === "tool");
  assert.ok(toolMsgs.length >= 1, "应当至少有一条 tool 消息");
});

test("内容:工具算出 1000,最终回答里带上了这个关键数字", () => {
  const last = String(messages[messages.length - 1].content);
  // 去掉逗号和空白,规避 "1,000" 这类写法差异
  assert.ok(last.replace(/[,\s]/g, "").includes("1000"), `最终回答应包含 1000,实际:${last}`);
});

test("内容:tool 消息里就是 1000(工具真的算对了)", () => {
  const toolMsgs = messages.filter((m) => m.getType() === "tool") as ToolMessage[];
  assert.ok(toolMsgs.some((m) => String(m.content).replace(/[,\s]/g, "").includes("1000")));
});

运行:

bash
npx tsx 12-testing-and-debugging/code/03-integration-test.ts

真实输出(真模型,本次运行):

========================================================================
  示例 3:集成测试(真模型,断言结构)
========================================================================

👤 提问:125 乘以 8 等于多少?请用 calculator 工具计算,不要心算。

      🔧 真的调用了工具 calculator("125 * 8")
📜 真实消息轨迹:
   0. [human] 125 乘以 8 等于多少?请用 calculator 工具计算,不要心算。
   1. [ai] 请求调用 calculator({"expression":"125 * 8"})
   2. [tool] 1000
   3. [ai] 125 乘以 8 等于 **1000**。

✔ 结构:第一条是 human 消息 (888.527ms)
✔ 结构:最后一条是 AI 消息 (0.23775ms)
✔ 结构:过程中出现过 tool 消息(模型确实调用了工具) (0.18625ms)
✔ 内容:工具算出 1000,最终回答里带上了这个关键数字 (0.308458ms)
✔ 内容:tool 消息里就是 1000(工具真的算对了) (0.27975ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 898.277125

💡 只有第一行耗时 888ms(真的调了模型),其余四条都是 0.2ms 级的纯断言。这正是这套写法的精髓:把「真调模型」压缩到一次(用 before 钩子缓存结果),其余全是零成本的断言。

⚠️ 注意那个 .replace(/[,\s]/g, ""):模型可能把 1000 写成 1,000 或 1000。断言前先把逗号和空白去掉,是在跟「模型输出的格式随机性」做和解 —— 我们只关心那个数字对不对,不关心它有没有加千分位。这就是「断言结构、不断言措辞」的具体操作。


五、调试工具箱 ​

测试帮你发现问题,调试帮你定位问题。本节把最常用的五种手段集中在一张图上演示。为了不花钱、结果稳定,示例里的模型也用假模型。

示例 4:五种手段 ​

完整代码(code/04-debug-toolbox.ts):

typescript
/**
 * 示例 4:调试工具箱 —— 同一张图,五种手段轮着上
 *
 * 写完图跑不通时,光靠 console.log 猜是没用的。本示例把最常用的五种调试手段
 * 集中在同一张图上依次演示,每种都告诉你「它能看到什么、什么时候用」:
 *
 *   ① 节点入口打印状态      —— 最土但最直接:每一步进来的是什么
 *   ② graph.stream 看每一步 —— 逐步观察每个节点的产出(update / values)
 *   ③ getStateHistory 回放  —— 恢复「执行路径」:第几步、去了哪、状态如何变
 *   ④ drawMermaid 看图结构  —— 用文本看清节点与边的拓扑,抓「边连错了」
 *   ⑤ 调小 recursionLimit   —— 让死循环快速爆炸,暴露「少写了退出条件」
 *
 * 为了不花 API 钱、结果稳定,示例里的模型用假模型(见示例 2)。
 */
import {
  StateGraph,
  START,
  END,
  Annotation,
  MemorySaver,
  GraphRecursionError,
} from "@langchain/langgraph";
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
import type { ChatResult } from "@langchain/core/outputs";
import type { CallbackManagerForLLMRun } from "@langchain/core/callbacks/manager";
import { AIMessage, type BaseMessage } from "@langchain/core/messages";
import { banner } from "../../lib/model.js";

// ══════════════════════════════════════════════════════════════════
//  假模型:固定回一句,避免联网、避免随机
// ══════════════════════════════════════════════════════════════════
class FakeChatModel extends BaseChatModel {
  constructor(private readonly text: string) {
    super({});
  }
  _llmType(): string {
    return "fake";
  }
  async _generate(
    _messages: BaseMessage[],
    _options: this["ParsedCallOptions"],
    _runManager?: CallbackManagerForLLMRun
  ): Promise<ChatResult> {
    const msg = new AIMessage(this.text);
    return { generations: [{ text: this.text, message: msg }] };
  }
}

// ══════════════════════════════════════════════════════════════════
//  被调试的图:plan → respond(模型)→ polish
// ══════════════════════════════════════════════════════════════════

const DebugState = Annotation.Root({
  question: Annotation<string>,
  draft: Annotation<string>,
  answer: Annotation<string>,
  // trace 用 reducer 追加,方便看「走了哪些节点」
  trace: Annotation<string[]>({
    reducer: (prev: string[], next: string[]) => prev.concat(next),
    default: () => [],
  }),
});

const model = new FakeChatModel("这是模型的初稿。");

/** 把节点包一层,进入时打印它收到的状态 —— 手段 ① */
function traced<T extends (s: typeof DebugState.State) => Promise<Record<string, unknown>>>(
  name: string,
  fn: T
): T {
  return (async (state: typeof DebugState.State) => {
    console.log(`   [进入 ${name}] trace=${JSON.stringify(state.trace)}`);
    return fn(state);
  }) as T;
}

function buildMainGraph() {
  return new StateGraph(DebugState)
    .addNode(
      "plan",
      traced("plan", async () => ({ trace: ["plan"] }))
    )
    .addNode(
      "respond",
      traced("respond", async (state: typeof DebugState.State) => ({
        draft: String((await model.invoke(state.question)).content),
        trace: ["respond"],
      }))
    )
    .addNode(
      "polish",
      traced("polish", async (state: typeof DebugState.State) => ({
        answer: `${state.draft}(已润色)`,
        trace: ["polish"],
      }))
    )
    .addEdge(START, "plan")
    .addEdge("plan", "respond")
    .addEdge("respond", "polish")
    .addEdge("polish", END)
    .compile({ checkpointer: new MemorySaver() });
}

async function main() {
  banner("示例 4:调试工具箱(五种手段)");
  const graph = buildMainGraph();
  const config = { configurable: { thread_id: "debug-demo" } };

  // ── 手段 ①:节点入口打印状态 ──────────────────────────────
  console.log("① 节点入口打印状态(上面每次 [进入 xxx] 就是)");
  const r1 = await graph.invoke({ question: "介绍一下 LangGraph", trace: [] }, config);
  console.log(`   → 最终状态:${JSON.stringify(r1)}\n`);

  // ── 手段 ②:graph.stream 逐步看 ────────────────────────────
  console.log("② graph.stream 逐步观察(streamMode: \"updates\")");
  const config2 = { configurable: { thread_id: "debug-stream" } };
  const stream = await graph.stream(
    { question: "介绍一下 LangGraph", trace: [] },
    { ...config2, streamMode: "updates" }
  );
  for await (const chunk of stream) {
    // chunk 形如 { 节点名: 该节点的更新 } 或 { __start__: ... }
    for (const [node, update] of Object.entries(chunk)) {
      console.log(`   ⏵ 节点 ${node} 产出:${JSON.stringify(update)}`);
    }
  }
  console.log("");

  // ── 手段 ③:getStateHistory 回放执行路径 ───────────────────
  console.log("③ getStateHistory 回放执行路径");
  let step = 0;
  for await (const snap of graph.getStateHistory(config)) {
    const next = snap.next.length ? snap.next.join(",") : "(结束)";
    console.log(
      `   第 ${step} 步 | next=${next} | trace=${JSON.stringify(snap.values.trace ?? [])} | checkpoint=${String(snap.config.configurable?.checkpoint_id).slice(0, 8)}…`
    );
    step += 1;
  }
  console.log("");

  // ── 手段 ④:drawMermaid 看图结构 ───────────────────────────
  console.log("④ drawMermaid 看图结构(节点 + 边)");
  const drawable = await graph.getGraphAsync();
  for (const n of Object.values(drawable.nodes)) console.log(`   • 节点:${n.id}`);
  for (const e of drawable.edges) {
    console.log(`   • 边:${e.source} → ${e.target}${e.data ? `  (条件)` : ""}`);
  }
  const mermaid = await drawable.drawMermaid();
  console.log("   -- Mermaid 源码(前 6 行)--");
  mermaid
    .split("\n")
    .slice(0, 6)
    .forEach((line) => console.log(`   ${line}`));
  console.log("");

  // ── 手段 ⑤:调小 recursionLimit 暴露死循环 ─────────────────
  console.log("⑤ 调小 recursionLimit,让死循环快速爆炸");
  const LoopState = Annotation.Root({ n: Annotation<number> });
  const loopGraph = new StateGraph(LoopState)
    .addNode("tick", (s: typeof LoopState.State) => ({ n: s.n + 1 }))
    .addEdge(START, "tick")
    // 忘了写退出条件,无条件指回自己 → 死循环
    .addConditionalEdges("tick", () => "tick", ["tick"])
    .compile();
  try {
    await loopGraph.invoke({ n: 0 }, { recursionLimit: 5 });
    console.log("   ❌ 意外:死循环没被拦下");
  } catch (e) {
    if (e instanceof GraphRecursionError) {
      console.log("   ✅ 被拦下(这正是我们要的:早点炸,别上生产才炸)");
      console.log(`      ${e.message.split("\n")[0]}`);
    } else {
      throw e;
    }
  }
}

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

运行:

bash
npx tsx 12-testing-and-debugging/code/04-debug-toolbox.ts

真实输出:

========================================================================
  示例 4:调试工具箱(五种手段)
========================================================================

① 节点入口打印状态(上面每次 [进入 xxx] 就是)
   [进入 plan] trace=[]
   [进入 respond] trace=["plan"]
   [进入 polish] trace=["plan","respond"]
   → 最终状态:{"question":"介绍一下 LangGraph","draft":"这是模型的初稿。","answer":"这是模型的初稿。(已润色)","trace":["plan","respond","polish"]}

② graph.stream 逐步观察(streamMode: "updates")
   [进入 plan] trace=[]
   [进入 respond] trace=["plan"]
   ⏵ 节点 plan 产出:{"trace":["plan"]}
   [进入 polish] trace=["plan","respond"]
   ⏵ 节点 respond 产出:{"draft":"这是模型的初稿。","trace":["respond"]}
   ⏵ 节点 polish 产出:{"answer":"这是模型的初稿。(已润色)","trace":["polish"]}

③ getStateHistory 回放执行路径
   第 0 步 | next=(结束) | trace=["plan","respond","polish"] | checkpoint=1f1c4601…
   第 1 步 | next=polish | trace=["plan","respond"] | checkpoint=1f1c4601…
   第 2 步 | next=respond | trace=["plan"] | checkpoint=1f1c4601…
   第 3 步 | next=plan | trace=[] | checkpoint=1f1c4601…
   第 4 步 | next=__start__ | trace=[] | checkpoint=1f1c4601…

④ drawMermaid 看图结构(节点 + 边)
   • 节点:__start__
   • 节点:plan
   • 节点:respond
   • 节点:polish
   • 节点:__end__
   • 边:__start__ → plan
   • 边:plan → respond
   • 边:polish → __end__
   • 边:respond → polish
   -- Mermaid 源码(前 6 行)--
   %%{init: {'flowchart': {'curve': 'linear'}}}%%
   graph TD;
   	__start__([<p>__start__</p>]):::first
   	plan(plan)
   	respond(respond)
   	polish(polish)

⑤ 调小 recursionLimit,让死循环快速爆炸
   ✅ 被拦下(这正是我们要的:早点炸,别上生产才炸)
      Recursion limit of 5 reached without hitting a stop condition. You can increase the limit by setting the "recursionLimit" config key.

逐条讲「什么时候用它」:

手段你能看到什么什么时候用
① 节点入口打印状态每个节点进来时状态长什么样怀疑「上一个节点没把字段传下来」
② stream每个节点的产出(updates 模式逐节点)想知道「到底走到哪一步卡住了」
③ getStateHistory从后往前的每个 checkpoint:第几步、下一步去谁、当时状态已经跑完了想复盘执行路径(配 checkpointer 才可用)
④ drawMermaid节点清单 + 边清单 + Mermaid 源码怀疑「边连错了 / 节点孤立了」
⑤ 小 recursionLimit死循环被立刻拦下本地确认循环到底会不会停

💡 手段 ② 有个容易踩的点:graph.stream(...) 返回的是一个 Promise,要 await 之后才能 for await。写成 for await (const c of graph.stream(...)) 会报类型错误 —— 因为 .stream() 本身不是 async iterable,await graph.stream(...) 才是。本书示例里已经是正确写法。

💡 手段 ③ 逆序是故意的:getStateHistory 从最新的 checkpoint 往回吐,所以「第 0 步」是终态。想按时间顺序看,就先 Array.from 再用 .reverse()。

⚠️ 手段 ① 和 ② 都在打 [进入 xxx],看起来重复 —— 这不是 bug。 手段 ② 因为配了 checkpointer 且第二次运行时并没有复用同一个 thread_id,节点照常执行,所以入口日志会再打一遍。真实调试时你通常只挑一个手段开。


六、报错速查手册 ​

这一节是本章最重要的部分。列出的每一条错误,脚本都会真的把它触发出来,用 try/catch 捕获,打印真实的错误类名、lc_error_code 和错误信息,然后给出三段式:触发条件 → 根因 → 修法。

⚠️ 诚实声明:下面的错误信息都来自本机实跑,不是凭记忆写死的。唯独第 10 条(思考模式回放的 400)因本书默认网关做了兼容、在本环境复现不出来,示例里如实打印了「没报错」的探测结果,并明确标注 —— 绝不编造错误文本。

示例 5:逐条真实触发 ​

完整代码(code/05-error-catalog.ts):

typescript
/**
 * 示例 5:报错速查手册 —— 把常见错误「刻意触发」一遍,看清它长什么样
 *
 * 本章最重要的一节。下面每一条都是**真的会被触发**的:脚本在 try/catch 里
 * 故意制造这个错误,打印出真实的错误类名、错误码、错误信息,然后给出
 * 「触发条件 → 根因 → 修法」三段式。它们全部来自本机实跑,不是凭记忆写死的。
 *
 * 运行:npx tsx 12-testing-and-debugging/code/05-error-catalog.ts
 */
import {
  StateGraph,
  START,
  END,
  Annotation,
  MessagesAnnotation,
  MemorySaver,
  interrupt,
  GraphRecursionError,
  InvalidUpdateError,
  UnreachableNodeError,
  GraphValueError,
} from "@langchain/langgraph";
import { ToolNode } from "@langchain/langgraph/prebuilt";
import { tool } from "@langchain/core/tools";
import {
  AIMessage,
  HumanMessage,
  ToolMessage,
  type BaseMessage,
} from "@langchain/core/messages";
import * as z from "zod";
import { createModel, banner } from "../../lib/model.js";
import { assertChatKey } from "../../lib/env.js";

interface Meta {
  trigger: string;
  cause: string;
  fix: string;
}

/** 统一渲染一条「报错卡片」:触发 → 打印真实错误 → 讲解 */
async function scenario(
  num: string,
  title: string,
  meta: Meta,
  fn: () => Promise<unknown>
): Promise<void> {
  console.log("\n" + "─".repeat(72));
  console.log(`【${num}】${title}`);
  console.log("─".repeat(72));
  try {
    const r = await fn();
    console.log(`(本次没有抛异常)返回:${ JSON.stringify(r).slice(0, 120) }`);
  } catch (e) {
    const err = e as Error & { lc_error_code?: string };
    console.log(`错误类名   :${err.constructor.name}`);
    console.log(`lc_error_code:${err.lc_error_code ?? "(无)"}`);
    console.log(`错误信息   :${err.message.split("\n")[0]}`);
  }
  console.log(`触发条件 → ${meta.trigger}`);
  console.log(`根因     → ${meta.cause}`);
  console.log(`修法     → ${meta.fix}`);
}

async function main() {
  banner("示例 5:报错速查手册(每条都真实触发)");

  // ── 1. InvalidUpdateError ────────────────────────────────────
  await scenario(
    "1",
    "InvalidUpdateError:一个超步里多个节点写同一字段且没加 reducer",
    {
      trigger: "START 同时扇出到两个节点,两个节点在同一超步里都返回 { x: ... },而 x 用普通 Annotation 声明。",
      cause: "普通字段是 LastValue 通道,一步只允许收到一个值;收到多个就冲突。",
      fix: "要么只让一个节点写该字段;要么给它加 reducer(如 Annotation<number>({ reducer: (a,b)=>a+b, default: ()=>0 }))把并发写变成可合并。",
    },
    async () => {
      const S = Annotation.Root({
        x: Annotation<number>,
        a: Annotation<string>,
        b: Annotation<string>,
      });
      const g = new StateGraph(S)
        .addNode("nodeA", () => ({ a: "from-A", x: 1 }))
        .addNode("nodeB", () => ({ b: "from-B", x: 2 }))
        .addEdge(START, "nodeA")
        .addEdge(START, "nodeB")
        .addEdge("nodeA", END)
        .addEdge("nodeB", END)
        .compile();
      return g.invoke({ x: 0 });
    }
  );

  // ── 2. UnreachableNodeError ──────────────────────────────────
  await scenario(
    "2",
    "UnreachableNodeError:条件边第三个参数漏列了某个可能到达的节点",
    {
      trigger: "mapNode 用 addConditionalEdges(\"start\", router, [\"b\"]) 声明可达目标,但 router 实际可能返回 \"c\",而 \"c\" 没被列进第三个参数。",
      cause: "编译期会检查「每个 addNode 声明的节点是否可达」;router 可能返回却没被登记的目标节点,等于不可达。",
      fix: "把 router 所有可能返回值都写进第三个参数:[\"b\", \"c\"](含 END 也一样)。用 Command.goto 时,则要在 addNode(..., { ends: [...] }) 里声明可能跳去的节点。",
    },
    async () => {
      const S = Annotation.Root({ v: Annotation<string> });
      const g = new StateGraph(S)
        .addNode("start", () => ({ v: "s" }))
        .addNode("b", () => ({ v: "b" }))
        .addNode("c", () => ({ v: "c" })) // c 永远到不了
        .addEdge(START, "start")
        .addConditionalEdges("start", () => "b", ["b"])
        .addEdge("b", END)
        .addEdge("c", END)
        .compile();
      return g.invoke({ v: "" });
    }
  );

  // ── 3. GraphRecursionError ───────────────────────────────────
  await scenario(
    "3",
    "GraphRecursionError:死循环,或 recursionLimit 设得太小",
    {
      trigger: "节点无条件指回自己(忘写退出条件),并把 recursionLimit 设成 5。",
      cause: "图跑的超步数超过了 recursionLimit(默认 25)。",
      fix: "排查循环是否真的会终止:条件边要能走到 END。确认会终止后,再用 invoke(input, { recursionLimit: N }) 适当调大。捕获用 e instanceof GraphRecursionError。",
    },
    async () => {
      const S = Annotation.Root({ n: Annotation<number> });
      const g = new StateGraph(S)
        .addNode("tick", (s: typeof S.State) => ({ n: s.n + 1 }))
        .addEdge(START, "tick")
        .addConditionalEdges("tick", () => "tick", ["tick"])
        .compile();
      return g.invoke({ n: 0 }, { recursionLimit: 5 });
    }
  );

  // ── 4. 节点名与状态字段重名 ──────────────────────────────────
  await scenario(
    "4",
    "节点名与状态字段重名",
    {
      trigger: "Annotation.Root 里有个字段 answer,又 addNode(\"answer\", ...)。",
      cause: "节点名与「状态字段(channel)」共用一个命名空间,重名会让框架分不清。",
      fix: "给节点换个名字(如 respond / generateAnswer),别和任何状态字段重名;也别用 \"__start__\" / \"__end__\"。",
    },
    async () => {
      const S = Annotation.Root({ answer: Annotation<string> });
      const g = new StateGraph(S)
        .addNode("answer", () => ({ answer: "x" }))
        .addEdge(START, "answer")
        .addEdge("answer", END)
        .compile();
      return g.invoke({ answer: "" });
    }
  );

  // ── 5. checkpointer 缺 configurable 键 ───────────────────────
  await scenario(
    "5",
    "带 checkpointer 却忘了传 thread_id(自己拼 config 丢了必需的键)",
    {
      trigger: "compile({ checkpointer }) 之后,invoke(input) 没带 configurable.thread_id(或自己手拼 config 时把框架管的键弄丢了)。",
      cause: "有 checkpointer 的图,每次执行都要知道「写进哪个会话」。thread_id 必填;checkpoint_ns / checkpoint_id 由框架管理,手拼 config 很容易把它们漏掉。",
      fix: "永远传 { configurable: { thread_id: \"你的会话名\" } };需要跨中断恢复时,直接把 interrupt 时框架回给你的 config(里面已含 thread_id + checkpoint_ns + checkpoint_id)原样透传,别自己重拼。",
    },
    async () => {
      const S = Annotation.Root({ n: Annotation<number> });
      const g = new StateGraph(S)
        .addNode("step", (s: typeof S.State) => ({ n: s.n + 1 }))
        .addEdge(START, "step")
        .addEdge("step", END)
        .compile({ checkpointer: new MemorySaver() });
      // 故意不传 configurable
      return g.invoke({ n: 0 });
    }
  );

  // ── 6. interrupt 在图上下文之外被调用 ────────────────────────
  await scenario(
    "6",
    "interrupt 在图的上下文之外被调用",
    {
      trigger: "直接在一个普通函数(没在节点里执行)里调用 interrupt(\"...\")。",
      cause: "interrupt 依赖图运行时的上下文(AsyncLocalStorage)才能把「暂停点」写进 checkpoint。脱离图调用,框架无从记录。",
      fix: "只在节点函数体内调用 interrupt;想在图外触发暂停,用 compile({ interruptBefore: [\"节点名\"] })。",
    },
    async () => {
      return interrupt("我在图外面被调用了");
    }
  );

  // ── 7. getState 但没配 checkpointer ─────────────────────────
  await scenario(
    "7",
    "没有 checkpointer 却调用 getState / getStateHistory / updateState",
    {
      trigger: "图用 compile()(无 checkpointer),随后调用 graph.getState(config)。",
      cause: "这些「查/改状态」的方法本质是读/写 checkpoint;没有 checkpointer 就无处可读。",
      fix: "compile({ checkpointer: new MemorySaver() })(或 SqliteSaver / PostgresSaver 等),再传 thread_id。",
    },
    async () => {
      const S = Annotation.Root({ n: Annotation<number> });
      const g = new StateGraph(S)
        .addNode("a", () => ({ n: 1 }))
        .addEdge(START, "a")
        .addEdge("a", END)
        .compile();
      return g.getState({ configurable: { thread_id: "t" } });
    }
  );

  // ── 8. recursionLimit 非法 ───────────────────────────────────
  await scenario(
    "8",
    "recursionLimit 传了个非法的值(< 1)",
    {
      trigger: "invoke(input, { recursionLimit: 0 })。",
      cause: "上限必须 ≥ 1。",
      fix: "给它一个正整数;排查死循环时可以先给个较小的数(如 5),确认流程正常后再调回。",
    },
    async () => {
      const S = Annotation.Root({ n: Annotation<number> });
      const g = new StateGraph(S)
        .addNode("a", () => ({ n: 1 }))
        .addEdge(START, "a")
        .addEdge("a", END)
        .compile();
      return g.invoke({ n: 0 }, { recursionLimit: 0 });
    }
  );

  // ── 9. 工具参数 schema 不匹配 ────────────────────────────────
  await scenario(
    "9",
    "工具参数不符合 schema(不抛异常,但会把错误塞进 ToolMessage)",
    {
      trigger: "模型给出的 tool_call 参数缺了 schema 里 required 的字段(这里 calc 缺 expression)。",
      cause: "ToolNode 会先按 schema 校验参数,不通过就不执行工具。",
      fix: "看 ToolMessage 的 status:为 \"error\" 时 content 里就是校验详情。把工具描述和参数字段写清楚,必要时让模型重试;也可在节点里检查 status 走重试分支。",
    },
    async () => {
      const calc = tool(async ({ expression }) => String(eval(expression)), {
        name: "calc",
        description: "计算数学表达式",
        schema: z.object({ expression: z.string() }),
      });
      const tn = new ToolNode([calc]);
      const badAi = new AIMessage({
        content: "",
        tool_calls: [{ name: "calc", args: {}, id: "c1", type: "tool_call" }],
      });
      const out = (await tn.invoke({ messages: [badAi] })) as { messages: BaseMessage[] };
      const msg = out.messages[0] as ToolMessage;
      console.log(`   ToolMessage.status = ${msg.status}`);
      console.log(`   ToolMessage.content 首行 = ${String(msg.content).split("\n")[0]}`);
      return "(见上方 ToolMessage)";
    }
  );

  // ── 10. 思考模式模型的 reasoning_content 400(本环境未能复现)──
  console.log("\n" + "─".repeat(72));
  console.log("【10】思考模式模型 + 消息回放的 400(reasoning_content)");
  console.log("─".repeat(72));
  await demoReasoningContent();

  // ── 11. 正文为空但 exit=0(maxTokens 太小)───────────────────
  await scenario(
    "11",
    "正文为空、退出码却是 0(maxTokens 太小,被思考吃光)",
    {
      trigger: "给推理型模型设了很小的 maxTokens(这里 16),让它答一个需要展开的问题。",
      cause: "推理型模型的 maxTokens 额度「包含思考」。额度被思考耗光,就没有余量写正文,content 返回空字符串 —— 且不报错。",
      fix: "不要给推理型模型设过小的 maxTokens(本书干脆不设);排查「回答为空」时,先怀疑这一条,再看 additional_kwargs.reasoning_content 是不是把额度吃掉了。",
    },
    async () => {
      const m = createModel({ maxTokens: 16 });
      const r = await m.invoke([new HumanMessage("用 200 字介绍一下图数据库。")]);
      const rc = (r.additional_kwargs as Record<string, unknown>)?.reasoning_content;
      console.log(`   content 长度 = ${String(r.content).length}(正文为空)`);
      console.log(`   reasoning_content 长度 = ${typeof rc === "string" ? rc.length : 0}(思考把额度吃了)`);
      return "(见上方统计)";
    }
  );

  console.log("\n" + "═".repeat(72));
  console.log("  手册完。全部错误都是本机实跑触发,不是凭记忆写的。");
  console.log("═".repeat(72));
}

/**
 * 10 号条目:真实探测「思考模式回放」的 400。
 * 说明:本书默认网关对 reasoning_content 的多种形态都返回 200(做了兼容),
 * 因此这条官方报错在本环境**复现不出来**。这里如实打印我们探测到的情况,
 * 并给出触发条件与修法,绝不编造错误文本。
 */
async function demoReasoningContent() {
  assertChatKey();
  const cfg = createModel({});
  const calc = tool(async ({ expression }) => String(eval(expression)), {
    name: "calc",
    description: "计算数学表达式",
    schema: z.object({ expression: z.string() }),
  });
  const bound = cfg.bindTools([calc]);

  // 真跑一轮,拿到「带 tool_calls 且带 reasoning_content 的 AI 消息」
  const first = (await bound.invoke([new HumanMessage("请用 calc 工具计算 2+3。")])) as AIMessage;
  const tc = first.tool_calls ?? [];
  const toolMsg = new ToolMessage({ content: "5", tool_call_id: tc[0].id as string });

  // 关键动作:把 AI 消息「重建成一条干净的」,丢掉 reasoning_content,再回放
  const replay = await bound.invoke([
    new HumanMessage("请用 calc 工具计算 2+3。"),
    new AIMessage({ content: "", tool_calls: tc }),
    toolMsg,
  ]);
  console.log("   回放结果:没有抛 400,模型照常回答 →", JSON.stringify(String(replay.content)).slice(0, 60));
  console.log("   官方报错文本(本环境未复现,仅作对照):");
  console.log('     400 - "The reasoning_content in the thinking mode must be passed back to the API"');
  console.log("触发条件 → 思考模式模型:把带 tool_calls 的 assistant 消息「剥掉 reasoning_content 后」回放。");
  console.log("根因     → 部分 DeepSeek 思考模式端点要求:回放带工具调用的 assistant 消息时,必须把 reasoning_content 一并传回。");
  console.log("修法     → 不要手工 new AIMessage 重建历史消息;原样透传上一轮模型返回的消息对象(它自带 reasoning_content)。");
  console.log("注意     → 本书默认网关对多种形态都返回 200,故本条无法在本地触发;上线换端点后请以实际报错为准。");
}

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

运行:

bash
npx tsx 12-testing-and-debugging/code/05-error-catalog.ts

真实输出(节选自一次完整运行;为节省篇幅,长错误信息已保留首行):

========================================================================
  示例 5:报错速查手册(每条都真实触发)
========================================================================

────────────────────────────────────────────────────────────────────────
【1】InvalidUpdateError:一个超步里多个节点写同一字段且没加 reducer
────────────────────────────────────────────────────────────────────────
错误类名   :InvalidUpdateError
lc_error_code:INVALID_CONCURRENT_GRAPH_UPDATE
错误信息   :Invalid update for channel "x" with values [1,2]: LastValue can only receive one value per step.
触发条件 → START 同时扇出到两个节点,两个节点在同一超步里都返回 { x: ... },而 x 用普通 Annotation 声明。
根因     → 普通字段是 LastValue 通道,一步只允许收到一个值;收到多个就冲突。
修法     → 要么只让一个节点写该字段;要么给它加 reducer(如 Annotation<number>({ reducer: (a,b)=>a+b, default: ()=>0 }))把并发写变成可合并。

────────────────────────────────────────────────────────────────────────
【2】UnreachableNodeError:条件边第三个参数漏列了某个可能到达的节点
────────────────────────────────────────────────────────────────────────
错误类名   :UnreachableNodeError
lc_error_code:UNREACHABLE_NODE
错误信息   :Node `c` is not reachable.
触发条件 → mapNode 用 addConditionalEdges("start", router, ["b"]) 声明可达目标,但 router 实际可能返回 "c",而 "c" 没被列进第三个参数。
根因     → 编译期会检查「每个 addNode 声明的节点是否可达」;router 可能返回却没被登记的目标节点,等于不可达。
修法     → 把 router 所有可能返回值都写进第三个参数:["b", "c"](含 END 也一样)。用 Command.goto 时,则要在 addNode(..., { ends: [...] }) 里声明可能跳去的节点。

────────────────────────────────────────────────────────────────────────
【3】GraphRecursionError:死循环,或 recursionLimit 设得太小
────────────────────────────────────────────────────────────────────────
错误类名   :GraphRecursionError
lc_error_code:GRAPH_RECURSION_LIMIT
错误信息   :Recursion limit of 5 reached without hitting a stop condition. You can increase the limit by setting the "recursionLimit" config key.
触发条件 → 节点无条件指回自己(忘写退出条件),并把 recursionLimit 设成 5。
根因     → 图跑的超步数超过了 recursionLimit(默认 25)。
修法     → 排查循环是否真的会终止:条件边要能走到 END。确认会终止后,再用 invoke(input, { recursionLimit: N }) 适当调大。捕获用 e instanceof GraphRecursionError。

────────────────────────────────────────────────────────────────────────
【4】节点名与状态字段重名
────────────────────────────────────────────────────────────────────────
错误类名   :Error
lc_error_code:(无)
错误信息   :answer is already being used as a state attribute (a.k.a. a channel), cannot also be used as a node name.
触发条件 → Annotation.Root 里有个字段 answer,又 addNode("answer", ...)。
根因     → 节点名与「状态字段(channel)」共用一个命名空间,重名会让框架分不清。
修法     → 给节点换个名字(如 respond / generateAnswer),别和任何状态字段重名;也别用 "__start__" / "__end__"。

────────────────────────────────────────────────────────────────────────
【5】带 checkpointer 却忘了传 thread_id(自己拼 config 丢了必需的键)
────────────────────────────────────────────────────────────────────────
错误类名   :Error
lc_error_code:(无)
错误信息   :Failed to put checkpoint. The passed RunnableConfig is missing a required "thread_id" field in its "configurable" property. When using a checkpointer, you must pass a "thread_id" so the checkpointer knows which conversation thread to persist state for. Example: graph.stream(input, { configurable: { thread_id: "my-thread-id" } })
触发条件 → compile({ checkpointer }) 之后,invoke(input) 没带 configurable.thread_id(或自己手拼 config 时把框架管的键弄丢了)。
根因     → 有 checkpointer 的图,每次执行都要知道「写进哪个会话」。thread_id 必填;checkpoint_ns / checkpoint_id 由框架管理,手拼 config 很容易把它们漏掉。
修法     → 永远传 { configurable: { thread_id: "你的会话名" } };需要跨中断恢复时,直接把 interrupt 时框架回给你的 config(里面已含 thread_id + checkpoint_ns + checkpoint_id)原样透传,别自己重拼。

────────────────────────────────────────────────────────────────────────
【6】interrupt 在图的上下文之外被调用
────────────────────────────────────────────────────────────────────────
错误类名   :Error
lc_error_code:(无)
错误信息   :Called interrupt() outside the context of a graph.
触发条件 → 直接在一个普通函数(没在节点里执行)里调用 interrupt("...")。
根因     → interrupt 依赖图运行时的上下文(AsyncLocalStorage)才能把「暂停点」写进 checkpoint。脱离图调用,框架无从记录。
修法     → 只在节点函数体内调用 interrupt;想在图外触发暂停,用 compile({ interruptBefore: ["节点名"] })。

────────────────────────────────────────────────────────────────────────
【7】没有 checkpointer 却调用 getState / getStateHistory / updateState
────────────────────────────────────────────────────────────────────────
错误类名   :GraphValueError
lc_error_code:MISSING_CHECKPOINTER
错误信息   :No checkpointer set
触发条件 → 图用 compile()(无 checkpointer),随后调用 graph.getState(config)。
根因     → 这些「查/改状态」的方法本质是读/写 checkpoint;没有 checkpointer 就无处可读。
修法     → compile({ checkpointer: new MemorySaver() })(或 SqliteSaver / PostgresSaver 等),再传 thread_id。

────────────────────────────────────────────────────────────────────────
【8】recursionLimit 传了个非法的值(< 1)
────────────────────────────────────────────────────────────────────────
错误类名   :Error
lc_error_code:(无)
错误信息   :Passed "recursionLimit" must be at least 1.
触发条件 → invoke(input, { recursionLimit: 0 })。
根因     → 上限必须 ≥ 1。
修法     → 给它一个正整数;排查死循环时可以先给个较小的数(如 5),确认流程正常后再调回。

────────────────────────────────────────────────────────────────────────
【9】工具参数不符合 schema(不抛异常,但会把错误塞进 ToolMessage)
────────────────────────────────────────────────────────────────────────
   ToolMessage.status = error
   ToolMessage.content 首行 = Error: Received tool input did not match expected schema
(本次没有抛异常)返回:"(见上方 ToolMessage)"
触发条件 → 模型给出的 tool_call 参数缺了 schema 里 required 的字段(这里 calc 缺 expression)。
根因     → ToolNode 会先按 schema 校验参数,不通过就不执行工具。
修法     → 看 ToolMessage 的 status:为 "error" 时 content 里就是校验详情。把工具描述和参数字段写清楚,必要时让模型重试;也可在节点里检查 status 走重试分支。

────────────────────────────────────────────────────────────────────────
【10】思考模式模型 + 消息回放的 400(reasoning_content)
────────────────────────────────────────────────────────────────────────
   回放结果:没有抛 400,模型照常回答 → "2 + 3 = **5**"
   官方报错文本(本环境未复现,仅作对照):
     400 - "The reasoning_content in the thinking mode must be passed back to the API"
触发条件 → 思考模式模型:把带 tool_calls 的 assistant 消息「剥掉 reasoning_content 后」回放。
根因     → 部分 DeepSeek 思考模式端点要求:回放带工具调用的 assistant 消息时,必须把 reasoning_content 一并传回。
修法     → 不要手工 new AIMessage 重建历史消息;原样透传上一轮模型返回的消息对象(它自带 reasoning_content)。
注意     → 本书默认网关对多种形态都返回 200,故本条无法在本地触发;上线换端点后请以实际报错为准。

────────────────────────────────────────────────────────────────────────
【11】正文为空、退出码却是 0(maxTokens 太小,被思考吃光)
────────────────────────────────────────────────────────────────────────
   content 长度 = 0(正文为空)
   reasoning_content 长度 = 31(思考把额度吃了)
(本次没有抛异常)返回:"(见上方统计)"
触发条件 → 给推理型模型设了很小的 maxTokens(这里 16),让它答一个需要展开的问题。
根因     → 推理型模型的 maxTokens 额度「包含思考」。额度被思考耗光,就没有余量写正文,content 返回空字符串 —— 且不报错。
修法     → 不要给推理型模型设过小的 maxTokens(本书干脆不设);排查「回答为空」时,先怀疑这一条,再看 additional_kwargs.reasoning_content 是不是把额度吃掉了。

════════════════════════════════════════════════════════════════════════
  手册完。全部错误都是本机实跑触发,不是凭记忆写的。
════════════════════════════════════════════════════════════════════════

速查表:看到错误 → 大概率是什么 ​

你看到的错误 / 现象在哪一类问题里最常见第一件事做什么
InvalidUpdateError(INVALID_CONCURRENT_GRAPH_UPDATE)并发写同一字段给该字段加 reducer,或只让一个节点写
UnreachableNodeError(UNREACHABLE_NODE)条件边漏列 / Command.goto 没登记把全部可能目标补进第三参数或 ends
GraphRecursionError(GRAPH_RECURSION_LIMIT)死循环 / 上限太小检查循环会不会终止,再调 recursionLimit
... is already being used as a state attribute ...节点名撞状态字段给节点改名
Failed to put checkpoint ... missing a required "thread_id"有 checkpointer 却没给会话键补 configurable.thread_id;恢复时透传原 config
Called interrupt() outside the context of a graph.interrupt 用错位置把它搬进节点函数体内
No checkpointer set(MISSING_CHECKPOINTER)无 checkpointer 却查状态compile({ checkpointer })
Passed "recursionLimit" must be at least 1.上限传错给正整数
ToolMessage.status === "error"工具参数不符合 schema读 content 里的校验详情,改提示词 / 加重试
content 为空但程序正常退出maxTokens 太小被思考吃光去掉过小的 maxTokens

💡 这本书里最值得记住的一条排查直觉:「程序不报错,不代表没问题。」 第 9 条(工具参数错→塞进 ToolMessage)和第 11 条(正文为空→content="")都不抛异常,只是「安静地坏掉」。所以测试必须主动断言这些地方,光看「有没有崩」是不够的。

⚠️ 第 10 条的诚实说明:官方那条 reasoning_content ... must be passed back 的 400,是由部分思考模式端点强制执行的。本书默认网关(deepseek-flash)对它做了兼容 —— 我们把 reasoning_content 置空、置 null、甚至整条剥掉再回放,端点都返回 200。所以我没能在本机复现它,示例里如实打印了「没报错」。保留这一条是为了让你知道:换到严格端点后它会出现,届时请原样透传上一轮的 AI 消息对象(自带 reasoning_content),别手工 new AIMessage 重建历史。


本章小结 ​

本章速查表

你要做的事代码 / 做法
节点单测把节点当 (state) => update 纯函数,喂假 state 断言返回值
单测框架import { test } from "node:test" + import assert from "node:assert/strict"
跑单测npx tsx <file>(全过退出码 0,有失败非零)
假模型基类extends BaseChatModel,实现 _llmType() 与 _generate()
假模型回放工具调用new AIMessage({ content: "", tool_calls: [...] })
测「工具被没被调用」在工具函数里计数 / 记录入参,断言它
集成测试该断言消息条数、类型序列、有无 tool 消息、末条是否 AI、关键数字
集成测试不该断言完整文本、措辞、标点
入口日志调试用高阶函数包一层节点,进入时打印 state
逐步调试const s = await graph.stream(input, { streamMode: "updates" }); for await (const c of s) {...}
回放执行路径for await (const snap of graph.getStateHistory(config))(需 checkpointer)
看拓扑await (await graph.getGraphAsync()).drawMermaid()
炸死循环用很小的 recursionLimit + catch (e) { e instanceof GraphRecursionError }

三句话总结

  1. 分层测试:节点是纯函数 → 单测;图是编排 → 假模型测;真模型只在最顶层露面,且只断言结构。底层多写、顶层少写。
  2. 假模型是编排测试的主力:它不花钱、结果确定,更重要的是能稳定断言「工具到底被调用了没有」—— 这是真模型永远给不了的把握。
  3. 调试靠「看得见」:入口日志看输入、stream 看过程、getStateHistory 看路径、drawMermaid 看拓扑、小 recursionLimit 看死循环;而排错手册告诉你每个报错对应哪一类根因。

动手练习

  1. 给示例 1 加一个 computeShipping 节点(满 99 包邮,否则 10 元运费),先写单测:
    • amount=200 → 运费 0;amount=50 → 运费 10。 再把节点接进图,跑一次验证「折扣 → 应付 → 运费」整条链路。
  2. 改示例 2 的假模型脚本:让它第一轮就不调工具、直接给答案,然后新增一条断言,验证「整图只有 2 条消息、工具一次都没被调用」。(提示:参考文件里最后那条 test。)
  3. 复现第 1 条错误并修好它:把示例 5 里场景 1 的 Annotation<number> 改成带 reducer 的写法,跑一遍确认 InvalidUpdateError 消失、且两个节点贡献的值被合并。
  4. 用 getStateHistory 复现示例 4 手段 ③,然后把它按时间正序打印一遍(提示:收集到数组后 .reverse()),观察 checkpoint 的先后关系。

下一章预告

下一章我们换一种写法:不搭 StateGraph,直接用函数式 API(@langchain/langgraph 的 entrypoint / task)把普通代码「自动并行化」。你会看到:同样的多步流程,图把「状态与边」显式画出来,而函数式 API 让你像写普通异步代码一样表达,并行由框架替你调度。两种风格各适合什么场景?第 13 章见。


← 上一章:从 LangChain 迁移 | 返回总目录 | 下一章:函数式 API →

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