第 23 课: Graph 可视化与调试技巧

1 阅读3分钟

课程目标

精读 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[] = [];
}

其中 NodeEdge 定义在 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
标准测试ChatModelUnitTestsProvider 一致性无/有
E2E 测试完整链 + 真实 API端到端流程最慢API key

23.8 源码精读路线

优先级文件关注点
P0runnables/graph.ts:53-112Graph 类、addNode/addEdge/firstNode/lastNode
P0runnables/graph.ts:241-283drawMermaid()/drawMermaidPng()reid()
P1runnables/graph_mermaid.ts:24-188drawMermaid() 函数、节点格式化、子图支持
P1testing/fake_model_builder.tsFakeBuiltModelfakeModel()
P2testing/matchers.tsVitest 扩展断言
P2utils/testing/chat_models.tsFakeChatModelFakeStreamingChatModel

本课收获总结

级别你应该掌握的
🟢 基础学会用 chain.getGraph().drawMermaid() 生成 Mermaid 图;理解单元测试和集成测试的命名约定
🔵 中阶掌握 Vitest 在 LangChain.js 中的用法:fakeModel() 编写无 API 依赖的测试
🟡 高阶理解 Graph 的节点/边数据结构:firstNode/lastNode 的查找算法;reid() 的 ID 重标识
🟠 资深FakeStreamingChatModel 测试流式场景;用 RunCollectorCallbackHandler 验证 callback 触发
🔴 架构设计 AI 应用的测试金字塔:FakeModel 单元测试 -> 真实 API 集成测试 -> E2E 测试

下一课预告

第 24 课进入 Agent 系统——从被动执行到主动推理。理解 ReAct 模式的核心循环:思考 -> 行动 -> 观察 -> 再思考。