Skip to content

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):事件只增不改。错了怎么办?用"补偿事件"表达修正(比如取消就是一个新事件),而不是改写历史。这让日志天然具备:

  1. 可回放:从头重放事件序列 = 精确重建任何时刻的会话状态;
  2. 可并发读:任何消费者读到的前缀永远稳定,不存在"读到一半被改"的问题;
  3. 可校验:第 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/chunkassistant/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/chunkrequest/headertool/calltool/result 的配对。

基于 DeepSeek Harness(开发者预览版 0.1.0-rc.6)与 Cordis 撰写