LangGraph Agent 不是黑盒。每个图都由命名节点组成,这些节点按顺序或并行执行:分类、研究、分析、综合。图执行卡片通过为每个节点渲染一个卡片,显示其状态、实时流式传输其内容,并跟踪整个工作流的完成情况,使这个管道可见。用户可以看到 Agent 正在做什么、它在哪个步骤上,以及每个步骤产生了什么。

图节点如何映射到 UI 卡片

LangGraph 图定义了一系列节点,每个节点负责特定任务。例如,研究管道可能有:
  1. 分类:对用户查询进行分类
  2. 研究:收集相关信息
  3. 分析:从研究中得出结论
  4. 综合:生成最终、经过润色的响应
每个节点将其输出写入图状态中的特定键。通过将这些节点名称和状态键映射到 UI 组件,您可以创建整个管道的可视化表示。
const PIPELINE_NODES = [
  { name: "classify", stateKey: "classification", label: "Classify" },
  { name: "do_research", stateKey: "research", label: "Research" },
  { name: "analyze", stateKey: "analysis", label: "Analyze" },
  { name: "synthesize", stateKey: "synthesis", label: "Synthesize" },
];

const PIPELINE_NODE_NAMES = new Set(PIPELINE_NODES.map((n) => n.name));

设置 useStream

像往常一样连接 useStream。您将使用的关键属性是 messages(用于流式内容路由)、values(用于完成的节点输出)和 getMessagesMetadata(用于识别哪个节点产生了每个令牌)。 定义一个与您的 Agent 状态模式匹配的 TypeScript 接口,并将其作为类型参数传递给 useStream,以实现类型安全的状态值访问,包括每个管道节点的自定义状态键。在下面的示例中,将 typeof myAgent 替换为您的接口名称:
import type { BaseMessage } from "@langchain/core/messages";

interface AgentState {
  messages: BaseMessage[];
  classification: string;
  research: string;
  analysis: string;
  synthesis: string;
}
import { useStream } from "@langchain/react";

const AGENT_URL = "http://localhost:2024";

export function PipelineChat() {
  const stream = useStream<typeof myAgent>({
    apiUrl: AGENT_URL,
    assistantId: "graph_execution_cards",
  });

  return (
    <div>
      <PipelineProgress nodes={PIPELINE_NODES} values={stream.values} />
      <NodeCardList
        nodes={PIPELINE_NODES}
        messages={stream.messages}
        values={stream.values}
        getMetadata={stream.getMessagesMetadata}
      />
    </div>
  );
}

将流式令牌路由到节点

当 Agent 流式传输时,每条消息都会附带元数据注释,标识哪个图节点产生了它。使用 getMessagesMetadata 提取 langgraph_node 值并将令牌路由到正确的卡片:
function getStreamingContent(
  messages: BaseMessage[],
  getMetadata: (msg: BaseMessage) => MessageMetadata | undefined
): Record<string, string> {
  const content: Record<string, string> = {};

  for (const message of messages) {
    if (message.type !== "ai") continue;

    const metadata = getMetadata(message);
    const node = metadata?.streamMetadata?.langgraph_node;

    if (node && PIPELINE_NODE_NAMES.has(node)) {
      content[node] = typeof message.content === "string"
        ? message.content
        : "";
    }
  }

  return content;
}
这为您提供了从节点名称到其当前流式内容的映射。随着令牌的到来,相应的卡片会实时更新。
streamMetadata.langgraph_node 字段由 LangGraph 自动设置。 您不需要在后端进行任何特殊配置。只需像往常一样流式传输消息,就会包含元数据。

确定节点状态

每个节点可以处于以下四种状态之一:未启动、流式传输中、完成或空闲。您从两个来源派生状态:流式内容映射(用于活动节点)和 stream.values(用于已完成的节点):
type NodeStatus = "idle" | "streaming" | "complete";

function getNodeStatus(
  node: { name: string; stateKey: string },
  streamingContent: Record<string, string>,
  values: Record<string, unknown>
): NodeStatus {
  if (values?.[node.stateKey]) return "complete";
  if (streamingContent[node.name]) return "streaming";
  return "idle";
}

构建管道进度条

顶部的水平进度条为用户提供整个管道的鸟瞰图。每个步骤都是一个标记的段,随着节点的完成而填充:
function PipelineProgress({
  nodes,
  values,
  streamingContent,
}: {
  nodes: typeof PIPELINE_NODES;
  values: Record<string, unknown>;
  streamingContent: Record<string, string>;
}) {
  return (
    <div className="flex items-center gap-1">
      {nodes.map((node, i) => {
        const status = getNodeStatus(node, streamingContent, values);
        const colors = {
          idle: "bg-gray-200 text-gray-500",
          streaming: "bg-blue-400 text-white animate-pulse",
          complete: "bg-green-500 text-white",
        };

        return (
          <div key={node.name} className="flex items-center">
            <div
              className={`rounded-full px-3 py-1 text-xs font-medium ${colors[status]}`}
            >
              {node.label}
            </div>
            {i < nodes.length - 1 && (
              <div
                className={`mx-1 h-0.5 w-6 ${
                  status === "complete" ? "bg-green-500" : "bg-gray-200"
                }`}
              />
            )}
          </div>
        );
      })}
    </div>
  );
}

构建可折叠的 NodeCard 组件

每个节点都有自己的卡片,显示状态徽章、内容(流式或最终)以及用于长输出的可折叠主体:
function NodeCard({
  node,
  status,
  streamingContent,
  completedContent,
}: {
  node: { name: string; stateKey: string; label: string };
  status: NodeStatus;
  streamingContent: string | undefined;
  completedContent: unknown;
}) {
  const [collapsed, setCollapsed] = useState(false);

  const displayContent =
    status === "complete"
      ? formatContent(completedContent)
      : streamingContent ?? "";

  const statusBadge = {
    idle: { text: "Waiting", className: "bg-gray-100 text-gray-600" },
    streaming: {
      text: "Running",
      className: "bg-blue-100 text-blue-700 animate-pulse",
    },
    complete: { text: "Done", className: "bg-green-100 text-green-700" },
  };

  const badge = statusBadge[status];

  return (
    <div className="rounded-lg border bg-white shadow-sm">
      <button
        onClick={() => setCollapsed(!collapsed)}
        className="flex w-full items-center justify-between p-4"
      >
        <div className="flex items-center gap-3">
          <h3 className="font-semibold">{node.label}</h3>
          <span
            className={`rounded-full px-2 py-0.5 text-xs font-medium ${badge.className}`}
          >
            {badge.text}
          </span>
        </div>
        <ChevronIcon direction={collapsed ? "down" : "up"} />
      </button>

      {!collapsed && displayContent && (
        <div className="border-t px-4 py-3">
          <div className="prose prose-sm max-w-none">
            {displayContent}
            {status === "streaming" && (
              <span className="inline-block h-4 w-1 animate-pulse bg-blue-500" />
            )}
          </div>
        </div>
      )}
    </div>
  );
}

function formatContent(value: unknown): string {
  if (typeof value === "string") return value;
  if (value == null) return "";
  return JSON.stringify(value, null, 2);
}

流式内容 vs. 已完成内容

每个节点有两个内容源,选择正确的源对于流畅的用户体验很重要:
来源何时使用
streamingContent[node.name]当节点正在主动流式传输时,这包含到达的令牌
stream.values[node.stateKey]节点完成后,这包含最终的、已提交的输出
模式是:为实时更新显示流式内容,节点完成后回退到已提交的状态值。
for (const node of PIPELINE_NODES) {
  const status = getNodeStatus(node, streamingContent, stream.values);

  const content =
    status === "streaming"
      ? streamingContent[node.name]
      : stream.values?.[node.stateKey];
}
流式内容可能包括部分令牌或尚未完全形成的 markdown。如果您渲染 markdown,请确保您的渲染器优雅地处理不完整的语法(例如,未关闭的粗体标记 **)。

组合所有内容

这是完整的卡片列表,它结合了路由、状态检测和卡片渲染:
function NodeCardList({
  nodes,
  messages,
  values,
  getMetadata,
}: {
  nodes: typeof PIPELINE_NODES;
  messages: BaseMessage[];
  values: Record<string, unknown>;
  getMetadata: (msg: BaseMessage) => MessageMetadata | undefined;
}) {
  const streamingContent = getStreamingContent(messages, getMetadata);

  return (
    <div className="space-y-3">
      {nodes.map((node) => {
        const status = getNodeStatus(node, streamingContent, values);
        return (
          <NodeCard
            key={node.name}
            node={node}
            status={status}
            streamingContent={streamingContent[node.name]}
            completedContent={values?.[node.stateKey]}
          />
        );
      })}
    </div>
  );
}

使用案例

图执行卡片适用于任何需要可见性的多步骤管道:
  • 研究管道:分类 → 收集来源 → 分析 → 综合报告
  • 内容生成:大纲 → 草稿 → 事实核查 → 编辑 → 发布
  • 数据处理:摄取 → 验证 → 转换 → 聚合 → 导出
  • 代码生成:理解需求 → 规划架构 → 编写代码 → 审查 → 测试
  • 决策工作流:收集上下文 → 评估选项 → 对替代方案评分 → 推荐

处理动态管道

并非所有图都有固定的节点集。某些管道根据输入添加或跳过节点。通过检查哪些状态键实际上有值来处理这个问题:
const activeNodes = PIPELINE_NODES.filter(
  (node) =>
    streamingContent[node.name] ||
    values?.[node.stateKey] ||
    node.name === currentNode
);
这确保您的 UI 只显示与当前执行相关的节点的卡片,避免空的占位符卡片。
如果您的图有条件分支(例如,对于简单的事实查询跳过”研究”),跳过的节点将永远不会出现在流式内容或状态值中。您的管道进度条应该通过变暗或隐藏跳过的步骤来反映这一点。

最佳实践

  • 声明性地定义节点。保持您的 PIPELINE_NODES 数组作为映射节点名称、状态键和显示标签的唯一真实来源。
  • 对活动节点优先使用流式内容。它为用户提供即时反馈。只有在节点完成后才回退到已提交的状态值。
  • 自动折叠已完成的节点。在长管道中,自动折叠完成的卡片,以便用户可以专注于当前活动的步骤。
  • 显示预计时间。如果您有关于每个节点需要多长时间的历史数据,显示时间估计以设置用户期望。
  • 添加全局进度指示器。在管道视图的顶部,用整体进度条(例如,“4 步中的第 2 步”)补充每个节点的卡片。
  • 按节点处理错误。如果节点失败,在其卡片中显示错误,而不折叠整个管道。其他节点可能仍能成功完成。