跳到主要内容

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/startcompaction/*handoff/*fork/startmessage/retracted

关键洞见:「当前对话」不是某一行,而是把整条磁带从头放一遍、算出来的剪辑版。第 6 条 assistant msg #2' 是第 3 条的修订;折叠时同一条消息只保留最优的那一版,旧版仍躺在磁带里(可追溯)但不进视图。这一步叫 fold(折叠)→ effective view(有效视图)

3. 存储:per-session 单调 entry_id + provenance 去重

Tape 落在一张表 deepchat_tape_entries,主键是 (session_id, entry_id):

deepchatTapeEntries.ts:111-123
CREATE TABLE deepchat_tape_entries (
session_id TEXT,
entry_id INTEGER, -- 每个 session 内单调递增
kind TEXT, -- anchor | message | tool_call | tool_result | event
name TEXT,
provenance_key TEXT, -- 幂等去重键(可空)
payload_json TEXT,
meta_json TEXT,
created_at INTEGER,
PRIMARY KEY (session_id, entry_id)
)

entry_id 不是全局自增,而是 每个会话内 getMaxEntryId(sessionId) + 1(deepchatTapeEntries.ts:156,append())——所以同一会话的事件有稳定的全序,折叠时直接按 entry_id 排。

幂等去重靠一个唯一索引 + provenance_key:

deepchatTapeEntries.ts:75-77
CREATE UNIQUE INDEX idx_deepchat_tape_entries_session_provenance
ON deepchat_tape_entries(session_id, provenance_key)
WHERE provenance_key IS NOT NULL;

append() 在写入前若 idempotent && provenanceKey 命中已有行,直接返回旧行不重复插入(deepchatTapeEntries.ts:146-153)。这条让「懒回填旧会话」可以安全重入:同一条 fact 回填一百次也只有一行。

4. 写入:把消息/工具变成 facts

每当一条消息定稿、或工具调用完成,运行时就把它翻译成 facts 追加进磁带。核心在 tapeFacts.ts:

  • appendMessageRecordToTape(table, record, source) —— 把一条 ChatMessageRecord 追加为 message/<role> fact;如果是 assistant,还顺手把它内部的工具调用块抽成 tool_call / tool_result facts(tapeFacts.ts:187-268)。
  • provenance key 的设计很讲究:已定稿(sent)的消息用稳定 key(基本只写一次),未定稿/修订(repair / 非 sent)用带 updatedAt 的 revision key(buildMessageProvenanceKey,tapeFacts.ts:43-55)——这样流式过程中的中间态会作为「新修订」不断追加,而最终态稳定唯一。
  • 工具 fact 的 key 用 kind:messageId:toolCallId:hashJson(payload)(buildToolFactProvenanceKey,tapeFacts.ts:57-64):内容没变就不重复入库,内容变了(比如重试)就追加新版。

压缩、撤回是 event 而非破坏性改写:

  • appendMessageReplacementToTape(...) —— 一条消息被修订,追加新版 + reason(tapeFacts.ts:270-318)。
  • appendMessageRetractionToTape(...) —— 追加 message/retracted event 把某条消息「划掉」(tapeFacts.ts:320-353),折叠时它会被排除。

5. 读取:fold 成 effective view(本章最关键的算法)

要展示给用户、或要发给模型时,把磁带这一堆 facts/anchors 折叠成「当前有效消息列表」。算法在 buildEffectiveTapeView(tapeEffectiveView.ts:249-328)。

思路:按 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 规则(messageRank, tapeEffectiveView.ts:118-123):
status = sent/error → 2 (定稿,最优先)
status = pending → 1 (仅 includePending 时)
其它 → 0 (丢弃)

胜出规则(shouldReplaceMessage, :125-143):
先比 rank,rank 高者胜;rank 相同则 entry_id 大者胜(更新的版本)

折叠完做三件收尾(tapeEffectiveView.ts:306-318):

  1. 过滤掉被 message/retracted 撤回的消息。
  2. 消息按 orderSeq 排成真正的对话顺序。
  3. 工具 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);判断逻辑见 tapeService.ts:105-114(匹配 compaction/ / handoff/ / auto_handoff/ 前缀)。换句话说:压缩不删历史,只是追加一个 anchor,告诉折叠器「从这里之后用摘要代替前面那段」。原始历史还在磁带里,Trace/Inspector 仍能看到。

子 agent 的 fork 会话 id 形如 ${parentSessionId}::fork::${forkId}(tapeService.ts:653-654),父会话通过 merge / discard 决定吸收还是丢弃子会话结果(见 03-tool-system.md 的 subagent 部分)。

7. ensureSessionTapeReady:旧会话懒回填

老会话(Tape 出现前的数据)在 SQLite 里只有结构化消息表,没有 tape 行。DeepChatTapeService.ensureSessionTapeReady(sessionId, messageStore) 负责在每次用到磁带前懒回填:把消息/工具 facts 补成 tape 行(provenance 去重保证可重入),并保证有 bootstrap anchor。聊天主链路在 processMessage 开头就调它(index.ts:976)。

8. 巧妙之处

  • 破坏性操作 → append:压缩/撤回/分叉/合并全部用「追加 anchor 或 event」表达,从不改写已有行——这是「过程可恢复」的根。
  • provenance 幂等:唯一索引 + provenance key 让回填、重放、并发写入天然去重(deepchatTapeEntries.ts:146-153)。
  • rank 折叠:用一个简单的 (rank, entry_id) 偏序,就同时处理了「pending→sent 升级」「重试覆盖」「撤回排除」三种情况,逻辑集中、好测(tapeEffectiveView.ts:118-143)。

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/presenter/sqlitePresenter/tables/deepchatTapeEntries.tsDeepChatTapeEntriesTableappendgetMaxEntryId
facts 写入src/main/presenter/agentRuntimePresenter/tapeFacts.tsappendMessageRecordToTapeappendToolFactsToTapeappendMessageRetractionToTape
折叠 effective viewsrc/main/presenter/agentRuntimePresenter/tapeEffectiveView.tsbuildEffectiveTapeViewmessageRankshouldReplaceMessage
服务边界 / anchor / handoff / forksrc/main/presenter/agentRuntimePresenter/tapeService.tsDeepChatTapeServiceensureSessionTapeReadyanchors
baseline / 范围docs/architecture/deepchat-tape-baseline/spec.md