DeepSeek Harness 的会话生命周期:事件、投影与持久化三层

2026-08-16

deepseek-harness 的会话这块,最容易犯的错是把它当成「一个消息数组,聊完存盘」。真去读代码会发现,它把一次会话切成了三层:底下是一条只增不改的事件日志,中间是从日志派生出来的模型可见消息,最上面才是负责把日志落到磁盘的持久化 seam。三层各有各的版本号、各有各的失败语义,混在一起理解就会得出错误结论。

先说清限定:本文全部依据 2026-08-16 采集的仓库快照 47f9438,版本是 0.1.0-rc.5,仓库建立于 2026-08-13,README 自述处于开发者预览阶段并明写未来会有破坏兼容性的变更。下面提到的字段名、常量、默认值都是那一刻仓库里的写法,随时可能变。我们没有安装、没有运行过它。

第一层:日志是唯一真相源,消息是派生物

docs/subsystems/session.md:5 把这一层定义得很直白:Session 是一条 append-only 的 SessionEvent 日志,它是一次 agent 交互的唯一真相源;给模型看的消息历史是从日志派生出来的,不单独存一份

这条设计带来几个可以直接在代码里核到的约束:

  • seq 是日志内的单调位置,契约就是 seq = log.lengthtime 是 Unix 毫秒(docs/subsystems/session.md:196)。也就是说 seq 不是自增主键,而是「你是第几条」。
  • Session.append(type, data, opts) 在入口就做 JSON 可序列化校验:BigInt、函数、symbol、undefined、负零、非有限数、循环引用、稀疏数组、Map/Set/Date/类实例全部会被拒(docs/subsystems/session.md:458-470)。
  • 事件与其嵌套数据在被接受时会被 deep-freeze;session.events 返回的快照在下一次 append 前会被复用,已经返回给你的那个数组不会再增长(docs/subsystems/session.md:428-434)。

事件类型有两个数,别混。packages/core/session/src/types.ts 里的 SessionEventMap(接口起始行 236)核心成员是 13 个turn/startturn/endstep/startstep/enduser/messageassistant/chunkassistant/messagetool/calltool/resulttodo/writerequest/headerrequest/contextsession/end-seed。但这张表是 merge-extensible 的——插件用 TypeScript 声明合并往里加自己的事件(session.md:11),所以对 event.typeswitch不允许assertNeversession.md:247)。整仓已声明的事件类型被生成到 packages/core/session/src/known-event-types.ts:19-64KNOWN_SESSION_EVENT_TYPES,我们数出 44 个;生成的目录 docs/persistence-catalog.md 里也是 44 个事件小节。读日志时遇到不在这张表里的类型,行为是拒绝重建,除非该事件信封上带 ignorable: truedocs/subsystems/persistence.md:94)。

一次 turn 的内部顺序在 docs/agent-lifecycle.md:19-72 有一张生成的 Mermaid 时序图(文件头注明由 scripts/gen-doc-graphs.ts 生成、不要手改):turn/start → 认领排队输入 → agent/pre-step waterfall → step/start → 进入的 user/messagesystem-prompt/assembleagent/requestllm/stream → 若干 assistant/chunkassistant/message → 工具分类与执行(tool/call / tool/result)→ step/endturn/end

turn/end 的原因是个封闭集合:completed / aborted / blocked / error / max-tokens / interruptedsession.md:553-572)。两条容易忽略的规则:一个 turn 里只要有任一 step 撞到输出 token 上限,整个 turn 的结束原因就是 max-tokens 而不是 completedsession.md:575);而 interrupted 是唯一一个 loop 永远不会写的原因,它由崩溃恢复合成(session.md:571,575docs/subsystems/persistence.md:15)——这一条到第三层还会再出现。

第二层:投影,把 44 种事件压成 3 种模型可见消息

44 个事件类型里,只有 3 个会产出模型可见消息,文档称之为 SurfaceEventTypeuser/messageassistant/messagetool/resultsession.md:263-267)。docs/persistence-catalog.md 里带 surface 徽标的正好是这 3 个,其余 41 个标 log-only

事件怎么影响 surface,由 SurfaceOp 决定,而它只有两种形态:'append'(追加到尾部)与 { op: 'replace'; start; end }(把 start..end 这段含端点的 surface 节点替换掉)(session.md:285-288)。压缩用的就是 replace——关于压缩本身我们另有一篇专门讲,这里只需要知道 surface 不是纯追加的。

派生过程有缓存:deriveMessages() 对每个 surface 节点只投影一次,而一次 replace 会让缓存重建(session.md:503-508)。另外两条边界行为值得记:空内容的 assistant/message 会被跳过、不进模型历史,但一个被 max-tokens 截断、没有内容的 step 仍然会记一条 assistant/message 用来承载 usage / provider / model(session.md:526);原始的 assistant/chunk 是重放与 UI 用的数据,派生历史里直接跳过(同处)。

除了「派生消息」这一种投影,仓库里还有一套通用的会话投影机制(packages/session/session-projection)。docs/subsystems/session-projection.md:14-21 给的约束很硬:ProjectionDefinition 的三个函数必须同步,理由写的是异步单元会撕裂 carrier 的一致性切面;state 必须是纯 JSON,这是持久化缓存的前提。还有一条容易踩的:单元对自己不关心的事件必须返回同一个 state 引用(按 Object.is 比较),否则会产生下游工作(session-projection.md:33-40)。空日志时 ProjectionSnapshot.asOfSeq-1session-projection.md:61-73)。

投影缓存这里有个「默认值到底在哪」的坑,值得单独说。ctx.sessionProjectionCache 的两个节流触发器 writeEveryEventswriteIntervalMs 在库里没有默认值,必须由 composition 显式给出(packages/session/session-projection-cache/src/index.ts:35-52);具体数字只存在于组合层,Web bundle 给的是 writeEveryEvents: 200writeIntervalMs: 5000packages/bundle/web-app/cordis.patch.yml:76-80)。另外还有两个强制写点——turn/end 与会话销毁——按源码注释的说法是策略、不是可调项。写失败是 fail-soft:只打 warning,下次写或冷读时自愈(session-projection.md:107)。所以引用 200 和 5000 这两个数时,应该说「出厂 composition 给的值」,而不是「该功能的默认值」。

第三层:落盘,以及「什么时候真的落」

核心会话包刻意不实现持久化packages/core/session/README.md:11src/index.ts:789 都写明这一点,落盘由 persistence 插件通过订阅 session/event 完成。抽象服务 ctx.sessionPersistence 的方法集是 locate / readRaw / create / append / prepare / load / inspect / readFrom / list / listSnapshotsdocs/subsystems/persistence.md:252-379;抽象类在 packages/session/session-persistence/src/index.ts:84)。

会话元数据不进事件日志,而是走 SessionHeader,字段为 versionidcreatedAtcwd?parentSession?seedLength?origin?: 'subagent'delegationDepth?agentPreset?persistence.md:47-89)。version 与常量 SESSION_FORMAT_VERSION 不等就直接抛错,错误文案是 session header version must be ${SESSION_FORMAT_VERSION}, got ...packages/core/session/src/index.ts:101-102)。这个常量当前钉在 0packages/core/session/src/types.ts:56),packages/core/session/README.md:143 原文说它仍钉在 0、pre-release、不承诺广泛兼容。

写入不是逐条落盘,而是有一个批处理窗口:DEFAULT_WRITE_BATCH_MAX_DELAY_MS = 200packages/session/session-persistence/src/coordinator.ts:30)。关键在语义——第一条 pending 事件开启这个固定窗口,后来的事件加入但不重置截止时间persistence.md:11)。冷会话准备缓存的默认大小是 DEFAULT_PREPARED_SESSION_CACHE_SIZE = 5coordinator.ts:27)。这些是配置里的默认值,不是对运行表现的承诺,别拿它去推算写入延迟或吞吐。

那什么时候强制 flush?由一个零配置的策略插件决定。packages/session/session-checkpoint-policy/README.md:5 写明三个检查点:模型适配器收到请求前、顶层工具体可能产生外部副作用前、每个 agent/pre-step 边界。这个插件消费 ctx.sessionsctx.llmctx.tools 以及 ctx.sessionPersistence 的存在(README.md:9)。检查点失败是 fail-closed:适配器与顶层工具体都不会跑(README.md:23)。它自己在 README.md:44 声明了限制:assistant/chunk 没有逐块检查点,硬崩溃可能丢掉当前内存批次。同一份 README 的 :19 还说明,只装持久化后端、不装这个策略是合法的,但崩溃时可能丢掉「还在批处理窗口里或还没写完」的事件。

崩溃之后的修复动作很克制:读到只有 turn/start 没有 turn/end 的日志,会补一条合成的 turn/end { reason: { kind: 'interrupted' } }前后的独立事件都不动、不截断persistence.md:15)。而且修复只对冷会话生效,活会话的开放 turn 会被拒绝而不是被补合成边界(persistence.md:17)。这就是第一层那个「loop 永远不写 interrupted」的另一半。

落到具体后端,JSONL 侧的目录布局是 <root>/--<normalized-cwd>--/<encoded-id>/session.jsonl.zstd,未压缩时是 session.jsonlpackages/session/session-persistence-jsonl/README.md:9-15)。首个逻辑行是不可变的 SessionHeader,其中 delegationDepth 在盘上是必填、顶层会话为 0,缺失或非法则拒绝整份日志(README.md:17)。出厂 bundle 把这个 root 设成 !!js dshHomePath('sessions')packages/bundle/base/cordis.patch.yml:98-101),而 resolveDshHome 的优先级是显式配置路径 → $DSH_HOME~/.dsh,空白的 $DSH_HOME 视为未设置(packages/util/home-paths/src/index.ts:76-90)。顺带一提,dshHomeDisplay 刻意永不返回绝对机器路径,默认家目录就显示成 ~/.dshhome-paths/src/index.ts:101-108)——配置过 $DSH_HOME 的则显示成 $DSH_HOME,也就是说文档与日志里看到的家目录一律是这种占位形态,而不是某台机器上的绝对路径。

三层各有版本号,别混成一个

这是本块最容易写错的地方。同一个仓库里至少有四个互相独立的版本号:

常量出处
SESSION_FORMAT_VERSION0packages/core/session/src/types.ts:56
SCHEMA_VERSION(会话持久化 SQLite)15packages/session/session-persistence-sqlite/src/schema.ts:20
SESSION_QUERY_SQLITE_SCHEMA_VERSION8packages/session-query/session-query-sqlite/src/schema.ts:8
STORAGE_SQLITE_SCHEMA_VERSION1packages/storage/storage-sqlite/src/schema.ts:20

session-persistence-sqlite/src/schema.ts:16-19 的注释自己就说明了它与会话格式版本「正交」。另外三份文档以不同措辞写了同一句话——没有迁移docs/subsystems/storage.md:47session-persistence-sqlite/README.md:13session-persistence-jsonl/README.md:38)。

顺便记一处文档与代码对不上的地方,方便你以谁为准:docs/subsystems/core.md:248 列举了「twelve event variants」,其中含 steering/message;而源码 SessionEventMappackages/core/session/src/types.ts:236 起)是 13 个成员,不含 steering/message,另有 request/contextsession/end-seedsteering/message 在源码里只作为遗留类型存在于持久化迁移路径:packages/session/session-persistence/src/coordinator.ts:328const legacySteeringType: string = 'steering/message',以及 :343-365migrateLegacySteeringEvent(把它升级成 user/message)。两处不一致,我们只陈述差异,以实读的仓库状态为准,不推断原因。

想自己核一遍,从哪儿下手

如果你要在自己的机器上把这条链路对一遍,建议的顺序是:先读 packages/core/session/src/types.tsSessionEventMap(第一层的词汇表),再读 known-event-types.ts 看整仓 44 个类型,然后回 docs/agent-lifecycle.md 对时序,最后读 packages/session/session-persistence/src/coordinator.ts 看窗口与恢复。中途凡是遇到一个数字,先确认它是源码常量出厂 composition 的值,还是文档里的表述——这三者在这个仓库里经常不是一回事,session-projection-cache 的两个触发器就是典型例子。

需要再强调一次:以上全部是仓库源码与文档的口径。这个仓库在我们采集时只有三天历史、版本 0.1.0-rc.5、没有任何 GitHub Release,README 明写会有破坏兼容性的变更,字段名和常量值都可能已经变了,请以仓库最新内容为准。

延伸阅读


本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的 架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-16,对应仓库快照 47f9438(版本 0.1.0-rc.5)。 本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目, 因此不涉及界面外观、操作手感与运行速度的任何描述。 该仓库建立于 2026-08-13,README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更, 文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。