夜雨聆风学习资料网

ARTICLE · 989162

DeepSeek-Harness 源码剖析(五):不保存任何会话状态,session 插件凭什么断点续跑

DeepSeek-Harness 源码剖析(五):不保存任何会话状态,session 插件凭什么断点续跑

摘要:dsh-session 的日志里没有「当前状态」,只有一条只追加的事件流:消息历史、请求头、重试计数全是 fold 出来的投影。崩溃重启后恢复逻辑只干一件事,给没配对的 tool/call 补边界。这篇从一条事件的一生,走到 kill -9 之后的重启。

设想一个场景:agent 正在跑一个 bash 命令,改了三个文件,命令还没返回,进程被 kill -9 干掉了。重启恢复时,harness 面对的问题很具体:这个工具调用执行到哪了?有没有副作用?能不能直接重试?

上一篇拆 llm 插件时,我们把三笔账都记在了「会话日志」上:重试计数写在里面,replayState 从里面剥,invariant 拿它校验请求。这篇就来拆这本账本本身:packages/core/session(dsh-session),加上外围落盘的 session-persistence-jsonl。

先说结论。这本账本里没有「余额」,只有「流水」:你找不到一个字段叫「当前消息历史」,也找不到「当前 todo」,所有会话状态都是从一条只追加的事件流里现算出来的。重启恢复时 dsh 不迁移任何状态格式,它只做一件事,给写到一半的回合补上配对的边界。

这块源码的名词比 llm 插件多一倍。surface、provenance、fold、撕裂尾,第一次读容易迷路。但主线只有一条:跟着一条 tool/call 事件走完它的一生,从 append 进日志,到投影成模型可见的消息,到落盘,到进程死掉,再到重启修复。

一、事件长什么样:一张词表和一条契约

日志的骨架小得反常。每条 SessionEvent 只有四个固定字段:type、seq、time、data,真正的内容全在 type 和 data 的配对里。词表定义在 types.ts:236-333,核心的 10 类事件,先按一个标准劈成两半:

类别
成员
模型可见?
消息型
user/message
、assistant/message、tool/result
是,会进「表面」
日志型
turn/start
、turn/end、step/*、assistant/chunk、todo/write、request/header 等
否,只在日志里

为什么 chunk 也要记?token 级的流式回放保真。UI 想逐字还原一次输出的过程,靠的就是这些不进表面的原始块。

词表之外还有一条贯穿全系统的契约:seq = log.length,事件序号从 0 连续无空洞。表面折叠、持久化游标、fork 边界,全部押在这条契约上,后面会反复看到它。

前向兼容的取舍也值得看一眼。新版本 harness 写了旧 reader 不认识的事件怎么办?dsh 的缺省语义是「必需」:旧 reader 遇到不认识的必需事件,必须拒绝重建,不允许静默跳过。写方只对纯信息性记录打 ignorable: true。缺省刻意选在「宁可报错」这一侧,报错能修,悄悄丢会话内容没法修。

日志之外还有一份存储层元数据 SessionHeader(types.ts:61-99):version、cwd、fork 血统(parentSession/seedLength),还有 delegationDepth 和 agentPreset。后两个也持久保存,原因很实际:重启后子代理的递归预算不能重置、preset 不能漂移,否则恢复会重放出一份模型根本执行不了的历史。

消息型事件怎么被强制声明表面意图?靠 discriminated union(types.ts:404-436):surfaceOp、sourceEventSeqs 两个元数据字段只存在于消息型事件的变体上,SessionEvent 按 type 收窄 data。源码注释写得很直白:编译器在 Session.append() 的调用点强制这件事。

词表定好了。事件想进 log,要过 append 这道关。

二、append:一次校验、一次快照、一次发布

Session.append(index.ts:604)是整个系统的写入热路径。四步,一步不多:

append<T extends SessionEventType>(  type: T, data: SessionEventMap[T],  ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []): SessionEvent<T>

这段是 append 的签名。注意可变参数那一行:消息型事件必须携带 SurfaceIntent,日志型事件禁止携带。上一节的类型契约,落到了每个调用点上。

第一步,snapshotJsonValue(data) 单遍完成「读取 + 校验 + 拷贝」(index.ts:613 附近)。为什么必须单遍?如果先检查再 structuredClone,一个带状态的 getter 可以在校验和存储两个时刻返回不同的值。BigInt、symbol、-0、稀疏数组、Map/Set/Date,全在这里拒收。

第二步,surfaceManager.validateNext(event) 在事件入 log 之前完成表面状态转移的校验(index.ts:634)。校验失败时表面不留下任何部分修改,log 里也进不去半套东西。

第三步,收集监听器快照,log.push(event),然后以逐监听器隔离的方式通知观察者。观察者抛异常只 warn,已提交的 append 不能因为某个听众出问题而失败。

第四步,appending 重入保护:发布边界打开期间再 append,直接抛错。监听器里递归写日志造成的顺序问题,在入口就被掐掉。

事件入 log 即 deepFreeze(index.ts:627)。之后无论怎么 cast 都改不了历史,events getter 返回的也是缓存过的冻结快照数组。

日志这边秩序井然。但模型不读日志,模型要的是消息列表。下一问:消息历史从哪来。

三、surface:账本到对话的投影

dsh 在「完整事实」和「模型可见」之间切了一刀。切出来的东西叫 surface(表面):最终送进 LLM 请求的那份有序消息视图。

三种消息型事件,append 时必须声明自己怎么加入表面(surface.ts):

  • 'append'
    :加到尾部,普通路径;
  • {op:'replace', start, end}
    :替换表面中 seq ∈ [start, end] 的节点,且被替换者必须完整列入 sourceEventSeqs。

replace 是 compaction(历史压缩)改写历史的唯一合法通道。校验函数 planSurfaceEvent(surface.ts:321)会逐项检查:provenance 无重复、全部早于本事件 seq、replace 覆盖每一个被遮蔽节点。少记一个被替换的 seq,过不了校验。

这里有个 plan/apply 两段式:validateNext 只产出 SurfacePlan,不动任何状态;applySurfacePlan(surface.ts:362)才真正 push/splice。对完整日志的纯重放走 foldSurface(surface.ts:387),与在线增量共享同一套转移函数。

为什么要专门讲这个结构?事件溯源有一类高发 bug:在线增量算的状态和离线重放算的对不上,跑得越久差得越远,还极难定位。dsh-session 让两条路径共享同一个转移函数,漂移在结构上就无从发生。

投影的出口只有一个:deriveMessages()(index.ts:726),agent-loop 组装请求的唯一消息来源。

deriveMessages(): Message[] {  const surface = this.surface  ...  for (const seq of nodes.slice(this.derivedNodes)) {    const msg = this.deriveEventMessage(this.log[seq]!)    if (msg) this.derived.push(msg)  }  this.derivedNodes = nodes.length  return [...this.derived]}

这段是增量投影的循环体。只处理上次之后的新节点,成本 O(新节点);replace 发生时按 replaceGeneration 计数整体重建。注意 if (msg) 那行:空 content 的 assistant/message(max-tokens 截断步只带 usage 的空壳消息)投影为 null,不进对话历史。我核稿时看到循环里那行 oxlint-disable 注释,专门解释为什么 seq 一定是合法下标,构造不变量直接写在注释里。

返回的数组每次是新快照,但 Message 对象共享且深冻结。缓存不用二次深拷贝,消费者也借它改不动日志。

这给了 agent-loop 一条能落地的不变量:请求 == deriveMessages()。如果消息历史是内存里随手拼的,这条断言根本无法表述。同样共享转移函数的还有请求头折叠(requestHeader,index.ts:670),llm 篇的 invariant 就拿它校验每次请求不发散。

replace 的合法范围还有一条窄门:tool/result 的 replace 只允许重写单个当前节点自己的 content(surface.ts:287-318,把双方 content 置 null 后做深度相等比较)。工具结果重写,比如输出截断,被限制到不能再窄的操作面,历史里其余一个字节都动不了。

四、fork:子代理不能带半个回合

subagent 给子代理发牌,用的就是这本账本。fork(source, boundary, childId)(index.ts:1081)取源会话 [0..boundary] 的稳定前缀做种子,meta 里记下 parentSession 和 seedLength。

OPEN_TURN 这条校验的代码比文字直白(index.ts:1128-1135):

const lastTurnBoundary = events.slice(0, boundary + 1)  .findLast(event => event.type === 'turn/start' || event.type === 'turn/end')if (lastTurnBoundary?.type === 'turn/start') {  throw new SessionForkError(    `fork boundary ${boundary} in session 「${session.id}」 ends inside open turn ${lastTurnBoundary.data.turn}`,    'OPEN_TURN',  )}

这段是「边界落在开着的回合里」的判定。注意看它的手法:不维护「回合开着吗」这个布尔状态,直接在事件流上 findLast 现查,边界之前最后一条回合事件若是 turn/start,边界就劈在未闭合的回合中间。另一条 INVALID_BOUNDARY 拒绝不连续的边界。fork 出的会话不能带半个回合,半截上下文重放不出合法状态。

fork 之外,会话在内存里的生命周期也全走事件。但落盘才是本篇真正的问题所在。

五、撕裂尾:崩溃留给重启的最后一帧

先把全景图放这儿,后面每一小节都在填它的一段:

写入、投影、落盘、崩溃、修复

持久化格式是围绕崩溃设计的,每一条都在回答同一个问题:写到一半怎么办。

磁盘格式:每个会话一个 append-only 文件,默认 zstd 帧拼接压缩。每个写入批压成一个带 checksum 的独立帧,追加即封帧;首帧恰好是 header 行,会话列表只解压第一帧就能列出全部会话。

撕裂尾因此有了定位办法。崩溃时只写了一半的末尾字节,checksum 对不上,纯结构扫描就能截断,不需要逐事件解析。截掉之后剩下的,一定是一个完整提交过的事件前缀。

落盘本身不是同步的。SessionWriteBehind 攒批写,窗口默认 200ms(DEFAULT_WRITE_BATCH_MAX_DELAY_MS = 200,coordinator.ts:30,我核稿时把这个默认值翻了出来)。失败时按序保留重试,事件只延迟,不丢。

冷恢复不直接 new 一个 Session,走 ctx.sessions.prepare(id, {seed, seedSource:'persistence'}) 的所有权转移路径(Session.fromRestore,index.ts:495 附近)。读取方拿到存储前缀后先做迁移校验:事件逐条 adopt,独占事件就地校验加深冻结、不拷贝。这是持久化热路径用 adoptSessionEvent、而查询路径用 snapshotSessionEvent(structuredClone 后再 adopt)的原因(index.ts:167-194),一个字节的拷贝都省在刀刃上。

然后是修复。interruptedTurnClosers(repair.ts:27)扫完整日志,找出没配对的工具调用:

  • tool/call
     已落盘、没有 tool/result:合成一条 TOOL_OUTCOME_UNKNOWN 的工具结果;
  • assistant 块里登记了调用、tool/call 都没落盘:合成 TOOL_NOT_STARTED,可直接重试。

随后补上 step/end 和 turn/end{kind:'interrupted'}。顺序有讲究:step 边界必须先于 turn 边界合成,否则 turn/end 落在开着的 step 上,本身就是 invariant 违例。时间戳也有讲究:合成事件复用最后一条真实事件的时间,不编造未来的时间戳,重放保持确定性。

回到开场的 kill -9。harness 不知道那个 bash 执行到哪了,它把「不知道」本身写成一条事件,连处理建议一起:

Its outcome is unknown. Decide whether to retry from the tool semantics:retry only if the operation is read-only or idempotent; if it may have sideeffects, first verify external state or ask the user. Do not retry blindly.

这段是 repair.ts:104 合成结果的原文。错误文案本身在教模型做事:决策权留给模型,规则就写在它下一条读到的消息里。

六、持久化层:内核不知道文件系统存在

session 内核和持久化之间的接缝,窄得反常。

分层是这样的:PersistenceCoordinator(session-persistence 包)管缓冲、per-id 串行化、修复编排;存储后端只实现约 6 个字节级原语(PersistenceBackend,coordinator.ts:127)。协调器挂在四个事件接缝上:session/created、session/event、session/flush、session/disposed。其中 flush 是被 await 的持久化屏障(SessionStore.flush,index.ts:1022),Promise.allSettled 跑完所有监听器,再抛第一个失败。

后端的必需原语长这样(签名摘录,readStoredRevision 等查询方法略):

export interface PersistenceBackend<TornMarker = unknown> {  readonly name: string  loadStored(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<TornMarker> | undefined>  appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise<void>  commitRepair(meta: SessionHeader, tornMarker: TornMarker | undefined, closers: readonly SessionEvent[]): Promise<void>  list(signal?: AbortSignal): Promise<SessionHeader[]>}

这段是接缝的宽度。注意看 commitRepair 的第三个参数 closers:修复合成的边界事件由协调器算好递进来,后端只管落盘。「修复从不编造内容」这条纪律,直接焊在接口签名上。

这个接缝不是纸面设计。初稿我本来把「换 SQLite 后端」当假设的扩展场景写,核对仓库时发现 packages/session/session-persistence-sqlite 已经躺在那儿,store.ts 实现的就是 PersistenceBackend。可换后端在这里是现状,不是许诺。

两个工程细节值得单独记下。原子发布:POSIX 上用 link 而非 rename,目标已存在时收到 EEXIST 报错,而非静默覆盖;Win32 上经 koffi 调 MoveFileExW(..., MOVEFILE_WRITE_THROUGH)(win32.ts:8)。版本语义:格式版本拒绝发生在任何结构校验之前,未来格式不必满足今天的结构检查。协调器把拒绝分成 SessionFormatUnsupportedError(日志完好,本运行时读不了)和 SessionPersistenceCorruptionError(真损坏)两类,用户该看到的是「升级 harness」,不该是「日志损坏」。

磁盘布局一句话:root///session.jsonl.zstd。root 没有默认值。默认 process.cwd() 会随 bash 和子进程漂移,把会话文件散落各处,所以干脆要求显式配置。

七、设计取舍

为什么内核不含持久化?事件溯源的核心,append-only 日志和 append 协议,是稳定内核;「状态怎么用、存到哪」是部署差异点。dsh-session 把 surface 和 deriveMessages 留在内核当事实源,core 层不知道文件系统存在。换存储后端因此是插件级替换,不涉及内核一行代码。

替换掉 session 本体会失去什么?几乎全部可恢复语义。agent-loop 的 deriveMessages、llm-retry 的持久计数、tools 的 call/result 配对、subagent 的 fork 血统,全部建立在 seq 连续的 append-only 日志上。谁依赖 ctx.sessions 和连续 seq,谁就依赖这个插件。

已知代价也说清楚:append 每次做完整 JSON 快照、深冻结、表面校验,热路径开销不小。换来的是坏事件在写入那一刻就失败,拖不到 flush 时才暴露。事件溯源系统里「校验过的值和存下的值不一致」这类重放发散 bug,在这里没有藏身的位置。

想扩展的,入口现成:declare module 往 SessionEventMap 里合并自己的事件类型。agent 插件给 inbox 加 agent/inbox/spliced 时就是这么写的(agent/src/types.ts:12-25):

declare module '@deepseek-ai/dsh-session/types' {  interface SessionEventMap {    'agent/inbox/spliced': {      target: InboxTarget      start: number      removedCount?: number      inserted: UserMessage[]      outcome?: 'canceled'    }  }}

这段是一个插件往事件词表里添词的全过程。注意看它合并的是接口不是对象:类型检查从此认得这个事件名,append 传错 data 编译期就报错。llm-retry 的 llm/retry 是同款写法;再写一个从事件流 fold 状态的投影类,你的插件就有了自己的持久化。

八、结语

重启之后,模型读到的下一条消息里躺着一条 ToolOutcomeUnknownError,文案写着 「retry only if the operation is read-only or idempotent... Do not retry blindly」。那个被 kill -9 打断的 bash,执行没执行过,日志不知道,恢复逻辑也不猜。它只负责把「不知道」和应对规则如实入账。

一条 tool/call 的一生走完了:词表和 seq 契约给它在日志里定了位,append 四步让它安全入册,surface 把它投影成模型可见的一行,200ms 后落进 zstd 帧,进程死掉后撕裂尾被截掉,repair 给它配上 TOOL_OUTCOME_UNKNOWN 的结局。

下一篇拆 agent-loop:这本账本上,一次完整的 turn 怎么跑起来。从 user/message 进日志,到 inbox 双队列在事件流上重建,到 turn/end 落盘,主循环的每一步都在消费今天这页账本。

你的 Agent 重启之后,执行到一半的工具调用是怎么处理的?有做恢复的,评论区聊聊方案。


本系列基于 DeepSeek Harness 源码(MIT,0.1.1-rc.1)与官方 Agent Notes 整理,仓库:github.com/deepseek-ai/deepseek-harness。有收获就点个关注,下一篇见。

相关学习资料