第 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三个好处,一个比一个重要:
- 不花 API 钱 —— 整个文件零网络请求,跑一万遍也是 0 成本。
- 结果确定 —— 脚本说第一轮回
tool_call、第二轮回最终答案,就一定这样。没有随机性,断言不会「今天过明天红」。 - 能测「工具到底被调用了没有」 —— 这是假模型最独有的价值。真模型很可能自己心算(尤其简单算术),你根本没法稳定断言「工具被调了」。用固定脚本,我们强制模型发出
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 } |
三句话总结
- 分层测试:节点是纯函数 → 单测;图是编排 → 假模型测;真模型只在最顶层露面,且只断言结构。底层多写、顶层少写。
- 假模型是编排测试的主力:它不花钱、结果确定,更重要的是能稳定断言「工具到底被调用了没有」—— 这是真模型永远给不了的把握。
- 调试靠「看得见」:入口日志看输入、
stream看过程、getStateHistory看路径、drawMermaid看拓扑、小recursionLimit看死循环;而排错手册告诉你每个报错对应哪一类根因。
动手练习
- 给示例 1 加一个
computeShipping节点(满 99 包邮,否则 10 元运费),先写单测:amount=200→ 运费 0;amount=50→ 运费 10。 再把节点接进图,跑一次验证「折扣 → 应付 → 运费」整条链路。
- 改示例 2 的假模型脚本:让它第一轮就不调工具、直接给答案,然后新增一条断言,验证「整图只有 2 条消息、工具一次都没被调用」。(提示:参考文件里最后那条
test。) - 复现第 1 条错误并修好它:把示例 5 里场景 1 的
Annotation<number>改成带 reducer 的写法,跑一遍确认InvalidUpdateError消失、且两个节点贡献的值被合并。 - 用
getStateHistory复现示例 4 手段 ③,然后把它按时间正序打印一遍(提示:收集到数组后.reverse()),观察 checkpoint 的先后关系。
下一章预告
下一章我们换一种写法:不搭 StateGraph,直接用函数式 API(@langchain/langgraph 的 entrypoint / task)把普通代码「自动并行化」。你会看到:同样的多步流程,图把「状态与边」显式画出来,而函数式 API 让你像写普通异步代码一样表达,并行由框架替你调度。两种风格各适合什么场景?第 13 章见。