DeerFlow 的可观测性(一):RunJournal 如何记录 Agent 运行过程
先简单介绍一下 DeerFlow。
DeerFlow 是字节开源的一个 Harness 框架。在 Harness 的设计中,可观测性(Observability)是非常重要的一部分。
因为如果一个 Agent 的内部执行过程完全不可见,那它和一个黑盒系统其实没有太大区别。
比如用户输入:
帮我查一下今天的天气。
我们最终看到的可能只是一个回答,但对于 Agent 系统来说,我们往往还希望知道:
- LLM 一共调用了几次;
- 模型是否决定调用工具;
- 调用了哪个工具;
- 给工具传了什么参数;
- 搜索了哪些网页;
- 工具返回了什么结果;
- 是否发生了错误;
- 是否调用了子 Agent;
- 整个 Run 最终是成功还是失败。
这些信息既方便调试,也可以用于运行记录、问题排查、前端过程展示以及后续分析。
这一篇先不讨论 LangSmith 这类外部可观测平台,只看 DeerFlow 自己内部是怎么记录这些运行事件的。
1. 核心思路:执行和观测分离
首先明确一个基本关系:
模型只负责决定"要不要调用工具,以及调用哪个工具",真正执行动作的是工具本身,而观测层只负责记录整个过程。
也就是说,RunJournal 并不参与 Agent 的决策。
它更像一个旁路观察者:
text
用户输入
↓
Agent / LLM
↓
决定是否调用 Tool
↓
Tool 真正执行
↓
产生运行事件
↓
RunJournal 监听
↓
EventStore 持久化
2. RunJournal 是怎么挂到 Agent 上的
在 DeerFlow 中,会先创建一个 RunJournal:
python
journal = RunJournal(
run_id=run_id,
thread_id=thread_id,
event_store=event_store,
)
config.setdefault("callbacks", []).append(journal)
...
agent.astream(..., config=config)
这里最关键的是:
python
config.setdefault("callbacks", []).append(journal)
RunJournal 被作为一个 LangChain Callback 注册到了当前 Agent 的运行配置中。
之后调用:
python
agent.astream(..., config=config)
Agent 在运行过程中产生的 LLM、Tool、Chain 等事件,就会触发 RunJournal 中对应的回调。
因此整体关系可以理解成:
text
agent.astream()
↓
LangChain Callback 系统
↓
RunJournal
↓
格式化运行事件
↓
EventStore
3. RunJournal 监听哪些东西
RunJournal 继承了 LangChain 的 BaseCallbackHandler,并实现了一系列事件回调。
例如:
python
class RunJournal(BaseCallbackHandler):
def on_llm_start(...):
...
def on_tool_start(...):
...
def on_tool_end(...):
...
def on_chain_end(...):
...
def on_llm_error(
self,
error: BaseException,
*,
run_id: UUID,
**kwargs: Any,
) -> None:
self._llm_start_times.pop(str(run_id), None)
self._put(
event_type=LLM_ERROR_EVENT.event_type,
category=LLM_ERROR_EVENT.category,
content=str(error),
)
这些 callback 并不是最终的存储格式。
RunJournal 会把 LangChain 原始 callback 转换成 DeerFlow 自己定义的一套运行事件。
4. DeerFlow 最终记录哪些事件
DeerFlow 记录的事件大致包括:
| 事件 | 什么时候产生 | 记录内容 |
|---|---|---|
run.start |
根 Graph 开始运行 | chain 名称、caller、metadata |
llm.human.input |
第一次真实 LLM 调用时 | 结构化用户输入 |
llm.ai.response |
每次模型调用结束时 | 文本、tool_calls、usage、延迟、调用序号 |
llm.tool.result |
工具返回 ToolMessage 或 Command 更新时 |
工具结果、tool_call_id |
llm.error |
模型调用异常时 | 错误信息 |
middleware:<tag> |
Middleware 主动发出审计事件时 | guardrail、skill activation 等 |
subagent.start |
开始委派子 Agent | 子任务信息 |
subagent.step |
子 Agent 执行过程中 | 当前步骤信息 |
subagent.end |
子 Agent 执行完成 | 执行结果摘要 |
run.end |
根 Graph 正常结束 | 最终输出、success 状态 |
run.error |
根 Graph 异常结束 | 异常类型、错误信息 |
这里有一个比较重要的设计点:
DeerFlow 并没有直接把 LangChain callback 原样保存下来,而是做了一层自己的事件抽象。
例如:
text
LangChain on_llm_end
↓
RunJournal 解析
↓
llm.ai.response
这样做之后,上层业务不需要依赖 LangChain callback 的具体结构,只需要理解 DeerFlow 自己定义的事件协议。
这也为后续更换底层 Agent Runtime 留出了一定空间。
5. 为什么不是每来一个事件就立即写数据库
事件产生之后,并不会马上写入存储。
RunJournal 会先调用 _put():
python
def _put(
self,
*,
event_type: str,
category: str,
content: str | dict = "",
metadata: dict | None = None,
) -> None:
self._buffer.append(
{
"thread_id": self.thread_id,
"run_id": self.run_id,
"event_type": event_type,
"category": category,
"content": content,
"metadata": metadata or {},
"created_at": datetime.now(UTC).isoformat(),
}
)
if len(self._buffer) >= self._flush_threshold:
self._flush_sync()
可以看到,事件先进入:
python
self._buffer
也就是一个内存 Buffer。
流程大概是:
text
callback 触发
↓
转换成 DeerFlow Event
↓
_put()
↓
append 到 buffer
↓
达到 flush_threshold
↓
_flush_sync()
↓
批量写入 EventStore
这里采用 Buffer 的主要原因是:
避免每产生一个 callback 就执行一次持久化操作。
Agent 一次运行过程中可能产生大量事件,例如:
text
LLM start
LLM end
Tool start
Tool end
LLM start
LLM end
Tool start
Tool end
...
如果每个事件都立即写数据库,会产生大量小 IO。
所以 DeerFlow 先把事件缓存在内存中,达到一定数量之后再批量写入。
最终真正落库时调用的是:
python
await self._store.put_batch(batch)
6. EventStore 从哪里来的
初始化 RunJournal 时我们还传入了:
python
event_store=event_store
这个 event_store 并不是 RunJournal 自己创建的。
它是在 DeerFlow Gateway 启动阶段创建,然后通过运行上下文一路传递进来的。
大致链路是:
text
config.yaml 中的 run_events 配置
↓
Gateway 启动
↓
创建 EventStore
↓
保存到 app.state.run_event_store
↓
get_run_context()
↓
run_agent(ctx=run_ctx)
↓
RunJournal(event_store=event_store)
所以 RunJournal 本身只负责:
text
采集事件
+
格式化事件
+
决定什么时候 flush
至于事件最终存在哪里,则交给 EventStore。
7. EventStore 的设计
DeerFlow 为运行事件提供了多种存储实现,例如:
text
Memory
JSONL
Database
这些实现都遵循统一的 EventStore 接口。
RunJournal 不需要知道底层到底是文件还是数据库,它只需要调用:
python
await self._store.put_batch(batch)
这实际上是一种典型的:
Strategy Pattern(策略模式)+ Interface Abstraction(接口抽象)
结构类似:
text
EventStore
│
┌───────────┼───────────┐
↓ ↓ ↓
MemoryStore JSONLStore DBStore
对于 RunJournal 来说:
python
await self._store.put_batch(batch)
始终不变。
不同部署场景只需要替换具体 Store。
例如开发环境可以使用:
text
Memory / JSONL
生产环境则可以使用:
text
Database
而上层运行逻辑完全不用修改。
8. 最终事件长什么样
经过 RunJournal 格式化之后,一条事件可能是:
json
{
"thread_id": "thread-demo",
"run_id": "run-001",
"event_type": "llm.human.input",
"category": "message",
"content": {
"type": "human",
"content": "帮我查一下今天的天气",
"additional_kwargs": {},
"response_metadata": {},
"id": "msg-user-001"
},
"metadata": {
"caller": "lead_agent"
},
"seq": 2,
"created_at": "2026-08-17T08:30:10.100000+00:00"
}
几个核心字段:
text
thread_id
表示事件属于哪个会话。
text
run_id
表示事件属于哪一次 Agent 执行。
text
event_type
表示具体是什么事件,例如:
text
llm.human.input
llm.ai.response
llm.tool.result
run.end
text
category
对事件进行更高层次的分类。
text
content
真正的事件内容。
text
metadata
附加上下文,例如 caller 等信息。
text
seq
表示当前 Run 内的事件顺序。
有了 seq 之后,就可以按照顺序重新还原一次完整 Agent 执行过程。
9. 不同 Store 最终只是持久化介质不同
如果使用 JSONL:
text
run-events.jsonl
那么文件中每一行就是一个 JSON Event。
如果使用 Database:
text
RunJournal
↓
put_batch()
↓
运行事件表
这些数据会写入数据库中的运行事件表。
如果使用 Memory:
text
RunJournal
↓
put_batch()
↓
进程内存
事件结构基本相同,只不过不会真正持久化。
所以从上层来看:
text
RunJournal
↓
EventStore
↓
┌───────────┼───────────┐
↓ ↓ ↓
Memory JSONL DB
10. 整体结构
最后把 DeerFlow 这一套内部可观测机制串起来:
text
Agent
│
agent.astream()
│
↓
LangChain Callback System
│
↓
RunJournal
│
┌─────────────┴─────────────┐
│ │
捕获 callback DeerFlow 自定义事件
│ │
└─────────────┬─────────────┘
↓
Buffer
│
flush_threshold
│
↓
put_batch()
│
↓
EventStore
│
┌─────────────┼─────────────┐
↓ ↓ ↓
Memory JSONL DB
所以 DeerFlow 这一层可观测性的核心可以概括成:
利用 LangChain Callback 捕获 Agent 的运行过程,再转换成 DeerFlow 自己统一的事件协议,通过 Buffer 批量写入可替换的 EventStore。
结尾
到这里,DeerFlow 已经完成了可观测性的第一步:
text
Agent / LLM / Tool
↓
Callback
↓
RunJournal
↓
统一 Run Event
↓
EventStore
也就是说,Agent 在运行过程中发生了什么,已经能够被结构化地记录下来。
但这里还有一个问题:
这些事件如果只是写进 JSONL 或数据库,对正在使用 Agent 的用户来说依然是"不可见"的。
比如模型正在搜索网页、调用工具、执行子 Agent 时,前端为什么能够实时显示:
text
在网络上搜索
更新 To-do 列表
Load frontend-design skill for creating the webpage
显然,前端不可能一直去查询 EventStore。
所以接下来还需要另外一条链路:
text
Agent Runtime
↓
运行事件
↓
Stream
↓
前端接收
↓
转换成 UI 状态
这里就出现了一个很有意思的问题:
DeerFlow 持久化到 EventStore 的事件,和实时 Stream 给前端的事件,是同一套事件吗?
如果不是,它们之间又是怎么转换和同步的?
下一篇继续沿着这条链路往下看:
有时间会总结第二篇文章