深色模式
07 · 会话日志:可回放的事实源
这一章讲 dsh 最独特的设计之一:会话日志(session log)。它是追加式的事件流,是模型所见上下文的唯一来源,也是 fork、回放、遥测、持久化的共同基石。
7.1 一条铁律
官方架构文档里有一句被反复强调的话:
Model-visible means logged. Anything that reaches a model request must be reconstructable from the log, and a runtime invariant asserts it.
模型可见即已记录。 任何进入模型请求的内容都必须能从日志重建,并且有一条运行时断言在守卫这条规则。
这条铁律回答了 Agent 框架的一个根本难题:模型到底"记得"什么? 在多轮对话、工具调用、上下文注入、消息改写交织在一起时,如果"发给模型的上下文"是在内存里临时拼出来的,那么断线重连、会话 fork、事后审计全部无从谈起。
dsh 的答案:一切进入模型的内容,先落日志,再进请求。 请求是日志的纯函数。
7.2 日志的结构:追加式事件序列
日志里的每一条记录是一个 SessionEvent:
ts
// 简化签名
interface SessionEvent {
seq: number // 单调递增序号,从 0 开始
type: string // 事件类型:turn/start、user/message、tool/call……
data: unknown // 该类型的事件载荷(可无损 JSON 序列化)
at: number // 时间戳
}追加语义(append-only):事件只增不改。错了怎么办?用"补偿事件"表达修正(比如取消就是一个新事件),而不是改写历史。这让日志天然具备:
- 可回放:从头重放事件序列 = 精确重建任何时刻的会话状态;
- 可并发读:任何消费者读到的前缀永远稳定,不存在"读到一半被改"的问题;
- 可校验:第 5 章说的"循环请求深度冻结、内容必须是日志纯函数"的运行时断言(invariant),就是在守护这个结构。
核心事件类型(完整清单见源码拆解第 3 章):
| 事件 | 含义 |
|---|---|
session/created | 会话建立(含 cwd、父会话血缘等元数据) |
turn/start / turn/end | 轮次边界 |
step/start / step/end | 步边界 |
user/message | 进入模型视野的用户消息 |
assistant/chunk | 模型流式输出的原始增量(回放 UI 保真度的关键) |
assistant/message | 一次完整模型回复(含用量),sourceEventSeqs 精确关联它由哪些 chunk 组成 |
tool/call | 模型发起工具调用(arguments 是原始 JSON 字符串) |
tool/result | 工具结果(含 error 标记与展示渲染) |
request/header | 模型请求的头部信息(模型、配置、元数据) |
注意 assistant/chunk 和 assistant/message 的关系:chunk 保存流式过程的原始增量(UI 打字机效果的回放数据源),message 保存最终组装结果(模型历史的数据源),两者通过序号精确挂钩。
7.3 投影:从日志到模型历史
模型要的上下文不是日志本身,而是"对话消息列表"。deriveMessages() 做这个投影:
- 跳过非模型可见的事件(如
turn/start这类边界标记); - 按顺序拼接 user / assistant / tool-call / tool-result 消息;
- 空内容的 assistant 回复不进历史(但事件保留,因为用量要记账);
- 投影结果深度冻结,杜绝任何消费者顺手修改。
投影是纯函数:同一份日志,任何时刻投影出的历史都相同。这就是"fork 后两个分支各自发展、互不干扰"的前提。
7.4 Fork:分支一个会话
有了日志,"给会话开个分支"就变得优雅:
ts
const child = ctx.sessions.fork(source, boundary?, childSessionId?)boundary 指定从父日志的哪个已完成 turn 切一刀:子会话拿到一个平衡的前缀(连续、从 seq 0 开始、没有打开的 turn/step、没有悬空的工具调用),然后带着父会话的元数据与血缘(parentSession)独立发展。dsh 的 subagent(子代理)机制就是在这上面构建的:子代理的会话 = 父会话日志的一个 fork。
7.5 消费者生态:一次写入,多方读取
因为日志是唯一事实源,所有下游都是它的消费者,互不知道对方存在:
每个消费者都通过 session/event 事件(emit 模式广播)订阅新增事件——加一个审计系统 = 写一个插件监听这个事件,不用碰任何现有代码。持久化本身也只是消费者之一:dsh-base 里挂的是 JSONL 持久化,你想换数据库存储,换掉这个插件即可(接缝思维,第 3 章)。
7.6 为什么不是"数据库"
看到这里你可能会问:为什么不直接用 SQLite 存对话?dsh 其实也有 session-query-sqlite。关键在于分层:
- 日志层(事实源):追加式事件流,语义简单、极难出错;
- 存储层(持久化):日志的一个消费者,JSONL / SQLite / 任何东西;
- 查询层(session-query):为 UI 的复杂查询("列出最近会话")建索引。
把"什么是事实"和"事实存哪里、怎么查"分开,每一层都可替换。官方子系统文档把这套结构称为持久化目录(persistence catalog),其中每层都是独立插件。
一个容易踩的坑
如果你给模型新增一种"可见输入"(比如一个新的上下文注入机制),必须先定义一个新的 SessionEvent 类型,让日志能表达它,再接入投影——否则运行时断言会拒绝你:模型看到了日志里不存在的东西。这条坑的正面价值是:任何"偷偷给模型塞东西"的代码都会在测试里立刻暴露。
7.7 本章小结与练习
会话日志的心智模型:Agent 的全部记忆是一条追加式事件流;模型历史、UI 回放、持久化、fork、遥测都是这条流的投影或消费者;"模型可见即已记录"是一条由运行时断言守护的铁律。
小练习:设计一个 replay 调试器
假设你想实现"把某个会话完整重放一遍,观察每一步的模型输入输出"。基于本章的知识,画出你的实现方案:1)数据从哪读?2)怎么重建每一步的模型请求?3)如果原始会话里有一次工具调用被用户否决,重放时如何还原这个事实?
提示:答案的关键词是 assistant/chunk、request/header、tool/call 与 tool/result 的配对。