第 20 章

流式传输

从 Deep Agent 运行和 Subagent 执行中流式传输实时更新

从"等结果"到"看过程"——实时掌握 Agent 的每一步

为什么需要它

你有没有调过一个跑了好几分钟的 Agent,全程盯着黑屏等结果?

不用流式传输的困境

  • Agent 跑了 30 秒,完全不知道在干什么
  • 多个 Subagent 并行执行,看不到各自的进度
  • LLM 回复要等全部生成完才能显示,用户体验差
  • 工具调用黑箱操作,调试时无从追踪
  • 出了问题只能看最终输出猜原因,没有实时诊断能力

流式传输的解法

  • 实时追踪每个 Subagent 的执行步骤
  • 逐 token 输出 LLM 回复,像 ChatGPT 一样打字
  • 流式传输工具调用和结果,每一步透明可见
  • 自定义进度事件,精准控制 UI 反馈
  • 组合多种流模式,获取完整执行视图
问题核心:没有流式传输的 Agent 就像没有进度条的下载——你不知道卡了还是还在跑。流式传输让你实时看到 Agent 在做什么、做到哪了、结果如何,是构建生产级 Agent UI 的必备能力。

四大流式能力

Deep Agent 流式传输可以做到的四种实时更新

Subagent 进度

跟踪每个 Subagent 并行运行时的执行情况。

用途:显示哪些 Subagent 处于活跃状态,它们各自完成了哪些工作。

streamMode: "updates"

LLM Token

从主 Agent 和每个 Subagent 逐 token 流式输出。

用途:像 ChatGPT 一样实时显示生成中的文本。

streamMode: "messages"

工具调用

流式传输 Subagent 执行中的工具调用和结果。

用途:查看 Agent 调了什么工具、传了什么参数、返回了什么。

streamMode: "messages"

自定义更新

从 Subagent 工具内部发出用户定义的进度信号。

用途:实现业务相关的进度反馈(百分比、状态文案等)。

streamMode: "custom"

新项目推荐:Deep Agents v0.6 引入了 Event Streaming(事件流) API,提供类型化的投影迭代器,更易用。本章介绍的是底层流式传输机制。

启用子图流式

一行配置打开 Subagent 事件的大门

Deep Agents 基于 LangGraph 的流式传输基础设施构建。要接收 Subagent 事件,关键就是在调用 stream() 时启用 subgraphs: true。

import { createDeepAgent } from "deepagents";

const agent = createDeepAgent({
  systemPrompt: "You are a helpful research assistant",
  subagents: [
    {
      name: "researcher",
      description: "Researches a topic in depth",
      systemPrompt: "You are a thorough researcher.",
    },
  ],
});

for await (const [namespace, chunk] of await agent.stream(
  { messages: [{ role: "user", content: "Research quantum computing" }] },
  {
    streamMode: "updates",     // 每个步骤完成时推送事件
    subgraphs: true,           // 关键开关:启用后才能收到子代理事件
  }
)) {
  // namespace 是字符串数组:空数组=主 Agent,含 "tools:" 前缀=子代理
  if (namespace.length > 0) {
    // Subagent 事件 —— namespace 标识了事件来源
    console.log(`[subagent: ${namespace.join("|")}]`);
  } else {
    // 主 Agent 事件
    console.log("[main agent]");
  }
  console.log(chunk);
}
核心要点:subgraphs: true 是开关。没有它,你只能看到主 Agent 的事件;加上它,每个 Subagent 的内部事件都会被推送到你的流里。

理解命名空间

每个事件都带"身份证",告诉你它是谁发的

启用 subgraphs 后,每个事件都包含一个命名空间(namespace),用于标识是哪个 Agent 生成了该事件。命名空间是由节点名称和任务 ID 组成的路径。

卸甲:"身份证"只是一个比喻。实际上,每个事件附带的是一个名为 namespace 的字符串数组。通过检查数组内容来判断来源——空数组 [] 表示主 Agent,包含 tools: 前缀的段表示来自子代理。它就是一条路由标识,跟身份证没有关系。
命名空间来源
()(空数组)主 Agent 的事件
("tools:abc123",)主 Agent 的 task 工具调用 abc123 派生的 Subagent
("tools:abc123", "model_request:def456")Subagent 内部的模型请求节点
for await (const [namespace, chunk] of await agent.stream(
  { messages: [{ role: "user", content: "Plan my vacation" }] },
  { streamMode: "updates", subgraphs: true }
)) {
  // 检查 namespace 中是否含 "tools:" 前缀的段
  const isSubagent = namespace.some(
    (segment) => segment.startsWith("tools:")
  );

  if (isSubagent) {
    // 从命名空间中提取工具调用 ID(如 "tools:call_abc123" 中的 "call_abc123")
    const toolCallId = namespace
      .find((s) => s.startsWith("tools:"))
      ?.split(":")[1];
    console.log(`Subagent ${toolCallId}:`, chunk);
  } else {
    console.log("Main agent:", chunk);
  }
}
常见误区:命名空间不是字符串,是一个字符串数组。namespace.length === 0 表示主 Agent,namespace[0].startsWith("tools:") 表示 Subagent。不要拿整个数组做字符串匹配。
图:namespace 层级命名空间

Subagent 进度

用 streamMode: "updates" 跟踪每个步骤的完成情况

stream_mode="updates" 在每个步骤完成时推送事件。主 Agent 和 Subagent 的事件可以通过命名空间长度区分。

import { createDeepAgent } from "deepagents";

const agent = createDeepAgent({
  systemPrompt:
    "You are a project coordinator. Always delegate research tasks " +
    "to your researcher subagent using the task tool. Keep your final response to one sentence.",
  subagents: [
    {
      name: "researcher",
      description: "Researches topics thoroughly",
      systemPrompt:
        "You are a thorough researcher. Research the given topic " +
        "and provide a concise summary in 2-3 sentences.",
    },
  ],
});

for await (const [namespace, chunk] of await agent.stream(
  {
    messages: [
      { role: "user", content: "Write a short summary about AI safety" },
    ],
  },
  { streamMode: "updates", subgraphs: true },
)) {
  // 空命名空间 = 主 Agent 步骤
  if (namespace.length === 0) {
    for (const [nodeName, data] of Object.entries(chunk)) {
      if (nodeName === "tools") {
        // "tools" 节点返回 tool 类型消息时,说明子代理已完成
        for (const msg of data.messages ?? []) {
          if (msg.type === "tool") {
            console.log(`\nSubagent complete: ${msg.name}`);
            console.log(`  Result: ${String(msg.content).slice(0, 200)}...`);
          }
        }
      } else {
        // 其他节点(如 model_request)是主 Agent 的推理步骤
        console.log(`[main agent] step: ${nodeName}`);
      }
    }
  }
  // 非空命名空间 = Subagent 步骤
  else {
    for (const [nodeName] of Object.entries(chunk)) {
      console.log(`  [${namespace[0]}] step: ${nodeName}`);
    }
  }
}
输出解读:空命名空间 = 主 Agent 步骤;tools:call_xxx 命名空间 = Subagent 步骤。当主 Agent 的 tools 节点返回 type: "tool" 消息时,说明 Subagent 已完成。

进度输出解读

理解流式事件的时间线——从启动到完成

上面的代码运行后,你会看到类似下面的输出。让我们逐行分析它的时间线:

Main agent step: model_request           # 主 Agent 开始推理
  [tools:call_abc123] step: model_request # Subagent A 开始推理
  [tools:call_abc123] step: tools         # Subagent A 调用工具
  [tools:call_abc123] step: model_request # Subagent A 再次推理
Subagent complete: task                   # Subagent A 完成
Result: ## AI Safety Report...
Main agent step: model_request            # 主 Agent 收到结果后继续
  [tools:call_def456] step: model_request # Subagent B 开始推理
  [tools:call_def456] step: model_request # Subagent B 再次推理
Subagent complete: task                   # Subagent B 完成
Result: # Comprehensive Report on AI Safety...
Main agent step: model_request            # 主 Agent 最终回复

时间线关键节点

  • 主 Agent 先推理,决定委派任务
  • Subagent A 启动,经历多轮推理+工具调用
  • Subagent A 完成,结果返回主 Agent
  • 主 Agent 可能继续委派更多 Subagent
  • 所有 Subagent 完成后,主 Agent 生成最终回复

实用技巧

  • 用 nodeName 过滤不需要的内部步骤
  • 只关注 model_request 和 tools 两个节点
  • 多个 Subagent 的 ID 不同,可以分别追踪
  • 进度 UI 可以按 toolCallId 分组显示

LLM Token 流

用 streamMode: "messages" 逐 token 输出 LLM 回复

使用 streamMode: "messages" 可以从主 Agent 和 Subagent 流式传输单个 token。每个消息事件都包含标识来源 Agent 的元数据。

let currentSource = "";  // 跟踪当前输出源,切换时打印分隔符

for await (const [namespace, chunk] of await agent.stream(
  {
    messages: [{
      role: "user",
      content: "Research quantum computing advances",
    }],
  },
  { streamMode: "messages", subgraphs: true },  // messages 模式逐 token 输出
)) {
  const [message] = chunk;  // 每个事件包含一条消息

  // 通过 namespace 区分主 Agent 和子代理的 token
  const isSubagent = namespace.some((s) => s.startsWith("tools:"));

  if (isSubagent) {
    // 子代理的 token,提取命名空间作为标识
    const subagentNs = namespace.find(
      (s) => s.startsWith("tools:")
    )!;
    if (subagentNs !== currentSource) {
      // 来源切换时打印分隔符,避免不同来源的文本混在一起
      process.stdout.write(
        `\n\n--- [subagent: ${subagentNs}] ---\n`
      );
      currentSource = subagentNs;
    }
    if (message.text) {
      process.stdout.write(message.text);  // 逐 token 写入,形成打字效果
    }
  } else {
    // 主 Agent 的 token
    if ("main" !== currentSource) {
      process.stdout.write(`\n\n--- [main agent] ---\n`);
      currentSource = "main";
    }
    if (message.text) {
      process.stdout.write(message.text);
    }
  }
}
注意区分来源:不要把主 Agent 和 Subagent 的 token 混在一起输出。用 currentSource 变量跟踪当前输出源,来源切换时打印分隔符,这样用户才能看清哪段文字是谁写的。

工具调用流

实时查看 Subagent 正在调什么工具、传什么参数

当 Subagent 使用工具时,工具调用事件出现在 messages 流模式下。你可以区分三种事件类型:工具调用名称、参数增量、工具结果。

import { AIMessageChunk, ToolMessage } from "langchain";

for await (const [namespace, chunk] of await agent.stream(
  {
    messages: [{
      role: "user",
      content: "Research recent quantum computing advances",
    }],
  },
  { streamMode: "messages", subgraphs: true },
)) {
  const [message] = chunk;

  // 通过 namespace 判断事件来源
  const isSubagent = namespace.some((s) => s.startsWith("tools:"));
  const source = isSubagent
    ? namespace.find((s) => s.startsWith("tools:"))!
    : "main";

  // 1. 工具调用数据块:流式传输工具名称和参数
  //    AIMessageChunk 包含 tool_call_chunks 数组
  if (AIMessageChunk.isInstance(message)
      && message.tool_call_chunks?.length) {
    for (const tc of message.tool_call_chunks) {
      if (tc.name) {
        console.log(`\n[${source}] Tool call: ${tc.name}`);
      }
      if (tc.args) {
        // args 是增量的,每次只返回一小段,需要拼接
        process.stdout.write(tc.args);
      }
    }
  }

  // 2. 工具执行结果:以 ToolMessage 形式返回
  if (ToolMessage.isInstance(message)) {
    console.log(
      `\n[${source}] Tool result [${message.name}]: ${
        message.text?.slice(0, 150)
      }`
    );
  }

  // 3. 常规 AI 文本内容(排除工具调用消息)
  if (AIMessageChunk.isInstance(message)
      && message.text
      && !message.tool_call_chunks?.length) {
    process.stdout.write(message.text);
  }
}

工具事件解析

三种工具事件的含义和处理方式

工具调用名称

tc.name 有值时,表示 Agent 决定调用某个工具。

UI 建议:显示"正在调用 xxx 工具..."

AIMessageChunk.tool_call_chunks

参数增量

tc.args 有值时,是工具参数的增量片段。

UI 建议:像打字机一样逐步显示参数 JSON。

多次触发,需拼接

工具执行结果

ToolMessage.isInstance(message) 为 true 时,是工具返回的结果。

UI 建议:显示结果摘要,可展开查看详情。

ToolMessage 实例

关键区别:工具调用(名称+参数)和工具结果是两个独立事件。调用在 AIMessageChunk 里,结果在 ToolMessage 里。不要把它们混为一谈。
避坑指南:tool_call_chunks 中的 args 是增量的,每次只返回一小段。你需要拼接它们才能得到完整的参数 JSON。如果只取最后一个 chunk,你会拿到不完整的参数。

自定义更新

用 config.writer 从工具内部发出进度信号

有时候你需要的不是 LLM token 或工具调用,而是业务级别的进度——比如"正在获取数据 50%"。这时就用 config.writer 发出自定义事件。

import { createDeepAgent } from "deepagents";
import { tool, type ToolRuntime } from "langchain";
import { z } from "zod";

// 通过 config.writer 发出自定义进度事件的工具
const analyzeData = tool(
  async ({ topic }, config: ToolRuntime) => {
    // writer 用于向 streamMode: "custom" 推送自定义数据
    const writer = config.writer;

    // 发出"启动"进度事件
    writer?.({ status: "starting", topic, progress: 0 });
    await new Promise((r) => setTimeout(r, 500));

    // 发出"分析中"进度事件(50%)
    writer?.({ status: "analyzing", progress: 50 });
    await new Promise((r) => setTimeout(r, 500));

    // 发出"完成"进度事件(100%)
    writer?.({ status: "complete", progress: 100 });
    // 返回值作为工具调用结果传给 Agent
    return `Analysis of "${topic}": Customer sentiment is 85% positive, driven by product quality and support response times.`;
  },
  {
    name: "analyze_data",
    description:
      "Run a data analysis on a given topic. " +
      "This tool performs the actual analysis and emits progress updates. " +
      "You MUST call this tool for any analysis request.",
    schema: z.object({
      topic: z.string().describe("The topic to analyze"),
    }),
  },
);
自定义更新流向:writer 发出的数据会出现在 streamMode: "custom" 中。你需要用对应的 stream mode 来消费它。

消费自定义事件

从流中读取 writer 发出的进度数据

定义了 writer 之后,在消费端用 streamMode: "custom" 来接收自定义事件:

const agent = createDeepAgent({
  systemPrompt: "You are a coordinator. Delegate analysis to analyst.",
  subagents: [{
    name: "analyst",
    description: "Performs data analysis with progress tracking",
    systemPrompt: "You MUST call analyze_data for every request.",
    tools: [analyzeData],
  }],
});

for await (const [namespace, chunk] of await agent.stream(
  {
    messages: [{
      role: "user",
      content: "Analyze customer satisfaction trends",
    }],
  },
  { streamMode: "custom", subgraphs: true },  // custom 模式接收 writer 推送的事件
)) {
  const isSubagent = namespace.some((s) => s.startsWith("tools:"));
  if (isSubagent) {
    const subagentNs = namespace.find((s) => s.startsWith("tools:"))!;
    console.log(`[${subagentNs}]`, chunk);
  } else {
    console.log("[main]", chunk);
  }
}
# 输出:自定义进度事件按时间顺序到达
[tools:call_abc123] { status: 'starting', progress: 0 }
[tools:call_abc123] { status: 'analyzing', progress: 50 }
[tools:call_abc123] { status: 'complete', progress: 100 }
典型应用:进度条(progress: 0~100)、状态文案(status: "analyzing")、阶段切换通知。你可以自由定义 writer 传入的对象结构。

组合多种模式

用数组传入多个 streamMode,一次获取全部信息

实际项目中,你通常需要同时看到步骤进度、token 输出和自定义事件。把 streamMode 设为数组即可组合:

const INTERESTING_NODES = new Set(["model_request", "tools"]);
let lastSource = "";
let midLine = false;

for await (const [namespace, mode, data] of await agent.stream(
  {
    messages: [{
      role: "user",
      content: "Analyze the impact of remote work on productivity",
    }],
  },
  // 组合三种流模式,数组形式启用多模式
  { streamMode: ["updates", "messages", "custom"], subgraphs: true },
)) {
  const isSubagent = namespace.some((s) => s.startsWith("tools:"));
  const source = isSubagent ? "subagent" : "main";

  // mode 字段标识当前事件的流模式类型
  if (mode === "updates") {
    // 步骤更新:只关注感兴趣的节点
    for (const nodeName of Object.keys(data)) {
      if (!INTERESTING_NODES.has(nodeName)) continue;
      console.log(`[${source}] step: ${nodeName}`);
    }
  } else if (mode === "messages") {
    // token 流:逐 token 写入
    const [message] = data;
    if (message.text) {
      if (source !== lastSource) {
        process.stdout.write(`\n[${source}] `);
        lastSource = source;
      }
      process.stdout.write(message.text);
    }
  } else if (mode === "custom") {
    // 自定义事件:writer 推送的业务进度数据
    console.log(`[${source}] custom event:`, data);
  }
}
注意解构差异:单模式时解构是 [namespace, chunk],多模式时多了一个 mode 字段,变成 [namespace, mode, data]。别搞混了!

Subagent 生命周期

监控 Subagent 的启动、运行、完成三个阶段

这是一个常见的实战模式——用 updates 模式追踪 Subagent 从创建到完成的完整生命周期:

阶段 1:启动

主 Agent 的 model_request 包含 task 工具调用

阶段 2:运行

收到 tools:UUID 命名空间的事件

阶段 3:完成

主 Agent 的 tools 节点返回 tool 消息

const activeSubagents = new Map();

for await (const [namespace, chunk] of await agent.stream(
  { messages: [
    { role: "user", content: "Research the latest AI safety developments" },
  ]},
  { streamMode: "updates", subgraphs: true },
)) {
  for (const [nodeName, data] of Object.entries(chunk)) {
    // 阶段 1:检测 Subagent 启动
    // 主 Agent 的 model_request 节点中包含 task 工具调用时,说明子代理即将启动
    if (namespace.length === 0 && nodeName === "model_request") {
      for (const msg of data.messages ?? []) {
        for (const tc of msg.tool_calls ?? []) {
          if (tc.name === "task") {
            // 记录子代理信息,状态为 pending
            activeSubagents.set(tc.id, {
              type: tc.args?.subagent_type,     // 子代理类型名称
              description: tc.args?.description?.slice(0, 80),  // 任务描述
              status: "pending",                 // 初始状态
            });
            console.log(
              `[lifecycle] PENDING → "${tc.args?.subagent_type}" (${tc.id})`
            );
          }
        }
      }
    }

    // 阶段 2:检测 Subagent 运行中
    // 收到 tools:UUID 命名空间的事件说明子代理已开始执行
    if (namespace.length > 0 && namespace[0].startsWith("tools:")) {
      const pregelId = namespace[0].split(":")[1];
      // 注意:pregel 运行时 ID 与 tool_call_id 不同,无法直接关联
      // 因此将所有 pending 状态的子代理标记为 running
      for (const [id, sub] of activeSubagents) {
        if (sub.status === "pending") {
          sub.status = "running";
          console.log(`[lifecycle] RUNNING  → subagent "${sub.type}" (pregel: ${pregelId})`);
          break;
        }
      }
    }

    // 阶段 3:检测 Subagent 完成
    // 主 Agent 的 tools 节点返回 tool 类型消息,说明子代理执行完毕
    if (namespace.length === 0 && nodeName === "tools") {
      for (const msg of data.messages ?? []) {
        if (msg.type === "tool") {
          // 通过 tool_call_id 匹配之前记录的子代理
          const sub = activeSubagents.get(msg.tool_call_id);
          if (sub) {
            sub.status = "complete";
            console.log(
              `[lifecycle] COMPLETE → subagent "${sub.type}" (${msg.tool_call_id})`,
            );
            console.log(
              `  Result preview: ${String(msg.content).slice(0, 120)}...`,
            );
          }
        }
      }
    }
  }
}

// 打印最终状态
console.log("\n--- Final subagent states ---");
for (const [id, sub] of activeSubagents) {
  console.log(`  ${sub.type}: ${sub.status}`);
}
图:Subagent 生命周期状态机

生命周期输出

三种状态转换的完整输出示例

运行上面的生命周期追踪代码,你会看到类似这样的输出:

[lifecycle] PENDING  → "researcher" (call_abc123)
[lifecycle] RUNNING  → "researcher"
[lifecycle] COMPLETE → "researcher" (call_abc123)
  Result preview: ## AI Safety Report
    Recent developments in AI safety include...
[lifecycle] PENDING  → "writer" (call_def456)
[lifecycle] RUNNING  → "writer"
[lifecycle] COMPLETE → "writer" (call_def456)
  Result preview: # AI Safety Summary
    Based on the research findings...

--- Final subagent states ---
  researcher: complete
  writer: complete

PENDING → RUNNING 延迟

注意:pregel 任务 ID 与 tool_call_id 不同,所以收到第一个 Subagent 事件时,将所有 pending 状态标记为 running。

多个 Subagent 并行

如果有多个 Subagent 同时运行,Map 中会同时存在多个 pending/running 状态的条目。按 tool_call_id 区分即可。

实战价值:这个模式是构建 Subagent 管理 UI 的基础。用 PENDING/RUNNING/COMPLETE 三种状态驱动 UI 组件的显示,就能做出类似 Claude Code 的多任务面板。

模式速查表

四种 streamMode 的用途、事件结构和使用场景一览

streamMode事件内容典型用途解构方式
"updates" 节点名称 + 更新数据 跟踪执行步骤、Subagent 生命周期 [namespace, chunk]
"messages" LLM token / 工具调用 / 工具结果 逐字输出、工具追踪 [namespace, chunk]
"custom" writer 发出的自定义对象 进度条、业务状态通知 [namespace, chunk]
["updates","messages","custom"] 以上全部 完整的执行视图 [namespace, mode, data]

单模式 vs 多模式

单模式:解构 [namespace, chunk],简单直接。

多模式:解构 [namespace, mode, data],需要用 mode 字段分支处理。

命名空间判断技巧

namespace.length === 0 → 主 Agent

namespace.some(s => s.startsWith("tools:")) → Subagent

namespace.find(s => s.startsWith("tools:"))?.split(":")[1] → 提取 ID

本章回顾

到这里,你已经掌握了 Deep Agent 流式传输的完整能力

你学到了什么

  • subgraphs: true 是接收 Subagent 事件的开关
  • 命名空间 用于标识事件来源——空=主 Agent,tools:=Subagent
  • updates 模式跟踪步骤进度
  • messages 模式流式传输 token 和工具调用
  • custom 模式接收 writer 发出的自定义事件
  • 多模式组合 获取完整执行视图
  • Subagent 生命周期:PENDING → RUNNING → COMPLETE

相关章节

核心结论:流式传输不是锦上添花,而是生产级 Agent 应用的基础设施。没有它,用户面对的是黑箱;有了它,用户能看到每一步、每个 token、每个工具调用——透明度决定信任度。