1933 字
约 6 分钟
4
事件溯源与回放
无标签

系列:ToyAgent 智能体知识库 22/26

事件溯源与回放:会话即日志,日志即状态

一句话核心:把对话的每一刻都追加为不可变事件(JSONL),Agent 的记忆不是内存变量,而是从事件日志回放出来的"投影视图"——服务重启不丢状态,审计天然免费,fork 会话只需复制日志前缀。

为什么需要

朴素做法下的所有 Agent,记忆都是进程内存里的 List<Message>。这带来三个在生产环境不可接受的问题:

  • 状态易失:服务一重启(发布、崩溃、扩容),所有会话记忆清零,用户被迫从头自我介绍;
  • 无法审计:"模型当时看到了什么、说了什么"没有权威记录,出了合规问题或线上事故只能靠翻零散日志拼凑;
  • 不可复制:想给一个正在进行的会话"分叉"出一条并行探索的支线,或者回滚到某个时点重来,内存结构做不到。

事件溯源(Event Sourcing)换了一个根本思路——不存"当前状态",只存"发生过什么"。当前状态永远可以从事件序列推导出来。这个思想和 Kafka 的日志、Git 的提交历史是同构的:append-only 的真相源 + 按需投影出视图。

核心机制

教学版只需要 4 种事件类型就够看清骨架:SESSION_STARTED(会话开始)、USER_MESSAGE_APPENDED(用户消息)、AGENT_REPLY_APPENDED(模型回复)、TURN_COMPLETED(一轮结束)。

// events/*.jsonl —— append-only,seq 单调递增,一行一条 JSON,永不改写
{"seq":1,"ts":1728380000000,"type":"SESSION_STARTED","payload":{"scenario":"step22"}}
{"seq":2,"type":"USER_MESSAGE_APPENDED","payload":{"content":"我叫阿星"}}
{"seq":3,"type":"AGENT_REPLY_APPENDED","payload":{"content":"你好,阿星!…"}}
{"seq":4,"type":"TURN_COMPLETED","payload":{"events":3}}
{"seq":5,"type":"USER_MESSAGE_APPENDED","payload":{"content":"我叫什么名字?"}}

三个环节构成闭环:

环节 做什么 关键点
事件追加 每轮对话追加 4 类事件 append-only,seq 单调递增,写后不改
回放投影 每轮开始前重放全部事件,重建上下文 记忆 = 投影视图,不是内存变量
类型过滤 投影只取 USER / AGENT 两类进上下文 其余事件留在日志里服务审计——视图与真相分离

投影的代码骨架极简,但体现了一个关键取舍:

private List<Message> project(List<Event> log) {
    List<Message> history = new ArrayList<>();
    for (Event e : log) {
        switch (e.type()) {
            case USER_MESSAGE_APPENDED  -> history.add(Message.user(e.payload()));
            case AGENT_REPLY_APPENDED   -> history.add(Message.assistant(e.payload()));
            default -> { /* SESSION/TURN 等事件不进上下文,但留在日志里 */ }
        }
    }
    return history;
}

视图与真相分离是这里的题眼:进入大模型上下文的只是日志的一个过滤视图。SESSION_STARTED、TURN_COMPLETED 这类元事件对模型没有信息价值,塞进去只会浪费 token;但它们必须留在日志里——审计、时点统计、计费都靠它们。哪些事件"进上下文"是投影策略问题,可以随版本演进调整(比如后来想让模型看到工具调用事件),而日志格式不用动。

graph LR
    W["用户输入"] --> AP["事件追加 append-only JSONL"]
    M["模型回复"] --> AP
    T["轮次元事件"] --> AP
    AP --> LOG["不可变事件日志 seq 单调递增"]
    LOG --> RP["回放全部事件"]
    RP --> PJ["投影过滤"]
    PJ -->|USER 与 AGENT 进上下文| CTX["重建的对话记忆"]
    PJ -->|元事件留在日志| AUD["审计与计费"]
    CTX --> LLM["发给大模型"]
    LLM --> W

事件流 → 回放投影 → 进上下文的闭环:日志是唯一真相源,记忆只是过滤视图;元事件不进模型但留在日志服务审计。

举例:重启恢复 + fork 快照

场景一:重启不丢。

第 1 次启动:
  user: 我叫阿星            → 追加 seq=2
  agent: 你好,阿星!        → 追加 seq=3
  (服务进程被 kill -9)

第 2 次启动(新进程):
  user: 我叫什么名字?
  宿主启动时重放 events.jsonl 全部 5 条 → project() 只取 seq=2、3
  发给模型的上下文 = [user:"我叫阿星", assistant:"你好,阿星!"]
  agent: 你叫阿星。

进程死了,状态没死——因为状态从来不在进程里,在日志里。反过来也成立:删日志 = 删状态——把 JSONL 文件删掉,记忆随之归零,这直观展示了"状态只存在于事件流中"。

场景二:时间旅行 / fork。

日志当前 seq=1..10,第 6 条是用户提出方向 A 的方案
想试探"如果当时选了方向 B 会怎样":
  fork = 复制 seq=1..5 的日志前缀 → 新文件 branch-b.jsonl
  在新会话上继续追加方向 B 的事件,原会话完全不受影响

子代理(subagent)的 fork 正是这个机制:不是"复制一份内存对象的深拷贝",而是"复制事件前缀"。因为日志是不可变的,前缀复制天然一致、零竞态。这也是事件溯源相对快照式持久化("把 List 序列化存下来")的结构性优势——快照只能恢复到保存点,事件流可以恢复到任意时点。

场景三:调试复现。 线上反馈"模型答非所问",不用猜:取该会话日志重放投影,精确复原模型当时看到的完整上下文,问题一目了然。对比传统做法(翻应用日志、拼请求体、对时间戳),回放的确定性无可替代。

教学版 vs 生产版

维度 教学版 生产版
事件类型 4 种 16 种 sealed 领域事件(TOOL_CALLED、审批、上下文压缩、子代理 spawn 等)
存储 本地 JSONL 文件 Kafka / 数据库事件表,分区按会话 ID
完整性 无校验 每条事件带校验和 / 哈希链,防篡改、可验证连续性
投影时机 每轮全量重放 全量重放 + 定期快照(投影缓存),避免长会话重放变慢
上下文拼接 直接全量投影 叠加压缩 / 裁剪策略后再进上下文

面试考点

  1. "Agent 的对话记忆怎么持久化?"——答事件溯源:append-only 事件流为真相源,记忆是回放投影出的视图;对比"直接序列化 List",前者白送审计、时间旅行、fork 三种能力。
  2. "事件溯源会不会越攒越慢?"——答快照机制:定期把投影结果落为快照,恢复时"最近快照 + 增量重放";上下文注入侧再叠加压缩/裁剪,两层关注点分离。
  3. "哪些事件该进模型上下文?"——答这是投影策略而非日志格式问题:USER/AGENT 消息进,元事件(SESSION/TURN/工具/审批)默认不进但必须留日志;策略可演进,真相源不变。

生产化路径

  • 事件模型从 4 种扩到完整领域事件集(工具调用、审批流、压缩、子代理),用 sealed 类型约束合法性;
  • 存储从文件换到 Kafka 或事件表,按会话分区保序,加哈希链校验满足合规审计的防篡改要求;
  • 引入投影快照 + 消费位点,控制长会话的重放成本;
  • 基于事件流构建二级产物:审计报表、会话回放 UI、评估数据集(真实对话即评测用例);
  • 定时任务/后台触发的结果也落进同一条事件流(与定时工具篇呼应),让"会话中发生的一切"都有统一的真相源。
事件溯源与回放
https://clxhxhhr.top/posts/4722/
作者
clxstart
发布于
2026-10-13
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。