核心定位

RunEventStore 记录 Agent 执行过程中发生的每一个事件(AI 输出的每个文字片段、工具调用、工具结果等),按顺序持久化。

场景为什么需要 Event Store
断线重连浏览器掉线后重放历史事件
运行回放查看”AI 一个字一个字怎么说出回复的”
多客户端多个窗口监听同一个 Run
分页消息前端”加载更多”功能

抽象接口

RunEventStore 定义了 8 个方法:

class RunEventStore(abc.ABC):
    async def put(...) → dict                        # 写入单条事件
    async def put_batch(events) → list[dict]         # 批量写入
    async def list_messages(...) → list[dict]        # 查消息 (category=message)
    async def list_events(...) → list[dict]          # 查全部事件
    async def list_messages_by_run(...) → list[dict] # 查某次 Run 的消息
    async def count_messages(...) → int              # 计数
    async def delete_by_thread(...) → int            # 按线程删
    async def delete_by_run(...) → int               # 按 Run 删

事件的数据结构

{
    "thread_id": "thread-abc",
    "run_id":   "run-xyz",
    "event_type": "ai_chunk",
    "category":  "message",            # message / trace / lifecycle
    "content":   {"content": "你"},
    "metadata":  {"id": "msg-001"},
    "seq":       42,                   # 线程级严格递增
    "created_at": "2026-06-04T..."
}

category 三种值

category含义典型 event_type用途
message面向用户的消息ai_chunkhuman_messagetool_result前端聊天界面展示
trace调试/审计信息tool_callllm_startllm_end开发者调试
lifecycle生命周期事件metadataenderrorRun 级别状态变更

seq 的保证

seq 在同一个 thread_id 内严格递增,无论属于哪个 Run。前端用 seq 做游标分页,保证消息顺序正确。


三种实现

MemoryRunEventStore

class MemoryRunEventStore(RunEventStore):
    def __init__(self):
        self._events: dict[str, list[dict]] = {}     # thread_id → 事件列表
        self._seq_counters: dict[str, int] = {}       # thread_id → 当前最大 seq
  • 两个字典,O(n) 列表过滤
  • 进程重启数据消失

JsonlRunEventStore

每个 Run 一个 .jsonl 文件:

graph LR
    subgraph ".deer-flow/threads/"
        TA["thread-abc/"]
        subgraph "runs/"
            R1["run-001.jsonl"]
            R2["run-002.jsonl"]
            R3["run-003.jsonl"]
        end
        TA --> runs
    end

关键设计:

  • 所有 IO 通过 asyncio.to_thread 推到线程池——避免阻塞事件循环
  • per-thread asyncio.Lock 串行化写入——防止 JSONL 行交叉
  • 路径校验防止 ../ 路径穿越
  • 冷启动:进程重启后首次写入前扫描所有文件恢复 max seq

DbRunEventStore

SQL 数据库存储,生产级实现。

ORM 模型:

class RunEventRow(Base):
    __tablename__ = "run_events"
    id: int                         # 主键自增
    thread_id: str(64)
    run_id: str(64)
    user_id: str(64) | None         # 多租户
    event_type: str(32)
    category: str(16)
    content: Text                   # JSON 字符串
    event_metadata: JSON
    seq: int
    created_at: DateTime(tz)

    UniqueConstraint("thread_id", "seq")
    Index("thread_id", "category", "seq")
    Index("thread_id", "run_id", "seq")

seq 原子性

多个请求可能同时写同一个线程的事件,seq 必须不重复:

async def _max_seq_for_thread(session, thread_id):
    if dialect == "postgresql":
        # Postgres 用 advisory lock 在事务级别串行化
        await session.execute(
            text("SELECT pg_advisory_xact_lock(hashtext(:thread_id))"),
            {"thread_id": thread_id},
        )
    else:
        # SQLite: SELECT ... FOR UPDATE 行锁
        return await session.scalar(stmt.with_for_update())

写入流程:

async with session.begin():
    max_seq = await _max_seq_for_thread(...)
    seq = (max_seq or 0) + 1
    session.add(RunEventRow(seq=seq, ...))
# 事务提交 → 锁释放 → 下一个事务可以拿到 seq+1

三种实现对比

维度MemoryJSONLDB
重启后数据❌ 丢失✅ 保留✅ 保留
多进程共享❌ (seq 不单调)✅ (事务锁)
seq 原子性单线程保证asyncio.Lock数据库事务锁
批量写入内存操作N 次 to_thread一次事务
分页查询列表过滤扫描文件SQL LIMIT
用户隔离user_id 过滤

谁在读写

写入者:RunJournal

RunJournal 作为 LangChain CallbackHandler 挂在 run_agent 里:

flowchart LR
    A["Agent 执行"] -->|"LLM 输出'你'"| C1["on_llm_chunk()"]
    A -->|"LLM 输出'好'"| C2["on_llm_chunk()"]
    A -->|"工具调用 search"| C3["on_tool_call()"]
    A -->|"工具返回'晴天'"| C4["on_tool_result()"]
    A -->|"Run 结束"| C5["flush()"]

    C1 --> ES1["event_store.put<br/>(ai_chunk, message)"]
    C2 --> ES2["event_store.put<br/>(ai_chunk, message)"]
    C3 --> ES3["event_store.put<br/>(tool_call, trace)"]
    C4 --> ES4["event_store.put<br/>(tool_result, message)"]
    C5 --> ES5["event_store.put_batch<br/>([缓冲事件])"]

读取者:Gateway 路由

graph LR
    subgraph API
        A1["GET /runs/{rid}/messages"]
        A2["GET /runs/{rid}/events"]
        A3["GET /threads/{tid}/messages"]
        A4["DELETE /threads/{tid}"]
    end

    subgraph RunEventStore
        M1["list_messages_by_run()"]
        M2["list_events()"]
        M3["list_messages()"]
        M4["delete_by_thread()"]
    end

    A1 --> M1
    A2 --> M2
    A3 --> M3
    A4 --> M4