🧠 第三篇:核心机制

第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,分四大类:

类别事件作用
轮/步边界TurnStartTurnEnd(reason)StepStartStepEnd标记 ReAct 循环的结构边界
消息UserMessageAssistantChunkAssistantMessage对话内容(Chunk 是流式增量,Message 是完整组装结果)
工具ToolCall(callId, name, arguments)ToolResult(callId, ...)工具调用与结果,用 callId 配对
元信息RequestHeaderRequestContextTodoWriteSessionEndSeedPlanModeChangeAgentInboxSpliced请求头快照、待办写入、会话结束种子等

每个事件有全局递增的 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 vs Replace 的本质区别

Append:新事件永远追加到对话末尾,模型/UI 看到完整历史。
Replace:新事件遮蔽一段 seq 区间,本质是"重新叙述"那段历史——压缩是"用一句话叙述 N 句话",流式渲染是"用最新 chunk 叙述上一个 chunk"。两种操作下事件日志都只增不改。

5.8.2 两条派生路径:deriveMessages vs extractChatMessages

同一个 SessionLog 至少有两个消费者去"看"事件:

路径入口消费者包含 ToolResult?cache
deriveMessages()SessionLog.deriveMessagesReactLoopAgent.buildRequest 喂给 LLM声明了 derivedCache 字段,注释写"until profiling shows hot path"——Java 版每次全量重算
extractChatMessages()SurfaceProjector.extractChatMessagesUI 聊天面板(不需要看到工具结果细节)✗(只保留 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 List deriveMessages() {
    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章讲意图识别与策略树。