DeepSeek Harness 的会话查询服务:历史会话是怎么被翻出来的
先摆一个具体问题:昨天在某个会话里让 agent 改过一段解析逻辑,今天想把当时那几条工具输出原样翻出来。这件事在 DeepSeek Harness 里不是一个按钮,而是一整族包在支撑——packages/session-query/ 下有四个包,各管一段。
顺带说清前提:deepseek-harness 仓库 README 自述该项目处于开发者预览阶段,并用大写明确写着未来会有破坏兼容性的变更。下面提到的配置键、默认值、错误码随时可能变,请以仓库最新内容为准。我们没有安装也没有运行过它,本文全部结论来自仓库里的源码与文档。
第一站:语料库是怎么拼出来的
ctx.sessionQuery 这个 seam 面对的不是一个数据源,而是两个:内存里活着的 ctx.sessions,以及一个可选的、可以动态挂载和卸载的 ctx.sessionPersistence。packages/session-query/session-query/README.md 把口径写得很死:同一个 id 只产出一条记录,live 事件胜出,而 SessionRecord 上的 live 和 persisted 两个布尔各自报告两边的可用性。
这个设计的直接后果是:一条记录 persisted: false 不代表它不存在,只代表当前持久化后端没有把它物化出来;反过来 live: false 也不代表它没了。把可用性和内容分开报,读的人才知道自己拿到的是谁的版本。
如果两边对同一个 id 给出的不可变 header 对不上,读操作会直接失败。packages/session-query/session-query/src/sources.ts 里的 assertSessionHeadersCompatible() 逐字段比对 version、id、createdAt、cwd、parentSession、seedLength,以及 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 定义那段,三个取值是 startup、first-search、never)。也就是说”字段默认值”和”随包发出的组合里写的值”是两层东西,base 层显式写了 never 把它盖掉了。你如果只看包 README 的配置表,会以为搜索开着。
三档的语义 README 也写清了:startup 在服务激活完成前就打开句柄,索引无效则在发布前失败;first-search 把 node:sqlite 的导入和句柄推迟到第一次搜索,多个并发首搜共享同一个就绪 promise,README 自述这是为了让 Node 22 启动输出保持干净(把 SQLite 的实验性警告推迟到真正搜索时,不是消除它);never 则是在任何请求规范化之前就拒绝。
打开之后,能搜到什么、搜不到什么
索引用的是 FTS5 的 unicode61 分词器。README 把这个取舍讲得很直白:这是 token/短语召回,不是任意子串召回,并且举了自己的例子——AI 不会匹配到 token BRAID。需要”字面的、空白灵活的子串扫描”时,官方给的建议是回头用 filterEvents() 的 text 子句。这两条路并存的原因在这里就闭环了。
另外 FTS5 语法本身(引号、OR、NEAR、*)一律当数据处理,不会被当成可执行的 MATCH 语法。
再就是几个硬上限,都在 packages/session-query/session-query-sqlite/src/query.ts 里:
| 常量 | 值 | 位置 |
|---|---|---|
SQLITE_FTS5_OUTER_PREDICATE_LIMIT | 14 | query.ts |
SQLITE_PORTABLE_VARIABLE_LIMIT | 32766 | query.ts |
跨会话请求最多编译 14 条会话与事件谓词;会话内请求只剩 13 条,因为固定的目标会话谓词自己占掉一格(这一条是 README 自述的算法,源码里只有 14 这个常量)。每个范围端点算一条谓词。超了,或者算上固定查询与分页值后绑定数超过 32766,都会在语句 prepare 之前就以 SESSION_QUERY_INVALID_FILTER 失败。
有些事件压根不产生文档
这是”搜不到”最隐蔽的一类原因。packages/session-query/session-query/src/extraction.ts 的 extractSessionEventText() 是一个 switch,把事件类型分成两拨:
- 产生文本的:
user/message、assistant/message、tool/call(名称加参数)、tool/result(内容加error.name、error.code)、todo/write(每条 todo 的status与content)、turn/end - 一律返回空串的:
turn/start、step/start、step/end、assistant/chunk、request/header,以及所有未知事件(注释写明SessionEventMap是可声明合并的,未知事件在有第一方消费方定义语义之前不可搜索)
turn/end 还要再分一层:只有 error、aborted、max-tokens、interrupted 会给出文本,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.ts:readWindowMax 默认 50,persistedInspectConcurrency 默认 4。前者限制 readEvent() 的上下文窗口——before 和 after 都默认 0,越界会抛 SESSION_QUERY_INVALID_WINDOW;后者是一次批量标题读取里最多同时检查多少条持久化日志。
SQLite 提供方那边在 session-query-sqlite/src/index.ts:defaultLimit 为 20、maxLimit 为 100、snippetChars 为 240(Unicode 码点计),journalMode 默认 wal。这些是配置默认值,不是对实际表现的承诺。
模型自己能不能搜
能,但要你主动挂。packages/session-query/tool-session-query/ 注册 session_search、session_event_search、session_trace、session_event_trace、session_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 边界就直接报错。配置默认值是 maxSearchResults 为 100、searchTimeoutMs 为 30000,且搜索不向模型暴露游标、偏移、页大小或可控 limit。
搜不到的时候,按这个顺序排
- 先看错误码。报
SESSION_QUERY_SEARCH_DISABLED,就是这一层组合里openAt是never,去翻你那一层的cordis.patch.yml,而不是去怀疑索引。 - 没报错但零命中,改用
filterEvents()加text子句做一次字面量扫描。两者结果不同,问题在unicode61的 token 召回,不在数据。 - 两条路都零命中,回
extraction.ts对一下事件类型——你要找的东西如果落在assistant/chunk、request/header或正常结束的turn/end上,它从来就没进过语料。 - 报
SESSION_QUERY_INVALID_FILTER,数谓词条数,记住范围的每个端点各算一条。 - 什么情况说明不是上面这些原因:如果错误码是
SESSION_QUERY_SESSION_NOT_FOUND、SESSION_QUERY_PERSISTENCE_FAILED或SESSION_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 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。