深入浅出 DeepSeek Harness 第八部:证明与守卫——怎样知道系统真的成立 Chapter 52

轨迹查询与重放验证:readSession 为什么要把整个日志再跑一遍

你想"读回三天前那条会话看看当时模型错在哪"。DSH 的 SessionQueryEngine 不许你直接拿索引数据看——它必须先 corpus.load 全量事件,再用 Session.create 把整条日志重放一遍,能跑完才把事件快照交给你。这不是偏执:一旦日志在落盘后被手改过,重放当场爆炸,你看到的历史就是假的。

Written in Chinese

Source version
dsh-v0.1.7-rc.2
Verified on
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

第 0 秒,你说:“把三天前那次对话调出来,我想看看模型到底在哪一步把任务做歪了。”

readSession(sessionId) 开始干活。它先去 corpus 把这份会话的全量事件加载进来——然后不直接返回。它做了一件看起来多余的事:Session.create(sessionId, loaded.events, loaded.header, inheritedEventCount, currentSessionMessageProjections),把整条日志原样喂给会话构造器重放一遍。重放通过了,它才 structuredClone(loaded.header)、把事件逐条快照化交给你。

为什么要多这一道?因为这份日志在落盘后可能被任何人手改过:你写脚本清洗过它,工具误写过它,甚至你自己好奇心上来编辑过。直接返回索引数据,你看到的是一份”看起来像历史”的字段拼接;重放一遍还能跑通,才证明它仍然是会话。一旦某条事件非法——投影对不上、表面节点错位——Session.create 当场爆炸,你什么都看不到,而不是拿着一份假历史做真判断。

这一章接第 51 章:telemetry 说”我只镜像观测”,那么历史本身的查询与验证就是 SessionQueryEngine 的事。

SessionQueryEngine 查询与重放全景:顶部,corpus.load 全量事件 → readSession 先用 Session.create 重放验证(失败即抛错)→ 通过后 structuredClone header + 事件快照返回;中部,轨迹分析三件套——analyzeEventLog 用 foldSurface 折叠后给事件打 current/shadowed/log-only 标签,traceEvent 双向收 replacementChain 与 derivedEventSeqs,traceSession 沿 parentSession 走血缘并检测环;底部,SQLite 后端 FTS5 双表(persisted_docs 常驻、temp.live_docs 临时),索引库以 wx+0o600 创建。

门面的形状:十个 concrete,两个抽象

SessionQueryEngine extends Service,构造时只装两个件:SessionCorpus(ctx, persistedReadConcurrency) 与 SessionObservationReader(ctx, preparedSessionCacheSize)。对外的十二个方法里,只有两个是抽象的:searchSessions 与 searchEvents——全文检索的”实现权”整个留给后端。其余全是 concrete:

类别方法干什么
抽象(后端实现)searchSessions / searchEvents全文观察、对账、排序、游标代数
日志读取listSessions / readSession / readTitle / listEvents / filterEvents / readEvent按 corpus 快照读
表面与轨迹readSurface / traceSession / traceEvent折叠表面、血缘、替换链分析
观察租约observeSession观察某个会话的进行态

为什么把抽象面压到这么窄? 因为”怎么存、怎么搜、怎么排序”是后端策略(SQLite FTS 还是别的什么引擎都行),而”怎么验证一条日志是合法会话""一条事件被替换后影响了谁”是会话语义——后端不该也不懂。把语义方法全部 concrete 钉在引擎基类里,任何后端换皮都不会把验证逻辑弄丢。

三个配置旋钮全部是安全整数校验:readWindowMax 必须是非负整数(否则 readWindowMax must be a non-negative integer),persistedReadConcurrency 与 preparedSessionCacheSize 必须是正的安全整数。读窗口还有一道钳制:请求的 offset/limit 若落在 [0, readWindowMax] 之外直接报错 must be an integer between 0 and ${readWindowMax}——查询深度有硬上限,防止一个 readAll 把磁盘读穿。

本节源码依据(4 处)

readSession:先重放,后交付

readSession(sessionId) 的实现只有十几行,但每一行都在回答”凭什么相信这份历史”:

const loaded = await this._corpus.load(sessionId)
Session.create(sessionId, loaded.events, loaded.header, inheritedEventCount, currentSessionMessageProjections)
return {
  session: structuredClone(loaded.header),
  inheritedEventCount: loaded.inheritedEventCount,
  events: loaded.events.map(snapshotSessionEvent),
}

三个动作各有讲究:

  1. corpus.load 是唯一事实入口。索引、缓存、观察租约都不是事实源——load 直接对着 canonical 持久化读;
  2. Session.create 是验证器。它不是真要开一个 live session,而是把整条事件流交给会话构造器走一遍 append/投影/折叠的全部约束;
  3. 返回的是快照不是活对象。header 深拷贝、事件逐条 snapshotSessionEvent——查询结果与后续会话演进完全脱钩,你拿到的是”那一刻的定格”。

为什么要深拷贝与快照化? 因为查询接口的调用方是工具层、是调试脚本,它们可能拿着返回值慢慢翻十分钟。如果返回 live 对象的引用,期间会话又 append 了几条事件,你读到的就是一份时间上错位的历史。快照化是”历史不可变”承诺的工程落地。

本节源码依据(2 处)

表面折叠:一条事件在”当前表面”里吗?

会话日志里消息会被替换、会折叠。analyzeEventLog 先 foldSurface(events, currentSessionMessageProjections) 把折叠做完,再给每个事件打归宿标签:

surface 标签含义你看到的
current折叠后仍在当前模型表面上的事件模型现在看得到的那一面
shadowed被后续替换事件盖掉了历史上存在过,但不在当前表面
log-only从未上表面的纯日志事件(如反馈、审计)只在日志里

currentSurfaceEvents 只取 currentSeqs,逐条校验它们确实是表面事件,否则抛 invalid session surface: current node ${seq} is not a surface event。折叠是一次全量验证:它假设日志合法地折叠出了一个表面;一旦日志损坏导致”current 集里混进非表面节点”,当场报错,不猜。

本节源码依据(2 处)

traceEvent 与 traceSession:两个方向的追凶

traceEvent 回答两个问题:这条事件被谁取代了,以及它影响了哪些派生事件。它同时收两组链:replacementChain——沿 replacement 指针一路向前的取代链;derivedEventSeqs——沿 eventSources 反查所有把本事件当源头的派生事件。单链追踪是破案的反义词:一条 assistant/message 被改写,只追”它变成了什么”不够,还要知道哪些后续事件是从它派生的、现在已经失真。

traceSession 沿 parentSession 向上走血缘。实现先按 id 建表,目标查不到直接 session "${sessionId}" not found(SESSION_QUERY_SESSION_NOT_FOUND);然后沿着 parent 链一步步爬,每爬一步把 parentId 塞进 ancestrySeen 集合——集合里再出现自己就抛 session lineage contains a cycle at "${parentId}"(SESSION_QUERY_INVALID_LINEAGE)。走到查无此 id 的祖先就停下,把它记为 unresolvedParentId。

追踪函数方向终止条件异常
traceEvent向前(被谁取代)+ 反向(派生了谁)链走尽事件不存在
traceSession向上(父会话)断链(记 unresolvedParentId)或集合成环SESSION_QUERY_SESSION_NOT_FOUND / SESSION_QUERY_INVALID_LINEAGE

为什么血缘要检测环? 因为 fork/派生链是 append-only 日志,正常情况下父指针只会向上。一旦数据损坏让 A 的 parent 指向 B、B 的 parent 又指回 A,血缘查询就会死循环。与其在查询时默默兜圈,不如在发现环的第一秒报错——血缘图里出现环,意味着日志已经不是”一棵树”了,那是结构性事故,不是查询可以容忍的情况。

本节源码依据(4 处)

SQLite 后端:把索引当临时产物,把日志当唯一事实

SQLite 后端的 schema 里有两个值得注意的细节:

文件级保密。创建索引库用 open(path, 'wx', 0o600)——wx 意味着文件已存在就报错(不覆盖),0o600 意味着只有属主能读写。索引库里混着会话内容的全文切片,权限必须和日志一个档次。

双重身份识别。打开后立刻 PRAGMA application_id 与 PRAGMA user_version 各查一遍:application_id 对不上就不是本引擎的索引文件,user_version 对不上就是旧 schema——两个都错,拒绝打开,不做兼容猜测。建库时先 PRAGMA user_version = 0,再写表,最后 PRAGMA user_version = SCHEMA_VERSION,application_id 在建库一开始就钉死。

为什么索引可以重建而日志不能? 因为索引是从日志派生的——坏了就 drop 重建。引擎在语义上从不假设索引是对的:readSession 直接 corpus.load,绕开索引读日志;索引只是 searchSessions/searchEvents 的加速层。这就是”重放验证”哲学在存储层的投影:查询入口永远有一条绕过索引、直读事实的路径。

本节源码依据(3 处)

从查询到重放:把录制好的会话变成 LLM 剧本

查询与验证解决了”历史是不是真的”,测试要解决的是”把真历史喂给一个不联网的模型客户端会怎样重放”。llm-replay 包把 readSession 读出来的同一份事件流再加工一层:它的包头注释写着”Keyless snapshot-test LLM replay. It derives one model-call script per recorded session from v3 embedded Assistant streams and explicitly marked local compaction calls, then binds fresh live sessions to parent/child scripts by first-call order.”

这句话藏着两个工程决策:

一个会话一份剧本。deriveReplayScript(events) 从录制事件里提取 v3 嵌入式 assistant 流与显式标记的本地压缩调用,为每个会话生成 ReplayEntry[]——剧本不是按”工具名”对齐,而是按”第几次模型调用”对齐。父子会话各拿各的剧本,靠首次调用顺序绑定。

三种结局都要演。ReplayEntry 不只有”吐 chunks”一种:throw 在前缀 chunks 之后失败(带 code 与 accepted 标记),hang 专门演”模型端永不响应”——它模拟的是取消语义。PACKED_CHUNK_ROW_TYPES 只收三种行类型:text-chunks、reasoning-chunks、tool-call-chunks——剧本不发明新的流形态,只把录制到的形态原样打包。

剧本条目语义测试目的
吐 chunks(text/reasoning/tool-call)正常流式输出重放当时的模型回答
throw(带前缀 chunks + code)吐到一半失败错误路径恢复
hang(永不响应)模型卡死取消信号是否生效

为什么重放也要”按第一次调用顺序绑定”而不是按 sessionId 绑定? 因为重放环境是全新进程:live session 的 id 每次跑都不同,录制快照里的旧 id 在新进程里不存在。按顺序绑定等于承认”重放的正确性来自调用序列的形态一致,而不是 id 一致”——这与 readSession 的快照哲学同源:重放交付的是那一刻的形态,不是某个具体 id 的对象。

本节源码依据(5 处)

小结

  • 门面窄、语义厚:只留 searchSessions/searchEvents 两个抽象,十个查询方法全部 concrete,换后端不换验证。
  • readSession 先重放后交付:Session.create 是验证器,通过后才深拷贝快照;手改日志在第一步就爆。
  • 表面折叠三种归宿:current/shadowed/log-only,折叠全量验证,不猜损坏表面。
  • 两个方向追凶:traceEvent 同时收 replacementChain 与 derivedEventSeqs;traceSession 血缘上爬,遇环即抛 INVALID_LINEAGE。
  • 索引可重建、日志是事实:FTS5 双表分工,wx+0o600 建库,application_id/user_version 双识别。

下一章:有了查询与重放能力,怎样用真实子进程把这种”重放”变成测试——第 53 章讲 mock snapshot 测试。

只读验证

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
git -C "$repo" rev-parse HEAD   # 应为 477b4f420553e8a52c2fbccc464d7561b239c443
grep -n "abstract searchSessions\|abstract searchEvents" "$repo/packages/session-query/session-query/src/index.ts"
grep -n "INVALID_LINEAGE\|not a surface event" "$repo/packages/session-query/session-query/src/tracing.ts"
grep -n "fts5\|0o600" "$repo/packages/session-query/session-query-sqlite/src/schema.ts"