dsh-lag-guard · 长会话不再一打开就卡死
我给 DeepSeek Harness(下称 DSH)写的一个小插件。它只干一件事:让很长的会话能正常打开。
不修改 DSH 安装目录里的任何文件,不动会话日志,不动模型上下文,不影响别的会话。全部逻辑在一个 253 行的文件里。
这是怎么来的
我有个会话叫 DSH 安装与快速启动,越大越长,后来越来越离谱:点开它,界面先愣几秒,然后整片白掉,接着 DSH 直接没了。
换会话就正常。重装没用。清缓存没用。一开始我怀疑是模型在后台跑东西,或者磁盘读太慢,都不是——卡死发生在数据已经到手、正准备画到屏幕上的那几秒。
于是我把那个会话的文件扒开数了一遍:解压后 5.3MB,1087 条事件。然后再算了一遍 DSH 打开会话时究竟搬多少东西过来。结果有点好笑:它一次搬了 3.3MB,占全部的 61.7%。
后面的事就顺了。下面是证据,不是猜测。
卡在哪
第一层:一次把 3.3MB 历史全拉过来。
DSH 打开会话时用的取数参数是写死的:
// dsh-api-session-controller/lib/client.js:2177
const HISTORY_PAGE_OPTIONS = { maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } };
maxMessages 数的是 user/assistant 消息条数。我那个会话一共只有 176 条消息——没到 500,所以「首页」等于「全部」:
| 全会话 | 1087 条事件 / 5308.7KB |
| 打开时实际搬入 | 484 条 / 3290.8KB(61.7%) |
其中 request/header |
4 条 / 2510.9KB,占 47.1% |
单条 header.tools |
201 个工具的完整 JSON schema,627KB |
也就是说:将近一半的传输量,是四次「把当时所有工具的说明书全文抄一遍」。这玩意儿每次请求都要带一份,所以一个会话跑久了就攒下好几兆。
第二层:虚拟列表按「turn」切,而 turn 已经大得离谱。
// dsh-client-ui-chat/lib/client.js:290-300
useVirtualizer({
getItemKey: (i) => items[i]?.turn ?? i,
estimateSize: () => TURN_SPACING_PX,
measureElement: () => TURN_SPACING_PX, // 动态测量被关掉了
});
虚拟化的单位是 turn。我那个会话只有 7 个 turn,而且肥瘦极不均:turn1 有 375 条事件 1.2MB,turn5 有 309 条 1.6MB。窗口里就那么几个 turn,等于整块整块地画,虚拟化形同虚设。
第三层:每画一次,工具 schema 就重新序列化一遍。
// dsh-client-ui-conversation/lib/client.js:1641
const toolsChanged = previous !== undefined && JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools);
窗口里 3 条 header、201/200/200 个 schema:一次比较 13.4ms 纯 JSON.stringify,顺手产出 3.6MB 临时字符串等着 GC 收。而这段代码不是跑一次,是每次视图重建都跑。
三样叠在一起:3.3MB 数据 × 不虚拟化的 turn × 反复 13.4ms 序列化,全落在首帧的同步路径上。主线程被占死,Chromium 判定 renderer 无响应,于是「卡一会儿,卡退」。
顺手排除掉两个我一开始的猜测:不是 1087 条事件逐条触发 1087 次重渲染(DSH 的 notifier 是 microtask 合批的,开窗大概就 1~2 次);也跟 dsh-chunked-list 无关,那只是持久化链表。
它怎么修
DSH 的 Host 侧不让我改:typert 注册表碰上重名会直接抛
typert: ... endpoint "..." is already registered
所以修复只能落在浏览器侧。好在会话控制器的客户端包把 SessionEventStream 导出来了,而它的 follow() 是所有历史取数的必经之路——开窗首页、断档修复、实时追加,全走这一个 async generator;「加载更早」和跳转走的是旁边的 readPage()。
于是插件就包这两个方法,做两件事:
- 把请求收紧:
maxMessages 500 → 120,turnWindow {50,2} → {20,1}。取较小值,不会把你手动设的更小窗口放大;也保证minMessages <= maxMessages——Host 那边的validatePageRequest不满足这个不等式会整页拒成gateway/bad-request。 - 把帧瘦身:
request/header.tools序列化后超过 20 万字符,就换成空数组,留下toolsDropped(原来几个)和toolsChars(原来多大);单条tool/result超过 128KB 就截断并标contentTruncated(这条是兜底,我那个会话里 0 命中)。
同一个会话,改完的效果:
| 窗口参数 | 搬入事件 | 体积 | 再丢掉 tools | 对比全量 |
|---|---|---|---|---|
| 500 / 50,2(原样) | 484 | 3290.8KB | — | 61.7% |
| 120 / 20,1(默认) | 175 | 1604.9KB | 350.5KB | −78% |
| 50 / 10,1(更猛) | 67 | 770.2KB | 143.0KB | −81% |
装上
插件市场里搜 lag-guard(或者 卡、卡顿)就能看到,点安装。
手动也行:
dsh plugin --profile desktop add github:456erddgr/dsh-lag-guard
装完要重启 DSH。然后点开那个一直卡你的会话,控制台应该会多一行:
[lag-guard] session-77a714c5-... opened with 175 events, dropped 627KB of tool schemas in 29ms
没这行说明没加载上,去看 profile 的 package.json 里 dsh.profile.bundles 有没有 dsh-lag-guard。
会不会丢东西
会少一点,但丢的都不是正文:
- 打开长会话时首屏历史短了,更早的内容点「加载更早」再取。数据一条没删。
- 超大 schema 的那几个请求,Chat 里「工具集已变更」的小徽标会失效(
tools变空数组,比较结果恒为「没变」)。 - Trajectory 页签里那几条请求的「工具列表 / 工具差异」视图会是空的,同样因为这个。
- 单条超过 128KB 的工具输出会截断显示,并标注截断。
不影响的:会话日志文件、Host 的分页与校验、模型上下文、工具实际执行、projections、实时流、其它会话。
想撤掉:把 dependencies 和 dsh.profile.bundles 里的 dsh-lag-guard 删掉,pnpm install,重启。或者临时改回来(在渲染进程控制台里执行,立即生效、不落盘):
globalThis.__DSH_LAG_GUARD__ = { maxMessages: 500, minMessages: 50, minTurns: 2, maxHeaderToolsChars: Infinity };
想自己调
改 profile 的 cordis.patch.yml(补丁会整体替换 config,没写的键回默认值):
- id: dsh-lag-guard
config:
maxMessages: 80 # 更省,首屏更短
minMessages: 10
minTurns: 1
maxHeaderToolsChars: 50000
嫌不够快就往小调,嫌首屏太短就往回调。没有「最优值」,只有你那个会话的最优值。
想看看它到底省了多少,跑一下自带的指标收集器:
node tools/sink.mjs # 本地 17891 端口收数据,写 tools/metrics.jsonl
node tools/sink.mjs --flush # 汇总:开了几次、搬了多少事件、砍了多少 KB schema、最长卡顿
数据只发到 127.0.0.1,不联网、不上报。
自己验一遍
node test/verify.mjs --asar <path to @deepseek-ai> --session <path to session.v4.jsonl>
这个测试不是拿 mock 骗自己:它把 DSH 真实的 dsh-api-session-controller/lib/client.js 丢进 node:vm 跑起来(只 stub 掉 window.__ModuleLoader__ 和三个依赖),再加载插件,然后用真实会话里的事件去驱动 follow 和 readPage。
PASS open request clamps maxMessages 500 -> 120
PASS request/header tools are dropped before publication (0 tools left)
PASS dropped tools keep their original count (201)
PASS live tail entries are slimmed too
PASS readPage clamps the page request
PASS a second module load does not stack wrappers
ALL CHECKS PASSED (21/21)
得说清楚的短板
- 体积数字是精确的,手感不是。 我没在你机器上做端到端帧率对比——那要带
--remote-debugging-port重启桌面端。重启后到底还卡不卡,以你的体感为准;还卡就把maxMessages调到 50 再试。 - 它修的是「传输 + 首帧」这一层。 如果你的卡顿来自别处(比如某个第三方插件在会话打开时做重型工作),它救不了你,最多减轻。
- 上游一改就失效。 我依赖的是
SessionEventStream的导出和follow/readPage的形态。哪天 DSH 改了名,install()会返回false然后安静地什么都不做——不会把 DSH 弄崩,但也不会帮你。
更新日志
0.1.1 — 补上 dsh.bundle 清单和 cordis.patch.yml(只声明 dsh.client 的包装不上,这是市场收录的硬要求)。中文说明写全。
0.1.0 — 第一版。包 follow / readPage,收紧窗口 + 丢超大 schema。21 项契约测试全绿。
许可
MIT。随便用、随便改、随便发;改坏了别找我。
English, briefly
dsh-lag-guard is a client-side plugin for DeepSeek Harness. Opening a long session loads up to 500 messages of history (dsh-api-session-controller/lib/client.js:2177); a session with only 176 messages therefore loads all 1087 events / 5.3MB at once, and 47% of that is four request/header events whose tools arrays total 2.5MB. The transcript virtualizes per turn with measurement disabled (dsh-client-ui-chat/lib/client.js:290-300) and re-serializes those schemas on every rebuild (13.4ms each, dsh-client-ui-conversation/lib/client.js:1641). A multi-megabyte synchronous first paint leaves the renderer unresponsive, and DSH goes down with it.
The plugin wraps SessionEventStream.prototype.follow and .readPage, clamps the page request to {maxMessages: 120, turnWindow: {minMessages: 20, minTurns: 1}}, and replaces oversized request/header.tools with an empty array plus toolsDropped / toolsChars. On the same session: 3290.8KB → 350.5KB (−78%). Session logs, host validation, model context and tool execution are untouched. MIT.
No comments yet. Be the first to write one.