LangGraph 智能体并非黑盒。每张图由命名节点组成,它们按顺序或并行执行:分类、研究、分析、综合。图执行卡片通过为每个节点渲染一张卡片来让这个管道可视化——展示其状态、实时流式传输内容,并跟踪整个工作流的完成进度。用户可以清晰地看到智能体在做什么、处于哪一步以及每一步产出了什么。 这种模式对于生产环境的智能体尤其有用,因为它将图结构转化为了产品级的用户体验。与其将一次运行视作单一的助手回复,不如直接暴露 LangGraph 内部使用的相同的检查点、节点名、状态键和流元数据。

图节点如何映射到 UI 卡片

一个 LangGraph 图定义了一系列节点,每个节点负责一个特定的任务。例如,一个研究管道可能包含:
  1. 分类:对用户的查询进行分类
  2. 研究:收集相关信息
  3. 分析:根据研究得出结论
  4. 综合:生成最终的精炼回复
每个节点将其输出写入图状态中的特定键。在前端,你不需要将这种映射关系硬编码,因为 useStream 会在运行时通过 stream.subgraphs 发现每一个节点,并为每个观察到的步骤暴露一个 SubgraphDiscoverySnapshot
// 节点会被自动发现 — 无需硬编码列表
const graphNodes = [...stream.subgraphs.values()];

// 每个快照都携带节点名和当前状态
graphNodes.forEach((node) => {
  console.log(node.nodeName, node.status); // 如 "classify", "running"
});
在进度条和卡片标题中使用 node.nodeName 作为标签。将每个快照传递给 useMessages(stream, node) 来渲染节点范围内的流式内容,而无需将 UI 与图状态的键名耦合。 这种映射关系构成了你的图与 UI 之间的契约。后端作者可以有意地添加、重命名或重新排序节点,而前端作者则决定如何将每个状态键可视化:一个状态徽章、Markdown 面板、表格、图表、追踪视图或审批卡片。

设置 useStream

像平常一样接入 useStream。你将用到的关键属性是 messages(用于对话)和 subgraphs(用于在当前运行中发现的图节点)。将每个发现的子图快照传递给一个选择器,以读取限定在该节点范围内的消息。
The code examples use useStream<typeof myAgent> for type-safe stream state. See Type inference for Python or JavaScript backends.
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",
  });
  const graphNodes = [...stream.subgraphs.values()];

  return (
    <div>
      <PipelineProgress nodes={graphNodes} isLoading={stream.isLoading} />
      <NodeCardList nodes={graphNodes} stream={stream} isLoading={stream.isLoading} />
    </div>
  );
}

将流式令牌路由到节点

当图进行流式传输时,每个发现的子图快照都标识了它所属的节点。将该快照传递给一个选择器钩子或可组合函数,以读取限定在该节点范围内的消息:
import { AIMessage } from "langchain";
import { useMessages, type AnyStream, type SubgraphDiscoverySnapshot } from "@langchain/react";

function NodeCard({
  node,
  stream,
}: {
  node: SubgraphDiscoverySnapshot;
  stream: AnyStream;
}) {
  const messages = useMessages(stream, node);
  const lastAIMessage = messages.find(AIMessage.isInstance);
  const streamingContent = lastAIMessage?.text ?? "";

  return <NodeCardBody node={node} content={streamingContent} />;
}
第一个被挂载的选择器会为该节点命名空间打开一个限定了范围的订阅。当节点卡片卸载时,订阅会自动释放。

确定节点状态

每个发现的节点都携带着它当前的状态。直接使用 node.status;发现快照会报告 "pending"(等待中)、"running"(运行中)、"complete"(已完成)或 "error"(出错):
type NodeStatus = SubgraphDiscoverySnapshot["status"];

const status: NodeStatus = node.status;

构建管道进度条

顶部的水平进度条为用户提供了整个管道的鸟瞰视图。每个步骤都是一个带标签的段落,随着节点的完成而填充:
function PipelineProgress({
  nodes,
  isLoading,
}: {
  nodes: SubgraphDiscoverySnapshot[];
  isLoading: boolean;
}) {
  const firstIncompleteIdx = nodes.findIndex((node) => node.status !== "complete");

  return (
    <div className="flex items-center gap-1">
      {nodes.map((node, i) => {
        const isRunning =
          isLoading && node.status !== "complete" && firstIncompleteIdx === i;
        const colors = {
          pending: "bg-gray-200 text-gray-500",
          running: "bg-blue-400 text-white animate-pulse",
          complete: "bg-green-500 text-white",
          error: "bg-red-500 text-white",
        };
        const status = isRunning ? "running" : node.status;

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

构建可折叠的节点卡片组件

每个节点都有自己的卡片,展示状态徽章、内容(流式或最终)以及一个用于长输出的可折叠主体:
function NodeCard({
  node,
  stream,
}: {
  node: SubgraphDiscoverySnapshot;
  stream: AnyStream;
}) {
  const [open, setOpen] = useState(node.status === "running");
  const messages = useMessages(stream, node);
  const lastAIMessage = messages.find(AIMessage.isInstance);

  useEffect(() => {
    if (node.status === "running") setOpen(true);
    if (node.status === "complete") setOpen(false);
  }, [node.status]);

  return (
    <div className="rounded-lg border bg-white shadow-sm">
      <button
        onClick={() => setOpen(!open)}
        className="flex w-full items-center justify-between p-4"
      >
        <div className="flex items-center gap-3">
          <h3 className="font-semibold">{node.nodeName}</h3>
          <StatusBadge status={node.status} />
        </div>
        <span className={open ? "rotate-90" : ""}></span>
      </button>

      {open && (
        <div className="border-t px-4 py-3">
          <div className="prose prose-sm max-w-none">
            {lastAIMessage?.text?.trim()
              ? <Markdown>{lastAIMessage.text}</Markdown>
              : <p className="italic text-gray-500">正在处理...</p>}
          </div>
        </div>
      )}
    </div>
  );
}

流式内容与已完成内容

节点卡片读取限定范围内的消息,用于流式内容和最终内容。这避免了假设图节点名称与其写入的状态键名相匹配(例如,在示例图中 do_research 写入到 research 键):
来源何时使用
useMessages(stream, node)渲染限定在节点范围内的流式消息和最终消息
stream.values使用实际的状态键,读取整个图的状态,例如最终的 synthesis 字段
模式是:在节点卡片中显示最近一条限定范围内的 AI 消息,并且在你有意需要某个图状态字段时才使用 stream.values 因为限定范围的消息与生成它们的节点是绑定的,所以 UI 可以支持并行的图路径,而无需从消息顺序进行猜测。每张卡片都根据属于其节点的流事件进行更新,而已完成的值则通过 stream.values 保持可用。
function NodeContent({ stream, node }: { stream: AnyStream; node: SubgraphDiscoverySnapshot }) {
  const messages = useMessages(stream, node);
  const content = messages.find(AIMessage.isInstance)?.text ?? "";

  return <Markdown>{content}</Markdown>;
}
流式内容可能包含尚未完全形成的部分令牌或 markdown。如果你渲染 markdown,请确保你的渲染器能优雅地处理不完整的语法(例如,未闭合的粗体标记 **)。

整合在一起

以下是完整的卡片列表,它结合了路由、状态检测和卡片渲染:
function NodeCardList({
  nodes,
  stream,
  isLoading,
}: {
  nodes: SubgraphDiscoverySnapshot[];
  stream: AnyStream;
  isLoading: boolean;
}) {
  const firstIncompleteIdx = nodes.findIndex((node) => node.status !== "complete");

  return (
    <div className="space-y-3">
      {nodes.map((node, i) => {
        const isComplete = node.status === "complete";
        const isRunning = isLoading && !isComplete && firstIncompleteIdx === i;
        if (!isComplete && !isRunning) return null;

        return <NodeCard key={node.id} node={node} stream={stream} />;
      })}
    </div>
  );
}

使用场景

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

处理动态管道

并非所有图都有一组固定的节点。有些管道会根据输入添加或跳过节点。发现映射只包含当前线程观察到的节点:
const activeNodes = [...stream.subgraphs.values()];
这确保你的 UI 只显示与当前执行相关的节点卡片,避免出现空的占位卡片。
如果你的图有条件分支(例如,对于简单的事实性查询跳过”研究”节点),被跳过的节点将不会出现在 stream.subgraphs 中。你的管道进度条可以只渲染已发现的节点,或者将那些没有对应快照的预期节点显示为灰色。

最佳实践

  • 从流中发现节点。从 stream.subgraphs 渲染卡片,而不是硬编码预期的节点;条件性或跳过的步骤在运行之前不会出现。
  • 将状态键视为 UI 契约。确定哪些图输出对于前端渲染来说足够稳定,并在图定义的旁边记录这些键。
  • 为节点卡片使用限定范围的消息。它们在节点流式传输期间和完成后都能工作,而无需将 UI 卡片与状态键名耦合。
  • 自动折叠已完成的节点。在长管道中,自动折叠已完成的卡片,以便用户可以专注于当前活动的步骤。
  • 显示预估时间。如果你有每个节点所需时长的历史数据,显示一个时间预估来设定用户期望。
  • 添加全局进度指示器。在管道视图的顶部,用一个总体的进度条(例如,“第 2 步,共 4 步”)来补充单个节点卡片。
  • 按节点处理错误。如果某个节点失败,在其卡片中显示错误,而不要折叠整个管道。其他节点可能仍能成功完成。