Session Memory
有些事实应当挺过总结,并跨会话存活。
追加式事实流 + 派生视图。
问题
到目前为止,messages 数组就是全部记忆。它有两个致命弱点:
- 易碎:进程崩溃,数组消失——Agent 忘记它做过的所有事;
- 不可派生:压缩后(s08)原始事实没了,只剩摘要——但"用户改了偏好""测试通过了""todo 完成了"这些事实本可以独立于对话文本而存在。
你需要的不是"记住更多对话",而是把事实与对话分离:事实追加进一个持久化的日志,对话文本只是从事实派生的一个视图。
解决方案
事件日志模式:
- 每个事实是一条事件(
{ seq, type, payload, ts }),只追加、不修改; - 所有视图(消息历史、todo 状态、计划模式、会话标题)都从日志派生;
- 恢复 = 重放日志(或从检查点 + 增量重放)。
const log = []; // 追加式事件日志,持久化到磁盘
let seq = 0;
function append(type, payload) {
log.push({ seq: seq++, type, payload, ts: Date.now() });
persist(log); // 追加到磁盘文件
}
工作原理
第 1 步:把循环产生的每一类事实建模为事件类型。用户消息、模型消息、工具结果、todo 更新——各有各的事件:
append("user/message", { content: query });
// ... 循环内:
append("assistant/message", { content: msg.content, toolCalls: msg.tool_calls });
append("tool/result", { callId: call.id, output });
append("todo/update", { id, status }); // s05 的 todo 现在有了持久化底座
第 2 步:消息历史变成派生函数——不再是你手维护的数组,而是从日志投影:
function deriveMessages() {
const msgs = [];
for (const ev of log) {
if (ev.type === "user/message") msgs.push({ role: "user", content: ev.payload.content });
if (ev.type === "assistant/message") msgs.push({ role: "assistant", content: ev.payload.content, tool_calls: ev.payload.toolCalls });
if (ev.type === "tool/result") msgs.push({ role: "tool", tool_call_id: ev.payload.callId, content: ev.payload.output });
}
return msgs;
}
第 3 步:任何状态都从日志折叠(fold)出来——todo 列表、会话标题、计划模式。状态是派生的,日志是唯一的:
function foldTodos() {
const todos = new Map();
for (const ev of log) if (ev.type === "todo/update") todos.set(ev.payload.id, ev.payload.status);
return [...todos.entries()];
}
恢复会话 = 加载日志文件 → 重放(或在压缩后加载检查点 + 增量重放)→ 所有视图自动还原。压缩不再破坏记忆:s08 的摘要只是派生视图之一,原始事实(只要没被裁剪)仍在日志里。
试一下
运行 s09,验证持久化:
- 跑一个会话(做点事、改个 todo),杀掉进程
- 重新运行,用
resume恢复——检查模型是否记得之前的 todo 和结论 - 观察
session.log文件的内容:是不是只追加、没有修改?
观察重点:事件类型设计有多重要?如果"模型回答"和"工具结果"是两条事件而不是一条,派生时谁先谁后?——事实的排序(seq)本身就是语义。
以下内容基于 packages/core/session 与 docs/subsystems/session.md 的核查。DSH 的会话子系统就是你这个模式的完整生产化:
一、追加式事件日志
- 每个
Session是一条追加式的SessionEvent日志(ctx.sessions)——会话的唯一事实源。 - 消息历史通过
deriveMessages()从日志派生;原始assistant/chunk事件保留回放与 UI 保真。 SessionEventMap是可合并扩展的类型映射:新的事实类型 = 新的事件类型(s05 的 todo、s12 的计划模式、s14 的调度、s15 的目标都以 session 事件持久化)。
二、不变量:Model-visible ⟺ logged
任何到达模型请求的内容必须能从会话日志重建——运行时不变量强制检查。这就是为什么"新模型可见输入"必须配"新 session 事件":日志是记忆的唯一合法入口。
三、会话生命周期
create(新建)、fork(从某 seq 分支出新会话,s20 子代理用它)、resume(加载持久化会话);- 持久化本身不在 session 包里——插件订阅
session/event,在session/flush时落盘(persistence)。存储与日志解耦,正是"日志是唯一事实源"的体现。
| 你的最小实现 | DSH 的真实实现 |
|---|---|
log 数组 + persist() 手写落盘 |
事件日志 + 订阅式持久化插件 + 类型化事件映射 |
deriveMessages 手写投影 |
surface 投影层(高效派生与压缩的基座) |
| 恢复 = 重放全部 | fork/检查点/增量重放 + 修复(repair) |
一句话:会话日志是 DSH 记忆的地基——s05 到 s20 里几乎所有"状态"(todo、计划、调度、目标)最后都长在它上面。这也是为什么这一课放在中间:它是后面所有课的地基。