DeepSeek Harness 的会话查询服务:历史会话是怎么被翻出来的

2026-08-17

先摆一个具体问题:昨天在某个会话里让 agent 改过一段解析逻辑,今天想把当时那几条工具输出原样翻出来。这件事在 DeepSeek Harness 里不是一个按钮,而是一整族包在支撑——packages/session-query/ 下有四个包,各管一段。

顺带说清前提:deepseek-harness 仓库 README 自述该项目处于开发者预览阶段,并用大写明确写着未来会有破坏兼容性的变更。下面提到的配置键、默认值、错误码随时可能变,请以仓库最新内容为准。我们没有安装也没有运行过它,本文全部结论来自仓库里的源码与文档。

第一站:语料库是怎么拼出来的

ctx.sessionQuery 这个 seam 面对的不是一个数据源,而是两个:内存里活着的 ctx.sessions,以及一个可选的、可以动态挂载和卸载的 ctx.sessionPersistencepackages/session-query/session-query/README.md 把口径写得很死:同一个 id 只产出一条记录,live 事件胜出,而 SessionRecord 上的 livepersisted 两个布尔各自报告两边的可用性。

这个设计的直接后果是:一条记录 persisted: false 不代表它不存在,只代表当前持久化后端没有把它物化出来;反过来 live: false 也不代表它没了。把可用性和内容分开报,读的人才知道自己拿到的是谁的版本。

如果两边对同一个 id 给出的不可变 header 对不上,读操作会直接失败。packages/session-query/session-query/src/sources.ts 里的 assertSessionHeadersCompatible() 逐字段比对 versionidcreatedAtcwdparentSessionseedLength,以及 delegationDepth(这一项用 ?? 0 兜底),任意一项不等就抛 SESSION_QUERY_SOURCE_CONFLICT。它不做合并、不挑一个”看起来更新”的,直接报错——这一点在排查时很有用,因为它把”两个源打架”和”数据本身坏了”分成了两个错误码,后者是 SESSION_QUERY_CORRUPT_SESSION

第二站:搜内容有两条路,别搞混

想按正文找东西,ctx.sessionQuery 上其实给了两条完全不同的路径。

第一条是 filterEvents(sessionId, filters) 里的 text 子句。这条路与全文索引提供方无关:README 写明调用方给的文本会被转义成一个 Unicode、不区分大小写的正则表达式,每段连续空白匹配一个或多个空白字符。说白了它是字面量扫描,不是全文查询。

第二条是 searchSessions()searchEvents()。这两个是整个 SessionQueryEngine仅有的两个抽象方法——精确读取、过滤、关系追踪都在基类里实现完了,具体后端只需要接管全文观测、对账、排序、游标世代和查询执行。仓库里的第一个实现是 packages/session-query/session-query-sqlite/,用 SQLite FTS5。

最容易踩的一处:默认组合里全文搜索是关的

这一处值得单独拎出来。packages/bundle/base/cordis.patch.yml 里确实挂了 session-query-sqlite,但配置是:

- id: session-query-sqlite
  name: '@deepseek-ai/dsh-session-query-sqlite'
  config:
    path: ':memory:'
    openAt: never

同一个文件里的注释自述了这么做的效果:openAt: never 保持 ctx.sessionQuery 挂载,精确读取、标题、谱系追踪照常可用,但搜索调用会以 SESSION_QUERY_SEARCH_DISABLED 失败,SQLite 根本不会被打开;注释里还写明这种情况下 Web 侧栏的搜索只匹配标题和 workspace 名。packages/bundle/web-app/cordis.patch.yml 又把同样的两行重述了一遍。

容易看岔的是:openAt 这个键在 schema 里的默认值是 startup(见 packages/session-query/session-query-sqlite/src/index.ts 中 zod 定义那段,三个取值是 startupfirst-searchnever)。也就是说”字段默认值”和”随包发出的组合里写的值”是两层东西,base 层显式写了 never 把它盖掉了。你如果只看包 README 的配置表,会以为搜索开着。

三档的语义 README 也写清了:startup 在服务激活完成前就打开句柄,索引无效则在发布前失败;first-searchnode:sqlite 的导入和句柄推迟到第一次搜索,多个并发首搜共享同一个就绪 promise,README 自述这是为了让 Node 22 启动输出保持干净(把 SQLite 的实验性警告推迟到真正搜索时,不是消除它);never 则是在任何请求规范化之前就拒绝。

打开之后,能搜到什么、搜不到什么

索引用的是 FTS5 的 unicode61 分词器。README 把这个取舍讲得很直白:这是 token/短语召回,不是任意子串召回,并且举了自己的例子——AI 不会匹配到 token BRAID。需要”字面的、空白灵活的子串扫描”时,官方给的建议是回头用 filterEvents()text 子句。这两条路并存的原因在这里就闭环了。

另外 FTS5 语法本身(引号、ORNEAR*)一律当数据处理,不会被当成可执行的 MATCH 语法。

再就是几个硬上限,都在 packages/session-query/session-query-sqlite/src/query.ts 里:

常量位置
SQLITE_FTS5_OUTER_PREDICATE_LIMIT14query.ts
SQLITE_PORTABLE_VARIABLE_LIMIT32766query.ts

跨会话请求最多编译 14 条会话与事件谓词;会话内请求只剩 13 条,因为固定的目标会话谓词自己占掉一格(这一条是 README 自述的算法,源码里只有 14 这个常量)。每个范围端点算一条谓词。超了,或者算上固定查询与分页值后绑定数超过 32766,都会在语句 prepare 之前就以 SESSION_QUERY_INVALID_FILTER 失败。

有些事件压根不产生文档

这是”搜不到”最隐蔽的一类原因。packages/session-query/session-query/src/extraction.tsextractSessionEventText() 是一个 switch,把事件类型分成两拨:

  • 产生文本的:user/messageassistant/messagetool/call(名称加参数)、tool/result(内容加 error.nameerror.code)、todo/write(每条 todo 的 statuscontent)、turn/end
  • 一律返回空串的:turn/startstep/startstep/endassistant/chunkrequest/header,以及所有未知事件(注释写明 SessionEventMap 是可声明合并的,未知事件在有第一方消费方定义语义之前不可搜索)

turn/end 还要再分一层:只有 errorabortedmax-tokensinterrupted 会给出文本,completed 返回空串。正常结束的那一轮,在全文层面是不留痕的。

内容块层面还有一层,同文件 68 行起的 blockText()text 块返回文本,tool-call 返回名称和参数,tool-result 递归展开,而 reasoning返回空数组

这里有一处文档口径不一致,值得指出来。docs/subsystems/session-query.md 第 141 行写的是”消息、推理(reasoning)、工具调用和工具结果……会纳入语义文本”;而 packages/session-query/session-query/README.md 写的是 reasoning 块不产生文档。源码 extraction.ts 第 72 行的 case 'reasoning' 返回 []。三处摆在一起就是这样,以我们实读的源码为准。我们不推断哪一处该改。

剩下几个默认值

服务定义包这边的两个在 packages/session-query/session-query/src/config.tsreadWindowMax 默认 50persistedInspectConcurrency 默认 4。前者限制 readEvent() 的上下文窗口——beforeafter 都默认 0,越界会抛 SESSION_QUERY_INVALID_WINDOW;后者是一次批量标题读取里最多同时检查多少条持久化日志。

SQLite 提供方那边在 session-query-sqlite/src/index.tsdefaultLimit20maxLimit100snippetChars240(Unicode 码点计),journalMode 默认 wal。这些是配置默认值,不是对实际表现的承诺。

模型自己能不能搜

能,但要你主动挂。packages/session-query/tool-session-query/ 注册 session_searchsession_event_searchsession_tracesession_event_tracesession_event_read 五个工具,README 自述随包发出的 host 组合默认不挂载它。

授权口径是硬的:调用方只从 ToolExecution.exec.agent 来;跨会话访问要求目标与调用方的 cwd 精确相等(workspace-access.ts 里就是 header.cwd === caller.header.cwd 这一句),没有 cwd 的调用方只能查自己。README 把这个口径的代价也写明了:这是保守的精确字符串比较,symlink 等价的路径不共享权限。另外 session_search 永远排除调用方自己这个会话;当前会话的 session_event_search 会停在调用它的那一步之前,operations.ts 里的做法是 findLast 找最后一个 step/start,找不到活跃 step 边界就直接报错。配置默认值是 maxSearchResults100searchTimeoutMs30000,且搜索不向模型暴露游标、偏移、页大小或可控 limit。

搜不到的时候,按这个顺序排

  1. 先看错误码。报 SESSION_QUERY_SEARCH_DISABLED,就是这一层组合里 openAtnever,去翻你那一层的 cordis.patch.yml,而不是去怀疑索引。
  2. 没报错但零命中,改用 filterEvents()text 子句做一次字面量扫描。两者结果不同,问题在 unicode61 的 token 召回,不在数据。
  3. 两条路都零命中,回 extraction.ts 对一下事件类型——你要找的东西如果落在 assistant/chunkrequest/header 或正常结束的 turn/end 上,它从来就没进过语料。
  4. SESSION_QUERY_INVALID_FILTER,数谓词条数,记住范围的每个端点各算一条。
  5. 什么情况说明不是上面这些原因:如果错误码是 SESSION_QUERY_SESSION_NOT_FOUNDSESSION_QUERY_PERSISTENCE_FAILEDSESSION_QUERY_SOURCE_CONFLICT,那问题在语料库解析这一层——会话根本没找到、持久化后端读不动、或者两个源的 header 打架,和全文索引没有关系,去查 sources.ts 那条 header 兼容性断言比调索引参数有用得多。

上面这些错误码的完整封闭联合就在 config.ts 里,一共 17 个。把它当排查用的分诊表读,比把它当类型定义读收益大。


本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的 架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。 本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目, 因此不涉及界面外观、操作手感与运行速度的任何描述。 该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更, 文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。