字节Agent框架 DeerFlow 的可观测性(二):运行中的中间事件是怎样到达前端的?

上一篇我们看了 DeerFlow 如何通过 RunJournal 记录 Agent 的运行过程:

text 复制代码
Agent / LLM / Tool
        ↓
LangChain Callback
        ↓
RunJournal
        ↓
RunEvent
        ↓
EventStore

这一篇继续看另一条链路:

用户在页面上看到的"正在执行什么",到底是怎么实时出现的?

比如 Agent 正在调研 GitHub 仓库时,页面可能会出现:

text 复制代码
Run GitHub API to get repository summary
python github_api.py bytedance deer-flow summary

更新 To-do 列表

正在读取 README
正在调用 GitHub API

这些内容并不是前端不断查询 EventStore 得到的。

同一次 Agent 执行,实际上存在两条路径:

text 复制代码
同一次 Agent 执行
        │
        ├── Callback
        │      ↓
        │  RunJournal
        │      ↓
        │  EventStore
        │
        └── Stream
               ↓
          agent.astream()
               ↓
          StreamBridge
               ↓
              SSE
               ↓
             前端

一句话概括:

EventStore 负责保存运行记录,Stream 负责把正在发生的事情实时送到前端。

这篇主要沿着 Stream 这条链路往下看。


1. RunEvent 和 Stream 是两条不同的链路

RunJournal 产生的 RunEvent 更偏向持久化。

比如模型调用结束后,可以记录:

json 复制代码
{
  "event_type": "llm.ai.response",
  "content": {
    "type": "ai",
    "tool_calls": [
      {
        "name": "bash",
        "args": {
          "command": "python github_api.py bytedance deer-flow summary"
        }
      }
    ]
  },
  "metadata": {
    "usage": {
      "input_tokens": 1200,
      "output_tokens": 80
    }
  }
}

它适合之后查询:

  • 模型调用了几次;
  • 调用了什么工具;
  • 工具参数和结果是什么;
  • 消耗了多少 token;
  • Run 最终是否成功。

而页面上的:

text 复制代码
正在读取 README

不能等任务执行结束后再查数据库。

它需要在 Agent 还在执行时就推给浏览器。

所以 DeerFlow 还有一条实时链路:

text 复制代码
Agent
  ↓
LangGraph Stream
  ↓
Worker
  ↓
SSE
  ↓
Browser

两者的职责可以简单区分为:

EventStore Stream
目的 保存、查询、审计 实时展示
来源 Callback astream()
消费者 后端 当前浏览器

2. LangGraph Stream 主要传什么?

LangGraph 的 astream() 支持多种 stream_mode

DeerFlow 定义了:

python 复制代码
type RunStreamMode = Literal[
    "values",
    "messages-tuple",
    "updates",
    "debug",
    "tasks",
    "checkpoints",
    "custom",
]

这里最值得关注的是三种:

Stream 作用
messages 模型消息、Tool Call、Tool Result
values 当前完整 Graph State
custom 业务代码主动发送的额外事件

例如:

messages

模型生成:

text 复制代码
Run GitHub API to get repository summary

或者产生 Tool Call:

json 复制代码
{
  "name": "bash",
  "args": {
    "command": "python github_api.py bytedance deer-flow summary"
  }
}

这些属于消息流。

values

Graph 当前状态可能是:

json 复制代码
{
  "messages": [...],
  "todos": [...],
  "title": "DeerFlow 调研",
  "artifacts": [...]
}

Todo、标题、Artifacts 这类状态可以从 values 更新。

custom

custom 则是业务自己定义的数据:

json 复制代码
{
  "type": "task_running",
  "task_id": "task-001",
  "message": "正在读取 README"
}

这里需要区分两层:

text 复制代码
custom       → LangGraph 的事件通道
task_running → DeerFlow 定义的业务事件

这也是 custom 的核心价值:

当某个运行状态既不是正常 AIMessage,也不应该塞进 Graph State 时,可以通过 custom stream 单独发给前端。


3. custom 事件从哪里产生?

以 DeerFlow 的子 Agent 为例。

相关代码在:

text 复制代码
backend/packages/harness/deerflow/tools/builtins/task_tool.py

task_tool.py 本质上做两件事:

text 复制代码
主 Agent
   ↓
调用 task tool
   ↓
启动子 Agent
   ↓
监听子 Agent 执行过程
   ↓
上报子任务进度

所以它可以简单理解成:

子 Agent 调度器 + 进度上报器。

执行时,它先拿到 LangGraph 的 StreamWriter

python 复制代码
writer = get_stream_writer()

然后在子 Agent 有新进度时发送事件:

python 复制代码
await aemit_custom_event(
    {
        "type": "task_running",
        "task_id": task_id,
        "message": message,
        "message_index": i + 1,
    },
    writer=writer,
)

同样还会产生:

text 复制代码
task_started
task_running
task_completed
task_failed

aemit_custom_event() 的核心逻辑可以简化为:

python 复制代码
async def aemit_custom_event(payload, *, writer):
    writer(payload)

    event_name = payload.get("type")
    if event_name:
        await adispatch_custom_event(event_name, payload)

这里最关键的是:

python 复制代码
writer(payload)

它把 payload 写进 LangGraph 的 custom stream

text 复制代码
task_tool
    ↓
aemit_custom_event()
    ↓
writer(payload)
    ↓
LangGraph custom stream

后面的 adispatch_custom_event() 是另一条 Callback Event 通知路径。

对于前端实时展示来说,主要关注 writer(payload) 即可。


4. custom 怎么从 LangGraph 到浏览器?

Worker 执行 Agent 时,会调用:

python 复制代码
async for item in agent.astream(
    input_payload,
    config=stream_config,
    stream_mode=lg_modes,
):
    ...

假设 lg_modes 包含:

text 复制代码
custom

那么之前通过:

python 复制代码
writer(payload)

写进去的数据,就会从 astream() 中流出来。

例如:

text 复制代码
mode = "custom"

对应的 chunk:

json 复制代码
{
  "type": "task_running",
  "task_id": "task-001",
  "message": {
    "type": "ai",
    "content": "正在读取 README"
  }
}

Worker 再把它交给 StreamBridge

python 复制代码
await bridge.publish(
    run_id,
    "custom",
    serialize(chunk),
)

最终通过 SSE 发给浏览器:

text 复制代码
event: custom
data: {
  "type": "task_running",
  "task_id": "task-001",
  "message": {
    "type": "ai",
    "content": "正在读取 README"
  }
}

因此后端这条链路可以压缩成:

text 复制代码
task_tool
    ↓
writer(payload)
    ↓
custom stream
    ↓
agent.astream()
    ↓
StreamBridge
    ↓
SSE
    ↓
Browser

5. 前端怎么把 custom 变成 UI?

前端通过 LangGraph SDK 的 useStream 接收数据:

typescript 复制代码
const thread = useStream<AgentThreadState>({
  client: getAPIClient(),
  ...
});

DeerFlow 在:

text 复制代码
frontend/src/core/threads/hooks.ts

中处理 Custom Event:

typescript 复制代码
onCustomEvent(event: unknown) {
  ...
}

首先判断业务事件类型:

typescript 复制代码
if (eventType === "task_running") {
  ...
}

然后更新对应的子任务:

typescript 复制代码
updateSubtask({
  id: e.task_id,
  latestMessage: e.message,
  steps: [
    messageToStep(
      e.message,
      e.message_index ?? 0,
    ),
  ],
});

这里最关键的是两个函数。

messageToStep()

把收到的消息转换成前端统一的 Step:

text 复制代码
AIMessage
    ↓
assistant step

ToolMessage
    ↓
tool step

updateSubtask()

把新的 Step 合并进对应子任务,然后触发页面重新渲染。

所以页面上的:

text 复制代码
正在读取 README

实际经历的是:

text 复制代码
task_running
    ↓
onCustomEvent()
    ↓
messageToStep()
    ↓
updateSubtask()
    ↓
Subtask UI

页面上的子任务时间线,本质上就是前端根据持续到来的事件一点点组装出来的。


6. 用一次 GitHub 调研把整条链路串起来

假设用户输入:

text 复制代码
研究 bytedance/deer-flow,并整理成一份报告。

Agent 执行过程中可能同时产生三类数据。

模型调用 GitHub 工具

模型产生:

json 复制代码
{
  "type": "ai",
  "content": "Run GitHub API to get repository summary",
  "tool_calls": [
    {
      "name": "bash",
      "args": {
        "command": "python github_api.py bytedance deer-flow summary"
      }
    }
  ]
}

它主要走:

text 复制代码
messages

前端因此可以显示模型文字和 Tool Call。


Todo 发生变化

Graph State 更新:

json 复制代码
{
  "todos": [
    {
      "content": "获取仓库信息",
      "status": "completed"
    },
    {
      "content": "读取 README",
      "status": "in_progress"
    }
  ]
}

它通过:

text 复制代码
values

到达前端,更新 TodoList。


子 Agent 上报进度

task_tool.py 产生:

json 复制代码
{
  "type": "task_running",
  "task_id": "task-001",
  "message": {
    "type": "ai",
    "content": "正在读取 README"
  }
}

它通过:

text 复制代码
custom

到达前端:

text 复制代码
task_running
    ↓
messageToStep()
    ↓
updateSubtask()
    ↓
页面显示子 Agent 当前进度

所以从 UI 的角度,可以这样理解三种 Stream:

text 复制代码
messages → Agent 产生了什么消息

values   → Agent 当前是什么状态

custom   → Agent 运行过程中额外发生了什么

其中 custom 的完整链路就是:

text 复制代码
业务代码
    ↓
writer(payload)
    ↓
LangGraph custom stream
    ↓
agent.astream()
    ↓
Worker
    ↓
StreamBridge
    ↓
SSE
    ↓
onCustomEvent()
    ↓
event.type
    ↓
前端 UI

这里还有一个容易混淆的点:

custom 默认只是实时事件,并不代表一定会写入 EventStore。

DeerFlow 对部分 task_* 事件做了额外处理,例如把 task_running 转换成:

text 复制代码
subagent.step

再保存到 EventStore。

因此部分子 Agent 事件会同时存在:

text 复制代码
实时路径:
task_running
    ↓
custom stream
    ↓
前端

和:

text 复制代码
持久化路径:
task_running
    ↓
subagent.step
    ↓
EventStore

但这是 DeerFlow 针对子 Agent 做的额外持久化,并不是所有 custom 事件都会自动保存。

最后回到开头。

DeerFlow 的运行过程之所以能实时出现在页面上,并不是前端不断查询 EventStore,而是 Agent 执行时主动通过 Stream 把数据推出来:

text 复制代码
messages → 消息和工具调用
values   → Graph State
custom   → 额外运行状态

custom 解决的正是这样一个问题:

当业务代码需要告诉前端"现在正在做什么",但它既不是正常消息,也不应该修改 Graph State 时,就通过 writer(payload) 写入 custom stream

这就是 DeerFlow 从 Agent 运行到前端展示的核心链路。

相关推荐
doitnow20001 小时前
2026淘宝运营机构课程体系怎么比较?
大数据·人工智能
网渡科技1 小时前
大模型推理成本优化实战:vLLM 与 KV Cache 调优指南
人工智能
土豆12501 小时前
半年 20 万 Star 的「反 Vibe Coding」:Matt Pocock 是如何用一套 Skill 驯服 AI 编程代理的
人工智能·ai编程
yuhulkjv3351 小时前
Gemini鸿蒙版导出word格式的终极解法:AI 导出鸭如何重构AI内容落地链路
人工智能·ai·word·harmonyos·ai导出鸭
雪的季节1 小时前
OPENCV学习(补充1)
人工智能·opencv·学习
9i编程2 小时前
四大准则也还是不靠谱啊:定好了铁律,AI 照样偷懒给你看
人工智能·openai·ai编程
狂僧2 小时前
从 Markdown、Wiki 到 AI 知识库:本体与知识图谱到底处在哪一层?
人工智能
lf13210272 小时前
用 JSON Schema 管装修节点记录:从照片台账到可校验工程数据
网络·数据库·人工智能·经验分享·物联网·json·智能家居
watersink2 小时前
机器学习XGBoost
人工智能·机器学习