DeerFlow 的可观测性(一):RunJournal 如何记录 Agent 运行过程

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 工具返回 ToolMessageCommand 更新时 工具结果、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 给前端的事件,是同一套事件吗?

如果不是,它们之间又是怎么转换和同步的?

下一篇继续沿着这条链路往下看:

有时间会总结第二篇文章

相关推荐
文心快码BaiduComate3 分钟前
从“代码补全”到“自主交付”:文心快码全私有化部署落地中信百信银行核心研发链路
人工智能·程序员·文心快码
__如果14 分钟前
Medical Benchmark
人工智能
β添砖java15 分钟前
深度学习32Transformer、BERT、
人工智能·深度学习
2601_9623878220 分钟前
学习笔记DAY1:Python编程从0开始学起,从自学入门到精通
人工智能·网络爬虫·学习笔记·网页开发·python编程
TechEdu20260630 分钟前
[人工智能]AI芯片家族:英伟达、AMD、英特尔、高通与华为
人工智能·ai
具身AGI34 分钟前
物理AI人类学习路线的答案
人工智能·学习
johnsong36 分钟前
AI前沿日报 2026-09-06
人工智能
海鸥8143 分钟前
AIOps(智能运维)理解
运维·人工智能
weixin_468466851 小时前
从“建模城市”到“按需查询”:D4RT如何用200+FPS重新定义动态3D世界
人工智能·3d·具身智能·d4rt·4d重建
广州硅基技术官方1 小时前
AIGK外贸工厂社媒引流实战教程:海外自媒体短视频AI矩阵获客玩法解析
人工智能·音视频·媒体