先问一个实际问题
你的 Agent 跑了一半,进程崩了。重启之后,之前的对话历史还在吗?
或者:你想从某一轮的某个节点"回到过去",试一条不同的路------dsh 支持吗?
这两个问题都指向同一个设计:Session。
Session 是什么
很多人以为 Session 就是"把消息列表存起来"。dsh 的 Session 不是这样设计的。
dsh Session 是一份 仅追加(append-only)的类型化事件日志。
css
┌─────────────────────────────────────────────────────┐
│ Session Log (seq = 日志位置,从 0 开始单调递增) │
│ │
│ seq=0: turn/start { turn: 1 } │
│ seq=1: user/message { role: 'user', ... } │
│ seq=2: system/message { message: {...} } │
│ seq=3: request/header { header: {...} } │
│ seq=4: assistant/message { message: {...} } │
│ seq=5: tool/call { name: 'read_file', ... } │
│ seq=6: tool/result { message: {...} } │
│ seq=7: assistant/message { message: {...} } │
│ seq=8: turn/end { reason: { kind: 'completed' } }│
│ │
│ 只能追加,永远不能修改或删除已有条目 │
└─────────────────────────────────────────────────────┘
模型的"消息历史"不是单独存储的------它从这份日志派生出来。
为什么要多此一举?因为仅追加日志有三个直接收益:
- 崩溃安全:进程中断时,已写入的事件不会丢失,也不会产生部分写入的脏状态
- 可回放:同一份日志重放,得到完全相同的派生历史------无论在哪台机器上
- 可审计 :失败的尝试(
assistant/attempt)永久保留在日志里,只是不会进入模型历史
这个思路在工程领域有个专门的名字叫"事件溯源"(Event Sourcing)。如果你用过 Git,就已经在用类似的概念了:每次提交只是新增一条记录,历史只增不改。
事件词汇:Session 里到底有什么
Session 日志由类型化的 SessionEvent 组成,每条事件有固定的结构:
typescript
// packages/core/session/src/types.ts
type SessionEvent<T extends SessionEventType = SessionEventType> = {
// 类型(如 'turn/start'、'assistant/message')
type: T
// 在本 Session 里的单调递增序号(等于 log.length,连续无间隙)
seq: SessionSeq
// 追加时的 Unix 时间戳(毫秒)
time: number
// 事件的具体数据(必须是可 JSON 序列化的)
data: SessionEventMap[T]
// 可选:这条事件未知时读取方可以跳过(而不是拒绝整个 Session)
ignorable?: true
}
核心事件类型一览:
| 事件类型 | 含义 |
|---|---|
turn/start |
一个 Turn 开始 |
turn/end |
一个 Turn 结束,携带结束原因 |
step/start |
一个 Step 开始 |
step/end |
一个 Step 结束 |
user/message |
用户发送的消息(或注入的上下文) |
system/message |
渲染后的系统提示词 |
assistant/message |
模型成功输出(进入派生历史) |
assistant/attempt |
模型尝试但失败(不进入派生历史) |
tool/call |
模型请求调用一个工具 |
tool/result |
工具执行完毕的结果 |
request/header |
本次请求的配置快照(模型、token 上限等) |
Surface:派生历史的来源
Session 日志里所有事件里,只有 4 种事件类型 会产生模型消息,合称 SurfaceEventType:
system/messageuser/messageassistant/messagetool/result
这 4 种事件构成了"Surface"(surface,可以理解为"浮出水面"的那部分)。其余事件(turn/start、step/end、assistant/attempt 等)是日志层面的结构信息,不产生任何 LLM 消息。
每个 Surface 事件都会携带一个 surfaceOp 标记,说明它是怎么加入这个有序队列的:
typescript
type SurfaceOp =
// 追加到尾部------所有正常消息走这条路
| 'append'
// 替换 startSeq ~ endSeq 之间的节点(压缩对话历史时用)
| { op: 'replace'; startSeq: SessionSeq; endSeq: SessionSeq }
平时用的都是 'append';replace 主要用于对话历史压缩(compaction)------当对话太长时,把一段对话摘要成一条消息替换掉原来的,节省 token。
deriveMessages():历史是怎么派生的
Session.deriveMessages() 就是那个"从日志重建消息列表"的核心方法:
typescript
// packages/core/session/src/index.ts(简化)
class Session {
/**
* 从有序的 Surface 事件派生 LLM 消息历史。
*
* 带缓存:每个 surface 节点只投影一次;surface 被替换时重建。
* 每次调用返回新数组,但数组内的 Message 对象是共享的深冻结引用。
*/
deriveMessages(): Message[] {
// 遍历 surface.nodes(有序的 surface 事件 seq 列表)
// 对每个节点调用 deriveEventMessage(event)
// 返回不为 null 的结果
}
}
投影规则(deriveEventMessage)很简单:
typescript
// packages/core/session/src/surface.ts
export function deriveEventMessage(event: SessionEvent): Message | null {
switch (event.type) {
case 'user/message':
// 原样投影为 user 角色消息
return event.data
case 'system/message':
case 'assistant/message':
// 内容为空时返回 null(空内容不应该出现在 transcript 里)
if (event.data.message.content.length === 0) return null
return event.data.message
case 'tool/result':
// 投影为带 tool-result block 的 user 消息
return event.data.message
default:
// turn/step 边界、assistant/attempt 等------不产生消息
return null
}
}
两个细节值得注意:
内容为空的 assistant/message :这种情况出现在模型因 max-tokens 被截断但还没来得及输出任何内容的步骤。这条事件还是会写入日志(用来记录 token 用量和 stream 信息),但不会产生消息------否则模型下次会看到一条空白的 assistant 轮次,造成混乱。
assistant/attempt 完全不参与投影:无论发生了什么(网络错误、上下文超限、用户取消),失败的尝试只写日志,不进历史。模型下次请求时看到的是干净的状态,就好像那次失败的尝试从来没有发生过。
assistant/message vs assistant/attempt:更深一层
上一篇文章(Agent Loop)介绍了这两个事件的区别,这里从 Session 层面再看一遍:
assistant/message |
assistant/attempt |
|
|---|---|---|
| 何时写入 | 模型成功完成输出 | 网络错误 / 上下文超限 / 取消 / 流错误 |
| Surface 事件 | ✅ 有 surfaceOp,进入派生历史 |
❌ 无 surfaceOp,仅日志 |
| 模型下次请求看到 | ✅ 会看到 | ❌ 看不到 |
| 保留在日志里 | ✅ | ✅(用于审计和 usage 统计) |
为什么 assistant/attempt 也要写入日志?
因为失败也算消耗了 token。即使模型没有返回有效内容,提供方可能已经按输入 token 收费了。把 attempt 保存在日志里,可以在事后统计真实的 token 用量(包括失败的那些请求),而不是只看成功的。
Session Header:格式版本与元数据
每个 Session 除了事件日志,还有一个存储在日志之外的 SessionHeader:
typescript
// packages/core/session/src/types.ts(简化)
interface SessionHeader {
// 格式版本,目前是 3
readonly version: typeof SESSION_FORMAT_VERSION // = 3
// Session 的唯一 ID
readonly id: SessionId
// Session 创建时间(Unix 毫秒)
readonly createdAt: number
// Session 所在的工作目录
readonly cwd?: string
// 这个 Session 是从哪个 Session fork 出来的
readonly parentSession?: SessionId
// 是否包含继承的 fork 前缀
readonly isSeeded: boolean
// 如果是子 Agent,记录委托深度(防止无限递归)
readonly delegationDepth?: number
}
// 当前最新格式版本
export const SESSION_FORMAT_VERSION = 3
格式版本的用途是迁移 。dsh 的 Session 日志会存在磁盘上,可能被不同版本的 dsh 读取。SESSION_FORMAT_VERSION 保证了这套机制:
- 旧格式日志被读取时,迁移链把它升级到当前版本
- 如果读取到完全未知的版本,dsh 拒绝处理,不会静默读坏
这和 SQLite 的 PRAGMA user_version 或 Git 对象格式的考量是一回事。
Fork:从任意历史节点分叉
这是 dsh Session 最有趣的功能之一。
假设你有一个对话,已经进行了 10 轮。到第 7 轮时,模型选了一条你觉得不好的路。你想回到第 7 轮结束时的状态,从那里重新开始,试一个不同的方法。
这在 dsh 里叫 Session Fork:
typescript
// packages/core/session/src/index.ts(SessionStore 的方法)
/**
* 从一个活跃 Session 的稳定前缀创建子 Session。
*
* @param source - 活跃的源 Session 对象或 ID
* @param boundary - 可选,切分点 SessionSeq(含);
* 默认截取到当前最后一个事件。
* 要求截取点结束时没有开放的 Turn。
* @param childSessionId - 可选的子 Session ID
*/
fork(
source: SessionForkSource,
boundary?: SessionSeq,
childSessionId?: SessionId,
): Session
Fork 的行为:
- 从源 Session 复制
boundary之前(含)的所有事件,作为子 Session 的"继承前缀" - 子 Session 持有继承事件的计数(
inheritedEventCount) - 子 Session 的后续写入不会影响源 Session
session/end-seed事件标记了继承前缀的边界
css
源 Session:[turn1][turn2][turn3][turn4]...[turn10]
↑ boundary
Fork 后:
源 Session:继续...
子 Session:[turn1][turn2][turn3] ← 继承,不可变
[新turn4]... ← 子 Session 独有
一个实际用途:让 Agent 探索不同的解题方案,各走一条 fork,最后比较结果------而不用开多个独立对话,浪费重复的上下文。
崩溃恢复:interrupted 结束原因
如果进程在 Turn 执行中途崩溃,Session 日志里会有一个没有对应 turn/end 的 turn/start。
dsh 在下次加载这个 Session 时,会检测到这种"开放的 Turn",并自动合成一条:
typescript
// turn/end 的结束原因类型之一
{ kind: 'interrupted' }
这是唯一一个不由 loop 主动发出 的 turn/end 原因------它只在崩溃恢复路径里由持久化层合成。
合成这条 turn/end 后,Session 处于一致的、可继续的状态,崩溃前的所有已写事件完整保留,之后可以正常 resume。
实战:读取历史 Session,跨会话传递记忆
把以上概念落地成代码。下面的插件在每次 Turn 结束时,把上一个 Session 的摘要注入当前 Session:
typescript
// 跨 Session 记忆插件(伪代码,说明核心逻辑)
export const name = 'cross-session-memory'
export const inject = ['sessions']
export function apply(ctx: Context): void {
ctx.on('agent/turn-stopping', async (payload) => {
const { agent } = payload
// 获取当前 Session
const currentSession = agent.session
// 找到上一个 Session(实际中可以从持久化存储里查询)
const prevSessionId = await getPreviousSessionId()
if (!prevSessionId) return
// 加载上一个 Session 的事件
const prevSession = await loadSession(prevSessionId)
// 从事件日志派生消息历史
const prevMessages = prevSession.deriveMessages()
// 提取最后几条对话作为"记忆摘要"
const summary = buildSummary(prevMessages.slice(-10))
// 把摘要注入当前 Session 的下一步
agent.inject({
type: 'user',
content: [{ type: 'text', text: `[记忆] ${summary}` }],
})
})
}
关键点:deriveMessages() 是纯函数式的------给定同一份事件日志,结果总是相同的。你可以随时从任意一个 Session 重建历史,不需要额外的"历史存储层"。
Session 的完整生命周期
arduino
ctx.sessions.create()
↓
session/created(通知监听器)
↓
[agent loop 开始运行 Turn]
↓
事件追加到日志:turn/start → ... → turn/end
↓
session/flush(持久化层把缓冲事件写入磁盘)
↓
[Agent idle 或者任务完成]
↓
session/disposed(Session 从 store 里移除)
持久化是插件负责的(session/flush 监听器),核心的 Session 类本身只管内存中的事件日志和派生逻辑。这样设计的好处是:持久化后端可以随时替换(JSONL 文件、SQLite、远端 API),Session 的核心语义保持不变。
设计总结
dsh Session 的整个设计可以用一句话概括:
日志是唯一真源,历史是从日志派生的。
| 设计决策 | 原因 |
|---|---|
| 仅追加日志 | 崩溃安全 + 可回放 + 审计完整 |
assistant/attempt 不进历史 |
失败不污染模型看到的上下文 |
deriveMessages() 是缓存的纯函数 |
多处读取不产生额外开销;重放结果一致 |
| 格式版本号 | 支持日志迁移,而不是静默读坏旧格式 |
| Fork API | 从任意稳定节点分叉,不破坏源 Session |
| 持久化由插件负责 | 核心与存储后端解耦 |
系列下一篇
下一篇 Profile 与 Bundle:dsh 的配置装配系统 讲 dsh 是怎么从配置文件组装出一个完整的 Agent 运行时的------Bundle 是什么、Profile 是什么,以及你怎么用这套机制为不同任务定制不同的 Agent 组合。
在 PrimeSkills 可以找到已在真实企业场景验证过的 AI Agent 技能和工作流,不是演示级的,是用在实际项目里的。
更多内容见我的个人主页