DSH plugin 大会话搜索崩 RangeError?Invalid string length 排查
当一个工作区里只要有会话长得够大,session_search / session_event_search 就会整体报 Error: session query operation failed——真实原因是搜索索引在算变更指纹时把整份事件日志做了一次 JSON.stringify,撞上 V8 约 512 MB(2^29−1 字符)的单字符串上限并抛 RangeError: Invalid string length。 会话列表与精确读事件仍然正常,所以故障会被长期掩盖;完整修法不止是增量指纹,还包括增量索引、有界片段、串行批量检查,以及 CJK 的 unigram+bigram 分词。
DeepSeek Harness 搜索崩溃现象
症状看起来是「搜索坏了」,其实是一条与查询本身无关的索引同步链断了。 实测记录:
- 整工作区失效:只要工作区里有任意一个会话长到触发阈值,
session_search/session_event_search都返回Error: session query operation failed;底层RangeError: Invalid string length抛出在搜索索引同步过程中,于是搜索对整个工作区失效,而不只是那个大会话(#1859)。 - 有掩盖效应:会话列表与精确事件读取继续正常工作,这让故障在一段时间内不易察觉——很多人会先以为只是那一个大会话有问题。
- 触发规模:线上观察到的极端样本约 500 万事件;而落盘日志只需约 150 MB 原始大小 / 22.3 万事件就足以越过上限。
- 真实调用栈:
RangeError: Invalid string length→at JSON.stringify→at observeSession (.../dsh-session-query-sqlite/lib/index.js:1000:49)→observeLive (:992)→Proxy._observeStable (:740)→async Proxy._reconcile (:651)。 - 错误被降级:工具层的映射(
tool-session-query/service-boundary.ts的SESSION_QUERY_INVALID_CONFIG/SESSION_QUERY_SOURCE_CONFLICT)把真实异常替换成通用文案,排查必须绕过它去看主机日志。 - 第二波症状更凶:只做增量指纹后,
dsh web会改以 V8 堆耗尽(约 4 GB,持续的 Mark-Compact 抖动)崩溃——一次约 15 小时后崩,另一次重启仅约 5 分钟就被一次工作区搜索打崩,单次session_search就足以杀死进程(#1859)。
DeepSeek Harness:整日志 stringify 超 V8 上限 + CJK 匹配为零机制
两条机制互相独立:一条关于内存边界,一条关于分词边界。 逐层拆解:
- 整日志一次序列化:
observeSession()(packages/session-query/session-query-sqlite/src/index.ts)用createHash('sha256').update(JSON.stringify({ header: detachedHeader, events: detachedEvents })).digest('base64url')计算变更指纹——整份日志被拼成单个字符串。 - 越过 V8 单字符串上限:V8 对单个字符串的上限约为 512 MB(2^29−1 字符),超过即由
JSON.stringify抛RangeError,同步失败、查询中止。 - 堆压力不止于字符串:每次搜索都重新观察整个语料,成本与工作区规模成正比而非与查询规模成正比。四个具体放大点:①
observeLive()克隆每个活跃会话的每个事件并为每个事件构建搜索文档;② 任何指纹变化的会话(即任何活跃对话)在每次搜索时其全部 FTS 文档都被删光再重插;③ 巨型匹配文档的highlight()输出被整段实体化成 JS 字符串(崩溃栈里的Builtins_ArrayPrototypeJoin);④ 首次索引时为所有被检查会话同时持有克隆事件与文档。 - 批量检查的并发也是杀手:
readTitleSnapshots批量投影持久化日志时并发度为 4(SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY = 4),最多同时把 4 份解压日志放在内存里。在真实语料(番茄工作区:5.48M / 5.28M / 2.42M / 1.04M 事件的会话)上,标题批次峰值达 2.36 GB 堆 / 4.06 GB RSS,正是那次「启动 3 分 19 秒后 OOM」的元凶;而这条路径在每次@提及自动补全按键(session-reference)以及每次session_search之后(tool-session-query读标题)都会被触发(#1859)。 - 精确读路径也在克隆全日志:
SessionCorpus.load()为每次精确读克隆整份日志(snapshotLive()对 5.4M 事件的活跃快照做structuredClone,持久化分支又克隆一遍);tracing.analyzeEventLog()还会为每个事件物化一个记录对象(events.map(event => ({...})),正是Builtins_CreateShallowObjectLiteral/ArrayMap的崩溃特征),出现在readSurface(选中@引用时触发)与traceEvent上。 - 中文查询零命中的两条独立原因:① 整条查询被当作一个 FTS5 短语加引号,多词查询因此要求严格相邻;② FTS5 的
unicode61把连续 CJK 字符视作一个 token,于是「内存溢出」内部搜「内存」或搜单字都是零命中。 - 第三波是活性死锁:v9 索引重置让每个会话都「已变更」,首次搜索需重新检查整个语料(该工作区约 12–15 秒);同时当前聊天会话的 write-behind 每几秒就向落盘日志追加。稳定门比较观察窗口前后两次快照版本,任何变动都重试整轮观察——重建窗口比 flush 间隔更长时永远不收敛,两次重试耗尽后每次搜索都失败、索引永远为空。更关键的是:这个变动版本属于活跃会话,其索引行已被 TEMP 覆盖层遮蔽、这条路径根本不会写入它的任何数据,本来就不该被比较。
DSH plugin 补丁分支与规避
修法分四层:先让指纹有界,再让观察与索引增量、片段有界,最后修稳定门与 CJK 分词——这也是 DSH插件 与其它 DeepSeek插件 处理检索类性能故障的通用次序。 具体如下:
- 增量指纹(最小改动):header 只哈希一次,其余逐个事件哈希,内存因此有界:
const fingerprintHash = createHash('sha256').update(JSON.stringify(detachedHeader))
for (const event of detachedEvents) {
fingerprintHash.update('\n')
fingerprintHash.update(JSON.stringify(event))
}
副作用是每个会话的指纹会变一次,触发一次性索引重建——哈希构造变更时属预期行为。
2. 增量观察与增量索引:按 Session 用 WeakMap 缓存 + 增量 SHA-256 流 + 缓存的 surface fold;事件深冻结、公开快照数组在追加时整体替换,因此指纹只哈希新增事件、观察路径上不再克隆任何日志内容。索引只插入上次已索引 seq 之后追加的文档,位置替换只更新新被遮蔽的旧行(surface='shadowed',分批 IN 列表),缓存记账只在 COMMIT 之后进行;持久化会话的 inspect() 结果不再克隆,文档在写入时流入索引,每个会话的行一写完即丢弃其日志。
3. 有界片段:在 SQL 侧用 instr / substr 把 highlight() 输出窗口限制在首个匹配标记附近,使每行 JS 内存与片段窗口成正比;Array.from(text).length 换成零分配的码点计数器。
4. CJK 子串搜索:CJK 串按 unigram+bigram 词流存储(用零宽分隔符),查询把 CJK 片段展开成同样的 bigram;空白分隔的词各自作为独立带引号字面量 AND 组合,让 MATCH 语法始终只是惰性数据;片段展示时把词流解码回可读文本。schema v8→9 会在原地重置一次派生索引(它是可弃物,重置属预期)。
5. 读路径不再克隆:persistedInspectConcurrency 默认改为 1(一批最多只持有 1 份被检查日志,小语料仍可调大并发);SessionCorpus.load() 不再复制日志(活跃读借用已冻结的快照数组,持久化读直接交出刚检查出的值,调用方只为真正保留的数据克隆);tracing 折叠成轻量关系映射,readSurface / traceEvent 只构建需要的那一条记录。
6. 稳定门修正:samePersistenceSnapshots 在比较时忽略活跃会话拥有的快照条目(比较时取 ctx.sessions.list() 的 id),非活跃变动(新会话、删除会话、已分离日志确实改变)仍照旧重试。
7. 先装个预警:dsh-plugin-doctor v1.10.0 起在 --profile 下新增 large-files 检查,profile 内任一文件超过 100 MB(跳过 node_modules/.pnpm)即告警并指出相对路径与大小:
npx dsh-plugin-doctor --profile ~/.dsh/profiles/web --json
注意 v9 索引重置会让升级后的第一次搜索明显更重,这正是该预警最有价值的时刻。取出大会话内容可用 dsh-shelf 的 rescue(把巨型/无法恢复会话导出为 markdown,不把整份塞进单个字符串),verify 可标出不健康会话。插件装卸走 DSH Plugin Hub 的「设置 → 插件市场」。
8. 补丁位置:官方仓库当时不接受 PR,完整修复(第一轮 + 第二轮 + 稳定门)在 fork 分支 flyingcoding/deepseek-harness @ fix/session-query-cjk-memory,commit c9abb534d,基于官方 0.1.0-rc.7(99f6f02fec),可直接 cherry-pick。仍存的已知边界:readSession / listEvents 按契约仍会分配完整输出(只是不再先克隆整份日志),UI 的对话渲染不走它们。
DSH plugin 排查注意事项
先绕过通用文案去看真实异常——session query operation failed 之下往往藏着 RangeError 与 observeSession 调用栈,这条报错与查询本身无关。 六条要点:
- 先看真实异常:通用文案
session query operation failed之外,要看主机日志里的RangeError与observeSession调用栈。 - 阈值比直觉低:约 150 MB 原始日志 / 22.3 万事件就足以触发,不必等到百万事件。
- 增量指纹不是终点:它解决字符串上限,不解决每次搜索重新观察整个语料的堆压力。
- 活跃会话会让重建永不收敛:稳定门必须排除活跃会话拥有的快照条目。
- 中文搜索需要 bigram:
unicode61的整段 CJK 单 token 行为不会因为查询写法而改变。 - 升级后第一次搜索偏重:schema 重置会一次性重建派生索引,属预期。

来源:Discussion #1859、fix/session-query-cjk-memory、dsh-plugin-doctor v1.10.0。
常见问题
DeepSeek Harness 里一个大会话拖垮整个工作区搜索,是因为搜索索引的同步(reconciliation)按工作区整体执行:observeSession() 会把**整份事件日志**序列化成一个字符串来算变更指纹,一旦这个字符串越过 V8 约 512 MB(2^29−1 字符)的单字符串上限,JSON.stringify 就抛 RangeError: Invalid string length,整个同步失败并中止查询——于是该工作区里**任何**查询都失败。会话列表与精确读事件仍然正常,这层假象会让故障被长时间忽略(来源:Discussion #1859)。
DeepSeek Harness 只显示 session query operation failed,是因为工具层把真实错误掩盖了:tool-session-query/service-boundary.ts 里的映射(SESSION_QUERY_INVALID_CONFIG / SESSION_QUERY_SOURCE_CONFLICT)会把底层的具体异常换成通用的 session query operation failed。排查时要直接看主机日志里 observeSession → observeLive → _observeStable → _reconcile 这条调用栈,才能看到真正的 RangeError(来源:Discussion #1859)。
DeepSeek Harness 里只改增量指纹并不够。实测改完之后 dsh web 仍会以另一种症状崩溃——**V8 堆耗尽**(约 4 GB,伴随持续的 Mark-Compact 抖动),一次运行约 15 小时后崩、另一次重启仅约 5 分钟就被一次工作区搜索打崩,单次 session_search 调用即可杀死进程。原因在于每次搜索仍**重新观察整个语料**:克隆每个活跃会话的每个事件、为指纹变化的会话删光再重插全部 FTS 文档、把巨型匹配文档的 highlight() 结果整段实体化,以及首次索引时同时持有所有被检查会话的克隆事件与文档(来源:Discussion #1859)。
DeepSeek Harness 里中文查询零命中,是两个独立原因叠加:① 整条查询被当作**一个 FTS5 短语**加引号,多词查询因此要求严格相邻;② FTS5 的 unicode61 分词把连续 CJK 字符当成**一个 token**,所以在「内存溢出」里搜「内存」或搜单字都会零命中。修法是把 CJK 串存成 unigram+bigram 词流(用零宽分隔符),查询时把 CJK 片段展开成同样的 bigram,并把空白分隔的词各自作为独立带引号字面量以 AND 组合,让 MATCH 语法始终只是惰性数据(来源:Discussion #1859)。
相关术语
- change fingerprint(变更指纹)
- change fingerprint(变更指纹)是搜索索引判断某个会话是否需要重建 index 所用的哈希。旧实现把整份事件日志塞进一次 JSON.stringify 再 sha256,因此受 V8 单字符串约 512 MB 上限约束。— https://github.com/deepseek-ai/deepseek-harness/discussions/1859
- stability gate(稳定门)
- stability gate(稳定门)是索引同步前后各取一次持久化快照并比较版本号的机制,任何变动都会重试整轮观察。当被观察的活跃会话每几秒就 write-behind 落盘一次时,重建窗口永远跨着下一次 flush,于是永不收敛、搜索永久失败。— https://github.com/deepseek-ai/deepseek-harness/discussions/1859
- bounded snippet(有界片段)
- bounded snippet(有界片段)是在 SQL 侧用 instr/substr 把 highlight() 的输出窗口限制在首个匹配标记附近的技巧,使每行的 JS 内存与片段窗口成正比,而不是与整份文档成正比。— https://github.com/deepseek-ai/deepseek-harness/discussions/1859
来源
- deepseek-harness Discussion #1859:session search crashes with RangeError: Invalid string length on large sessions(root cause + fix)· deepseek-ai(GitHub Discussions)
- 补丁分支 flyingcoding/deepseek-harness @ fix/session-query-cjk-memory(commit c9abb534d,基于官方 0.1.0-rc.7)· GitHub(flyingcoding)
- dsh-plugin-doctor v1.10.0:新增 large-files 检查,profile 内单文件超 100 MB 即告警· GitHub(zoahdev)