前置:第 04 课 | 预计时长:60 分钟 | 动手环节:必须完成
你将学会
- session 的事件溯源结构:只追加日志、
seq = log.length、事件接纳即冻结; - 「模型可见 ⟺ 已记录」的三层强制:类型层 → 派生层 → 运行时层;
- 为什么裸事件不能出现在模型请求里。
问题引入
第 04 课你看到请求体来自 session.deriveMessages()。这一课回答随之而来的问题:这个日志凭什么能重建请求?如果有人往日志里塞了一个模型看得见但没走正常通道的事件呢?
dsh 的答案是三层防御,并且每一层都有具体的代码载体。
正文
5.1 只追加的事件日志
对应 packages/core/session/src/index.ts。内存结构是私有数组,唯一写入点是 append():
class Session {
private log: SessionEvent[] = [] // index.ts:424 唯一存储
append(type, data, opts?) {
event = { type, seq: this.log.length, time: now(), data: deepFreeze(snapshot(data)) }
// 类型层:产生模型消息的事件必须带 SurfaceIntent(index.ts:602-606)
this.log.push(event) // index.ts:641 唯一写入点
emit('session/event', event) // 持久化等订阅者在此接收(同步入队,不阻塞)
}
deriveMessages() { // index.ts:724-745
// 只遍历 surfaceOp 标记维护的有序节点;裸事件不出现在请求里
}
}
三个契约:
seq = log.length:连续性契约,持久层靠它校验完整性(第 06 课)。- 事件接纳即
deepFreeze+ 无损 JSON 快照:对外只给冻结快照,没人能改写历史。 - 写入即广播:
session/event是 emit 模式——持久化、投影、遥测都是它的订阅者(第 06 课)。
5.2 三层强制:模型可见 ⟺ 已记录
目标:到达模型的任何内容必须能从 session log 重建;反之,新的模型可见输入必须新增一种会话事件。三层防线:
| 层 | 载体 | 拦什么 |
|---|---|---|
| 类型层 | SurfaceIntent 编译期约束(session/src/index.ts:602-606) | 「会产生模型消息的 append」必须声明 surface 意图,写错编译不过 |
| 派生层 | deriveMessages() 只走 surface 节点(:724-745) | 裸事件天然不在请求里——派生器根本不看它们 |
| 运行时层 | llm/stream 前逐字节比对(第 04 课的 agent-loop/src/invariant.ts:21-52) | 任何绕过前两层的路径,在发请求当场失败 |
这条不变量连上下文压缩都遵守:摘要替换通过
surfaceOp: replace的事件完成,派生历史随之重建(第 06 课与 compaction 相关部分见packages/compaction/)。
5.3 SessionEvent:按 type 判别的事件
SessionEvent 按 type 判别(packages/core/session/src/types.ts:396-404)。你会遇到的事件类型家族(非穷举):
- 对话面:
user/message、assistant/chunk、assistant/message; - 请求面:
request/header(提示词渲染结果)、request/context; - 工具面:
tool/call、tool/result(带sourceEventSeqs回引); - 结构面:
turn/start|end、step/start|end; - 值面(log-only,第 06 课):
session/title、plan/mode、goal/change、todo/write、approval/asked|decided、sandbox/mode。
surface 事件附带 surfaceOp / sourceEventSeqs 字段,驱动派生层的节点维护。
5.4 为什么是「事件溯源」而不是「消息数组」
对比传统 agent 框架的 messages[]:
- 多消费者解耦:持久化、标题、遥测、投影、查询全都从同一日志折叠,核心不耦合任何一家(第 06 课);
- 可回放可分叉:恢复 = 重放;压缩 = 用 replace 事件改写区间;fork = 以日志前缀为种子(subagent 的 fork 后端);
- 不变量可校验:因为历史是结构化事件流,
session/src/invariant.ts才能校验「turn/step 开闭配对、tool/result 有 call、seq 递增」这类结构规则。
代价是纪律:每个新模型可见输入都要设计事件类型与 surface 语义——这正是三层强制的意义:让「偷懒直发」在类型期或运行期爆炸。
动手环节
- 校验一个快照:取第 04 课用的录制会话,写个 10 行脚本(或肉眼)检查:
seq是否从 0 严格递增;每个tool/result的sourceEventSeqs是否指向同 step 的tool/call;turn/step是否开闭配对。 - 找到 SurfaceIntent:
grep -rn "SurfaceIntent" packages/core/session/src/ | head,找出类型层把什么约束编码在哪里。 - 推演题:假设某插件想「只在 UI 上显示一句提示,不让模型看见」。它应该
append什么?(提示:log-only 事件没有 surface 意图,不会进派生。)反过来,如果它想让模型看见,必须做什么?
自检清单
-
seq = log.length契约被谁、在什么时机校验? - 三层强制分别在什么阶段失败?各举一个会被拦下的错误代码形态。
-
deriveMessages()为什么只走 surface 节点?裸事件去哪了? - 为什么事件要
deepFreeze?如果不冻结,哪条不变量最先被破坏?
常见误解
- 「session 是数据库表」——内存里只是只追加数组;持久化是外部订阅插件(第 06 课),核心不知道 JSONL/SQLite 的存在。
- 「日志可以就地修改」——事件接纳即冻结;改历史只能用新事件(如 surfaceOp: replace)表达。
- 「
session/event的监听器可以阻塞生产者」——append()只同步入队,写盘由协调器异步批处理。
延伸阅读
- 维基:《core-spine》(session 一节)
- 仓库:
docs/architecture.md、packages/core/session/README.md - 下一课:第 06 课 · 持久层、投影与日志化状态