第5章 会话与事件溯源:SessionLog 的秘密
为什么说"事件日志就是真相之源"——取消、回放、压缩都建立在它之上
5.1 本章导读
上一章里 ReactLoopAgent 反复调用 session.append(...)——TurnStart、StepStart、UserMessage、AssistantChunk、ToolCall、TurnEnd……本章揭开这个 session 的真面目:它不是简单的"聊天记录",而是一个事件溯源(Event Sourcing)系统。理解了它,你就理解了本项目为什么能做到取消不丢数据、历史完整回放、上下文自动压缩。
5.2 什么是事件溯源
传统系统存"当前状态",事件溯源存"发生的所有事"。类比银行账户:
账户表只有一行 balance = 100。你怎么变成 100 的?不知道。中间被人改过一次?也不知道。状态即一切,历史不可考。
存的是 +200, -50, -50 的完整流水。余额 100 是流水"回放"出来的。历史完整、可审计、可回放、可从任意时间点重建状态。
对 Agent 会话来说,"流水"就是会话事件日志(Session Event Log):用户说了什么、模型回了什么、调用了哪个工具、工具返回了什么、什么时候取消的——全部按顺序追加,只增不改。
5.3 SessionLog:内存追加式 WAL
SessionLog(📦 domain/session/event/model/entity/SessionLog.java)本质是一个内存中的追加式写前日志(WAL)。Agent 运行过程中产生的一切事件都先追加到这里,再由 ISessionEventStore 端口异步落库(MySQL 的 harness_session_event_log 表)。
事件类型是 sealed 接口 SessionEvent,分四大类:
| 类别 | 事件 | 作用 |
|---|---|---|
| 轮/步边界 | TurnStart、TurnEnd(reason)、StepStart、StepEnd | 标记 ReAct 循环的结构边界 |
| 消息 | UserMessage、AssistantChunk、AssistantMessage | 对话内容(Chunk 是流式增量,Message 是完整组装结果) |
| 工具 | ToolCall(callId, name, arguments)、ToolResult(callId, ...) | 工具调用与结果,用 callId 配对 |
| 元信息 | RequestHeader、RequestContext、TodoWrite、SessionEndSeed、PlanModeChange、AgentInboxSpliced | 请求头快照、待办写入、会话结束种子等 |
每个事件有全局递增的 seq(序号),这是回放和投影的顺序依据。
5.4 SurfaceProjector:从事件到界面
事件日志是"原始流水",但 UI 要的是"当前应该显示什么"。中间的翻译层是 SurfaceProjector(表面投影器):
核心代码极简,却撑起整个 UI 渲染:
// SurfaceProjector.project()(简化)
for (SessionEvent event : events) {
SurfaceIntent intent = intentOf(event);
if (intent.surfaceOp() instanceof SurfaceOp.Append) {
nodes.add(new SurfaceNode(event.seq(), event));
} else if (intent.surfaceOp() instanceof SurfaceOp.Replace r) {
// 按 r.targetSeq() 找到旧节点替换
}
}为什么需要 Replace?典型场景:AssistantChunk 流式追加时,UI 应该更新同一个气泡而不是每来一个 chunk 就加一个气泡。chunk 事件投影为 Replace(替换上一个未完成的气泡),完整 AssistantMessage 到来时再 Replace 成终态——界面上看到的就是"文字逐字浮现"的效果。
同一个事件流,投影多少次结果都应该一样。这条性质让"重新加载会话"和"实时接收事件"走同一套渲染逻辑——历史回放和实时对话在 UI 层完全一致。
5.5 BasicCompactionEngine:上下文压缩
会话长了,把所有事件塞进 LLM 上下文会爆 token。本项目的压缩策略在 BasicCompactionEngine(📦 domain/agent/compaction/):
| 设计点 | 选择 | 权衡 |
|---|---|---|
| 保留比例 | 最近 1/4 事件原文 | 最近的上下文对当前任务最重要,保留原文避免关键细节(如刚读到的文件内容)被总结丢失 |
| 压缩方式 | LLM 总结 | 比硬截断智能,但消耗一次额外 LLM 调用 |
| token 估算 | length / 4 粗估 | 不精确但零成本;context-window 默认 128000,留足余量后粗估够用 |
| 事件处理 | 摘要作为新事件追加 | 不删除原始事件——日志仍是完整流水,只是"喂给模型的上下文"变了 |
5.6 事件溯源带来的三个超能力
回到本章开头的问题——为什么说事件日志是真相之源?因为它同时支撑了三件看似无关的事:
控制台"历史会话"功能不需要单独存储——把 harness_session_event_log 的事件读出来重新投影一遍,就能精确重现当时的每一句话、每一次工具调用。
第4章的协作式取消之所以不丢数据,正因为所有事件先追加到内存 WAL 再落库。即使 turn 中途 abort,已产生的 TurnStart/Chunk/ToolCall 都已安全记录,TurnEnd(Aborted) 补齐结尾。
Agent 什么时候调了 Shell、参数是什么、返回了什么——全部在事件流里。这是审批治理(第11章)和安全审计的技术基础。
5.7 课程补充:SurfaceOp.Replace 的两个场景与两条派生路径的口径差异
本章第 5.4 节讲了 SurfaceOp 的 Append/Replace 两种操作,但只展开了一个场景(流式气泡的逐字浮现)。实际上 Replace 还有另一个关键用途,且系统里有两条独立的派生路径在不同的层用不同语义读事件日志——这一节补全这两个细节。
5.8.1 Replace 的第二个关键场景:压缩摘要遮蔽历史
第 5.5 节说过压缩引擎的策略是"保留最近 1/4 原文、其余 LLM 总结"。那这条总结怎么进事件流?它不追加在末尾——那样会和原文共存,模型会看到两份。正确做法是用 Replace 把前 3/4 的旧节点"遮蔽"掉:
// BasicCompactionEngine.compactNow()(简化)
// 找到待压缩区间的第一个和最后一个 surface seq
long startSeq = -1, endSeq = -1;
int msgIdx = 0;
for (SessionEvent event : events) {
if (!event.isSurfaceEvent()) continue;
if (msgIdx >= compactEnd) break; // 越过保留区就停
if (event.surfaceIntent().surfaceOp() instanceof SurfaceOp.Append) {
if (startSeq < 0) startSeq = event.seq();
endSeq = event.seq();
msgIdx++;
}
}
// 追加摘要消息,意图是 Replace [startSeq, endSeq]
session.append(SessionEventFactory.userMessage(
0, 0, summaryMsg,
new SurfaceIntent(new SurfaceOp.Replace(startSeq, endSeq), null)
));日志里原始事件全在(不可篡改是事件溯源纪律),但任何下游投影这批事件时,startSeq..endSeq 区间内的节点都会被这条新摘要替换掉。这就是为什么叫"遮蔽"而不是"删除"——历史保留、可审计,但当前视图变化。
Append:新事件永远追加到对话末尾,模型/UI 看到完整历史。
Replace:新事件遮蔽一段 seq 区间,本质是"重新叙述"那段历史——压缩是"用一句话叙述 N 句话",流式渲染是"用最新 chunk 叙述上一个 chunk"。两种操作下事件日志都只增不改。
5.8.2 两条派生路径:deriveMessages vs extractChatMessages
同一个 SessionLog 至少有两个消费者去"看"事件:
| 路径 | 入口 | 消费者 | 包含 ToolResult? | cache |
|---|---|---|---|---|
deriveMessages() | SessionLog.deriveMessages | ReactLoopAgent.buildRequest 喂给 LLM | ✓ | 声明了 derivedCache 字段,注释写"until profiling shows hot path"——Java 版每次全量重算 |
extractChatMessages() | SurfaceProjector.extractChatMessages | UI 聊天面板(不需要看到工具结果细节) | ✗(只保留 user/assistant) | 无(SurfaceProjector 每次 project 也全量) |
AgentCollectNode 投影 | case 层自定义遍历 | SSE done 事件给前端 | ✓(按 callId 合并 call + result) | 无 |
这里有一个真实隐患(已记在 MEMORY.md):前两条路径的口径不一致——LLM 看到的消息历史包含 ToolResult(工具结果是模型推理的依据),UI 聊天面板却不显示它们。带来的副作用是:用户看历史时不能直观地"看到模型读了什么文件、跑过什么命令",只能从 SSE done 事件的 messages 数组里翻 tool 消息。
代码层面的修复思路(目前未做):要么让 SurfaceProjector.extractChatMessages 可参数化(includeTools=true/false),要么把 UI 的对话视图改成"以折叠卡片展示工具结果"。从设计上看,extractChatMessages 是为"对话"服务的(聊天场景不应被工具噪音淹没),deriveMessages 是为"模型"服务的(必须完整才能继续推理)——两者用途不同、口径不同是正确的,关键是要让调用方知道自己在调哪个。
5.8.3 deriveMessages 的简易投影:Java 版为什么是 O(n²)
TS 版用增量缓存(按 surface node 索引),每次只在尾部追加新事件时更新缓存。Java 版为了"实现简单"选择了全量重算:
// SessionLog.deriveMessages()(简化) public synchronized ListderiveMessages() { List messages = new ArrayList<>(); Set shadowed = new HashSet<>(); List surfaceSeqs = new ArrayList<>(); for (SessionEvent event : events) { // O(n) 遍历 if (!event.isSurfaceEvent()) continue; SurfaceOp op = event.surfaceIntent()?.surfaceOp(); if (op == null) continue; if (op instanceof SurfaceOp.Append) { messages.add(projectMessage(event)); // O(1) } else if (op instanceof SurfaceOp.Replace r) { // Replace 的代价:遍历 surfaceSeqs 找出 [r.start, r.end] 内的 seq Iterator it = surfaceSeqs.iterator(); Iterator mit = messages.iterator(); while (it.hasNext()) { // O(k),k = 当前 surface 数 long seq = it.next(); if (seq >= r.start() && seq <= r.end()) { it.remove(); mit.next(); mit.remove(); } else if (seq > r.end()) break; else mit.next(); } messages.add(projectMessage(event)); } } return Collections.unmodifiableList(messages); }
每个 Replace 都要 O(k) 扫一次当前 surface,Append 也要遍历事件日志判断自己是不是 surface 事件——单次 deriveMessages() 是 O(n) × O(k) ≈ O(n²)。短会话(几十条)毫无感觉;长会话(几百条 + 多次压缩)会成为热点。derivedCache 字段已经在类里声明了但未启用(Java 注释:"The TS version incrementally caches by surface node, but the Java version favors simplicity until profiling shows a hot path")——这是一个留作未来优化的空间。
会话事件日志的"只增不改"性质让增量缓存非常诱人,但实现要保证:缓存 key 跟事件 seq 同步;多线程下用 synchronized 串行;Replace 区间要正确失效对应节点。不建议在没有 hot path 数据之前就引入——复杂度代价可能比省下的微秒更贵。
5.8 小结与下一章预告
本章要点:SessionLog 是内存追加式 WAL,事件只增不改;SurfaceProjector 用 Append/Replace 两种操作把事件流投影成 UI 状态(幂等性让历史回放与实时渲染共用一套逻辑);BasicCompactionEngine 保留最近 1/4 原文、其余 LLM 总结;事件溯源同时支撑回放、安全取消、审计三大能力。
下一章:会话事件从哪来?要回到消息的入口——一条用户消息到达后,系统怎么判断"该闲聊还是该调工具"?多个步骤如何编排成一条链?第6章讲意图识别与策略树。