青雲的博客
深入浅出 DeepSeek Harness 第八部:证明与守卫——怎样知道系统真的成立 第 46 章

Trajectory、Query 和 Replay

你读session log就是读JSONL文件。错。SessionQueryEngine做的不仅是读取——它用Session.create()构造detached session做重放验证,traceEvent追踪替换链和派生事件。

源码版本
47f943859bef60e4160492346772ded9b24f765a
验证日期
Commit
47f943859bef60e4160492346772ded9b24f765a

查历史会话时,最容易想到的是直接 cat 那个 JSONL 文件,或者 JSON.parse() 反序列化成对象。但这只能看到字节和字段,看不到这条日志是否还能被系统接受。

在 Harness 里,readSession() 做的事情比“读取”多得多。它不只是把 JSONL 解析成事件数组,而是用 Session.create() 构造一个 detached session,从 seed 开始重放所有事件,顺手验证日志完整性。如果日志损坏、seq 断裂、事件顺序不对,重放会直接失败,而不是返回一个“看起来没问题”的残缺对象。

历史不是用来”看”的,是用来”重放验证”的。

SessionQueryEngine抽象层

SessionQueryEngine定义了六个核心操作,不是简单的CRUD:

  • searchSessions():按条件搜索会话元数据
  • searchEvents():搜索事件内容(支持全文)
  • listSessions():列出会话列表(分页)
  • readSession():读取并重放验证一个会话
  • traceEvent():追溯事件血缘(替换链+派生事件)
  • traceLineage():追踪完整会话血缘(fork/parent关系)

这六个操作背后可以有不同后端。SQLite后端提供FTS5全文搜索;内存后端用于测试;tool-session-query把查询能力暴露给agent作为工具使用(带workspace访问控制,agent不能随便查其他workspace的会话)。

session-log-export支持UI导出JSONL——但导出前同样要经过重放验证,不会导出损坏的日志。

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "searchSessions\|readSession\|traceEvent\|Session.create" "$repo/packages/session-query/session-query/src/index.ts" | head -30

你会看到readSession的返回不是原始事件数组,而是重放后的Session实例。

readSession():不只是读取,是重放验证

这是最反直觉的设计。当你调用readSession(id),系统做了这些事:

  1. 从持久化介质读取该会话的所有事件(JSONL)
  2. 解析header和seed events
  3. 调用Session.create(id, seed, header)构造一个detached session(不连接运行时服务)
  4. 按seq顺序逐个append事件到这个detached session
  5. 如果append过程中任何invariant检查失败(seq不连续、turn嵌套错误、tool result没有对应call等),整个readSession失败
  6. 重放成功后,返回这个Session实例——它的surface投影、context、goal等状态都和当时运行时一致

为什么这么麻烦?因为直接读JSONL你不知道日志是否损坏。一个事件缺了seq,或者surfaceOp: replace的事件顺序错了,你直接读可能看到一个”看起来合理”的对话,但实际上和真实运行状态不一致。重放验证是唯一能证明”这份日志确实代表了一个合法运行状态”的方法。

assistant/chunk事件是逐条持久化的——不只是final message,连中间的token chunks都存了。这支持token级别的重放保真度。

traceEvent():追踪事件血缘

Compaction会替换旧消息,fork会创建子会话,工具调用会cite sourceEventSeqs。一个事件不是孤立存在的——它有来龙去脉。traceEvent()给你两个维度的血缘:

replacement chain(替换链): 这个事件被哪些compaction replace了?compaction本身又可能被后续compaction replace。traceEvent()沿着surfaceOp: replace链一直追到当前可见的版本。

derived events(派生事件): 哪些后续事件cite了这个事件的sourceEventSeqs?比如一个tool result引用了tool call,一个assistant message引用了它的context来源。

analyzeEventLog()执行规范surface折叠,建立三个索引:

  • replacedBy:每个事件被哪个事件替换了
  • replacedEventSeqs:每个替换事件替换了哪些事件
  • currentSeqs:当前可见(未被替换)的事件seq集合
flowchart TD
    A[原始事件 seq:5] -->|surfaceOp: replace| B[Compaction事件 seq:42]
    B -->|surfaceOp: replace| C[后续Compaction seq:88]
    C --> D[currentSeqs: 88]
    E[Tool Call seq:10] -->|sourceEventSeqs| F[Tool Result seq:15]
    E -->|sourceEventSeqs| G[Assistant Summary seq:20]
    H[Session JSONL] --> I[readSession]
    I --> J[Session.create 构造detached]
    J --> K[逐个append重放]
    K -->|invariant失败| L[拒绝: 日志损坏]
    K -->|成功| M[返回重放后的Session]
    style L fill:#8b0000,stroke:#fff,color:#fff
    style D fill:#2e7d32,stroke:#fff,color:#fff

Replay能力:三层重放机制

Harness有三层重放能力,不是只有一种:

第一层:Session重放。 就是上面说的readSession(),用Session.create()从seed+events重放,验证日志完整性,重建当时的surface状态。

第二层:LLM Replay适配器。 packages/test-support/llm-replay提供一个LLM服务适配器,它不调用真实API,而是从记录好的session log里读LLM响应,按原时序重放。这用于快照测试——你记录一次真实运行,以后重放时LLM输出完全一致,不会因为模型版本变化导致测试flaky。

第三层:ACP Snapshot Harness重放。 这是最高保真度的——启动真实agent bin子进程,通过ACP JSON-RPC over stdio驱动,重放输入步骤,对比stdout和session log snapshot。下一章详细讲。

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
ls "$repo/packages/test-support/llm-replay/src/"

你会看到llm-replay的核心就是匹配记录的请求顺序,依次返回预存响应。

容易踩的坑

坑一:把readSession当JSON.parse。 直接解析JSONL不验证重放,你可能得到一个seq断裂、turn不闭合的”残废会话”,但代码不会报错。readSession的重放验证是安全网,不要绕过。

坑二:不理解compaction后的事件引用。 Compaction后旧事件还在日志里(只是被标记为replaced),currentSeqs才是模型当前看到的版本。traceEvent()帮你找对版本。

坑三:以为FTS索引覆盖所有内容。 SQLite FTS索引的是事件的text内容,但tool arguments、结构化metadata不一定在索引里。精确查询要用searchEvents的filter条件,不能只靠关键词。

坑四:tool-session-query没有workspace隔离。 不,它有——agent只能查询当前workspace的会话,不能跨workspace访问。这是强制的访问控制,不是可选配置。

坑五:重放会连接真实服务。 不,Session.create()构造的是detached session——不连接LLM、不连接工具、不连接任何运行时服务。它只是纯状态重放。

你能重放历史、追溯血缘、搜索过往,但怎么保证你记录的snapshot是可信的?怎么系统地测试各种故障场景?下一章讲Mock LLM和Snapshot测试。