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 给前端的事件,是同一套事件吗?

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

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

有时间会总结第二篇文章

相关推荐
迷迭香yy1 小时前
行业板块轮动因子实战从板块资金到因子建模的本地化Python全流程
数据库·人工智能·python
牛奶咖啡131 小时前
AI助力运维——AIGC运维应用实践—Deepseek的介绍与本地部署选型
运维·人工智能·deepseek·deepseek能做什么·deepseek本地部署配置·本地部署选型避坑原则·本地部署的典型方案
阿里云大数据AI技术2 小时前
阿里云 Milvus 知识库开启邀测,助力客户构建企业级 Agent
人工智能·agent
正经教主2 小时前
AI提示词工程(进阶)第7课:角色设定与身份模拟
人工智能
lucky_syq2 小时前
第3篇 · S1·上:什么是大语言模型 + Transformer 架构深讲
人工智能·语言模型·架构·transformer
SelectDB2 小时前
网易游戏 湖仓一体架构:Apache Doris / SelectDB 的技术能力与实践
开源
小玮看世界2 小时前
当“安全“变成“教训“:《拟人化暂行办法》时代,AI护栏的过度拒答之困与破局
大数据·人工智能
本当迷ya2 小时前
8月17日最新 Codex gpt-5.6-sol 开启 1M 上下文封印
人工智能
jinggongszh3 小时前
使用AI协同开发产品条码规则功能——从“理解方案、原型、接口文档”到“落地真实前端代码”
前端·人工智能·mes系统·mes工程架构