DeepSeek Harness 系列(05):Session 与记忆——对话历史是怎么活下来的

先问一个实际问题

你的 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' } }│
│                                                     │
│  只能追加,永远不能修改或删除已有条目               │
└─────────────────────────────────────────────────────┘

模型的"消息历史"不是单独存储的------它从这份日志派生出来。

为什么要多此一举?因为仅追加日志有三个直接收益:

  1. 崩溃安全:进程中断时,已写入的事件不会丢失,也不会产生部分写入的脏状态
  2. 可回放:同一份日志重放,得到完全相同的派生历史------无论在哪台机器上
  3. 可审计 :失败的尝试(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/message
  • user/message
  • assistant/message
  • tool/result

这 4 种事件构成了"Surface"(surface,可以理解为"浮出水面"的那部分)。其余事件(turn/startstep/endassistant/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 的行为:

  1. 从源 Session 复制 boundary 之前(含)的所有事件,作为子 Session 的"继承前缀"
  2. 子 Session 持有继承事件的计数(inheritedEventCount
  3. 子 Session 的后续写入不会影响源 Session
  4. 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/endturn/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 技能和工作流,不是演示级的,是用在实际项目里的。

更多内容见我的个人主页

相关推荐
DogDaoDao1 小时前
SimToolReal 解读:用“物体中心“视角重新定义灵巧工具操作
人工智能·机器人·github·关键点·人形机器人·gpt-4o·simtoolreal
冬奇Lab1 小时前
一天一个开源项目(第216篇):OpenViking - 给 AI Agent 装上可自进化的上下文数据库
人工智能·开源·资讯
一切皆是因缘际会1 小时前
从存量释放
人工智能
Xuantong_901 小时前
从 0 到 1 搭建本地 AI 工作环境:我的完整方案与经验总结
人工智能
晓窗科技2 小时前
口碑好的AI基座服务商
大数据·人工智能·python
一航jason2 小时前
Android平台推理框架及试用场景模型对比
android·人工智能·ai·架构·ai编程·llama
IT·陈寒2 小时前
Redis内存暴涨时,我忘记检查这个参数
人工智能·大模型·api·创业·变现·简历优化
东鸿电子Eastron2 小时前
AI 算力爆发,数据中心能源管理系统(EMS)正在经历什么?
人工智能·数据中心·智能电表·ai 算力·多回路计量·智能计量
DeepAgent2 小时前
AI Agent 开发实战(14):AI Agent 开发工具推荐
人工智能·agent