第 22 章

子代理流式渲染

将协调器消息与子 Agent 流式输出分别渲染,展示可折叠进度卡片

从"交错混乱"到"井然有序"的前端流式渲染方案

消息流混乱

协调器派出子 Agent 后,前端会收到什么?

当协调器 Agent 派生出研究员、分析师、撰稿人等多个子 Agent 时,每个子 Agent 生成的 token 会交错出现在同一个消息流中。

不过滤的混乱

  • 协调器的推理 + 子 Agent 的输出混在一起
  • 用户看到的内容上下文跳跃,难以阅读
  • 无法知道哪段文字来自哪个子 Agent
  • 无法为每个子 Agent 独立展示进度

过滤后的清晰

  • stream.messages 只含协调器消息
  • 子 Agent 内容通过 stream.subagents 独立访问
  • 用 getSubagentsByMessage 精确关联
  • 每个子 Agent 渲染为可折叠进度卡片
核心思路:设置 filterSubagentMessages: true,将子 Agent 的 token 从主消息流中剥离。协调器的推理与各专家的工作成果各归其位。

数据流架构

从后端 Agent 到前端 UI 的完整数据流

整个渲染方案涉及三个关键角色的协作:

后端 Agent

协调器 + 子 Agent 在 LangGraph 中运行。

通过 streamSubgraphs: true 启用子图流式传输。

LangGraph Server

useStream Hook

前端通过 useStream 连接 Agent API。

设置 filterSubagentMessages: true 分离消息。

@langchain/react

UI 组件

MessageWithSubagents 渲染协调器消息。

SubagentCard 渲染每个子 Agent 的进度卡片。

React 组件

// 数据流:后端 → useStream → UI 组件
LangGraph Server
  ↓ streamSubgraphs: true
useStream({ filterSubagentMessages: true })
  ↓ stream.messages → 协调器消息
  ↓ stream.subagents → 子 Agent 数据
  ↓ getSubagentsByMessage(msgId) → 按消息关联
MessageWithSubagents → SubagentCard
图:后端 Agent 到前端 UI 数据流

配置 useStream

一行配置开启子 Agent 消息过滤

使用 useStream 连接 LangGraph Agent API,传入 typeof myAgent 获得类型安全的状态访问。

import { useStream } from "@langchain/react";
import type { myAgent } from "./agent";

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

export function DeepAgentChat() {
  const stream = useStream<typeof myAgent>({
    apiUrl: AGENT_URL,                              // LangGraph Server 地址
    assistantId: "deep_agent_subagent_cards",       // Agent 标识
    filterSubagentMessages: true,  // 关键:开启后子 Agent token 从主消息流中剥离关键配置
  });

  return (
    <div>
      {stream.messages.map((msg) => (
        <MessageWithSubagents
          key={msg.id}
          message={msg}
          subagents={stream.getSubagentsByMessage(msg.id)}
        />
      ))}
    </div>
  );
}

关键参数

  • apiUrl:LangGraph Server 地址
  • assistantId:Agent 的唯一标识
  • filterSubagentMessages:开启子 Agent 过滤

类型安全

  • 传入 typeof myAgent 泛型参数
  • stream.messages 自动获得正确的消息类型
  • 编译时捕获状态字段错误

提交与流式

启用子图流式传输并设置递归限制

提交消息时需要启用 streamSubgraphs,否则前端收不到子 Agent 的中间输出。

// 提交用户消息,同时启用子图流式传输
stream.submit(
  { messages: [{ type: "human", content: text }] },  // 用户输入
  { streamSubgraphs: true }  // 必须开启,否则前端收不到子 Agent 的中间输出
);

streamSubgraphs

设为 true 后,LangGraph 会推送每个子图的实时输出。如果不开启,子 Agent 的中间 token 不会到达前端。

递归限制

Deep Agents 默认递归限制为 10,000,对多数多专家配置已足够。如有需要可通过 config.recursionLimit 覆盖。

常见问题:如果前端收不到子 Agent 的流式数据,检查两件事:(1) streamSubgraphs 是否设为 true;(2) 后端 Agent 是否使用了子图结构(task 工具派生子 Agent)。

核心接口

SubagentStreamInterface——每个子 Agent 的元数据容器

每个子 Agent 都暴露一个 SubagentStreamInterface 对象,包含任务描述、状态、消息流和时间信息。

interface SubagentStreamInterface {
  id: string;                                        // 子 Agent 实例的唯一标识
  status: "pending" | "running" | "complete" | "error";  // 生命周期状态
  messages: BaseMessage[];                           // 子 Agent 的流式消息,实时更新
  result: string | undefined;                        // 最终输出,仅 complete 时可用
  toolCall: {                                        // 触发此子 Agent 的 task 工具调用
    id: string;
    name: string;
    args: {
      description: string;      // 任务描述
      subagent_type: string;     // 专家类型名称
      [key: string]: unknown;
    };
  };
  startedAt: number | undefined;     // 启动时间戳
  completedAt: number | undefined;   // 完成时间戳
}
属性说明
id子 Agent 实例的唯一标识
status生命周期:pending → running → complete 或 error
messages子 Agent 自身的消息流,实时更新
result最终输出,仅 complete 时可用
toolCall.args任务描述 + 专家类型名称
startedAt / completedAt执行时间戳,用于计算耗时

关联消息

getSubagentsByMessage——将子 Agent 精确挂到对应的协调器消息下方

协调器的一条 AI 消息可能同时派生多个子 Agent。通过消息 ID 查询,可以将这些子 Agent 的卡片精确地渲染在触发它们的协调器消息之后。

// 获取某条消息派生的所有子 Agent
const turnSubagents = stream.getSubagentsByMessage(msg.id);
// 返回 SubagentStreamInterface[]
// 如果该消息没有派生任何子 Agent,返回空数组

渲染逻辑

{stream.messages.map((msg) => (
  <MessageWithSubagents
    key={msg.id}
    message={msg}
    subagents={
      stream.getSubagentsByMessage(msg.id)
    }
  />
))}

布局效果

  • 协调器消息正常渲染
  • 如果该消息派生了子 Agent,在下方渲染缩进的卡片组
  • 左侧用蓝色竖线标记"这些是子任务"
  • 没有派生子 Agent 的消息不受影响
设计精妙之处:不是按"全局顺序"排列所有消息,而是按"谁派生了谁"来组织。这让 UI 与 Agent 的思维结构完全对齐。

卡片组件

SubagentCard——展示专家名称、任务描述、流式内容和耗时

每个子 Agent 用一张可折叠卡片呈现。卡片头部显示专家类型、任务描述和状态,展开后显示实时内容或最终结果。

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

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

  // 从 task 工具调用参数中提取专家类型和任务描述
  const title = subagent.toolCall?.args?.subagent_type
    ?? `Agent ${subagent.id}`;
  const description = subagent.toolCall?.args?.description ?? "";

  // 取子 Agent 最后一条 AI 消息作为流式内容
  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">
      {/* 头部:专家名 + 描述 + 状态 + 耗时 */}
      {/* 内容:流式文本或最终结果 */}
      {/* 运行中时末尾显示闪烁光标 */}
    </div>
  );
}
注意内容来源:status === "complete" 时用 subagent.result,否则用 lastAIMessage.content。不要始终用 messages,因为完成时消息可能不包含最终结果。

状态指示

StatusIcon + StatusBadge——一眼识别子 Agent 生命周期

一致的视觉指示器帮助用户立即了解每个子 Agent 当前处于哪个阶段。

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>
  );
}
状态图标徽章颜色含义
pending○ 灰色灰色已创建,等待执行
running◉ 蓝色旋转蓝色正在生成输出
complete✓ 绿色绿色执行完成,结果可用
error✕ 红色红色执行出错

进度追踪

SubagentProgress——让用户知道"完成了几个"

当有多个子 Agent 并行工作时,一个进度条 + 计数器能显著减少用户的焦虑感。

function SubagentProgress({ subagents }: { subagents: SubagentStreamInterface[] }) {
  // 统计已完成的子 Agent 数量(error 不计入)
  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>子 Agent 进度</span>
        <span>{completed}/{total} 已完成</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>
  );
}

设计要点

  • completed/total 格式直观
  • 进度条用 CSS transition 平滑过渡
  • 放在卡片组顶部,一眼看到全局进度

边界情况

  • total 为 0 时不显示进度条
  • 百分比整数取整,避免小数
  • error 状态不计入 completed

耗时计算

getElapsedTime——展示每个子 Agent 的执行时间

耗时数据帮助用户了解性能特征,也便于识别哪个子 Agent 是瓶颈。

function getElapsedTime(startedAt: number | undefined, completedAt: number | undefined) {
  if (!startedAt) return null;  // 尚未启动,不显示耗时
  // 运行中用 Date.now() 作为终点,每次重渲染自动更新
  const end = completedAt ?? Date.now();
  const seconds = Math.round((end - startedAt) / 1000);

  // 超过 60 秒切换为"分+秒"格式
  if (seconds < 60) return `${seconds}s`;
  return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
}

// 使用:
const elapsed = getElapsedTime(
  subagent.startedAt,
  subagent.completedAt
);

运行中

completedAt 为 undefined,自动用 Date.now() 作为终点。每次组件重渲染,耗时都会更新——形成实时计时效果。

已完成

completedAt 有值,直接算出差值,显示固定耗时。超过 60 秒自动切换为"分 + 秒"格式。

小技巧:由于 useStream 持续推送更新,子 Agent 卡片会频繁重渲染。Date.now() 在每次渲染时取值,自然形成动态计时——无需额外 setInterval。

组装消息

MessageWithSubagents——协调器消息 + 子 Agent 卡片的布局模式

这是最关键的布局组件:渲染协调器消息,如果该消息派生了子 Agent,则在下方用蓝色竖线包裹卡片组。

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>
      )}

      {/* 子 Agent 卡片组 */}
      {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>
  );
}
布局精髓:ml-4 + border-l-2 border-blue-200 + pl-4 创建了一个缩进的蓝色竖线区域。视觉上清晰表达"这些子任务属于上方那条协调器消息"。

综合指示

SynthesisIndicator——子 Agent 全部完成后,协调器正在综合结果

所有子 Agent 完成后,协调器需要时间将它们的结果综合为最终响应。此时应显示清晰的指示器,避免用户误以为 Agent 已停滞。

function SynthesisIndicator({ subagents, isLoading }: { subagents: SubagentStreamInterface[]; isLoading: boolean }) {
  // 判断所有子 Agent 是否都已结束(无论成功或失败)
  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>
      正在综合 {subagents.length} 个子 Agent
      {subagents.length !== 1 ? "们" : ""}的结果...
    </div>
  );
}

没有指示器

  • 所有子 Agent 显示"已完成"
  • 页面看起来"卡住了"
  • 用户不知道协调器在做什么
  • 可能重复提交请求

有指示器

  • 紫色提示"正在综合结果..."
  • 旋转图标暗示"还在工作"
  • 用户安心等待最终响应
  • 配合子 Agent 数量给出预期
提示:综合阶段在复杂的多专家工作流中可能需要数秒。一个清晰的"正在综合结果..."指示器可以防止用户误以为 Agent 已经停滞。

全局访问

通过 stream.subagents 一次性访问所有子 Agent

除了按消息查找,你还可以用 stream.subagents 获取全部子 Agent 的 Map,用于构建全局仪表盘或进度汇总。

// 访问所有子 Agent(subagents 是 Map,需转为数组)
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");

// 构建全局摘要
const summary = `
  总计: ${allSubagents.length} 个子 Agent
  运行中: ${running.length}
  已完成: ${completed.length}
  出错: ${errors.length}
`;

按消息查找

getSubagentsByMessage(msgId)

适合:消息级渲染,子 Agent 卡片紧跟协调器消息。

全局访问

stream.subagents.values()

适合:全局进度指示器、仪表盘、汇总面板。

注意:stream.subagents 返回的是 Map 对象,需要用 [...map.values()] 或 Array.from() 转为数组才能使用 filter 等数组方法。

调试技巧

开发阶段可临时关闭过滤,查看原始交错输出

当你怀疑子 Agent 的 token 没有正确流转时,可以将 filterSubagentMessages 临时设为 false。

过滤模式(生产用)

useStream({
  filterSubagentMessages: true
});
// stream.messages → 只有协调器消息
// 子 Agent 内容在 stream.subagents 中

原始模式(调试用)

useStream({
  filterSubagentMessages: false
});
// stream.messages → 所有消息交错
// 可以看到子 Agent 的原始输出顺序
仅在开发环境使用!原始模式会产生协调器和子 Agent token 交错混杂的不可读内容,不适合展示给最终用户。生产环境务必保持 true。
调试流程:先用原始模式确认数据流通 → 再切回过滤模式验证 UI 渲染。两步走,快速定位问题是在后端还是前端。

使用场景

哪些工作流最适合子 Agent 卡片式渲染?

深度研究

协调器派遣研究员调查问题的不同方面,然后综合他们的发现。每个研究员一张卡片,任务描述清晰可见。

多专家分析

法律、金融、技术等领域专家各自贡献观点。用户能同时看到各专家的实时输出,不会混淆。

复杂任务分解

规划器将大型任务分解为子任务,每个子任务分配给专业工作者。进度条让用户掌握全局。

代码审查流水线

不同 Agent 分别处理安全审查、风格检查、性能分析和文档审查。出错时只影响对应卡片,不崩溃全局。

共同特征:多个子 Agent 并行或串行工作,每个子 Agent 有明确的任务描述和独立的输出。卡片式渲染让用户清楚地看到"谁在做、在做什么、做到哪了"。

最佳实践

六条经过实战验证的子 Agent 渲染准则

#准则为什么
1 始终设置 filterSubagentMessages: true 未过滤的流会产生不可读的交错内容
2 展示 toolCall.args.description 让用户知道每个子 Agent 被要求做什么
3 使用可折叠卡片 5 个以上子 Agent 时自动折叠,聚焦进行中的任务
4 展示耗时数据 帮助用户识别性能瓶颈
5 设置适当的递归限制 嵌套子图需要更高限制,建议从 100 开始
6 按子 Agent 处理错误 一个失败不影响其他子 Agent 和整个 UI
最容易忽略的第 6 条:不要在全局 try/catch 中处理子 Agent 错误。应该在每个 SubagentCard 内部根据 subagent.status === "error" 展示错误信息,让其他子 Agent 继续运行。

多框架适配

useStream 不只是 React——Vue、Svelte、Angular 都能用

LangChain 为每个主流前端框架提供了 useStream 实现,核心 API 完全一致。

Vue

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

const stream = useStream<typeof myAgent>({
  apiUrl: AGENT_URL,
  filterSubagentMessages: true,
});
// 模板中用 stream.messages.value

Svelte

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

const { messages,
  getSubagentsByMessage } =
  useStream<typeof myAgent>({
    filterSubagentMessages: true,
  });
// 用 $messages 响应式访问

Angular

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

// 在 Component 中
stream = useStream<typeof myAgent>({
  filterSubagentMessages: true,
});
// 模板中用 stream.messages()
框架无关的思路:filterSubagentMessages 和 getSubagentsByMessage 的概念在所有框架中完全相同。区别仅在于响应式语法——React 用 .map,Vue 用 .value,Svelte 用 $,Angular 用 Signal。

完整框架示例

Vue / Svelte / Angular 中 useStream 的完整写法

Vue 3 — Composition API

<script setup lang="ts">
import { useStream } from "@langchain/vue";

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

const stream = useStream<typeof myAgent>({
  apiUrl: AGENT_URL,
  assistantId: "deep_agent_subagent_cards",
  filterSubagentMessages: true,
});
</script>

<template>
  <div>
    <MessageWithSubagents
      v-for="msg in stream.messages.value"
      :key="msg.id"
      :message="msg"
      :subagents="stream.getSubagentsByMessage(msg.id)"
    />
  </div>
</template>

Svelte 5

<script lang="ts">
  import { useStream }
    from "@langchain/svelte";

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

  const { messages,
    getSubagentsByMessage,
    submit } = useStream<typeof myAgent>({
    apiUrl: AGENT_URL,
    assistantId:
      "deep_agent_subagent_cards",
    filterSubagentMessages: true,
  });
</script>

<div>
  {#each $messages as msg (msg.id)}
    <MessageWithSubagents
      message={msg}
      subagents={getSubagentsByMessage(msg.id)}
    />
  {/each}
</div>

Angular 17+

import { Component }
  from "@angular/core";
import { useStream }
  from "@langchain/angular";

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

@Component({
  selector: "app-deep-agent-chat",
  template: `
    @for (msg of stream.messages();
          track msg.id) {
      <app-message-with-subagents
        [message]="msg"
        [subagents]="stream
          .getSubagentsByMessage(msg.id)"
      />
    }
  `,
})
export class DeepAgentChatComponent {
  stream = useStream<typeof myAgent>({
    apiUrl: AGENT_URL,
    assistantId:
      "deep_agent_subagent_cards",
    filterSubagentMessages: true,
  });
}

各框架关键差异

框架消息访问列表渲染
Reactstream.messages.map()
Vuestream.messages.valuev-for
Svelte$messages{#each}
Angularstream.messages()@for
核心配置不变:无论哪个框架,apiUrl、assistantId、filterSubagentMessages 三个参数完全一致,只是响应式绑定语法不同。

组件全貌

从前端入口到最终渲染的完整组件树

DeepAgentChat               // 顶层:useStream 连接后端
├── MessageWithSubagents     // 每条协调器消息
│   ├── HumanMessage         // 用户消息(直接渲染)
│   └── div.prose            // 协调器 AI 消息
│       └── SubagentProgress // 进度条 + 计数器
│       └── SubagentCard[]   // 子 Agent 卡片组
│           ├── StatusIcon   // 状态图标(○ ◉ ✓ ✕)
│           ├── StatusBadge  // 状态徽章
│           └── div.prose    // 流式内容 + 闪烁光标
├── SynthesisIndicator       // "正在综合结果..."
└── 全局仪表盘(可选)
    └── stream.subagents     // 所有子 Agent 汇总

核心数据源

  • stream.messages → 协调器消息
  • stream.subagents → 全局子 Agent Map
  • getSubagentsByMessage() → 按消息查找

核心交互

  • 卡片可折叠/展开
  • 运行中显示闪烁光标
  • 完成后显示固定结果 + 耗时
  • 综合阶段显示紫色指示器
图:前端组件树全貌

本章回顾

到这里,你已经掌握了子 Agent 流式渲染的完整方案

你学到了什么

  • filterSubagentMessages: true 分离协调器与子 Agent 消息
  • SubagentStreamInterface 接口的完整结构
  • getSubagentsByMessage 精确关联消息与子 Agent
  • 可折叠卡片、状态图标、进度条、耗时计算
  • 综合指示器防止用户误判"卡住"
  • 六条最佳实践确保生产可用

后续章节预告

  • 前端 Todo 列表:在 UI 中管理和跟踪 Agent 任务
  • 前端沙箱:安全地展示 Agent 的代码执行结果
  • ACP 协议:Agent Communication Protocol 的集成
  • 代码模式:用编程方式构建和部署 Deep Agent
核心结论:子 Agent 流式渲染的关键不是"技术有多复杂",而是"信息组织有多清晰"。过滤 + 关联 + 卡片的组合,让用户看到的不再是混乱的 token 交错,而是一组井然有序、各司其职的专家在工作。