系列: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 |
| 完整性 | 无校验 | 每条事件带校验和 / 哈希链,防篡改、可验证连续性 |
| 投影时机 | 每轮全量重放 | 全量重放 + 定期快照(投影缓存),避免长会话重放变慢 |
| 上下文拼接 | 直接全量投影 | 叠加压缩 / 裁剪策略后再进上下文 |
面试考点
- "Agent 的对话记忆怎么持久化?"——答事件溯源:append-only 事件流为真相源,记忆是回放投影出的视图;对比"直接序列化 List
",前者白送审计、时间旅行、fork 三种能力。 - "事件溯源会不会越攒越慢?"——答快照机制:定期把投影结果落为快照,恢复时"最近快照 + 增量重放";上下文注入侧再叠加压缩/裁剪,两层关注点分离。
- "哪些事件该进模型上下文?"——答这是投影策略而非日志格式问题:USER/AGENT 消息进,元事件(SESSION/TURN/工具/审批)默认不进但必须留日志;策略可演进,真相源不变。
生产化路径
- 事件模型从 4 种扩到完整领域事件集(工具调用、审批流、压缩、子代理),用 sealed 类型约束合法性;
- 存储从文件换到 Kafka 或事件表,按会话分区保序,加哈希链校验满足合规审计的防篡改要求;
- 引入投影快照 + 消费位点,控制长会话的重放成本;
- 基于事件流构建二级产物:审计报表、会话回放 UI、评估数据集(真实对话即评测用例);
- 定时任务/后台触发的结果也落进同一条事件流(与定时工具篇呼应),让"会话中发生的一切"都有统一的真相源。