上篇

引言

本篇主要是了解一下前端api,了解就行,记是记不住的。用的时候再查。框架可能选择react会很好一些。vue生态可能不是太好。vue3学习成本和代码规范会比react。react纯js操作html和js脚本,看起来比较混乱。样式、html、css、js、配置、数据实际上能隔离才是最好维护的。

1 概览

构建实时可视化深度智能体工作流的前端,这些设计模式展示了如何渲染以下内容:

  • 子智能体进度:显示下级智能体的工作状态。
  • 任务规划:展示智能体的思考路径和计划步骤。
  • 流式内容:像打字机一样实时呈现生成的内容。
  • 类 IDE 的沙盒体验:提供类似代码编辑器的交互式环境。

以上功能均适用于使用 createDeepAgent 创建的智能体。

1.1 架构

深度智能体(Deep Agents)采用“协调者-工作者”架构。主智能体(协调者)负责规划任务,并将其委派给专门的子智能体(工作者),每个子智能体都在隔离的环境中运行。在前端,useStream 能够同时呈现协调者的消息以及每个子智能体的流式状态。

from deepagents import create_deep_agent

agent = create_deep_agent(
    model="google_genai:gemini-3.1-pro-preview",
    tools=[get_weather],
    system_prompt="You are a helpful assistant",
    subagents=[
        {
            "name": "researcher",
            "description": "Research assistant",
        }
    ],
)

在前端,连接方式与 createAgent 一样,都是使用 useStream

但深度智能体(Deep Agent)模式会使用 useStream 的额外特性,例如:

  • stream.subagents
  • stream.values.todos
  • filterSubagentMessages

这些特性用于渲染子智能体专属的用户界面

import { useStream } from "@langchain/react";

function App() {
  const stream = useStream<typeof agent>({
    apiUrl: "http://localhost:2024",
    assistantId: "agent",
  });

  // Deep agent state beyond messages
  const todos = stream.values?.todos;
  const subagents = stream.subagents;
}

1.2 模式

子智能体流式传输

展示专家级子智能体的实时内容流,包含进度追踪和可折叠的卡片设计。

待办事项列表

通过从智能体状态同步的实时待办列表,来追踪智能体的执行进度。

沙盒

构建一个类似集成开发环境(IDE)的用户界面,包含文件浏览器、代码查看器和差异对比面板,并由沙盒环境提供支持。

1.3 相关模式

LangChain 的前端模式,包括 Markdown 消息工具调用人机协同,同样都适用于深度智能体(Deep Agents)。由于深度智能体是构建在相同的 LangGraph 运行时之上的,因此 useStream 提供了相同的核心 API

2 模式

2.1 子智能体流式传输

当协调者智能体(Coordinator)生成专家级子智能体(如研究员、分析师、作家)时,你需要将协调者的消息每个子智能体的流式输出分开渲染。

  1. 在 useStream 中设置 filterSubagentMessages: true,以清晰地将这两类流分离。

  2. 然后使用 getSubagentsByMessage,将每个子智能体的进度卡片挂载到触发它的那个协调者消息下方。

2.1.1 过滤子智能体消息

如果不进行过滤,每个子智能体产生的每一个字符(Token)都会交错穿插在协调者的消息流中,导致内容无法阅读

当设置 filterSubagentMessages: true 后:

  • stream.messages 仅包含协调者的消息。
  • 每个子智能体的内容可以通过 stream.subagents 和 stream.getSubagentsByMessage 单独获取。
  • 用户界面保持整洁:协调者的推理过程与专家的工作内容是分开的。

这种分离让你能够在一个地方渲染协调者的消息,并将每个子智能体的进度卡片精准地挂载到它所属的位置:即生成它的那条协调者消息下方。

2.2.2 设置 useStream

  1. 始终设置 filterSubagentMessages: true
    这会从主消息流中移除子智能体的字符(Tokens),这样你就可以独立渲染协调者的消息和子智能体的输出了。

  2. 定义一个 TypeScript 接口,使其与你智能体的状态模式(Schema)相匹配,并将其作为类型参数传递给 useStream,以实现对状态值的类型安全访问

在下面的示例中,请将 typeof myAgent 替换为你自己的接口名称

import type { BaseMessage } from "@langchain/core/messages";

interface AgentState {
  messages: BaseMessage[];
}

2.1.2 提交并开启子图流式传输

当提交消息时,请启用子图流式传输,并设置一个合适的递归限制。深度智能体的工作流通常涉及多层嵌套的子图,因此较高的递归限制可以防止任务过早终止

stream.submit(
  { messages: [{ type: "human", content: text }] },
  { streamSubgraphs: true }
);

DeepAgents 设置的默认递归限制为 10,000,这对于大多数多专家协作的场景来说已经足够了。如果需要,你可以通过 config.recursion_limit 来覆盖这个默认值。

2.1.3 子智能体流式接口

每个子智能体都会暴露出一个 SubagentStreamInterface,其中包含关于该子智能体任务的元数据,具体包括:

  • 任务:具体在做什么。
  • 状态:当前进展如何。
  • 计时:耗时多久。
interface SubagentStreamInterface {
  id: string;
  status: "pending" | "running" | "complete" | "error";
  messages: BaseMessage[];
  result: string | undefined;
  toolCall: {
    id: string;
    name: string;
    args: {
      description: string;
      subagent_type: string;
      [key: string]: unknown;
    };
  };
  startedAt: number | undefined;
  completedAt: number | undefined;
}
属性 (Property) 描述 (Description)
id 此子智能体实例的唯一标识符
status 生命周期状态:pending(等待中) → running(运行中) → complete(完成)或 error(错误)
messages 子智能体自己的消息流,实时更新
result 最终输出的文本,仅在状态为 complete 时可用
toolCall 生成此子智能体的工具调用对象,包含任务元数据
toolCall.args.description 协调者分配给此子智能体的任务描述
toolCall.args.subagent_type 专家的类型或名称(例如 "researcher"、"analyst")
startedAt 子智能体开始执行的时间戳
completedAt 子智能体结束执行的时间戳

2.1.4 将子智能体与消息关联

getSubagentsByMessage 方法会返回由某条特定 AI 消息生成的子智能体。这让你能够直接在触发它们的协调者消息下方渲染子智能体卡片:

const turnSubagents = stream.getSubagentsByMessage(msg.id);

这会返回一个 SubagentStreamInterface 对象数组。如果该消息没有生成任何子智能体,则返回一个空数组。

2.1.5 构建子代理卡片

每个子代理卡片显示专家的姓名、任务描述、流式内容或最终结果,以及时间信息。

import { AIMessage } from "@langchain/core/messages";

function SubagentCard({
  subagent,
}: {
  subagent: SubagentStreamInterface;
}) {
  const [expanded, setExpanded] = useState(true);

  const title =
    subagent.toolCall?.args?.subagent_type ?? `Agent ${subagent.id}`;
  const description = subagent.toolCall?.args?.description ?? "";

  const lastAIMessage = subagent.messages
    .filter(AIMessage.isInstance)
    .at(-1);

  const displayContent =
    subagent.status === "complete"
      ? subagent.result
      : typeof lastAIMessage?.content === "string"
        ? lastAIMessage.content
        : "";

  const elapsed = getElapsedTime(subagent.startedAt, subagent.completedAt);

  return (
    <div className="rounded-lg border bg-white shadow-sm">
      <button
        onClick={() => setExpanded(!expanded)}
        className="flex w-full items-center justify-between p-4"
      >
        <div className="flex items-center gap-3">
          <StatusIcon status={subagent.status} />
          <div>
            <h4 className="font-semibold capitalize">{title}</h4>
            <p className="text-xs text-gray-500">{description}</p>
          </div>
        </div>
        <div className="flex items-center gap-2">
          {elapsed && (
            <span className="text-xs text-gray-400">{elapsed}</span>
          )}
          <StatusBadge status={subagent.status} />
        </div>
      </button>

      {expanded && displayContent && (
        <div className="border-t px-4 py-3">
          <div className="prose prose-sm max-w-none line-clamp-6">
            {displayContent}
            {subagent.status === "running" && (
              <span className="inline-block h-4 w-1 animate-pulse bg-blue-500" />
            )}
          </div>
        </div>
      )}
    </div>
  );
}

function getElapsedTime(
  startedAt: number | undefined,
  completedAt: number | undefined
): string | null {
  if (!startedAt) return null;
  const end = completedAt ?? Date.now();
  const seconds = Math.round((end - startedAt) / 1000);
  if (seconds < 60) return `${seconds}s`;
  return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
}

上面这个代码看起来是有点丑陋,特别是

程序设计艺术、优雅代码价值慢慢的磨灭了。现在推行的AI生成代码,更加恶化了这一现象。

2.1.5状态图标和徽章

一致的视觉指示器可帮助用户一目了然地识别子代理的状态:

function StatusIcon({ status }: { status: SubagentStreamInterface["status"] }) {
  switch (status) {
    case "pending":
      return <span className="text-gray-400">○</span>;
    case "running":
      return <span className="animate-spin text-blue-500">◉</span>;
    case "complete":
      return <span className="text-green-500">✓</span>;
    case "error":
      return <span className="text-red-500">✕</span>;
  }
}

function StatusBadge({ status }: { status: SubagentStreamInterface["status"] }) {
  const styles = {
    pending: "bg-gray-100 text-gray-600",
    running: "bg-blue-100 text-blue-700",
    complete: "bg-green-100 text-green-700",
    error: "bg-red-100 text-red-700",
  };

  return (
    <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${styles[status]}`}>
      {status}
    </span>
  );
}

你看上面代码要是这样写多简洁明了,估计是现在动态类型语言望尘莫及的。历史包袱可不好甩掉,几乎所有使用比较广泛的语言都在想方设法模拟对象对象和类型,不是原生的,搞得五花八门,乌七八糟,严重造成了视觉和记忆上的冲击,美其名为灵活性、简洁( •̀ ω •́ )✧。

string StatusIcon(status: SubagentStreamInterface["status"]) {
  switch (status) {
    case "pending":
      return <span className="text-gray-400">○</span>;
    case "running":
      return <span className="animate-spin text-blue-500">◉</span>;
    case "complete":
      return <span className="text-green-500">✓</span>;
    case "error":
      return <span className="text-red-500">✕</span>;
  }
}

2.1.6 进度追踪

显示进度条和计数器,以便用户了解已完成多少个子代理:

function SubagentProgress({
  subagents,
}: {
  subagents: SubagentStreamInterface[];
}) {
  const completed = subagents.filter((s) => s.status === "complete").length;
  const total = subagents.length;
  const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;

  return (
    <div className="space-y-1">
      <div className="flex items-center justify-between text-xs text-gray-500">
        <span>Subagent progress</span>
        <span>
          {completed}/{total} complete
        </span>
      </div>
      <div className="h-2 overflow-hidden rounded-full bg-gray-200">
        <div
          className="h-full rounded-full bg-blue-500 transition-all duration-300"
          style={{ width: `${percentage}%` }}
        />
      </div>
    </div>
  );
}

2.1.7 渲染带子代理卡片的消息

关键的布局模式是先渲染每条协调器消息,如果该消息生成了子代理,则立即在其下方渲染它们的卡片:

function MessageWithSubagents({
  message,
  subagents,
}: {
  message: BaseMessage;
  subagents: SubagentStreamInterface[];
}) {
  if (message.type === "human") {
    return <HumanMessage content={message.content} />;
  }

  return (
    <div className="space-y-3">
      {message.content && (
        <div className="prose prose-sm max-w-none">
          {message.content}
        </div>
      )}

      {subagents.length > 0 && (
        <div className="ml-4 space-y-3 border-l-2 border-blue-200 pl-4">
          <SubagentProgress subagents={subagents} />
          {subagents.map((subagent) => (
            <SubagentCard key={subagent.id} subagent={subagent} />
          ))}
        </div>
      )}
    </div>
  );
}

2.1.8 综合指示器

在所有子代理完成后,协调器需要时间来综合它们的结果并生成最终响应。在此阶段显示一个清晰的指示器:

function SynthesisIndicator({
  subagents,
  isLoading,
}: {
  subagents: SubagentStreamInterface[];
  isLoading: boolean;
}) {
  const allComplete =
    subagents.length > 0 &&
    subagents.every((s) => s.status === "complete" || s.status === "error");

  if (!allComplete || !isLoading) return null;

  return (
    <div className="flex items-center gap-2 rounded-lg bg-purple-50 px-4 py-2 text-sm text-purple-700">
      <span className="animate-spin">⟳</span>
      Synthesizing results from {subagents.length} subagent
      {subagents.length !== 1 ? "s" : ""}...
    </div>
  );
}

对于复杂的多专家工作流,综合阶段可能需要几秒钟时间。一个清晰的“正在综合结果…”指示器可以防止用户误以为代理已卡住。

2.1.9 调试未过滤的输出

在开发过程中,你可以临时将 filterSubagentMessages 设置为 false,以便在主消息流中查看所有子代理的原始交错输出。这对于验证子代理的令牌(tokens)是否正确流动非常有用,但不应在生产环境的用户界面中使用。

2.1.10 使用场景

当你的智能体工作流涉及以下情况时,深度智能体子智能体卡片是合适的选择:

  • 深度研究:协调员分派研究人员调查问题的不同方面,然后综合他们的发现。
  • 多专家分析:领域专家(如法律、金融、技术)各自贡献他们的观点。
  • 复杂任务分解:规划者将一个大任务分解为子任务,并将每个子任务分配给专业工作者。
  • 代码审查流程:不同的智能体分别处理安全审查、风格检查、性能分析和文档审查

2.1.11 访问完整的子代理映射

除了按消息查找,你还可以通过 stream.subagents 一次性访问所有子代理:

const allSubagents = [...stream.subagents.values()];
const running = allSubagents.filter((s) => s.status === "running");
const completed = allSubagents.filter((s) => s.status === "complete");
const errors = allSubagents.filter((s) => s.status === "error");

这对于构建全局进度指示器或仪表板非常有用,可以汇总所有子代理的活动,而不管它们是由哪条协调器消息生成的

2.1.12 最佳实践

  • 始终设置 filterSubagentMessages: true:未过滤的流会导致协调器和子代理的令牌(tokens)混杂在一起,难以阅读。
  • 显示任务描述toolCall.args.description 字段准确地告诉用户每个子代理被要求做什么。请务必醒目地显示此信息。
  • 使用可折叠卡片:在包含 5 个以上子代理的工作流中,自动折叠已完成的卡片,以便用户专注于正在进行的工作。
  • 显示时间数据:展示每个子代理的耗时有助于用户了解性能特征并识别瓶颈。
  • 设置适当的递归限制:具有嵌套子图的深度智能体工作流需要比默认值 25 更高的限制。建议从 100 开始。
  • 单独处理子代理错误:一个子代理失败不应导致整个界面崩溃。应在该子代理的卡片中显示错误,同时让其他代理继续运行

2.2 待办事项列表

并非所有的智能体交互都是聊天。有时,智能体在执行一个多步骤计划,而展示进度的最佳方式是实时更新待办列表。深度智能体待办列表模式直接从智能体状态中读取待办事项数组,并在智能体执行计划时渲染每个项目的当前状态。这是一个基于你用于聊天的 useStream 钩子构建的进度仪表板。它表明智能体状态可以为任何用户界面提供支持,而不仅仅是消息气泡。

2.2.1 工作原理

在 LangGraph 智能体中,状态并不局限于消息。你可以定义自定义状态键来保存任意数据,在本例中即为一个待办事项数组。当智能体执行其计划时,它会将每个待办事项的状态从“待处理”更新为“进行中”,再到“已完成”。useStream 钩子通过 stream.values 暴露这些自定义状态值,你的用户界面则会响应式地渲染它们。

流程如下:

  1. 用户提交请求
  2. 智能体制定计划并在其状态中填充待办事项
  3. 智能体开始执行每个待办事项,状态依次转换:待处理 → 进行中 → 已完成
  4. 随着智能体的推进,stream.values.todos 实时更新
  5. 你的用户界面根据当前状态重新渲染待办列表

2.2.2 设置上游流

你不需要做任何特殊的配置。只需将 useStream 指向你的智能体,并从 stream.values 中读取待办事项即可。为了获得更好的开发体验,建议定义一个与智能体状态结构匹配的 TypeScript 接口,并将其作为类型参数传递给 useStream。这样,包括 todos 在内的自定义状态键都能获得类型安全的访问权限。在下面的示例中,请将 typeof myAgent 替换为你自己的接口名称:

import type { BaseMessage } from "@langchain/core/messages";

interface TodoItem {
  title: string;
  status: "pending" | "in_progress" | "completed";
  description?: string;
}

interface AgentState {
  messages: BaseMessage[];
  todos: TodoItem[];
}

2.2.3 代办任务接口

数组中的每个待办事项都具有简单的结构:

待办列表会根据当前状态渲染每个项目,通过状态图标、颜色编码和视觉样式来反映进度:

  • status:当前任务的状态。选项包括:pending(未开始)、in_progress(智能体正在处理)、completed(已完成)。
  • content:任务内容的易读描述。

智能体在制定计划时会填充这个数组,然后在执行每个步骤时更新单个项目。

2.2.4 构建代办列表组件

待办列表会根据当前状态渲染每个项目,通过状态图标、颜色编码和视觉样式来反映进度:

function TodoList({ todos }: { todos: Todo[] }) {
  const completed = todos.filter((t) => t.status === "completed").length;
  const percentage = todos.length
    ? Math.round((completed / todos.length) * 100)
    : 0;

  return (
    <div className="rounded-lg border bg-white p-4 shadow-sm">
      <div className="mb-4 flex items-center justify-between">
        <h2 className="text-lg font-semibold">Agent Progress</h2>
        <span className="text-sm text-gray-500">
          {completed}/{todos.length} tasks
        </span>
      </div>

      <ProgressBar percentage={percentage} />

      <ul className="mt-4 space-y-2">
        {todos.map((todo, i) => (
          <TodoItem key={i} todo={todo} />
        ))}
      </ul>
    </div>
  );
}

2.2.5 进度条

一个可视化的进度条能够为用户提供整体完成情况的概览,让人一眼就能掌握当前进度

function ProgressBar({ percentage }: { percentage: number }) {
  return (
    <div className="space-y-1">
      <div className="flex items-center justify-between text-xs text-gray-500">
        <span>Progress</span>
        <span>{percentage}%</span>
      </div>
      <div className="h-2 overflow-hidden rounded-full bg-gray-200">
        <div
          className="h-full rounded-full bg-green-500 transition-all duration-500"
          style={{ width: `${percentage}%` }}
        />
      </div>
    </div>
  );
}

2.2.6 独立的待办事项

待办列表中的每个项目都会通过状态图标、颜色编码的文字以及完成后的删除线样式来直观地反映其当前状态。

function TodoItem({ todo }: { todo: Todo }) {
  const config = {
    pending: {
      icon: "○",
      textClass: "text-gray-600",
      bgClass: "bg-gray-50",
      iconClass: "text-gray-400",
    },
    in_progress: {
      icon: "◉",
      textClass: "text-amber-800",
      bgClass: "bg-amber-50 border-amber-200",
      iconClass: "text-amber-500 animate-pulse",
    },
    completed: {
      icon: "✓",
      textClass: "text-green-800 line-through",
      bgClass: "bg-green-50 border-green-200",
      iconClass: "text-green-500",
    },
  };

  const style = config[todo.status];

  return (
    <li
      className={`flex items-start gap-3 rounded-md border px-3 py-2 ${style.bgClass}`}
    >
      <span className={`mt-0.5 text-lg leading-none ${style.iconClass}`}>
        {style.icon}
      </span>
      <span className={`text-sm ${style.textClass}`}>{todo.content}</span>
    </li>
  );
}

2.2.7 计算进度

进行中的图标会利用 animate-pulse 效果,以吸引用户注意到当前正在执行的任务

const todos = stream.values?.todos ?? [];

const completed = todos.filter((t) => t.status === "completed").length;
const inProgress = todos.filter((t) => t.status === "in_progress").length;
const pending = todos.filter((t) => t.status === "pending").length;
const percentage = todos.length
  ? Math.round((completed / todos.length) * 100)
  : 0;

你可以直接从 todos 数组中派生出进度指标,无需智能体额外发送状态更新。这完全可以在前端通过简单的计算实时完成

2.2.8 整合聊天消息

待办列表可以与常规聊天界面协同工作。一个实用的布局是将待办列表作为固定的侧边栏或顶部面板,聊天消息则显示在下方。

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

  const todos = stream.values?.todos ?? [];

  return (
    <div className="flex h-screen flex-col">
      {todos.length > 0 && (
        <div className="border-b bg-gray-50 p-4">
          <TodoList todos={todos} />
        </div>
      )}

      <main className="flex-1 overflow-y-auto p-6">
        <div className="mx-auto max-w-2xl space-y-4">
          {stream.messages.map((msg) => (
            <Message key={msg.id} message={msg} />
          ))}
        </div>
      </main>

      <ChatInput
        onSubmit={(text) =>
          stream.submit({ messages: [{ type: "human", content: text }] })
        }
        isLoading={stream.isLoading}
      />
    </div>
  );
}

在智能体生成计划之前展示一个空的待办列表组件纯属浪费空间。只有当 todos.length > 0 时才渲染它,能保持界面的整洁

2.2.9 待办事项之外的自定义状态

这种模式展示了一个强大的原则:stream.values 可以暴露你的智能体定义的任何自定义状态,而不仅仅是消息。todos 数组只是其中一个例子。你可以用同样的方法来实现:

  • 进度指标stream.values.progress 包含数字化的完成度数据
  • 生成的产物stream.values.document 包含智能体正在构建的结构化文档
  • 决策日志stream.values.decisions 追踪智能体做出的每一个选择
  • 资源列表stream.values.sources 包含智能体找到的链接和参考资料
// Any custom state key your agent defines is accessible
const document = stream.values?.document;
const sources = stream.values?.sources ?? [];
const confidence = stream.values?.confidence_score;

自定义状态键是在 LangGraph 图的状态模式(state schema)中定义的。useStream 钩子会自动将它们包含在 stream.values 中,无需任何额外的客户端配置。

这意味着你只需要在后端的图状态定义里添加字段(比如 todosprogress 或 document),前端就能直接通过 stream.values 访问到这些数据,实现了后端状态到前端 UI 的无缝同步。

2.2.10 动画过渡效果

待办事项的状态转换是实时发生的,流畅的动画让这些变化看起来更加精致,而不是生硬突兀

function TodoItem({ todo }: { todo: Todo }) {
  return (
    <li
      className={`
        flex items-start gap-3 rounded-md border px-3 py-2
        transition-all duration-300 ease-in-out
        ${getStatusStyles(todo.status)}
      `}
    >
      <span
        className={`
          mt-0.5 text-lg leading-none transition-colors duration-300
          ${getIconStyles(todo.status)}
        `}
      >
        {getStatusIcon(todo.status)}
      </span>
      <span
        className={`
          text-sm transition-all duration-300
          ${todo.status === "completed" ? "line-through opacity-60" : ""}
        `}
      >
        {todo.content}
      </span>
    </li>
  );
}

transition-all duration-300 类确保了颜色变化、删除线和透明度的切换都能平滑地进行动画过渡

2.2.11 使用场景

待办列表模式适用于智能体执行结构化计划的任何场景:

  • 项目规划:智能体将项目分解为任务,并按顺序逐一完成。
  • 研究工作流:每个研究问题都变成一个待办事项,由智能体进行调查并完成。
  • 数据处理:数据摄入、验证、转换和导出等步骤,每个都有自己的待办事项。
  • 新手引导流程:智能体逐步完成设置步骤,在配置服务时逐一核对勾选。
  • 报告生成:报告的各个部分变成待办事项:收集数据、分析趋势、撰写摘要、格式化输出。

2.2.12 处理空置和加载装填

在智能体生成计划之前,界面上应该只显示聊天区域,保持简洁。

function TodoList({ todos, isLoading }: { todos: Todo[]; isLoading: boolean }) {
  if (todos.length === 0 && !isLoading) {
    return null;
  }

  if (todos.length === 0 && isLoading) {
    return (
      <div className="rounded-lg border bg-white p-4 shadow-sm">
        <div className="flex items-center gap-2 text-sm text-gray-500">
          <span className="animate-spin">⟳</span>
          Agent is creating a plan...
        </div>
      </div>
    );
  }

  return (
    <div className="rounded-lg border bg-white p-4 shadow-sm">
      {/* ... full todo list rendering */}
    </div>
  );
}

2.2.13 最佳实践

待办列表是展示智能体进度的核心,一定要放在显眼的位置,别让用户费劲去找。同时,加上平滑的动画效果,智能体看起来会更灵敏。

这里有几个设计要点需要注意:

  1. 突出显示:把它放在首屏,别藏在页面底部。
  2. 平滑过渡:利用 CSS 给背景色、文字装饰和透明度加上过渡动画。
  3. 单一焦点:只高亮显示一个“进行中”的任务(比如让它闪烁),避免界面显得杂乱。
  4. 弱化已完成项:随着列表变长,把已完成的任务折叠或变暗,让用户的注意力集中在当前进度上。
  5. 显示百分比:直接展示“67% 已完成”这样的数字,一目了然。
  6. 保持同步:利用 stream.values 的响应式特性,列表会自动更新,不需要写额外的轮询代码。

2.3 沙箱

编程智能体需要的不仅仅是一个聊天窗口。它们需要文件浏览器、代码查看器和差异对比面板——也就是一种集成开发环境(IDE)的体验。这种模式将深度智能体连接到一个沙箱环境,使其能够在隔离的环境中读取、写入和执行代码,然后通过自定义 API 服务器暴露沙箱的文件系统,以便前端能够在智能体工作时实时显示文件

2.3.1 架构

沙箱模式包含三个层级:

  • 带沙箱后端的深度智能体:智能体通过沙箱自动获得文件系统工具(读取文件、写入文件、编辑文件、执行命令)。
  • 自定义 API 服务器:一个 FastAPI 应用,通过 langgraph.json 的 http.app 字段暴露出来,提供前端可调用的文件浏览接口。
  • IDE 前端:采用三栏布局(文件树、代码/差异查看器、聊天窗口),在智能体进行修改时实时同步文件。

2.3.2 沙箱生命周期

在深入代码之前,理解沙箱的作用域至关重要。作用域策略决定了谁共享沙箱、沙箱的生命周期以及运行时如何解析它。

🧵 线程级沙箱(推荐)

每个 LangGraph 线程拥有独立的沙箱。沙箱 ID 存储在线程的元数据中,并在运行时通过 getConfig() 解析。这是大多数应用的推荐方案:

  • 对话隔离:一个线程中的文件更改不会影响另一个线程。
  • 状态持久化:页面刷新后沙箱状态依然存在(同一线程 = 同一沙箱)。
  • 清理简单:删除线程时,其对应的沙箱也可以一并删除。

 智能体级沙箱

同一个助手下的所有线程共享一个沙箱。这种模式适用于持久化的项目环境,特别是当你希望更改能在不同对话之间保留时:

  • 跨会话持久化:无论开启多少个对话线程,操作的都是同一个项目环境,上下文不丢失。
  • 共享状态:适合团队协作或多轮次开发同一项目的场景。
from langgraph.config import get_config

def get_sandbox_backend_for_assistant():
    config = get_config()
    assistant_id = config.get("metadata", {}).get("assistant_id")
    return get_or_create_sandbox_for_assistant(assistant_id)

用户级沙箱

每个用户跨越所有线程拥有独立的沙箱。这需要自定义身份验证和用户识别机制:

  • 用户隔离:无论用户使用哪个助手或开启多少线程,所有操作都局限在该用户专属的环境中。
  • 多项目共享:适合 SaaS 场景,一个用户可以在不同项目(助手)间切换,但文件系统保持统一且与其他用户隔离。
  • 依赖外部认证:必须通过 API 网关或中间件解析用户身份(如 JWT),并将其注入到配置中。
from langgraph.config import get_config

def get_sandbox_backend_for_user():
    config = get_config()
    user_id = config.get("configurable", {}).get("user_id")
    return get_or_create_sandbox_for_user(user_id)

会话级沙箱(客户端)

适用于没有 LangGraph 线程的简单应用,前端可以生成一个会话 ID 并直接传递。这种方法不会在浏览器会话之间持久化,最适合演示或原型开发:

  • 轻量级:无需后端管理线程或用户,前端直接控制生命周期。
  • 临时性:刷新页面或关闭浏览器后沙箱即失效(除非前端自行持久化 ID)。
  • 快速原型:适合黑客马拉松、Demo 展示或一次性代码生成工具。
import uuid
import urllib.parse
import urllib.request

session_id = str(uuid.uuid4())
query = urllib.parse.urlencode({"sessionId": session_id})
urllib.request.urlopen(f"http://localhost:2024/api/sandbox/tree?{query}")

本指南的其余部分将以线程级沙箱作为主要示例

2.3.3 设置智能体

选择沙箱提供商

Deep Agents 支持多种沙箱提供商。任何实现了 SandboxBackendProtocol 接口的提供商都可以使用:

from deepagents import create_deep_agent
from deepagents.sandbox import LangSmithSandbox  # or DaytonaSandbox, etc.

sandbox = LangSmithSandbox.create()
agent = create_deep_agent(model="google_genai:gemini-3.1-pro-preview", backend=sandbox)

智能体会自动获得文件系统工具(读取文件、写入文件、编辑文件、列出目录、通配符查找、内容搜索)以及用于运行 Shell 命令的执行工具。无需进行任何工具配置。

为每个线程解析沙箱

不要在模块级别创建沙箱(这会导致所有线程共享同一个沙箱,且可能过期),而是在运行时为每个线程动态解析沙箱。沙箱通过 getConfig() 从 LangGraph 配置中读取 thread_id

from deepagents import create_deep_agent
from deepagents.sandbox import LangSmithSandbox
from langgraph.config import get_config


def get_or_create_sandbox_for_thread(thread_id: str) -> LangSmithSandbox:
    # Look up or create sandbox based on thread_id
    ...


sandbox = LangSmithSandbox(
    resolve=lambda: get_or_create_sandbox_for_thread(
        get_config()["configurable"]["thread_id"]
    ),
)

agent = create_deep_agent(
    model="google_genai:gemini-3.1-pro-preview",
    backend=sandbox,
)

在智能体运行之前,使用 uploadFiles 将你的项目文件填充到沙箱中:
基于快照初始化:对于 LangSmith 沙箱,容器镜像和资源限制来自于沙箱快照。
指定模板:创建沙箱时请传入 templateName(参见上文的 get_or_create_sandbox_for_thread)。
运行时注入:upload_files 会在该镜像的基础上,在运行时植入或更新项目文件。

填充沙箱

在智能体运行之前,使用 uploadFiles 将你的项目文件填充到沙箱中:

  • 基于快照初始化:对于 LangSmith 沙箱,容器镜像和资源限制来自于沙箱快照。
  • 指定模板:创建沙箱时请传入 templateName(参见上文的 get_or_create_sandbox_for_thread)。
  • 运行时注入upload_files 会在该镜像的基础上,在运行时植入或更新项目文件
const SEED_FILES: Record<string, string> = {
  "package.json": JSON.stringify({ name: "my-app", version: "1.0.0" }, null, 2),
  "src/index.js": 'console.log("Hello");',
};

const encoder = new TextEncoder();
await sandbox.uploadFiles(
  Object.entries(SEED_FILES).map(([path, content]) => [`/app/${path}`, encoder.encode(content)]),
);

在上传 package.json 文件后,智能体启动前,运行 sandbox.execute("cd /app && npm install") 来安装依赖项。

2.3.4 添加文件浏览API

智能体虽然可以读写文件,但前端也需要直接访问沙箱文件系统以便浏览。为此,你需要添加一个自定义的 FastAPI API 服务器,并通过 langgraph.json 中的 http.app 字段将其暴露出来。

创建 API 服务器

沙箱的 API 端点使用线程 ID 作为 URL 路径参数。这能确保前端始终访问当前会话对应的正确沙箱,其背后使用的 get_or_create_sandbox_for_thread 函数与智能体后端完全一致:

# src/api/server.py
from fastapi import FastAPI, Query, Path
from utils import get_or_create_sandbox_for_thread

app = FastAPI()

@app.get("/api/sandbox/{thread_id}/tree")
async def list_tree(
    thread_id: str = Path(...),
    path: str = Query("/app"),
):
    sandbox = await get_or_create_sandbox_for_thread(thread_id)
    result = await sandbox.aexecute(
        f"find {path} -printf '%y\\t%s\\t%p\\n' 2>/dev/null | sort"
    )
    entries = []
    for line in result.output.strip().split("\n"):
        if not line:
            continue
        type_char, size_str, full_path = line.split("\t")
        entries.append({
            "name": full_path.split("/")[-1],
            "type": "directory" if type_char == "d" else "file",
            "path": full_path,
            "size": int(size_str),
        })
    return {"path": path, "entries": entries, "sandbox_id": sandbox.id}

@app.get("/api/sandbox/{thread_id}/file")
async def read_file(
    thread_id: str = Path(...),
    path: str = Query(...),
):
    sandbox = await get_or_create_sandbox_for_thread(thread_id)
    results = await sandbox.adownload_files([path])
    return {"path": path, "content": results[0].content.decode()}

智能体的后端和 API 服务器都调用同一个 get_or_create_sandbox_for_thread 函数。这确保了对于给定的线程,它们总是解析到同一个沙箱。线程元数据中的沙箱 ID 是唯一的真实来源——无需内存缓存。

配置 langgraph.json

同时注册智能体图和 API 服务器。http.app 字段指示 LangGraph 平台在默认路由之外提供你的自定义路由:

{
  "graphs": {
    "coding_agent": "./src/agents/my_agent.py:agent"
  },
  "env": ".env",
  "http": {
    "app": "./src/api/server.py:app"
  }
}

你的自定义路由与 LangGraph API 位于同一主机下。使用 langgraph dev 进行本地开发时,地址为 http://localhost:2024

在 http.app 中定义的自定义路由优先于默认的 LangGraph 路由。这意味着你可以根据需要覆盖内置端点,但要小心不要意外覆盖 /threads 或 /runs 等关键路由

2.3.5 构建前端

前端包含三个面板:文件树侧边栏、代码/差异查看器和聊天面板。它使用 useStream 处理智能体对话,并使用自定义 API 端点进行文件浏览。
 线程创建
在页面加载时创建一个 LangGraph 线程,并将其 ID 持久化存储在 sessionStorage 中,以便页面刷新后能重新连接到同一个沙箱:

const THREAD_KEY = "sandbox-thread-id";

function IDEPreview() {
  const [threadId, setThreadId] = useState<string | null>(
    () => sessionStorage.getItem(THREAD_KEY),
  );

  const updateThreadId = useCallback((id: string | null) => {
    setThreadId(id);
    if (id) sessionStorage.setItem(THREAD_KEY, id);
    else sessionStorage.removeItem(THREAD_KEY);
  }, []);

  const stream = useStream<typeof myAgent>({
    apiUrl: AGENT_URL,
    assistantId: "coding_agent",
    threadId,
    onThreadId: updateThreadId,
  });

  // Create thread on first mount
  useEffect(() => {
    if (threadId) return;
    stream.client.threads.create().then((t) => updateThreadId(t.thread_id));
  }, [stream.client, threadId, updateThreadId]);

  // Pass threadId to sandbox file hooks
  const { tree, files } = useSandboxFiles(threadId);
  // ...
}

“新建线程”按钮会清除存储的 ID,以便下次挂载时创建一个新的线程(以及沙箱):

function handleNewThread() {
  stream.switchThread(null);
  updateThreadId(null);
}

文件状态管理

跟踪沙箱文件系统的两个快照:初始状态(智能体运行前)和当前状态(实时更新)。线程 ID 包含在 API URL 中,确保请求始终访问正确的沙箱:

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

async function fetchTree(threadId: string): Promise<FileEntry[]> {
  const res = await fetch(
    `${AGENT_URL}/api/sandbox/${encodeURIComponent(threadId)}/tree?filePath=/app`,
  );
  const data = await res.json();
  return data.entries.filter((e: FileEntry) => !e.path.includes("node_modules"));
}

async function fetchFile(threadId: string, path: string): Promise<string | null> {
  const res = await fetch(
    `${AGENT_URL}/api/sandbox/${encodeURIComponent(threadId)}/file?filePath=${encodeURIComponent(path)}`,
  );
  const data = await res.json();
  return data.content ?? null;
}

实时文件同步

IDE 体验的关键在于:在智能体工作的同时实时更新文件,而不是等它完成后再更新。你需要监听流中的消息,捕捉来自文件修改工具的 ToolMessage 实例。当 write_file 或 edit_file 工具调用完成时,刷新该特定文件;当 execute 完成时,刷新所有文件(因为 shell 命令可能会修改任何文件)

#react
import { useStream } from "@langchain/react";
import { ToolMessage, AIMessage } from "langchain";

const FILE_MUTATING_TOOLS = new Set(["write_file", "edit_file", "execute"]);

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

  const processedIds = useRef(new Set<string>());

  useEffect(() => {
    // Build a map of file-mutating tool calls from AI messages
    const toolCallMap = new Map();
    for (const msg of stream.messages) {
      if (!AIMessage.isInstance(msg)) continue;
      for (const tc of msg.tool_calls ?? []) {
        if (tc.id && FILE_MUTATING_TOOLS.has(tc.name)) {
          toolCallMap.set(tc.id, { name: tc.name, args: tc.args });
        }
      }
    }

    // When a ToolMessage appears for a file-mutating tool, refresh
    for (const msg of stream.messages) {
      if (!ToolMessage.isInstance(msg)) continue;
      const id = msg.id ?? msg.tool_call_id;
      if (!id || processedIds.current.has(id)) continue;

      const call = toolCallMap.get(msg.tool_call_id);
      if (!call) continue;
      processedIds.current.add(id);

      if (call.name === "write_file" || call.name === "edit_file") {
        refreshSingleFile(call.args.path);
      } else if (call.name === "execute") {
        refreshAllFiles();
      }
    }
  }, [stream.messages]);
}

检测变更文件

在每次智能体运行之前,对当前文件内容进行快照。文件刷新后,将其与快照进行比较,以识别哪些文件发生了变更

function detectChanges(current: FileSnapshot, original: FileSnapshot): Set<string> {
  const changed = new Set<string>();
  for (const [path, content] of Object.entries(current)) {
    if (original[path] !== content) changed.add(path);
  }
  for (const path of Object.keys(original)) {
    if (!(path in current)) changed.add(path);
  }
  return changed;
}

当用户选择一个变更的文件时,默认显示差异视图,以便他们能立即看到智能体修改的内容。

 实现差异视图逻辑

使用适合框架的差异库来渲染统一差异:

框架 组件
React @pierre/diffs <FileDiff> 配合 parseDiffFromFile
Vue @git-diff-view/vue <DiffView> 配合来自 @git-diff-view/file 的 generateDiffFile
Svelte @git-diff-view/svelte <DiffView> 配合来自 @git-diff-view/file 的 generateDiffFile
Angular ngx-diff <ngx-unified-diff> 配合 [before] 和 [after]

这是一个使用 @pierre/diffs 库的 React 组件示例

import { FileDiff } from "@pierre/diffs/react";
import { parseDiffFromFile } from "@pierre/diffs";

function DiffPanel({ original, current, fileName }) {
  const diff = parseDiffFromFile(
    { name: fileName, contents: original },
    { name: fileName, contents: current },
  );

  return (
    <FileDiff
      fileDiff={diff}
      options={{ theme: "github-dark", diffStyle: "unified", diffIndicators: "bars" }}
    />
  );
}

变更文件摘要

展示所有修改文件的摘要,包括行级别的增加/删除计数。这能让用户快速了解智能体的影响范围——类似于 git status 的输出:

2.3.6 三方面板布局

DE 布局将三个面板并排排列:

表格

面板 宽度 用途
文件树 固定 (208px) 浏览沙箱文件,查看变更指示器
代码 / 差异 弹性 (自适应) 查看文件内容或统一差异视图
聊天 固定 (320px) 与智能体交互
<div className="flex h-screen">
  <div className="w-52 shrink-0">
    <FileTree />
    <ChangedFilesSummary />
  </div>

  <CodePanel /* flex-1 */ />

  <div className="w-80 shrink-0">
    <ChatPanel />
  </div>
</div>

文件树显示 VS Code 风格的图标(使用 @iconify-json/vscode-icons),并在修改过的文件上显示琥珀色圆点。选择修改过的文件会自动切换到差异标签页。

2.3.7 使用场景

沙箱(Sandbox)在以下场景中是最佳选择:

  • 需要为编写、修改和运行代码的智能体提供超越纯聊天的可视化界面时。
  • 在代码审查工作流中,智能体建议更改,用户在接受前审查差异。
  • 在教程或学习类应用中,AI 助手逐步帮助用户构建项目,并在上下文中展示更改。
  • 在原型设计工具中,用户用自然语言描述功能,并实时观看智能体实现它们。

2.3.8 最佳实践

  • 采用线程级作用域的沙箱
    在生产环境应用中,请使用线程级作用域的沙箱。将沙箱 ID 存储在线程元数据中,并在运行时通过 getConfig() 进行解析。这样可以避免模块级的状态污染,并确保每个对话的沙箱都是相互隔离的。

  • 共享沙箱获取逻辑
    在 Agent 后端和 API 服务器之间共享 getOrCreateSandboxForThread 函数。两者都应该通过线程元数据以相同的方式解析沙箱,从而确保单一事实来源,无需依赖内存缓存。

  • 持久化线程 ID
    将 threadId 持久化存储在 sessionStorage 中。这样页面刷新后就能重新连接到同一个线程和沙箱,而不是重新创建新的实例。

  • 实时同步文件
    在每一个相关的工具调用时同步文件,而不仅仅是在运行结束时。这能让 IDE 体验更具“实时感”。请监听 write_fileedit_file 和 execute 工具消息,并立即刷新文件状态。

  • 默认使用差异视图
    对于变更的文件,默认展示差异视图。当用户点击被 Agent 修改过的文件时,首先展示差异对比——这才是他们真正关心的内容。

  • 精简只读操作的结果展示
    对于只读操作,显示紧凑的工具结果。不要在聊天界面中直接转储 read_file 的全部输出,而是显示一行摘要,例如“已读取 router.js L1-42”。仅对变更类工具才保留完整输出的显示。

  • 预置真实项目
    用真实的项目来初始化沙箱。从一个空沙箱开始会让用户感到困惑。请上传一个可运行的入门项目,以便用户和 Agent 能立即拥有上下文环境。

  • 过滤 node_modules
    在文件树中过滤掉 node_modules。没人想浏览成千上万个依赖文件。在获取文件树时请将其过滤掉

总结

本篇主要介绍前端的架构、实现需要处理的问题、最佳实践等。下篇主要介绍一下开发部署

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐