DeepSeek Harness 的会话持久化:一次对话在磁盘上留下哪些东西
先摆一个很实际的问题:你用一个 agent 跑了一次长对话,中途机器蓝屏了。重启之后你想知道三件事——这次对话在硬盘上到底留下了哪些文件?那个文件能不能直接打开看?崩溃时那半截没跑完的轮次,是被丢了还是被留下了?
deepseek-harness 这个仓库把这三件事写得比较死,而且大部分答案不在 README 里,在 docs/subsystems/persistence.md 和 packages/session/ 下面的几个包里。下面按「一次会话从内存走到磁盘」的顺序走一遍,途中把关键文件和数值标出来,你看完可以自己回仓库对。
需要先说清楚的限定:该项目 README 自述处于开发者预览阶段(Developer preview),并明写会有破坏兼容性的变更,本文提到的目录名、配置项与默认值随时可能变动,请以仓库最新内容为准。另外我们只读仓库,没有安装也没有运行过它,所以这里不会出现任何「跑起来怎么样」的描述。
第一步:根目录是怎么定下来的
会话日志的根不是持久化包自己拍的。packages/session/session-persistence-jsonl/src/index.ts 里的 Config.root 是必填项,没有默认值,源码注释写明了理由(文档自述):如果默认成 process.cwd(),进程 cwd 一变(bash 调用、子进程),会话文件就会被撒得到处都是。
那默认值在哪?在部署组合里。packages/bundle/base/cordis.patch.yml 把这一条写成:
- id: session-persistence-jsonl
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
root: !!js dshHomePath('sessions')
dshHomePath 来自 packages/util/home-paths/src/index.ts,它先解析 harness home,优先级依次是:显式配置的路径 > 环境变量 DSH_HOME > ~/.dsh(DSH_HOME_DIR_NAME 常量就是 .dsh)。源码里还专门处理了一种情况:DSH_HOME 是空串或全空白时按未设置处理,否则 resolve('') 会把 home 解析到当前目录去。
所以在默认组合下,会话根是 ~/.dsh/sessions。这个包同时被 resolve() 过一次,注释说明是为了让后来的 cwd 变化不会把一个后端拆到两个根上。
第二步:三层目录,名字是算出来的
进了根之后是三层:项目目录 / 会话目录 / 日志文件。规则全在 packages/session/session-persistence-jsonl/src/format.ts。
项目目录名由 projectKey(cwd) 生成:把 /、\、: 三种分隔符统一换成 -,连续分隔符只留一个;A-Za-z0-9._- 之外的码元(包括 ~ 本身)转义成 ~XXXX 形式;开头的 - 剥掉,全空时退化成 root;最后截到 251 个字符,两边各包一对 --。按这个规则,Windows 上一个 D:\work\demo 会得到 --D-work-demo--,Linux 上 /srv/app 会得到 --srv-app--。函数注释自己写明分隔符替换和截断是有意有损的,为的是让目录人类可读。
有损就意味着两个不同的项目路径有可能落进同一个项目目录,所以真正保证唯一的是下一层:会话目录用 encodeSegment(id),这个函数是对全部 UTF-16 码元单射的,. 编成 ~002E、.. 编成 ~002E~002E,把 ../、绝对路径、NUL、分隔符全部中和掉——因为 SessionId 是个没做过校验的 branded string,直接拿去拼路径就是路径穿越。
会话没有 cwd 时,项目目录固定是 _no-cwd。
日志文件名固定叫 session,后缀由物理编码决定:logSuffix() 在 zstd 下返回 .jsonl.zstd,否则返回 .jsonl。默认编码是 zstd(DEFAULT_COMPRESSION),所以默认情况下你 cat 不出来内容,那是一串带 checksum 的 Zstandard frame。
这里有个容易踩的口径:同一个 root 下不允许两种编码混着放。index.ts 里有一段专门检查相反后缀的日志是否存在,命中时抛错并提示「换一个 root,或者选匹配的 compression 模式」。也就是说,你把配置从 zstd 改成 none 又指着同一个目录,得到的是拒绝,不是自动兼容。
第三步:第一行是 header,不是对话
文件的第一条记录不是消息,是会话元数据。format.ts 的 HeaderLine 带一个 type: 'session' 标签,字段包括 version、id、createdAt、可选的 cwd、parentSession、seedLength、origin、delegationDepth、agentPreset。
几个点值得单独拎出来:
version打的是SESSION_FORMAT_VERSION,这个常量在packages/core/session/src/types.ts里当前是 0。注释写明它是单一递增整数,没有主次版本之分,且「是否要 bump 由写入方决定,不由读取方能否接受决定」。delegationDepth在序列化时用了?? 0,也就是顶层会话也会实打实写一个0进去,不是省略。origin: 'subagent'的注释写明它只是「粗粒度的展示用分类」,不代表这个子会话可以被继续。这类措辞在这个仓库里很常见,读的时候别自己加戏。fromHeaderLine()里有一条硬拒绝:header 上出现sandboxMode或approvalPolicy时直接抛session header uses retired policy baseline fields。这是给旧日志留的挡板。
为什么元数据不进事件日志?docs/subsystems/persistence.md 自述的理由是:格式版本、cwd、血统与 seed 边界属于存储层关注点,不属于对话事件,所以它们不进 SessionEventMap,也不会流到 deriveMessages()。
header 之后每一行是一个事件记录。packChunks 默认为 true(DEFAULT_PACK_CHUNKS),会把连续的 assistant/chunk 增量事件打包成 text-chunks / reasoning-chunks / tool-call-chunks 存储行;配置注释自述这是无损的、且「在一次真实会话上测得日志小约 60%」——这是源码注释里的说法,不是我们测的。关掉它则一行一个 SessionEvent,便于诊断。读取侧不受这个开关影响,scanLog 两种排布都认。
第四步:事件不是当场落盘的
这一点最容易造成误会。docs/subsystems/persistence.md 写明 session/event 是一个同步通知,持久化插件把事件复制到逐会话控制器,不阻塞生产方。第一个待处理事件开启一个固定的批处理窗口,后续事件加入但不重置截止时间;窗口到期才启动一次写入批次。
窗口默认多长?packages/session/session-persistence/src/coordinator.ts 里 DEFAULT_WRITE_BATCH_MAX_DELAY_MS = 200,上限是 MAX_WRITE_BATCH_DELAY_MS(取自 Node 定时器的最大延迟)。同一个文件里 DEFAULT_PREPARED_SESSION_CACHE_SIZE = 5,那是冷会话准备的 LRU 大小。
注意文档给的限定:这个配置的最大值只限制有意的批处理等待,不限制事件循环调度、也不限制后端完成持久化的延迟。换句话说 200 毫秒不是「最多 200 毫秒就一定在盘上」的承诺。
真正的落盘保证由另一个插件给:packages/session/session-checkpoint-policy。它的 README 写明会在三个语义边界上做 checkpoint——模型适配器收到请求之前、顶层工具体可能产生外部副作用之前、以及每个 agent/pre-step 边界。README 同时明说:只加载持久化后端而不加载这个策略是合法的,但崩溃可能丢掉还在批处理窗口里或写入未完成的事件。
第五步:真写文件时干了什么
首次实体化走 materialize():先编码,再按平台分叉。
POSIX 侧:mkdir 三层目录,模式都是 0o700,每层建完 fsync 父目录;临时文件用 open(tmp, 'wx', 0o600) 写完 sync();然后用 link() 而不是 rename() 发布。源码注释写明理由:link() 在目标已存在时报 EEXIST,两个进程同时实体化同一个 id 不会互相覆盖,而 rename() 会静默覆盖。发布成功后再 fsync 一次目录,最后尽力清理临时硬链接,清理失败不让 append 失败。
Windows 侧在 packages/session/session-persistence-jsonl/src/win32.ts:注释写明 Node 没有把「fsync 父目录」这个语义暴露到 Windows,所以改用原生的 MoveFileExW(..., MOVEFILE_WRITE_THROUGH) 发布,不允许替换、也不做跨卷复制回退。kernel32.dll 通过 koffi 懒加载,注释写明这样做是为了让非 Windows 进程永远不会加载 koffi。文件里还把一串 Win32 错误码(ERROR_FILE_EXISTS、ERROR_NOT_SAME_DEVICE、ERROR_ACCESS_DENIED 等)映射成 Node 的 errno 字符串。
后续追加走 appendLines():写之前先 stat 记下当前大小,写入并 sync();一旦部分写入或 sync 失败,就 truncate 回原来的大小再抛错。注释给的理由是——游标没动,这一批会重试,留着半截字节会造成重复的序号。
第六步:崩溃之后那半截轮次
这是我最想让人记住的一段。后端重新加载一个中途崩溃的日志时,会看到一个已打开的 turn/start 却没有 turn/end。它不截断,而是补一个合成的 turn/end { reason: { kind: 'interrupted' } } 把这个轮次配平。docs/subsystems/persistence.md 给的理由是:长周期任务里单个轮次可能非常庞大,那些事件在崩溃前已经被持久追加了。文档还点明 interrupted 是唯一一个不由循环发出的 TurnEndReason。
修复只对冷会话生效。对还活着的 id,load(id) 会等内存里的权威快照落盘,只在日志平衡时返回;活跃轮次没闭合就直接拒绝,不会补合成边界。
只有真正撕裂的最后一条记录会被丢。zstd 模式下这体现在帧层面:zstd.ts 的 scanZstdFrames() 不解压就扫帧结构,遇到 EOF 落在最后一帧中间时返回 tornStart,交给 repair() truncate 到那个偏移再 fsync。
另外还有一类拒绝不是损坏:header 的 version 和本构建的 SESSION_FORMAT_VERSION 对不上时抛 SessionFormatUnsupportedError,且 refuseForeignFormatVersion() 是在校验 header 形状、解码任何事件行之前跑的——文档自述这样做是为了让未来格式的用户看到「升级 harness」,而不是看到「日志损坏」。这两类错误在代码里是不同的类。
那这次对话到底留下了什么
回到开头的问题,按仓库里能核到的部分列:
| 落在哪 | 内容 | 依据 |
|---|---|---|
<root>/--项目名--/<编码后的会话 id>/session.jsonl.zstd | header 行 + 事件行 | format.ts 的 logPath / logSuffix |
~/.dsh/attachments/v1/objects/<sha256 前两位>/<sha256> | 图片等附件字节 | packages/attachment/attachment-local/src/store.ts |
| SQLite 后端的单个库文件(换后端时) | 每个事件一行 | packages/session/session-persistence-sqlite/src/schema.ts |
附件是内容寻址的:store.ts 里 objectPath() 用 sha256 分两级目录,读回来还会再算一次摘要校验,对不上抛 ATTACHMENT_CORRUPT。基础组合的注释也写明了这个分工——图片字节不进只追加的会话日志,日志里留的是引用。
换成 SQLite 后端则是另一套:SCHEMA_VERSION 当前是 15,和会话自己的 version 是两回事(前者管表结构,后者管事件词汇表,存在 sessions 行里);文档写明 events 表和 SessionEvent 一比一。这里有一处口径差异值得记一笔:schema.ts 的 CREATE TABLE events 实际建了八列——session_id、seq、type、time、data、source_event_seqs、surface_op、ignorable,主键是 (session_id, seq);而 docs/subsystems/persistence.md 里列出的行字段是七个,没有 ignorable;同一个 schema.ts 里的 EventRow 接口也是七个,缺的那个是 session_id。三处的清单各不相同,以建表语句为准,差异的原因我们不猜。因为各会话共享一个库,locate() 在 SQLite 下返回 undefined,supportsRawArtifacts 也是 false。
还有一个默认不落盘的:基础组合里 session-query-sqlite 的 path 写的是 :memory:,注释说明全文搜索是 opt-in,openAt: never 时 SQLite 根本不会被打开。想要持久的搜索索引得自己在后续 patch 层里改。
一个安全侧的事实,别绕过去
packages/shell/shell-env/README.md 写明:模型的每次 shell 调用都会拿到一份 DSH_* 环境,其中包含 DSH_SESSION_ID;当持久化 seam 能定位到 JSONL 工件时,还会拿到 DSH_SESSION_JSONL=<绝对路径>。README 同时明说这是位置提示,不是授权凭据,也不保证新鲜——第一次 flush 之前文件可能压根不存在,开着的轮次内容也不在里面。
SessionLocation 那个接口的 JSDoc 里也重复了同一句:consumer 必须把它当位置提示,绝不能当授权 token。
这意味着跑在你机器上的模型是知道会话日志在哪的。这不是「有沙箱所以安全」能糊过去的问题:仓库自己在 .agents/notes/ 下那份凭据边界的架构记录里写明,把整个 harness home 一并拒读这个方案被否掉的原因之一,就是那样会连 sessions/ 一起盖住,而 DSH_SESSION_JSONL 是一个有文档的、模型可见的能力。同一份记录还写明,只有把秘密挪进操作系统钥匙串那一种设计,才能做到「模型的进程确实读不到这个秘密」——按这句的口径,当前形态下模型侧进程是能读到本机上这些文件的。要不要接受这个边界,得你自己按环境评估。
什么情况下这些细节会咬到你
- 改了
compression又指着老 root:拿到的是拒绝而不是自动迁移,报错文案就写在index.ts里。 - 换了项目目录再恢复会话:项目目录名是从 cwd 算出来的,cwd 变了就是另一个目录;
projectKey的截断和分隔符替换是有损的,别把它当唯一性来源。 - 备份只拷了
sessions/:附件在attachments/v1/下,是另一棵树。 - 崩溃后发现日志末尾多了一条
turn/end:那是设计中的合成闭合,不是数据被改写;文档写明前后的独立事件不动。 - 拿 200 毫秒当落盘承诺:它只是有意批处理等待的上限,真正的语义屏障来自 checkpoint 策略插件。
最后重复一遍那条限定:这是一个自述处于开发者预览阶段、明说会有破坏性变更的仓库,上面每一个常量和目录名都可能在下一次提交里改掉。真要依赖,回去读 docs/subsystems/persistence.md 和 packages/session/session-persistence-jsonl/src/format.ts 这两个文件本身。
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。