Dsh 会话记忆机制:the log IS the state
受众: 关注 Agent 运行时、会话状态管理与上下文压缩实现的开发者。 最后更新: 2026-08-23
0. 引言
DeepSeek Harness(dsh)的会话记忆建立在一个非常干净的原则上:append-only Event Log。
dsh 使用 SessionEvent[] 记录会话,事件只允许 append,不允许修改历史事件。模型真正看到的消息通过 deriveMessages(events) 投影得到。这一原则可以总结为一句话:
the log IS the state
而不是“日志记录状态”。
当然,并非所有 Event 都进入模型。真正影响模型历史的核心 Surface Event 主要包括:
user/message
assistant/message
tool/result
而:
turn/start
turn/end
step/start
step/end
assistant/chunk
request/header
compaction/*
可以保留在 Event Log 中,但不直接成为 Message History。这就实现了:
Session Event Log
│
┌────────┴────────┐
↓ ↓
Model Projection Observability
↓ ↓
Messages Trajectory
模型可见事件与结构/回放事件、log-only 事件被分层存储,从而同时获得完整可观测性和稳定模型上下文。
问题的提出
这套设计乍看很优雅,但稍加推敲就会冒出一个尖锐的矛盾:
如果对话超出模型上下文、触发了上下文压缩(compaction),模型看到的历史就被改变了;但事件又只允许 append、不允许修改——旧事件不能动,新上下文又对不上旧日志,那“the log IS the state”还成立吗?Log 和 Context 岂不就出现了不一致?
答案是:如果 dsh 真的只是“所有事件 append,然后 deriveMessages() 简单把所有 user/message / assistant/message 拼起来”,这个矛盾确实会成立。
但 dsh 实际上多设计了一层叫 Session Surface 的结构。真正的结构不是:
Append-only Event Log
↓
把所有消息直接拼起来
↓
LLM Context
而是:
Append-only Event Log
↓
根据每个事件的 surfaceOp
↓
构造 Current Surface
↓
deriveMessages(surface)
↓
LLM Context
所以这里真正需要区分三个概念:
Raw Event Log 永远 append-only
Surface 当前“有效历史”的投影
LLM Context 从 Surface 派生出的模型输入
Compaction 不修改旧 Event Log;它 append 一个新的“替换事件”,告诉 Surface:旧的这一段以后不再直接出现在模型历史中,用这个摘要节点替代。
这正是 dsh 为解决这个矛盾专门设计的机制。官方 Session Surface 设计文档甚至明确说,过去如果由 compaction 插件直接在 request hook 里改写 history,会导致改写行为没有被持久记录;因此才加入了一个“由 Event Log 中的 surfaceOp 驱动的有序投影 Surface”。(GitHub)
1. 先纠正一句容易产生误解的话
前面的表述:
the log IS the state
容易让人理解成:
Event Log 中的每一条消息
=
当前送给模型的消息
这其实不准确。
更准确的说法应该是:
Event Log 是唯一权威状态源(source of truth);当前模型历史是 Event Log 的确定性投影。
即:
State = reduce(EventLog)
而不是:
State = EventLog 最后长什么样
这其实就是 Event Sourcing 的典型思想。
例如银行账户也可以是 append-only:
Event 1: +100
Event 2: -30
Event 3: +50
你不能修改:
Event 1
但当前余额依然会变化:
100
↓
70
↓
120
因为:
Balance = reduce(events)
同理,dsh 是:
Surface = reduce(SessionEvents)
然后:
Messages = deriveMessages(Surface)
所以:
append-only 不代表“当前状态不能变化”;它代表“状态变化只能通过追加新的事实来表达,而不能篡改历史事实”。
2. dsh 实际有一个 Surface 层
官方现在明确维护了一个:
Session Surface
官方定义它是:
“an ordered projection over the event log”
也就是:
Event Log 上的一个有序投影视图。
官方源码设计里,Session 内部有一个 SurfaceManager,它维护一个:
number[]
里面存的不是消息本身,而是:
当前有效 Surface Event 的 seq
例如原始 Event Log:
seq=1 user/message
seq=2 assistant/message
seq=3 user/message
seq=4 assistant/message
Surface:
[1, 2, 3, 4]
于是:
deriveMessages()
按照 Surface 去取 Event:
seq1 → user
seq2 → assistant
seq3 → user
seq4 → assistant
得到模型历史。官方文档明确写道,deriveMessages() 在存在 Surface 标记时会以 Surface 作为派生路径,而不是直接扫描所有历史消息。(GitHub)
所以结构其实是:
SessionEvent[]
│
│ authoritative
↓
SurfaceManager
│
[1,2,3,4]
│
↓
deriveMessages()
│
↓
LLM Context
3. 那 Compaction 怎么做?
关键来了。
假设现在历史是:
seq 1 user: 帮我分析项目
seq 2 assistant: 好,我先分析...
seq 3 tool: ...
seq 4 assistant: ...
seq 5 user: 再看一下数据库
seq 6 assistant: ...
seq 7 tool: ...
seq 8 assistant: ...
seq 9 user: 再继续...
seq10 assistant: ...
Surface:
[1,2,3,4,5,6,7,8,9,10]
模型看到:
U1
A1
Tool1
A2
U2
A3
Tool2
A4
U3
A5
随着任务越来越长:
Context Token > Threshold
触发 Compaction。
比如决定把:
seq 1 ~ seq 6
压缩成:
用户要求分析项目,Agent 已检查核心代码与数据库结构,发现……
4. 最重要的地方:它不删除 seq 1~6
dsh 不会:
DELETE seq1
DELETE seq2
...
DELETE seq6
也不会:
UPDATE seq1...
而是继续 append。
官方 Compaction 流程大致是:
seq11 compaction/start
seq12 compaction/summary
seq13 user/message: "摘要..."
seq14 compaction/end
也就是说原始 Event Log 变成:
1 old user
2 old assistant
3 old tool result
4 old assistant
5 old user
6 old assistant
7 ...
8 ...
9 ...
10 ...
11 compaction/start
12 compaction/summary
13 summary checkpoint
14 compaction/end
所有原始事件仍然存在。
这点官方写得非常明确:
被 shadow 的事件仍然保留在 log 中,只是不再处于当前 Surface 上。(GitHub)
5. 真正神奇的是 seq13 上有一个 surfaceOp
seq13 不是普通:
user/message
而是类似:
{
seq: 13,
type: "user/message",
data: {
content: "此前用户要求分析项目……"
},
surfaceOp: {
op: "replace",
start: 1,
end: 6
},
sourceEventSeqs: [
1, 2, 3, 4, 5, 6
]
}
这里:
surfaceOp:
{
op: "replace",
start: 1,
end: 6
}
的意思不是:
修改 Event 1~6。
而是:
对当前 Surface 进行投影时,把 Surface 中 seq1~seq6 这一段 shadow 掉,然后把 seq13 插在它原来的位置。
官方 SurfaceOp 就定义了两种操作:
type SurfaceOp =
| 'append'
| {
op: 'replace'
start: number
end: number
}
其中 replace 会把 Surface 中 start 到 end 的节点移除,并把新的 event seq 放进去;旧事件仍留在 raw log。(GitHub)
6. 所以 Compaction 前后其实是这样
Compaction 前
Raw Log:
1 U1
2 A1
3 T1
4 A2
5 U2
6 A3
7 T2
8 A4
9 U3
10 A5
Surface:
[1,2,3,4,5,6,7,8,9,10]
模型 Context:
U1
A1
T1
A2
U2
A3
T2
A4
U3
A5
Compaction 发生
新增:
11 compaction/start
12 compaction/summary
13 user/message
content = SUMMARY
surfaceOp = replace(1,6)
14 compaction/end
注意:
1~10
一个都没改。
Compaction 后 Raw Log
[
1 U1,
2 A1,
3 T1,
4 A2,
5 U2,
6 A3,
7 T2,
8 A4,
9 U3,
10 A5,
11 compaction/start,
12 compaction/summary,
13 SUMMARY replace(1,6),
14 compaction/end
]
仍然 append-only。
但是 SurfaceManager 看到 seq13:
replace(1,6)
所以原 Surface:
[1,2,3,4,5,6,7,8,9,10]
变成:
[13,7,8,9,10]
官方实现说明的就是:replace 根据 Surface 中 start/end 的位置,将这一段 splice 掉,再放入 replacement seq。(GitHub)
于是:
deriveMessages()
现在不是读取:
1,2,3,4,5,6,7,8,9,10
而是读取:
13,7,8,9,10
得到:
[此前历史摘要]
T2
A4
U3
A5
这才送给模型。
7. “上下文改变了”,确实改变了
但改变的是:
Derived State / Surface
而不是:
Event Log
可以把它画成:
Event Log
│
│ append only
↓
┌──────────────────────────────────────┐
│ 1 U1 │
│ 2 A1 │
│ 3 T1 │
│ 4 A2 │
│ 5 U2 │
│ 6 A3 │
│ 7 T2 │
│ 8 A4 │
│ 9 U3 │
│10 A5 │
│11 compaction/start │
│12 compaction/summary │
│13 SUMMARY + replace(1,6) │
│14 compaction/end │
└──────────────────────────────────────┘
│
│ fold surfaceOp
↓
Current Surface
│
[13,7,8,9,10]
│
↓
deriveMessages()
│
↓
┌──────────────────────────────────────┐
│ Summary of seq 1-6 │
│ T2 │
│ A4 │
│ U3 │
│ A5 │
└──────────────────────────────────────┘
│
↓
LLM
所以不存在:
Log 表示旧上下文
Context 是新上下文
二者不一致
因为 Log 本身也记录了:
“从现在开始,
在模型 Surface 中,
1~6 被 13 替代。”
8. 这反而是 the log IS the state 最漂亮的地方
假如 dsh 不是这么做,而是:
原 Event Log 保留:
1 U1
2 A1
3 A2
...
然后某个内存变量直接变成:
messages = [
SUMMARY,
recent messages...
]
那么这个矛盾就真的出现了。
此时:
Event Log
≠
Actual Model Context
因为“压缩”这个动作只发生在内存里,没有记录。
机器一重启:
读 Event Log
↓
不知道之前压缩过
↓
重新得到全部历史
或者 Replay:
昨天模型看到 Summary
今天 replay 却看到原始历史
这就是不一致。
官方 Session Surface 设计说明恰恰说,他们引入 Surface 的原因就是:如果历史改写只是通过 request listener 临时完成,那么“改了什么”没有 durable record,replay 也无法可靠重建。(GitHub)
所以 dsh 的做法是:
不要修改 State
不要偷偷修改 Projection
而是:
append 一个
“Projection 应如何改变”
的 Event
这就是 Event Sourcing 的核心精神。
9. sourceEventSeqs 又是干什么的?
你可能又会问:
sourceEventSeqs: [1,2,3,4,5,6]
不是已经有:
replace(1,6)
了吗?
为什么还需要它?
因为 dsh 想保证:
这个 Summary 到底是从哪些 Event 生成的?
例如:
seq13 SUMMARY
声称:
replace seq1 ~ seq6
那么必须证明它引用了:
1
2
3
4
5
6
所以:
sourceEventSeqs
就是 lineage / provenance:
SUMMARY seq13
│
├── source: seq1
├── source: seq2
├── source: seq3
├── source: seq4
├── source: seq5
└── source: seq6
官方 Session 在 append 边界还会校验:
- source seq 必须已经存在;
- 不能引用未来事件;
- 引用不能重复;
- replace 的 start/end 必须存在于当前 Surface;
sourceEventSeqs必须覆盖所有被 shadow 的 Surface node。(GitHub)
所以不是随便写:
“我总结过了”
而是有完整溯源。
10. compaction/summary 和那个 user/message Summary 又有什么区别?
这个地方也容易混。
官方流程实际是:
compaction/start
↓
调用模型生成 summary
↓
compaction/summary
↓
user/message + surfaceOp replace
↓
compaction/end
其中:
compaction/summary
是:
log-only
记录:
- 原始 summary;
- 使用哪个 provider;
- 哪个 model;
- token usage;
- shadow 哪个范围;
- 哪些 seq 被 shadow。
主要为了:
Audit
Replay
Telemetry
Debug
但它不进入模型上下文。(GitHub)
user/message + surfaceOp replace
才是真正:
Surface Mutation
也就是说:
Compaction Metadata
↓
compaction/summary
真正给模型看的摘要
↓
user/message
官方文档甚至特意解释:
compaction/*不允许成为 Surface Event;真正的摘要由一个user/message承载。(GitHub)
最终:
deriveMessages()
→ [
summary_as_user_message,
...retained_entries
]
11. 重启以后会不会丢失 Surface 状态?
不会,因为:
Surface
不是唯一真源。
真正真源仍然是:
Event Log
重启后可以重新 replay:
seq1 append
Surface = [1]
seq2 append
Surface = [1,2]
seq3 append
Surface = [1,2,3]
...
seq13 replace(1,6)
Surface = [13,7,8,9,10]
最终一定恢复到:
[13,7,8,9,10]
官方明确写:
被 shadow 的事件仍留在原始 log,因此 replay 是 deterministic 的。(GitHub)
所以:
Event Log
↓ replay
Surface
↓
Messages
是一个确定性函数。
可以近似理解成:
def replay(events):
surface = []
for event in events:
if event.surfaceOp == "append":
surface.append(event.seq)
elif event.surfaceOp.op == "replace":
start = surface.index(
event.surfaceOp.start
)
end = surface.index(
event.surfaceOp.end
)
surface[start:end+1] = [
event.seq
]
return surface
于是:
同一 Event Log
↓
永远得到
↓
同一个 Surface
这才是 dsh 所谓可重放。
12. 你可以把它类比成 Git
这个类比其实非常好理解。
Event Log 类似:
Git Commit History
你不会为了得到新的代码状态就去修改旧 commit。
比如:
Commit A
Commit B
Commit C
Commit D
后来:
Commit E:
“把前面那套实现替换掉”
旧 Commit 仍然存在。
但:
git checkout HEAD
得到的是新的工作树状态。
所以:
Git Commit History
↓
replay commits
↓
Current Working Tree
对应:
Session Event Log
↓
replay surfaceOp
↓
Current Surface
↓
deriveMessages
↓
Current LLM Context
这里:
Raw Log ≈ Commit History
Surface ≈ 当前逻辑历史视图
Messages ≈ checkout 后的工作树
这个理解会非常接近 dsh 的真实设计。
13. 更准确的表述
之前:
SessionEvent[]
事件只允许 append
不允许修改历史事件。
模型真正看到的消息通过
deriveMessages(events)
投影得到。
这个表述太简化了,正因为少了 Session Surface,才会产生引言中提出的那个逻辑矛盾。
更准确应该写成:
SessionEvent[] ← append-only authoritative log
↓
SurfaceOp
↓
Session Surface ← 当前有效消息节点序列
↓
deriveMessages()
↓
LLM Message History
其中普通消息:
surfaceOp = append
上下文压缩则追加:
user/message
+
surfaceOp = {
op: replace,
start,
end
}
被替换的旧 Event:
仍保留在 Event Log
但:
不再存在于 Current Surface
因此:
Raw history preserved
+
Current context compacted
+
Compaction decision persisted
三件事情可以同时成立。
总结
回到最初的问题:
“上下文压缩后上下文变了,但 Event 又不能修改,怎么还能说 the log IS the state?”
答案是:
dsh 不把“压缩后的上下文”存成一份独立可变状态,而是把“用摘要替换旧 Surface 区间”这件事本身作为新的 Event append 进去。旧 Event 不改,新的 replace Event 改变的是对整个 Event Log 的投影结果。
也就是:
不是:
修改历史
↓
得到新状态
而是:
历史1
历史2
历史3
...
“将历史1~3替换为摘要S” ← 新事件
↓
replay/fold
↓
当前状态 = S
因此更严格的公式是:
Authoritative State = Event Log
Current Surface
= fold(Event Log, surfaceOp)
LLM Context
= deriveMessages(Current Surface)
而不是简单的:
LLM Context = Event Log
这三个公式一分开,DeepSeek Harness 这套设计基本就完全说通了。(GitHub)
评论(0)