课程目标
精读 LangChain.js 的 Graph 系统和测试工具:Graph 类的节点/边数据结构、drawMermaid() 可视化输出、Vitest 测试约定、FakeModel 系列的测试实践。
23.1 为什么需要可视化
当链变得复杂时:
const chain = RunnableParallel.from({
context: retriever.pipe(formatDocs),
question: new RunnablePassthrough(),
}).pipe(prompt).pipe(model).pipe(parser);
肉眼很难理清数据流向。getGraph() 方法将 Runnable 链转换为可视化的 Graph 结构。
23.2 Graph 类
源码位置: libs/langchain-core/src/runnables/graph.ts
23.2.1 核心数据结构
export class Graph {
nodes: Record<string, Node> = {}; // nodeId -> Node
edges: Edge[] = [];
}
其中 Node 和 Edge 定义在 runnables/types.ts 中:
export interface Node {
id: string;
data: RunnableInterface | RunnableIOSchema;
name: string;
metadata?: Record<string, any>;
}
export interface Edge {
source: string; // 源节点 ID
target: string; // 目标节点 ID
data?: string; // 边的标签(如条件描述)
conditional?: boolean; // 是否是条件边
}
23.2.2 节点管理
addNode(data: RunnableInterface | RunnableIOSchema, id?: string, metadata?: Record<string, any>): Node {
if (id !== undefined && this.nodes[id] !== undefined) {
throw new Error(`Node with id ${id} already exists`);
}
const nodeId = id ?? uuidv4();
const node: Node = {
id: nodeId,
data,
name: nodeDataStr(id, data),
metadata,
};
this.nodes[nodeId] = node;
return node;
}
removeNode(node: Node): void {
delete this.nodes[node.id];
// 同时移除与该节点关联的边
this.edges = this.edges.filter(
edge => edge.source !== node.id && edge.target !== node.id
);
}
23.2.3 边管理
addEdge(source: Node, target: Node, data?: string, conditional?: boolean): Edge {
if (this.nodes[source.id] === undefined) {
throw new Error(`Source node ${source.id} not in graph`);
}
if (this.nodes[target.id] === undefined) {
throw new Error(`Target node ${target.id} not in graph`);
}
const edge: Edge = { source: source.id, target: target.id, data, conditional };
this.edges.push(edge);
return edge;
}
23.2.4 首尾节点查找
firstNode(): Node | undefined {
return _firstNode(this);
}
lastNode(): Node | undefined {
return _lastNode(this);
}
// _firstNode: 找到不是任何边的 target 的节点(入口节点)
function _firstNode(graph: Graph, exclude: string[] = []): Node | undefined {
const targets = new Set(
graph.edges
.filter(edge => !exclude.includes(edge.source))
.map(edge => edge.target)
);
const found: Node[] = [];
for (const node of Object.values(graph.nodes)) {
if (!exclude.includes(node.id) && !targets.has(node.id)) {
found.push(node);
}
}
return found.length === 1 ? found[0] : undefined;
}
23.2.5 Graph 扩展与合并
// 将子图合并到主图
extend(graph: Graph, prefix = "") {
// 添加子图的所有节点(带前缀避免 ID 冲突)
// 添加子图的所有边
// 返回子图的首尾节点引用
}
RunnableSequence.getGraph() 就是通过 extend 将每个子 Runnable 的 graph 合并到一个整体图中。
23.2.6 reid -- 重新标识
reid(): Graph {
// 将 UUID 格式的节点 ID 替换为可读名称
// 名称唯一的节点用名称作为 ID
// 名称不唯一的保留原始 ID
}
这个方法在 drawMermaid() 前调用,让生成的图更易读。
23.3 drawMermaid -- Mermaid 语法生成
源码位置: libs/langchain-core/src/runnables/graph_mermaid.ts
23.3.1 核心函数
export function drawMermaid(
nodes: Record<string, Node>,
edges: Edge[],
config?: {
firstNode?: string;
lastNode?: string;
curveStyle?: string;
withStyles?: boolean;
nodeColors?: Record<string, string>;
wrapLabelNWords?: number;
}
): string
23.3.2 生成示例
const chain = prompt.pipe(model).pipe(parser);
const graph = chain.getGraph();
const mermaid = graph.drawMermaid();
console.log(mermaid);
输出:
%%{init: {'flowchart': {'curve': 'linear'}}}%%
graph TD;
PromptInput([PromptInput]):::first
ChatPromptTemplate(ChatPromptTemplate)
ChatOpenAI(ChatOpenAI)
StringOutputParser(StringOutputParser)
StringOutputParserOutput([StringOutputParserOutput]):::last
PromptInput --> ChatPromptTemplate;
ChatPromptTemplate --> ChatOpenAI;
ChatOpenAI --> StringOutputParser;
StringOutputParser --> StringOutputParserOutput;
classDef default fill:#f2f0ff,line-height:1.2;
classDef first fill-opacity:0;
classDef last fill:#bfb6fc;
23.3.3 节点样式
const defaultNodeColors = {
default: "fill:#f2f0ff,line-height:1.2", // 普通节点
first: "fill-opacity:0", // 入口节点(透明)
last: "fill:#bfb6fc", // 出口节点(紫色)
};
- 入口节点用
([...])圆角矩形表示 - 出口节点用
([...]):::last带样式 - 条件边用虚线
-.->表示 - 边标签显示条件描述
23.3.4 子图支持
// 边按公共前缀分组,生成嵌套的 subgraph
function addSubgraph(edges: Edge[], prefix: string): void {
if (prefix && !selfLoop) {
mermaidGraph += `\tsubgraph ${subgraph}\n`;
}
// ... 添加边
if (prefix && !selfLoop) {
mermaidGraph += "\tend\n";
}
}
23.3.5 drawMermaidPng -- 图片渲染
async drawMermaidPng(params?): Promise<Blob> {
const mermaidSyntax = this.drawMermaid(params);
return drawMermaidImage(mermaidSyntax, {
backgroundColor: params?.backgroundColor,
});
}
drawMermaidImage 使用 Mermaid.INK API 将 Mermaid 语法渲染为 PNG 图片。
23.3.6 JSON 序列化
toJSON(): Record<string, any> {
const stableNodeIds: Record<string, string | number> = {};
// UUID 节点 ID 替换为数字索引,便于稳定的序列化输出
return {
nodes: Object.values(this.nodes).map(node => ({
id: stableNodeIds[node.id],
...nodeDataJson(node), // type: "runnable" | "schema"
})),
edges: this.edges.map(edge => ({
source: stableNodeIds[edge.source],
target: stableNodeIds[edge.target],
})),
};
}
23.4 Vitest 测试框架约定
LangChain.js 项目使用 Vitest 作为测试框架。
23.4.1 文件命名约定
| 文件模式 | 测试类型 | 运行条件 |
|---|---|---|
*.test.ts | 单元测试 | 无需外部依赖 |
*.int.test.ts | 集成测试 | 需要 API key |
23.4.2 运行命令
# 运行单元测试
pnpm --filter @langchain/core test
# 运行集成测试
pnpm --filter @langchain/openai test:int
# 运行特定文件
pnpm --filter @langchain/core vitest run src/runnables/tests/runnable.test.ts
23.4.3 项目测试最佳实践
import { describe, it, expect, beforeEach } from "vitest";
import { fakeModel } from "@langchain/core/testing";
import { AIMessage, HumanMessage } from "@langchain/core/messages";
describe("我的功能", () => {
let model: ReturnType<typeof fakeModel>;
beforeEach(() => {
model = fakeModel(); // 每个测试使用独立的 mock
});
it("正常路径", async () => {
model.respond(new AIMessage("ok"));
const result = await model.invoke([new HumanMessage("test")]);
expect(result.content).toBe("ok");
});
it("错误路径", async () => {
model.respond(new Error("API 错误"));
await expect(
model.invoke([new HumanMessage("test")])
).rejects.toThrow("API 错误");
});
});
23.5 FakeModel 系列测试实践
23.5.1 测试链的组合逻辑
import { fakeModel } from "@langchain/core/testing";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
it("prompt -> model -> parser 链", async () => {
const model = fakeModel()
.respond(new AIMessage("巴黎是法国的首都"));
const prompt = ChatPromptTemplate.fromMessages([
["system", "回答问题"],
["human", "{question}"],
]);
const chain = prompt.pipe(model).pipe(new StringOutputParser());
const result = await chain.invoke({ question: "法国首都是哪里?" });
expect(result).toBe("巴黎是法国的首都");
// 验证模型收到的消息
expect(model.callCount).toBe(1);
expect(model.calls[0].messages).toHaveLength(2);
});
23.5.2 测试工具调用链
import { tool } from "@langchain/core/tools";
import { z } from "zod";
it("模型调用工具", async () => {
const weatherTool = tool(
(input) => `${input.city}: 晴天 25度`,
{
name: "get_weather",
description: "获取天气",
schema: z.object({ city: z.string() }),
}
);
const model = fakeModel()
.respondWithTools([{ name: "get_weather", args: { city: "北京" } }]);
const result = await model.invoke([new HumanMessage("天气")]);
expect(result.tool_calls).toHaveLength(1);
expect(result.tool_calls[0].name).toBe("get_weather");
expect(result.tool_calls[0].args).toEqual({ city: "北京" });
});
23.5.3 测试流式输出
import { FakeStreamingChatModel } from "@langchain/core/utils/testing";
import { AIMessageChunk } from "@langchain/core/messages";
it("流式输出", async () => {
const model = new FakeStreamingChatModel({
chunks: [
new AIMessageChunk({ content: "Hello" }),
new AIMessageChunk({ content: " World" }),
],
sleep: 10,
});
const chunks: string[] = [];
for await (const chunk of await model.stream([new HumanMessage("hi")])) {
if (typeof chunk.content === "string") {
chunks.push(chunk.content);
}
}
expect(chunks).toEqual(["Hello", " World"]);
});
23.5.4 测试 Callback 触发
import { RunCollectorCallbackHandler } from "@langchain/core/tracers/run_collector";
it("callback 正确触发", async () => {
const collector = new RunCollectorCallbackHandler();
const model = fakeModel().respond(new AIMessage("ok"));
await model.invoke([new HumanMessage("test")], {
callbacks: [collector],
});
expect(collector.tracedRuns).toHaveLength(1);
expect(collector.tracedRuns[0].run_type).toBe("llm");
});
23.5.5 自定义 Vitest 断言
import { langchainCoreMatchers } from "@langchain/core/testing";
expect.extend(langchainCoreMatchers);
it("消息类型断言", async () => {
const model = fakeModel().respond(new AIMessage("hello"));
const result = await model.invoke([new HumanMessage("hi")]);
expect(result).toBeAIMessage("hello");
});
23.6 Graph 可视化实战
23.6.1 可视化简单链
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { FakeChatModel } from "@langchain/core/utils/testing";
const prompt = ChatPromptTemplate.fromMessages([
["system", "你是助手"],
["human", "{question}"],
]);
const model = new FakeChatModel();
const parser = new StringOutputParser();
const chain = prompt.pipe(model).pipe(parser);
const graph = chain.getGraph();
// 输出 Mermaid 语法
console.log(graph.drawMermaid());
// 查看图结构
const json = graph.toJSON();
console.log(JSON.stringify(json, null, 2));
23.6.2 可视化并行链
import { RunnableParallel, RunnablePassthrough } from "@langchain/core/runnables";
const parallel = RunnableParallel.from({
context: retriever,
question: new RunnablePassthrough(),
});
const chain = parallel.pipe(prompt).pipe(model).pipe(parser);
console.log(chain.getGraph().drawMermaid());
// 会看到分叉和合并的结构
23.6.3 分析图结构
const graph = chain.getGraph();
// 节点数量
console.log(`节点数: ${Object.keys(graph.nodes).length}`);
// 边数量
console.log(`边数: ${graph.edges.length}`);
// 入口和出口
console.log(`入口: ${graph.firstNode()?.name}`);
console.log(`出口: ${graph.lastNode()?.name}`);
// 遍历所有节点
for (const [id, node] of Object.entries(graph.nodes)) {
console.log(`${node.name} (${id})`);
}
23.7 测试金字塔
| 层级 | 工具 | 覆盖范围 | 速度 | 依赖 |
|---|---|---|---|---|
| 单元测试 | fakeModel() / FakeChatModel | 单个组件逻辑 | 极快 | 无 |
| 集成测试 | 真实 API(带 key) | 组件间交互 | 慢 | API key |
| 标准测试 | ChatModelUnitTests | Provider 一致性 | 快 | 无/有 |
| E2E 测试 | 完整链 + 真实 API | 端到端流程 | 最慢 | API key |
23.8 源码精读路线
| 优先级 | 文件 | 关注点 |
|---|---|---|
| P0 | runnables/graph.ts:53-112 | Graph 类、addNode/addEdge/firstNode/lastNode |
| P0 | runnables/graph.ts:241-283 | drawMermaid()/drawMermaidPng()、reid() |
| P1 | runnables/graph_mermaid.ts:24-188 | drawMermaid() 函数、节点格式化、子图支持 |
| P1 | testing/fake_model_builder.ts | FakeBuiltModel、fakeModel() |
| P2 | testing/matchers.ts | Vitest 扩展断言 |
| P2 | utils/testing/chat_models.ts | FakeChatModel、FakeStreamingChatModel |
本课收获总结
| 级别 | 你应该掌握的 |
|---|---|
| 🟢 基础 | 学会用 chain.getGraph().drawMermaid() 生成 Mermaid 图;理解单元测试和集成测试的命名约定 |
| 🔵 中阶 | 掌握 Vitest 在 LangChain.js 中的用法:fakeModel() 编写无 API 依赖的测试 |
| 🟡 高阶 | 理解 Graph 的节点/边数据结构:firstNode/lastNode 的查找算法;reid() 的 ID 重标识 |
| 🟠 资深 | 用 FakeStreamingChatModel 测试流式场景;用 RunCollectorCallbackHandler 验证 callback 触发 |
| 🔴 架构 | 设计 AI 应用的测试金字塔:FakeModel 单元测试 -> 真实 API 集成测试 -> E2E 测试 |
下一课预告
第 24 课进入 Agent 系统——从被动执行到主动推理。理解 ReAct 模式的核心循环:思考 -> 行动 -> 观察 -> 再思考。