上一篇我们看了 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 运行到前端展示的核心链路。