DeepSeek Harness 到底往磁盘写了多少种东西:持久化目录逐条看

2026-08-17

问题是这么来的:你想写个外部脚本去读 dsh 留下的会话记录,或者只是想弄清楚一次对话跑完之后,本机硬盘上到底多了些什么。翻 README 只会看到一行开发者预览的警告,真正能回答这个问题的是仓库里两份东西——docs/persistence-catalog.md 这份由脚本生成的事件目录,和 packages/storage/ 下那一族管非会话数据的源码。这两条线是分开的,混着看就会数错。

先说清限定:下面所有常量、默认值和路径都出自版本 0.1.0-rc.5 的仓库快照,README 自述该项目处于开发者预览阶段,并明确写明会有破坏兼容性的变更。把这些当参考坐标可以,当稳定接口不行。

第一条线:会话事件日志,44 种事件类型

docs/persistence-catalog.md 是生成出来的,文件头写明由 scripts/gen-persistence-catalog.ts 产出,并由 pnpm run verify-persistence-catalog 校验新鲜度。我们把英文版里的事件条目数了一遍:44 条,分在 24 个命名空间前缀下(agent/*approval/*compaction/*tool/*tool-workflow/* 这些)。

代码侧对得上。packages/core/session/src/known-event-types.ts 里的 KNOWN_SESSION_EVENT_TYPES 集合同样是 44 条,这个文件头部也写明是同一个脚本生成的、不要手改。文档和代码在这个数上一致,这一点值得记下来——本批里不是每个数字都这么老实。

44 条里只有 3 条被标成 surfaceuser/messageassistant/messagetool/result。剩下 41 条全是 log-only。文档对这两个词给了定义:surface 表示该事件会生成一条 LLM 消息,并声明它如何加入 surface 列表;log-only 表示「可持久化、可回放,但不参与派生历史」。

3 比 41 这个比例值得停一下。剩下那 41 条按目录给的定义就是「不参与派生历史」的记录,approval/askedapproval/decidedhook/invokedhook/resultpermission/presetplan/modestep/startturn/end 都在这一档。其中有两条的 JSDoc 把话说得更死:approval/policysandbox/mode 的注释里都出现了同一句 never in the model transcript,approval/policy 那条还顺带写明模型是从运行时上下文快照与实时切换通知里知道当前策略的,不靠这条日志。要说清楚的是,这句话只在这两条条目上逐字出现,目录没有给其余 log-only 条目统一加这一句,别把它当成全体 41 条的通用注脚——它们同属 log-only 是目录里的 badge 给的判定,不是这句注释给的。所以「日志变大了会不会撑爆上下文」和「日志里记了多少东西」是两个问题,别拿一个推另一个。

格式版本是 0,读到不认识的类型会拒绝

packages/core/session/src/types.ts:56 定的是 SESSION_FORMAT_VERSION = 0。文档在信封那一节写得很直接:这是预发布格式,不暗示任何兼容性。

读取侧对未知类型的处置也在源码里:packages/session/session-persistence/src/coordinator.ts 的检查是——事件类型落在 KNOWN_SESSION_EVENT_TYPES 里,或者事件信封带 ignorable: true,才放过。信封上 ignorable 字段的注释把取舍说明白了:读到一个不认识、又没带这个标记的必需事件时,读取方必须拒绝重建会话,而不是悄悄丢掉它,因为一个不认识的必需事件可能改变整份日志其余部分的解读方式;写入方只在纯信息性、丢了也不影响重建的记录上标 true

拒绝的错误类型跟「损坏」是分开的两类:SessionFormatUnsupportedErrorSessionPersistenceCorruptionError。文档写明,header 的 version 比本构建新时,错误消息会说明方向(由更新的 harness 写入,请升级后打开);比它旧时说明本构建没有升级路径。

磁盘上的行不止 44 种

这是最容易踩的一处。事件词汇有 44 个,但 JSONL 日志文件里的行标签不止 44 个。

第一行是 header 行,tag 是 session。除此之外,packages/core/session/src/chunk-rows.ts 定义了三种「storage row」:text-chunksreasoning-chunkstool-call-chunks。这个模块的头注释把边界说得很死——这三种是持久编码词汇,不是 session 事件,它们不进 Session.events,没有 SessionEventMap 条目,并且刻意使用不带斜杠的裸 tag,好让读取方没法把它们跟事件分类混起来。

打包的触发条件是常量 MIN_RUN = 3:连续的同一块 delta chunk 少于三条就不打包,注释说明理由是低于这个数时一行 storage row 的信封开销跟它替掉的事件行不相上下。而 packages/session/session-persistence-jsonl/src/index.tsDEFAULT_PACK_CHUNKS = true,也就是说默认就在打包。同一份注释还自述了打包动机:provider 按 token 级 delta 推流,JSON 信封比 payload 大得多,注释里给的观察值是在一次真实 DeepSeek 会话上约 56 倍——这是源码注释自报的数字,我们没有运行过这个项目,不做背书。

结论很实际:你要自己写一个日志解析器,必须按行标签分四类处理——44 个带斜杠的事件类型、session 头行、三种 chunk row。只认那 44 种的解析器在默认配置下会直接读不懂一半的行。

文件落在哪,header 又放在哪

packages/session/session-persistence-jsonl/src/format.ts 里的 logPath 把路径拼成三段:sessionDir(root, cwd, id) 下的 session 再接后缀。logSuffix 只有两个取值,.jsonl.zstd.jsonlindex.tsDEFAULT_COMPRESSION = 'zstd'。会话没有 cwd 时,项目那一段用 _no-cwd,这写在 logPath 的参数注释里。

元数据不在事件里。SessionHeader 有 9 个字段:必填的 versionidcreatedAt,可选的 cwdparentSessionseedLengthorigindelegationDepthagentPresetdocs/subsystems/persistence.md 给的理由是这些属于存储层关注点而不是对话事件,所以不进 SessionEventMap,也到不了 deriveMessages()

SQLite 会话后端是另一种物理形态:packages/session/session-persistence-sqlite/src/schema.tsevents 表每个 SessionEvent 一行,建表语句写的是八列——session_idseqtypetimedatasource_event_seqssurface_opignorable,主键 (session_id, seq),表带 STRICT。注意最后那列 ignorable:信封上的这个可选标记在 SQLite 侧是一等列,写解析器时别按七列去数。同一个文件里另有 sessions 表,把 header 的字段逐个摊成列(versioncreated_atcwdparent_sessionseed_lengthorigindelegation_depthagent_preset),另加 incarnationrevision 两列。由此带出一个会咬人的差异——SessionPersistence.locate(meta) 在 JSONL 后端返回 transcript 的绝对路径,在 SQLite 后端返回 undefined,因为各会话共享一个数据库、没有逐会话产物。你要是做了个「打开当前会话记录文件」的入口,换到 SQLite 后端时它没有路径可给。同理还有 readRaw:消费方得先看 supportsRawArtifactsfalse 表示后端不提供这个能力(SQLite 就是),而返回 undefined 只表示这个会话还没有实体化的产物——两种情况含义不同。

第二条线:会话日志以外的数据

packages/storage/ 下是四个包:枢纽 storagectx.storage)、注册名为 jsonstorage-json、注册名为 sqlitestorage-sqlite,以及数据形式层 storage-domainctx.storageDomain)。枢纽自己不做 IO,后端拥有介质,数据形式拥有语义。包 README 的「已知限制」一节自述:目前 kv 是唯一的数据形状。

真正声明了领域的地方,我们在仓库里数出三处 defineDomain( 调用(排除测试与 storage-domain 自身):

领域名versionglobal 单例槽声明位置
message_feedback0sessionspackages/feedback/message-feedback/src/spec.ts
session_projcache3sessionspackages/session/session-projection-cache/src/spec.ts
workspace2workspacespackages/workspace/workspace/src/spec.ts

三个版本号各走各的:session_projcache 已经到 3,它的 spec 注释写明版本号一升就整块丢弃介质(缓存语义,代价是回放尾部更长,不会给出错值);workspace 是三者里唯一声明了 global 槽的,槽里放的是 initializedworkspaceIds 这些注册表状态。

名字规则落在 packages/storage/storage/src/backend.ts:10UNIT_NAME_RE = /^[a-z][a-z0-9_]*$/。unit 名和表名都得过这条正则,因为这个名字同时要当文件名和 SQL 标识符片段用;记录键反倒是任意字符串——同一份注释写明记录键永远不会进入文件路径。

两个后端各自怎么落盘

json 后端<root>/<unit>.json,一个 unit 一个文件,人类可读,每次写整文件重发布。这里有个细节值得抄走:root 故意没有默认值,配置注释自述的理由是,一个 process.cwd() 兜底会让 unit 文件散落在进程碰巧启动的目录里,所以要求组装方显式指定。

写入协议在 packages/storage/storage-json/src/atomic.ts:同目录建临时文件 .<uuid>.tmp,以 wx0o600 打开,写完 fsync,再 rename 覆盖目标。Windows 侧要单独看两句。一是模块注释写明 rename 在 POSIX 和 Windows 上都是原子替换,Windows 由 libuv 映射到 MoveFileExW(..., MOVEFILE_REPLACE_EXISTING);二是 rename 之后那次父目录 fsync 只在非 win32 上执行——fsyncDirectory 第一行就是 if (process.platform === 'win32') return,注释写明 Windows 拒绝以 O_RDONLY 打开目录。两处都在源码里白纸黑字,差异摆在这里,我们不往后推断它在崩溃场景下的具体后果。

sqlite 后端:物理布局版本存在 PRAGMA user_version,常量是 packages/storage/storage-sqlite/src/schema.ts 里的 STORAGE_SQLITE_SCHEMA_VERSION = 1,注释写明它与每个 unit 自己的 version 正交。元表两张,都是 STRICT 表:units(name, version)unit_globals(unit, value);每张领域表的物理名由 recordTableName 拼成 u_<unit>_<table>journal_mode 默认 wal,另外接受 deletetruncatepersist,注释说明这几个回滚日志模式是给 WAL 共享内存文件用不了的文件系统(网络挂载)留的;memoryoff 被排除在联合类型之外,理由写明是丢掉日志持久性会与 KV 后端约定的持久性条款相抵触。新建数据库文件按 0o600 独占创建、缺失目录按 0o700 建,同一段注释也明说:当别的主体能替换父目录里的数据库条目时,这挡不住机密性与完整性问题。

用这些数字的时候注意几条边界

版本号有三套,排查时别只看一个。 会话日志看 SESSION_FORMAT_VERSION,storage 数据库看 PRAGMA user_version,某个领域看它自己 spec 里的 version。三者互不相干,任何一个不匹配都是直接拒绝——json 后端读到存储版本与 descriptor 不符就抛 version-mismatch,介质解析不出来就抛 malformed-medium,文档把这条立场写得很明白:不做迁移。

domain/changed 只在进程内。 每次持久写入在后端确认持久性之后发一个事件,按该领域的写链顺序到达;跨进程的变更推送是包 README 记录在案的限制。想靠它做多进程同步的,先看这一条。

日志里是逐字的。 工具结果与原始 stream chunk 都会落盘,tool/call 记的是模型产出的原始 arguments JSON 字符串、未经解析。我们在这两个存储后端的配置里没有找到与加密相关的选项,.jsonl.zstd 只是压缩编码。至于要不要把这些目录纳入磁盘加密或从备份里排除,那是自己环境的通用运维判断,不属于该项目文档的内容。


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

安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

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