
字节笔记本
2026年10月6日 · 约 7 分钟读完
DeepSeek Harness 会话持久化子系统解析

Agent 跑得越久,会话历史越值钱:断点续跑、回放调试、派生子任务,全都建立在一份完整的事件日志上。麻烦在于,写这份日志的进程自己也会崩,断电、升级重启、内存耗尽,都会把一个轮次切成两半。DeepSeek Harness 是 DeepSeek AI 开源的智能体框架,口号是「一切皆插件」,目前处于开发者预览阶段。它把「事件日志如何落盘」这件事收敛成一个独立的能力接缝 SessionPersistence,挂载后以 ctx.sessionPersistence 访问。本文基于其官方文档,拆解这套持久化设计的写入节奏、崩溃恢复与格式策略。
一个接缝,两个后端
持久化接缝只定义动作,不发明新数据:locate、create、append 负责定位与追加,prepare、load、inspect 负责恢复现场,readFrom 提供物理后缀读取,list 与 listSnapshots 做轻量观察。最关键的一条约束是:接缝直接复用内存中的 SessionEvent 词汇,没有平行的持久化事件类型,存储层与运行时之间不存在需要同步的第二套 schema。
两个后端实现同一契约,并共享 runPersistenceContract 契约测试套件。JSONL 后端为每个会话维护一个追加式日志,默认存成带校验和的 Zstandard 拼接帧,也可配置为原始行,写入走崩溃安全的原子路径。SQLite 后端基于 node:sqlite,一行对应一个事件,行字段 session_id、seq、type、time、data 等与事件一一对应,因此同样没有平行 schema 要维护。
刷写检查点:批量窗口与显式排空
session/event 是同步通知,持久化插件把事件复制进每会话的写控制器,不阻塞生产方。第一个待写事件开启一个固定时长的批量窗口,后续事件加入窗口但不重置截止时间;窗口到期即启动一次持久化批次,写入期间进来的事件获得自己的截止时间,组成下一批。这样写入节奏有上界,延迟也可预期:配置的最大值只限制有意的批处理等待,不管事件循环调度,也不管后端完成落盘的耗时。
session/flush 是人工检查点:它取消等待并把队列排空到完全静默,事件循环在领取下一个普通轮次之前都经过它来观察顺序与错误。失败语义也分了层:后台写入被拒绝时保留事件并暂停自动重试,等新事件开启新窗口;显式 flush 则立即重试,失败通过 agent/error 事件与日志报告,绝不会把失败记录写成已关闭轮次之后的会话事件。
崩溃恢复:补边界,不删历史
重载一个轮次中途崩溃的日志,会看到一个开口的 turn/start 没有配对的 turn/end。这里的设计是绝不截断:长周期任务里单个轮次可能非常大,大量步骤与工具输出在崩溃前已经持久落盘,删掉等于扔掉已经付出代价的工作。后端改为补一个合成的闭合事件:
seq 12 turn/start
seq 13 step/start
seq 14 tool/result
seq 15 turn/end { reason: { kind: 'interrupted' } } # 合成的闭合事件interrupted 是全部轮次结束理由中唯一一个不会由正常循环发出的值,读日志的人一眼就能认出这是恢复留下的痕迹。修复只作用于冷会话:对仍然活跃的会话 id,load 会等权威内存快照落盘且平衡后才返回,活跃轮次还没闭合时直接拒绝,而不是塞进合成的中断边界;热更新场景则只接管活跃前缀,不去关闭进行中的轮次。

inspect 提供只读版本:构造不可变的逻辑视图但不发布、不写恢复。冷检查会在内存里配平中断的轮次,物理上撕裂的尾部保持原样;检查已活跃的会话则借用其当前快照。prepare 负责恢复现场:预留会话、提交待定修复并返回可释放的发布句柄。实现里用有界 LRU 缓存未发布的冷会话,重复读历史和随后的 prepare 共享同一次读取、解压、验证与冻结,长列表场景不必反复付出解码成本。
元数据单列,格式拒绝指路
会话的存储元数据不进事件日志:它们是存储层的关注点,留在随行的 SessionHeader 里,不会到达消息推导逻辑。几个字段的设计理由相当扎实:
| 字段 | 存在的理由 |
|---|---|
| version | 磁盘格式版本,加载时不匹配即拒绝,不做隐式迁移 |
| parentSession | fork 血统,记录本会话从哪个会话分出 |
| seedLength | 种子边界,区分继承的父历史与子会话自己的工作 |
| delegationDepth | 委派深度,持久化后子任务的递归预算不会在重启后归零 |
| agentPreset | 组装代理所用预设,它决定工具与提示词,恢复错组合会重放模型无法执行的历史 |
遇到读不懂的日志,后端抛 SessionFormatUnsupportedError,与代表数据损坏的 SessionPersistenceCorruptionError 明确区分,因为前者根本没有坏。版本超前会指路:提示这份日志由更新版本的 harness 写入,升级即可打开;版本滞后则声明当前构建没有升级路径。不属于本构建事件词汇的记录,除非信封带 ignorable: true,否则同样拒绝,因为静默跳过一个必需事件可能改变后续日志的读法。JSONL 后端在解析任何事件之前就凭原始头行拒绝外来版本,结构再不同的未来格式也能报告升级方向,而不是被误报成损坏;SQLite 则先过自己的 SCHEMA_VERSION 检查。
给读模型留的口子
派生状态不必每次全量重放。readFrom 是按 seq 起点的后缀读取原语,投影缓存带着水位线只折叠尾部增量;它只返回有效连续前缀内的事件,撕裂的片段到不了调用方。listSnapshots 为每个会话返回轻量的变更令牌 revision,随追加或修复事务性地变化,调用方比较相等即可判断要不要重载。readRaw 还能取回后端逐字写入的原始工件文本,保留压缩前的序列化细节,供外部工具核对字节级差异。
写在最后
这套设计有三点值得自建 agent 运行时的人借鉴:其一,追加式日志加合成闭边界,恢复可以无破坏地完成,崩溃不吞工作量;其二,元数据与对话事件分家,存储演进不惊动模型可见的历史;其三,把「读不懂」和「坏了」拆成两种错误,升级方向直接写进报错文本。会话持久化听起来是脏活,但边界划干净之后,崩溃就只是日志里多出一条 interrupted 而已。



