Event-Sourced Session:AI Agent 的“会话即事件流“设计

DeepSeek Harness 系列第四篇。本文分析 Harness 为什么把 Agent 会话设计为 append-only 事件流,而非大多数框架采用的 mutable message list。

问题:mutable state 的脆弱性

主流 Agent 框架如何管理会话?

python 复制代码
# LangChain 风格
messages = []
messages.append(HumanMessage("hello"))
response = llm.invoke(messages)
messages.append(response)
# messages 是可变数组,任何代码都能插入/修改/删除

这种方式的问题:

  1. 状态和日志可以分离 :内存中的 messages 和落盘的日志是两个独立数据,需要手动同步
  2. replay 不可靠:如果中间经过 compaction(压缩)、fork(分叉),重放得到的状态可能和当时不一致
  3. 并发不安全 :多个插件同时操作 messages 需要显式锁
  4. 遥测是事后补丁:trace/logging 是额外加上去的,而非结构性保证

Harness 的解法来自一个经典后端模式:Event Sourcing

核心设计:Session = Append-only Event Log

typescript 复制代码
// 不是这样
interface Session {
  messages: Message[]  // mutable
}

// 而是这样
interface Session {
  events: readonly SessionEvent[]  // append-only, deep-frozen
  append(type, data): SessionEvent  // 唯一写入路径
  deriveMessages(): Message[]       // 从事件流投射
}

一个 Session 是一个只能追加的类型化事件流------单一事实源 。模型看到的 message history 是从这个流 派生(derive)出来的,而非独立存储。

这是架构决策笔记中的原话:

A mutable message array with events fired as notifications --- simpler, but state and log can diverge; with event-sourcing the log IS the state, so divergence is structurally impossible.

事件词汇表

SessionEventMap 是 merge-extensible 的 TypeScript 接口:

typescript 复制代码
interface SessionEventMap {
  'turn/start': { turn: number }
  'turn/end': { turn: number; reason: TurnEndReason }
  'step/start': { turn: number; step: number }
  'step/end': { turn: number; step: number }
  'user/message': UserMessage
  'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
  'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage }
  'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
  'tool/result': { turn: number; step: number; message: ToolResultMessage; error?; meta? }
  'request/header': { header: EpochHeader; reason: RequestHeaderReason }
  'request/context': RequestContext
  'todo/write': { todos: TodoItem[] }
  'session/end-seed': Record<string, never>
  // ...plugins 可以通过 declaration merging 追加新事件类型
}

每个事件携带:

  • type:discriminated union 的 tag
  • seq:单调递增序列号(= log.length)
  • time:epoch 毫秒
  • data:事件载荷
  • ignorable?:标记这个事件是否可以被不认识它的 reader 跳过

关键约束:事件一旦 append,就是 deep-frozen、不可变的。 Session.append() 做一次递归 JSON 校验 + 深拷贝 + freeze,之后没有任何代码路径可以修改已记录的历史。

三种事件类型

类别 事件 作用
Surface(表面) user/message, assistant/message, tool/result 产生模型可见 message
Structural(结构) turn/*, step/*, assistant/chunk 标记边界、保留 replay 精度
Log-only(仅日志) request/header, request/context, todo/write 记录运行时状态,不投射为 message

只有 Surface 事件参与 deriveMessages() 投射。其他事件虽然在日志中,但模型永远看不到。

派生机制:deriveMessages()

typescript 复制代码
// 投射规则(简化)
function deriveEventMessage(event: SessionEvent): Message | null {
  switch (event.type) {
    case 'user/message':
      return { role: 'user', content: event.data.content }
    case 'assistant/message':
      // 空内容的 assistant message 被跳过(max-tokens 截断时的占位)
      if (isEmpty(event.data.message.content)) return null
      return { role: 'assistant', content: event.data.message.content }
    case 'tool/result':
      return { role: 'user', content: [toolResultBlock(event.data)] }
    default:
      return null  // 非 surface 事件不产生 message
  }
}

deriveMessages() 的特性:

  • 缓存:每个 surface node 只投射一次,后续调用 O(new nodes)
  • Frozen :返回的 Message[] 引用是新的,但内部 Message 对象是共享的 frozen 值
  • 一致性:因为从同一份不可变日志投射,不可能出现两个消费者看到不一样的历史

Surface 与 Compaction

当上下文太长需要压缩时,压缩不是修改原始事件------而是追加一个新的 surface 事件,带有 replace 操作:

typescript 复制代码
type SurfaceOp =
  | 'append'                                    // 正常追加
  | { op: 'replace'; start: number; end: number }  // 替换一段表面

// 压缩结果是一个新的 assistant/message,surfaceOp = { op: 'replace', start: 5, end: 42 }
// 它取代了 seq 5~42 的 surface 节点,但原始事件仍然在日志中

这意味着:

  • 压缩是可审计的------你可以看到是哪次压缩替换了哪些节点
  • 原始数据永不丢失------UI 回放、遥测分析仍然可以读到全部历史
  • 模型只看到压缩后的 surface------deriveMessages() 自动跳过被 replace 的节点

"模型可见 ⟺ 已记录" 不变式

这是 Harness 最硬的一条运行时约束:

任何到达模型请求的内容,必须能从会话日志重建。

实际代码中有 runtime invariant 断言这一点。如果你写了一个插件想给模型注入内容,你必须先 append 一个 session event------不能绕过日志直接塞进 message list。

typescript 复制代码
// 正确:通过 agent.inject() 注入,它会 append 一个 user/message 事件
agent.inject({ content: 'workspace has changed', source: { kind: 'context' } })

// 错误:直接修改 message 数组(在 Harness 中不可能,因为 deriveMessages 是纯投射)
session.messages.push(...)  // 不存在这个 API

这条不变式带来的保证:

场景 传统框架 Harness
Resume(恢复会话) 从文件加载 messages,希望和当时一样 从事件流重新 derive,结构性一致
Fork(分叉会话) 深拷贝 messages seed = 原日志前缀,新 session 从中 derive
Replay(回放) 额外的 trace 系统 日志本身就是完整 replay 源
Telemetry 另一套数据采集 session/event 广播就是遥测源
调试 猜测"当时模型看到了什么" 100% 确定,因为日志就是它看到的

持久化是插件关注点

Session 本身是纯内存的------它不关心怎么落盘。持久化是独立的 Capability Seam:

scss 复制代码
dsh-session (Service Definition) --- 内存 event log
dsh-session-persistence (Service Definition) --- 持久化接口
dsh-session-jsonl (Provider) --- JSONL + Zstandard 压缩
dsh-session-sqlite (Provider) --- SQLite 后端

持久化插件通过 session/event 同步通知异步缓冲写入,在 turn 结束时的 session/flush checkpoint 保证已落盘。

关键设计:append 是同步的(热路径不阻塞 I/O),持久化插件在后台 write-behind。

与 LangChain Memory 的对比

维度 LangChain Memory Harness Session
数据模型 Mutable message list Append-only event log
压缩 修改 messages in-place 追加 replace surface event
持久化 需要手动 save/load 插件自动 write-behind
Fork 深拷贝 seed 前缀
Replay 需要额外系统 结构性保证
类型安全 运行时 编译时(discriminated union)
可扩展 子类覆写 declaration merging 追加事件类型
并发 需要锁 append 是唯一写入路径,不需要锁

request/header:让每个请求可重建

除了 message history,模型请求还包含 system prompt、tool schemas、call config。这些也被事件化:

typescript 复制代码
'request/header': {
  header: {
    config: LlmCallConfig        // model, temperature, max_tokens...
    system?: string              // rendered system prompt
    tools?: ToolSchema[]         // assembled tool schemas
  }
  reason: 'initial' | 'resume' | 'change'
}

每次请求前,如果 header 发生变化就追加一个 request/header 事件。这意味着:从任意一段日志前缀,可以完整重建那次请求的完整 payload------包括当时的 system prompt 是什么、tool 列表是什么、用的什么模型。

实际效果

Session Fork

typescript 复制代码
// Fork = 用原 session 的事件日志前缀作为种子创建新 session
const child = ctx.sessions.fork(parentSession, boundarySeq)
// child.events[0..boundarySeq] 来自 parent
// child 之后的 append 不影响 parent

Compaction(上下文压缩)

typescript 复制代码
// Compaction 不删除事件------它追加一个 summary 事件取代一段 surface
session.append('assistant/message', summaryData, {
  surfaceOp: { op: 'replace', start: oldStart, end: oldEnd },
  sourceEventSeqs: [oldStart, ..., oldEnd],  // 记录哪些事件被替换
})
// deriveMessages() 之后只看到 summary,但原始事件仍在日志中

Telemetry 采集

typescript 复制代码
// 任何插件都可以监听 session/event 做实时遥测
ctx.on('session/event', (session, event) => {
  if (event.type === 'assistant/message' && event.data.usage) {
    metrics.recordTokenUsage(event.data.usage)
  }
})

工程代价

Event Sourcing 不是免费的:

  1. 派生开销随日志增长deriveMessages() 缓存缓解,但长 session 仍有 O(n) 的首次投射
  2. 所有新的模型可见输入都需要事件化 :想给模型注入一个新类型的内容?先设计 SessionEventMap 的新成员
  3. 事件格式变更是破坏性的SESSION_FORMAT_VERSION 目前为 0,格式变更拒绝旧日志
  4. 调试时要理解"事件 → 投射"的间接性:不是直接看 messages,要先看 events 再理解投射规则

但对于一个 Agent 运行时来说,replay 可靠性和审计能力的价值远超这些代价。

参考链接


DeepSeek Harness 系列文章:

  • 第四篇:Event-Sourced Session(本文)
相关推荐
用户337922545681 小时前
DeepSeek Harness 架构解析:MCP 和 Skill 如何被统一为 Cordis 插件
人工智能
cxr8281 小时前
上下文工程框架之11 模块与优先级链和冲突消解、淘汰与版本
人工智能·架构
Cosolar1 小时前
DeepSeek Harness 理解 Harness 的设计哲学 - 可组合的插件运行时
人工智能·设计模式·架构
DFT计算杂谈1 小时前
Janus单层Cr2SSe中的应变可调多压电效应与谷电子学
人工智能·算法·机器学习
今天AI了吗1 小时前
从 LLM 到 Agent Skill:把 AI 底层概念串起来
数据库·人工智能·sql·深度学习·神经网络·算法·机器学习
DS随心转小程序1 小时前
巧用 AI 导出鸭攻克各类难题完善 ChatGPT 输出 word 文档转化工作
人工智能·chatgpt·aigc·word·豆包·deepseek·ai导出鸭
梦想的旅途22 小时前
企微 API 二次开发:结合 AI 打造考勤打卡与报表智能分析系统
人工智能·企业微信
Kari112 小时前
腾讯云 ADP 实施问题解析:回答异常时企业如何组织排查与支持协同?
人工智能
刘新洲2 小时前
别再只做会聊天的 Agent:我用 1 天把工具调用做成了可验证、可评测的工程系统
人工智能·python·openai