数据截至 (上游 commit 9fb509bab3e0)
Tape 系统:append-only 会话日志
本章一句话: 这是 DeepChat 的灵魂。别的客户端把「消息」当真相;DeepChat 把「产生消息的事件流」当真相,消息只是从事件流折叠出来的结果。
1. 它要解决的小问题
聊天 agent 的会话天生会被「破坏性修改」:上下文太长要压缩(丢掉一段历史)、一条回复重试会覆盖、子 agent 跑完要把结果合并进父会话。如果你只存「当前消息列表」,这些操作一旦发生,之前的真实过程就找不回来了——没法恢复、没法追溯「当时到底发给模型了什么」、没法重放做评测。
Tape 的答案很简单:永远不改、永远不删,只往后追加。 任何变化都表达成「再追加一条事件」,而不是「改写已有的行」。
2. 思路 / 直觉
把一次会话想成一条磁带:
entry_id: 1 2 3 4 5 6
┌────────┬────────┬──────────┬────────────┬────────┬──────────┐
│ anchor │ user │ assistant│ tool_call │ tool_ │ assistant│ ...
│ session│ msg #1 │ msg #2 │ (read X) │ result │ msg #2' │
│ /start │ │ │ │ │(修订版) │
└────────┴────────┴──────────┴────────────┴────────┴──────────┘
只能往右加 ────────────────────────────────────────►
两类条目:
条目类型(kind) | 是什么 | 例子 |
|---|---|---|
fact(message/tool_call/tool_result) | 真实发生的事 | 一条 user 消息、一次工具调用、它的结果 |
| anchor / event | 结构性标记 | session/start、compaction/*、handoff/*、fork/start、message/retracted |
关键洞见:「当前对话」不是某一行,而是把整条磁带从头放一遍、算出来的剪辑版。第 6 条 assistant msg #2' 是第 3 条的修订;折叠时同一条消息只保留最优的那一版,旧版仍躺在磁带里(可追溯)但不进视图。这一步叫 fold(折叠)→ effective view(有效视图)。
3. 存储:per-session 单调 entry_id + provenance 去重
Tape 落在一张表 deepchat_tape_entries,主键是 (session_id, entry_id):
src/main/tape/infrastructure/sqlite/tapeEntryStore.ts:781-795
CREATE TABLE IF NOT EXISTS deepchat_tape_entries (
session_id TEXT NOT NULL,
entry_id INTEGER NOT NULL, -- 每个 session 内单调递增
kind TEXT NOT NULL, -- anchor | message | tool_call | tool_result | event
name TEXT,
source_type TEXT, source_id TEXT, source_seq INTEGER, -- 溯源三件套
provenance_key TEXT, -- 幂等去重键(可空)
payload_json TEXT NOT NULL DEFAULT '{}',
meta_json TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL,
PRIMARY KEY (session_id, entry_id)
)
entry_id 不是全局自增,而是 每个会话内 getMaxEntryId(sessionId) + 1(tapeEntryStore.ts:882,append())——所以同一会话的事件有稳定的全序,折叠时直接按 entry_id 排。
幂等去重靠一个唯一索引 + provenance_key:
tapeEntryStore.ts:253-255
CREATE UNIQUE INDEX IF NOT EXISTS idx_deepchat_tape_entries_session_provenance
ON deepchat_tape_entries(session_id, provenance_key)
WHERE provenance_key IS NOT NULL;
append() 在写入前若 idempotent && provenanceKey 命中已有行,直接返回旧行不重复插入(tapeEntryStore.ts:873-880)。这条让「懒回填旧会话」可以安全重入:同一条 fact 回填一百次也只有一行。
4. 写入:把消息/工具变成 facts
每当一条消息定稿、或工具调用完成,运行时就把它翻译成 facts 追加进磁带。核心在 src/main/tape/application/factPersistence.ts(旧的 session/data/tapeFacts.ts 只剩一个 @deprecated 转发桶):
appendMessageRecordToTape(table, record, source)—— 把一条ChatMessageRecord追加为message/<role>fact;如果是 assistant,还顺手把它内部的工具调用块抽成tool_call/tool_resultfacts(factPersistence.ts:446起)。- provenance key 的设计很讲究:已定稿(sent)的消息用稳定 key(基本只写一次),未定稿/修订(repair / 非 sent)用带
updatedAt的 revision key(buildMessageProvenanceKey,factPersistence.ts:123-140)——这样流式过程中的中间态会作为「新修订」不断追加,而最终态稳定唯一。 - 工具 fact 的 key 用
kind:messageId:toolCallId:hashJson(payload)(buildToolFactProvenanceKey,factPersistence.ts:141):内容没变就不重复入库,内容变了(比如重试)就追加新版。
压缩、撤回是 event 而非破坏性改写:
appendMessageReplacementToTape(...)—— 一条消息被修订,追加新版 + reason(factPersistence.ts:507)。appendMessageRetractionToTape(...)—— 追加message/retractedevent 把某条消息「划掉」(factPersistence.ts:570),折叠时它会被排除。
5. 读取:fold 成 effective view(本章最关键的算法)
要展示给用户、或要发给模型时,把磁带这一堆 facts/anchors 折叠成「当前有效消息列表」。算法在 buildEffectiveTapeView(src/main/tape/domain/effectiveView.ts:179-282)。
思路:按 entry_id 顺序扫一遍,对每个稳定 id 只留「最优」的一版。
怎么读:同一条 message id 可能出现多次(初版 + 修订);用 rank 决定谁胜出。
扫描所有行(按 entry_id 升序)
├─ anchor → 收进 anchorRows
├─ event → 若是 message/retracted,把该 messageId 标记为「撤回」
├─ message → 算 rank;按 (rank, entry_id) 决定是否替换当前候选
└─ tool_call / → 同理按 (toolRank, entry_id) 去重保留最优
tool_result
rank 规则(`tapeMessageRank`, `src/main/tape/domain/effectiveSemantics.ts:105-111`):
status = sent/error → 2 (定稿,最优先)
status = pending → 1 (仅 includePending 时)
其它 → 0 (丢弃)
胜出规则(`shouldReplaceMessage`, `effectiveView.ts:107-133`):
先比 rank,rank 高者胜;rank 相同则 entry_id 大者胜(更新的版本)
折叠完做三件收尾(effectiveView.ts:239-262):
- 过滤掉被
message/retracted撤回的消息。 - 消息按
orderSeq排成真正的对话顺序。 - 工具 facts 只保留那些「仍属于有效消息」的——某条 assistant 消息被撤回,它名下的 tool_call/result 也一并出局。
产物是 EffectiveTapeView:messageRecords(给上层当历史)、rows(有效原始行)、messageEntries(消息配 entry_id,用于 lineage/分叉)。
6. Anchors:压缩、handoff、fork 都只是「加一条标记」
破坏性操作之所以不破坏过程,是因为它们都被表达成 anchor:
| anchor 名 | 含义 | 触发 |
|---|---|---|
session/start | 会话起点(bootstrap) | ensureBootstrapAnchor |
compaction/* | 上下文压缩点,带 summary 游标 | 手动 sessions.compact / 自动压缩 |
handoff/*、auto_handoff/* | 交接点:之后的上下文从这里的重建状态起算 | tape_handoff 工具 / 自动 |
fork/start | 分叉(子会话)起点 | 子 agent 编排 |
「最新重建 anchor」决定了摘要游标和 prompt 可见的重建状态(摘要/handoff);判断逻辑见 src/main/tape/application/viewReplayService.ts:89-99(isReconstructionAnchorName:命中 SUMMARY_ANCHOR_NAMES(定义在 src/main/tape/domain/entry.ts:89-96,覆盖 compaction/* 与 auto_handoff/context_overflow)或 handoff/ / auto_handoff/ 前缀)。换句话说:压缩不删历史,只是追加一个 anchor,告诉折叠器「从这里之后用摘要代替前面那段」。原始历史还在磁带里,Trace/Inspector 仍能看到。
子 agent 的 fork 会话 id 形如 ${parentSessionId}::fork::${forkId}(src/main/tape/application/forkService.ts:90),父会话通过 merge / discard 决定吸收还是丢弃子会话结果(见 03-tool-system.md 的 subagent 部分)。
7. ensureSessionTapeReady:旧会话懒回填
老会话(Tape 出现前的数据)在 SQLite 里只有结构化消息表,没有 tape 行。SessionTape.ensureSessionTapeReady(sessionId, messageStore)(src/main/tape/application/sessionTape.ts:210)负责在每次用到磁带前懒回填:把消息/工具 facts 补成 tape 行(provenance 去重保证可重入),并保证有 bootstrap anchor(实现在 reconcilerService.ts)。聊天主链路在轮次协调的多个出口调它(src/main/agent/deepchat/runtime/turnCoordinator.ts:779、:1625)。
8. 巧妙之处
- 破坏性操作 → append:压缩/ 撤回/分叉/合并全部用「追加 anchor 或 event」表达,从不改写已有行——这是「过程可恢复」的根。
- provenance 幂等:唯一索引 + provenance key 让回填、重放、并发写入天然去重(
tapeEntryStore.ts:873-880)。 - rank 折叠:用一个简单的
(rank, entry_id)偏序,就同时处理了「pending→sent 升级」「重试覆盖」「撤回排除」三种情况,逻辑集中、好测(effectiveView.ts:107-133、effectiveSemantics.ts:105-111)。
9. 边界与局限
- 当前折叠出的上下文仍由
legacy_context_v1选择器(contextBuilder.ts)决定,ViewManifest 主要作为「解释/回归工件」,parity 证明后才会成为唯一真相(docs/architecture/deepchat-tape-baseline/spec.md)。 - 原始 prompt / provider 请求体不存进 Tape,只存进 trace 存储;Tape 里只放 id、hash、token 估算、policy 名/版本、排除原因(同上 spec 的 Implementation Rules)。
10. 代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| Tape 存储表 + 单调 entry_id + 幂等 | src/main/tape/infrastructure/sqlite/tapeEntryStore.ts | DeepChatTapeEntriesTable、append、getMaxEntryId |
| facts 写入 | src/main/tape/application/factPersistence.ts | appendMessageRecordToTape、appendToolFactsToTape、appendMessageRetractionToTape |
| 折叠 effective view | src/main/tape/domain/effectiveView.ts | buildEffectiveTapeView、tapeMessageRank(effectiveSemantics.ts)、shouldReplaceMessage |
| 服务边界 / anchor / handoff / fork | src/main/tape/application/sessionTape.ts | SessionTape、ensureSessionTapeReady(fork 在 forkService.ts,anchor 判定在 viewReplayService.ts) |
| baseline / 范围 | docs/architecture/deepchat-tape-baseline/spec.md | — |