DeepSeek Harness 源码精读 04:一切可见皆被记录——会话日志

第 04 章 一切可见皆被记录:会话日志

代码基线:commit 8f86b979a1(2026-10-01)| 0.2.0-rc.1 | 本系列讲次:M3

本章导读

这一章讲 dsh 最核心的产品设计:一条只增不改的事件日志。

书稿里最漂亮的一段洞见在这:模型看到的历史不是"存"出来的,是从日志派生出来的;压缩不是删除旧历史,只是换一个投影方式。"删不掉的现在"和"可裁剪的表面"合而为一。本章要把这个洞见落到代码——60 个事件类型、SessionEventMap 词表、SurfaceOp 的严格适用面。

本章也是全系列勘误最集中的一章。三处需要特别提醒:assistant/chunk 这个事件根本不存在(流是内嵌在事件载荷里的,不是逐 token 追加事件);SurfaceEventType 是 5 种不是 3 种;assistant/message 的 sourceEventSeqs 类型上是 never。

但本章真正的独家贡献是用你自己机器上的真实日志说话。我们解开了 ~/.dsh/sessions/ 里的 session.v4.jsonl.zstd——发现它是 zstd 多帧拼接(不能直接解压)、文件名带格式版本、同一会话的 v2/v3/v4 多代并存。还读到了一条真实会话,它的一个 turn 装了 6 个 step,以及一个被截断的回合。

学习要点

  • 事件词表有 60 个,known-event-types.ts 是权威
  • surfaceOp 的适用面由类型系统强制,给非 surface 事件写它编译不过
  • ignorable 缺省即必需:读到不认识的类型必须拒绝重建,而不是静默跳过
  • 会话文件是 zstd 多帧,要用仓库自己的 scanZstdFrames 读
  • 格式代际从不互相覆盖,迁移只产生新版本名的后继

本章目标

  1. 拿到 SessionEventMap 的真实规模——书稿讲了 13 个事件,仓库里有 60 个
  2. 说清 SessionEvent 信封如何用类型系统强制"哪些事件能带 surfaceOp"
  3. 用你本机的真实会话日志,验证 §3.5 那条铁律和 §3.3.1 的 turn/step 层级
  4. 掌握三件书稿完全没写的事实:日志是 zstd 多帧、文件名带格式版本、迁移后多代并存

一、回读书稿 §3.3 的核心主张

书稿列了 13 个"核心官方成员":

turn/start、user/message、step/start、assistant/chunk、assistant/message、tool/call、tool/result、step/end、turn/end,外加纯日志事件 todo/write、request/header、request/context、session/end-seed

先给结论:13 个里有 1 个根本不存在,另外 4 个被作者自己列为"最重要的"。 实测词表在 known-event-types.ts,由 scripts/gen-persistence-catalog.ts 生成,共 60 个,且被 verify-persistence-catalog 门禁。

实测你本机一条真实会话(--Users-ygs--/6c3afa8f-…/session.v3.jsonl.zstd,72 条事件)的分布:

  15  tool/call          15  tool/result         10  step/start  
   9  assistant/message   9  step/end             3  user/message  
   2  agent/inbox/spliced  1  session  1  subagent/descriptor  
   1  sandbox/mode   1  approval/policy   1  turn/start  
   1  system/message  1  request/header   1  request/context   1  session/title  

七种类型书稿一个字没提:agent/inbox/spliced、subagent/descriptor、sandbox/mode、approval/policy、system/message、request/context、session/title。


二、★ 勘误一:assistant/chunk 从来不是正式事件

书稿 §3.3 把它列为"记录 token 级增量块的官方核心成员"。实测:

grep -rn "assistant/chunk" packages/ --include=*.ts | grep -v node_modules | grep -v '/lib/'  

命中的全部是测试夹具——而且是故意当作"未知的外部事件"来用的(packages/core/test-support/session-snapshot/tests/normalize.spec.ts:1149 等)。

真相是:流不是逐块记成事件的,而是内嵌在两条事件的 data 里。

// types.ts:351-355  
'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] }  

assistant/attempt 的文档注释说得很清楚:

One model attempt that committed no surface message. The embedded stream preserves a failed, retried, cancelled, or stream-error attempt that reached settlement without fabricating model-visible history.

"没产出表面消息的尝试"要单独记一条事件,整条流内嵌在 stream 数组里。 成功的尝试则内嵌在 assistant/message 里。

这修正了书稿的一个隐含模型:不是"逐 token 追加事件",而是"一个 step 提交一条事件,流是它的载荷"。这与 §3.6 的 SurfaceIntent 完全吻合(见下)。


三、★ 勘误二:SurfaceEventType 是 5 种,不是 3 种

书稿 §3.6 写"那三种会产生 LLM 消息的事件:user/message、assistant/message、tool/result"。实测(types.ts:436-442):

export type SurfaceEventType =  
  | 'system/message'  
  | 'developer/message'  
  | 'user/message'  
  | 'assistant/message'  
  | 'tool/result'  

多两个:系统消息与开发者消息。 你本机那条会话里就有一条真实的 system/message。

3.1 surfaceOp 的适用面被类型系统强制

export type SurfaceIntent<T extends SurfaceEventType = SurfaceEventType> = {  
  surfaceOp: SurfaceOp  
} & (T extends 'assistant/message' ? {  
  /** Assistant messages embed their provider stream instead of citing source events. */  
  sourceEventSeqs?: never  
} : {  
  /** Complete non-empty set of known earlier source-event seqs. */  
  sourceEventSeqs?: SessionSeq[]  
})  

再配合 SessionEvent 的条件展开:

} & (K extends SurfaceEventType ? SurfaceIntent<K> : {  
  surfaceOp?: never  
  sourceEventSeqs?: never  
})  

推论:给一个 todo/write 事件写 surfaceOp 会编译不过。这不是约定,是类型。

3.2 ★ 勘误三:assistant/message 的 sourceEventSeqs 恒为 never

书稿 §3.6 写:

比如一条 assistant/message 会记录"它是哪几条 assistant/chunk 拼出来的"

这是反的,而且和 assistant/chunk 不存在是同一件事的两面。 类型定义写的是 sourceEventSeqs?: never,注释解释:"Assistant messages embed their provider stream instead of citing source events."

流内嵌在事件里,不需要引用源事件。 sourceEventSeqs 只对"由若干条更早事件折叠出来"的节点有意义——典型就是 compaction 的 replace 摘要(它要声明盖住了哪些旧表面节点)。

3.3 SurfaceOp 的字段名

书稿写 { op: 'replace', start, end },实际是:

export type SurfaceOp =  
  | 'append'  
  | { op: 'replace'; startSeq: SessionSeq; endSeq: SessionSeq }  

startSeq / endSeq,不是 start / end。 两端都含(inclusive)。注释另有一条硬约束:"The node's sourceEventSeqs must include every shadowed surface node."


四、ignorable:书稿 §3.7 完全正确

这一节一个字都不用改,连哲学都写对了。代码侧的原话(types.ts:496-505):

Absent means required: a reader meeting an unrecognized type without this marker MUST refuse to reconstruct the session instead of silently dropping the event, because an unrecognized required event may change how the rest of the log is interpreted. A writer sets true only on purely informational records whose loss cannot affect reconstruction; defaulting to required means a forgotten marker over-refuses (an inconvenience) rather than silently resuming a gutted session.

"过度拒绝只是不便,静默降级是隐蔽的错误" —— 书稿的这句话就是从这儿来的。

配套的生成文件头还说明了为什么不用"事件名注册表"这种更宽松的方案:

event-name registration was rejected because it does not classify omission safety and would make reads composition-dependent


五、★ 三个书稿完全没写的事实(本章最有价值的部分)

5.1 日志文件是 zstd 多帧拼接,不是单帧

ls ~/.dsh/sessions/--Users-ygs-ygs-deepseek-harness--/*/session.v4.jsonl.zstd  
#   → session.v4.jsonl.zstd  

压缩率极高(12.5 MB 的文件解出 44027 帧)。每次追加写一个新帧,所以文件是"帧的拼接"。用 Node 的 zstdDecompressSync 直接解只能拿到第一帧——必须先用仓库自己的 scanZstdFrames() 扫描帧边界(zstd.ts:48-98),再用 createZstdFrameDecoder() 一次性解码。

那个帧扫描器还处理了撕裂帧:EOF 落在最后一帧中间时返回 tornStart,供修复路径使用。这就是"崩溃恢复"在字节层的实现。

教学点:这解释了为什么"会话文件不能直接用 cat 打开看"。要看,先解压。

5.2 文件名带格式版本号,而且多代并存

实测你本机 559 个日志文件,其中 34 个会话同时存在多个代际:

--Users-ygs--/session-16e18af8-…  →  v3, v4  
--Users-ygs--/session-df91e369-…  →  v2, v3  
--Users-ygs-Downloads-…/repl-6539a74d-…  →  v3, current  

同一目录里 session.v3.jsonl.zstd 和 session.v4.jsonl.zstd 并排躺着。 这正是根 AGENTS.md 那条规则的实证:

never move, overwrite, or delete committed generations

仓库里有完整的迁移链包:session-format-v0-to-v1 … session-format-v3-to-v4。迁移产生的是"新版本名的后继",前代原封不动。 没有任何回退或降级支持——docs/session-format-status.md 是它的权威。

另外注意还有不带版本号的 session.jsonl.zstd:那是尚未迁移的当前版本。

5.3 一个真实会话里的 turn 不配平

实测那条 72 事件的会话:

seq 单调递增: true (0 → 70)  
turn/start = 1   turn/end = 0   (不配平 → 有被截断的回合)  

这是一个被截断的真实回合。 书稿 §3.3.2 讲的是"崩溃时插入合成 turn/end {reason: interrupted}"——那是修复之后的状态;日志里读到的这份还没被修复(修复发生在 session-persistence-jsonl 的读路径上,不在文件里)。


六、实测:把一次 turn 的调用链画出来

从同一条真实日志里按 step 重建:

  step1 ← assistant        step1 → read    step1 → read    ✓result ✓result  
  step2 ← assistant        step2 → read    step2 → read    ✓result ✓result  
  step3 ← assistant        step3 → read    step3 → read    ✓result ✓result  
  step4 ← assistant        step4 → bash                    ✓result  
  step5 ← assistant        step5 → bash                    ✓result  
  step6 ← assistant        step6 → read                     ✓result  

一个 turn 装了 6 个 step,每个 step = 一次模型请求 + 它触发的工具。这正是书稿 §3.3.1 说的"turn 包含零个或多个 step"——现在有了真实数据支撑。

顺带注意:这条会话来自你《时间简史》跨章一致性审校的项目,step4/step5 用的是 bash,不是 read——工具选择确实跨 step 变化。



本章实验(附录)

七、动手验证

export PATH="/opt/homebrew/bin:$PATH"  
cd /Users/ygs/ygs/deepseek-harness  
node --import tsx/esm "ygsdoc/学习笔记/labs/M3-session-lab.ts"  
  
# 换一条会话:  
SESSION=~/.dsh/sessions/<目录>/session.v4.jsonl.zstd node --import tsx/esm "ygsdoc/学习笔记/labs/M3-session-lab.ts"  

七项输出:

# 验证什么
0-1 文件名、压缩体积、帧数(多帧证据)
2 解码后的事件条数
3 真实事件类型分布(对照 known-event-types.ts 的 60 个)
4 一条 user/message 的完整信封:type / seq / time / data / surfaceOp
5 按 step 重建的工具调用链
6 全盘代际扫描:多少日志、多少会话多代并存
7 seq 单调性、turn 配平、turn/end 的 reason 集合

M3 通过标准:能说出 known-event-types.ts 里有多少个事件类型(60),并解释为什么 assistant/message 的 sourceEventSeqs 在类型上是 never(因为流内嵌在事件载荷里,不需要引用源事件——而 assistant/chunk 这类独立 chunk 事件根本不存在)。


实验脚本:labs/M3-session-lab.ts

运行(在仓库根目录):

export PATH="/opt/homebrew/bin:$PATH"  
cd /Users/ygs/ygs/deepseek-harness  
node --import tsx/esm "dsh-源码精读/labs/M3-session-lab.ts"  

预期输出(本机实测,任何一行对不上就说明基线变了):

0) 会话日志: ~/.dsh/sessions/--Users-ygs-ygs--/6c3afa8f-b829-40a9-9315-738e162fc8dc/session.v3.jsonl.zstd  
   压缩后 200039 bytes  
1) 帧扫描: 完整帧 38 个 (无残帧)  
2) 解出事件 72 条(来自 38 个帧)  
3) 事件类型分布(这就是 SessionEventMap 的运行时投影):  
      15  tool/call  
      15  tool/result  
      10  step/start  
       9  assistant/message  
       9  step/end  
       3  user/message  
       2  agent/inbox/spliced  
       1  session  
       1  subagent/descriptor  
       1  sandbox/mode  
       1  approval/policy  
       1  turn/start  
       1  system/message  
       1  request/header  
       1  request/context  
       1  session/title  
4) 样例事件( user/message )的字段:  
    "content": [  
      {  
        "type": "text",  
        "text": "你是《时间简史:人类如何度量宇宙》跨章一致性审校的资料提取员。只读、只写笔记,**绝对不要修改 chapters/ 下任何章节文件**。\n\n工作目录:/Users/ygs/ygs/book-projects/时间简史-人类如何度量宇宙/\n\n你负责 3 章(逐字通读,用 read 工具,文件带行号):\n- chapters/ch07_伽利略的脉搏.md\n- chapters/ch08_铁路的咆哮.md\n- chapters/ch09_本初子午线.md\n\n参考口径文件(先读):BRIEF.md、OUTLINE.md、_decision_log.md。注意统一口径:1884 年华盛顿国际子午线会议确立格林尼治为零度经线;时区每 15°;旧秒=平太阳日 1/86400;GPS 38 微秒;闰秒 1972 年以来 27 次(截至 2025 年);2022 年 CGPM 决定最迟 2035 年不再引入闰秒;伽利略比萨吊灯属传说须标注;ch08 不得编造铁路惨案。\n\n产出:把笔记写入 `/Users/ygs/ygs/book-projects/时间简史-人类如何度量宇宙/_qa_notes/groupC.md`(用 write 工具)。严格用下面模板,每条都必须带「章节文件名 + 小节标题 + 行号 + 原文片段(照抄,不要改写)」:\n\n```\n# 审校笔记 groupC(ch07–ch09)\n\n## 一、逐章事实与数字清单\n### ch07_伽利略的脉搏.md\n- 【小节名|L行号】原文片段(含数字/年份/人名)\n(尽可能穷尽所有可核查的数字、年份、人名、专名,每章至少 15 条)\n\n## 二、专名与术语写法\n- 术语|出现章/行号|具体写法(秒摆、等时性、锚式擒纵、GMT、铁路时间、平太阳日、历书秒等)\n\n## 三、交叉引用原文\n- 【章|小节|L行号】原文引用句 → 它指向哪一章/哪个概念(特别核对 ch09 结尾是否预告了 ch10–ch13 的内容)\n\n## 四、禁用词检查\n- 是否出现「本章小结/小结/总结/综上/笔者」,逐章报告,没有就写「未发现」\n\n## 五、疑似大段重复素材\n- 主题|章/小节/行号|该段首句|估计字数|为什么怀疑会与其他章重复(重点:ch09 是否大段预演 ch10 石英钟/ch11 原子钟/ch13 闰秒;ch07 与 ch06 的摆钟前史)\n\n## 六、章内自相矛盾\n- 无则写「未发现」\n\n## 七、章首导语与章末收束原文\n- ch07/ch08/ch09 各自章首回指上一章的句子(L行号)、章末最后 1–2 句(L行号)\n```\n\n要求:中文全角标点;不要虚构,找不到就写「未发现」;原文片段照抄;数字必须精确。完成后用一段话回报:文件路径 + 每章提取条目数 + 你最确定的 3 个跨章风险点。"  
      },  
      {  
        "type": "text",  
        "text": "Your parent agent id is \"abf1dc76-3848-465e-8e1c-5acefcebb6ac\". Before you finish, send your result to that agent with send_message({ agent_id: \"abf1dc76-3848-465e-8e1c-5acefcebb6ac\", message: \"<self-contained result>\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."  
      }  
    ],  
    "source": {  
      "kind": "user"  
    },  
    "role": "user",  
    "id": "151ab724-de3e-4fce-ba98-be3ed4dacc9a"  
5) 第一个 turn 的调用链:  
6) 格式代际扫描:共 559 个日志文件,其中 34 个会话同时存在多个代际  
    --Users-ygs--/session-16e18af8-7a67-40…  →  v3, v4  
   …(完整输出见运行脚本)  

本章的勘误条目见 附录 A · 勘误总表。