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

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

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

有时间会总结第二篇文章

相关推荐
机 _ 长几秒前
05 智算运维|8 张卡怎么才算「捆成一张」:NCCL 实测与拓扑
运维·人工智能·算法·智算运维
fthux7 分钟前
待办清单写满了,别忘了给休息留个位置:试试 Tick Rest
开源·github·mac·提醒·open source·消息提醒·tick rest·tick rest for mac·消息提醒app
用户43567829363721 分钟前
五线并行认知架构:从算力内卷到思考秩序的底层革命
人工智能
一 铭27 分钟前
WrenAI 详解:给 AI Agent 的开源 GenBI 引擎
人工智能·开源
say_fall29 分钟前
【AI大模型】MCP 模型上下文协议:客户端-服务器架构、JSON-RPC 消息与能力协商、工具发现与调用链路、与函数调用的分工
服务器·人工智能·架构
早起CaiCai1 小时前
【2023-01 OM】利用深度学习算法融合异质观测实现海洋场降尺度
人工智能·深度学习·算法
周杰伦的稻香1 小时前
AI 助手 2.0:多轮对话、卡片化与文章页感知
人工智能
Martina_03211 小时前
AI生成3D场景从白模换成精模后镜头全偏了?用6步检查单位、枢轴与层级替换
图像处理·人工智能·3d·ai·自然语言处理·材质·1024程序员节
狒狒AI1 小时前
手绘白板动画的生产线拆解:AI 出图、笔迹对齐、镜头渲染七道工序(附 21 个开源脚本)
人工智能·aigc
鶴哥只手遮天2 小时前
《Redis 8.10.3 深度剖析》系列 ·第 10 章 Hash 一:`listpack`、`hashtable` 与 `LISTPACK_EX`
redis·后端·开源