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 是可变数组,任何代码都能插入/修改/删除
这种方式的问题:
- 状态和日志可以分离 :内存中的
messages和落盘的日志是两个独立数据,需要手动同步 - replay 不可靠:如果中间经过 compaction(压缩)、fork(分叉),重放得到的状态可能和当时不一致
- 并发不安全 :多个插件同时操作
messages需要显式锁 - 遥测是事后补丁: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 的 tagseq:单调递增序列号(= 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 不是免费的:
- 派生开销随日志增长 :
deriveMessages()缓存缓解,但长 session 仍有 O(n) 的首次投射 - 所有新的模型可见输入都需要事件化 :想给模型注入一个新类型的内容?先设计
SessionEventMap的新成员 - 事件格式变更是破坏性的 :
SESSION_FORMAT_VERSION目前为 0,格式变更拒绝旧日志 - 调试时要理解"事件 → 投射"的间接性:不是直接看 messages,要先看 events 再理解投射规则
但对于一个 Agent 运行时来说,replay 可靠性和审计能力的价值远超这些代价。
参考链接
DeepSeek Harness 系列文章:
- 第四篇:Event-Sourced Session(本文)