dsh-coldread-cache
English | 简体中文
把正在写入的会话日志常驻在内存里解析,长会话不再越用越卡。
这是一个 DeepSeek Harness(DSH)插件。
它不修改 DSH 安装目录里的任何文件:挂载时在运行中的持久化后端实例上写入自有属性,
卸载时还原。
症状与根因
会话开得越久、轮次越多,界面就越卡:切换会话变慢、发一条消息要等、磁盘一直有读写。
关掉重开就好了——过一会儿又慢慢卡起来。
根因不是内存泄漏,是整份冷读:
- DSH 把每个会话存成
session.v4.jsonl.zstd:可独立解码的 zstd 帧拼接,每帧一个
JSONL 批次,首帧是头部。
- JSONL 后端会缓存已解析的日志,但缓存上限只有两条,且以 stat 推导出的
revision 作为键。
- 每次追加都会改变这个 revision,所以正在写入的会话几乎每次读都缓存未命中。
- 未命中就是一次整份冷读:整个文件
readFile → 逐帧 zstd 解码 → 逐行 JSON.parse。
本机真实日志实测:
| 文件 | 事件数 | 整份冷读 |
|---|
| 16 MB | 15 958 | 576 ms |
| 73 MB | 约 39 700 | 约 2 400 ms |
开销几乎全在 JSON 层,不在解压也不在磁盘:73 MB 的文件里 JSON.parse 约
1 750 ms、JSON.stringify 约 2 470 ms。
抓到的现场:一个开着长会话的宿主进程 30 秒读了 846 MB,峰值 61.6 MB/s,
30 秒内 37 477 次读操作;而刚启动的宿主基本是 0 MB/s。
这个插件做了什么
为每个会话保留一个常驻解码器,用的是后端同一套已发布的格式解码器,只做增量扩展、
不重建:
- 文件没变 → 一次
stat 之后直接从常驻结果返回;
- 文件变长了 → 只读新增的那段字节,解码后喂给常驻解码器追加;
- 其它任何情况 → 原样交给后端自身的读取逻辑,不猜、不凑。
实测:把真实追加产生的帧喂给一个已有的 16k 事件解码器,耗时不到 1 ms,
而同一份日志整份冷读是 576 ms,且扩展结果与整份解析深度相等。
已经发出去的事件对象在增长后仍是同一批对象,调用方不会看到抖动。
一个踩过的坑
后端在返回结果前会调 freezeStoredEvents,它原地冻死解码器持有的那个事件数组。
所以插件必须让常驻解码器保持未冻结,每次读发一份冻结副本出去。
否则第二次追加会直接炸:
Cannot add property 15953, object is not extensible
正确性
只有在能够证明「复用等价于整份解析」时才复用常驻结果:
- 同一个文件——device + inode 相同,且路径相同;
- 文件只增长,且头部帧的字节未变;
- 常驻长度之后的每个字节都是完整、可独立解码的 zstd 帧(尾部撕裂就回退);
- 把这些帧喂给常驻解码器只会新增行,且格式解码器自身的关系约束仍然成立;
- 新增行通过与后端读取逻辑相同的 v4 行准入、存储事件校验与关系断言,
并且上报的是实际观测到的 revision。
任何一条无法证明就回退。所以最坏情况只是「和没装一样慢」,不会更慢,也不会给出
一份和整份解析不同的答案。
安装
插件由 profile 加载,并自带插入行:
// <profile>/cordis.patch.yml
- insert:
- id: coldread-cache
name: 'dsh-coldread-cache'
推荐用 dsh 自带的插件命令装:
dsh plugin --profile <name> add dsh-coldread-cache
手工挂载需要同时改三处,缺一处启动就会失败:
| 文件 | 需要什么 |
|---|
<profile>/package.json | dependencies 里有本包 |
<profile>/package.json | dsh.profile.bundles 里有 dsh-coldread-cache |
<profile>/cordis.patch.yml | 上面那条 insert 行 |
挂载顺序不用管:插件先试 ctx.get("sessionPersistence"),服务还没起来就注册
ctx.on("internal/service", …) 等它出现。所以宿主启动时自动生效,不需要人工干预。
配置
| 键 | 默认值 | 含义 |
|---|
enabled | true | 允许从常驻结果直接返回 |
maxEntries | 8 | 本进程常驻的会话数 |
maxResidentBytes | 134217728 | 常驻源字节总量 |
statusFile | "" | 可选的实时诊断文件路径 |
前两项是进程的内存策略。后端自己那份两条的缓存原样不动。
statusFile 是排查用的:写一份实时文档(心跳刷新),说明这份进程实际装上了哪些钩子、
命中了多少次、回退的原因分布。一个静默失效的插件比一个从没装上的插件更糟,所以留了这个
口子。
验证
pnpm run gate # 验收门禁,跑真实 vendored 后端
pnpm run fault # 故障注入:证明上面的门禁真的能变红
pnpm run perf # 真实会话日志上的「追加后读取」A/B
pnpm test # 依赖与安装机制探针
门禁
GATE_GREEN checks=85 failures=0。门禁把真实的 vendored 后端跑两遍同一批真实会话
副本(一遍原始、一遍打上插件),深度比对结果,覆盖:语料、追加路径、尾部撕裂文件、
同长度改写、卸载还原、关闭常驻的对照组,并包含一次故意注入错误答案的故障注入——
用来证明这个比对本身能失败。没有故障注入的绿色不算绿色。
pnpm run fault 单独跑 7 项故障注入,证明门禁的关键断言都能真的变红:
翻转一个字节让 vendored 摘要检查失败、少一个事件 / 多一个字段让读取比对失败、
关掉常驻后命中数必须为 0(否则性能断言就是自证)。同时门禁脚本自带互斥锁——
两实例同跑会污染同一份 fixture,锁让第二次运行直接拒绝而不是悄悄给出错误结论。
两棵 vendored 目录始终保持与出厂 SHA-256 字节一致
(027803DDE72AAC7038253F0A8FF9FDCDBF0EB8CAD53ED26EFA3EC610F01587E7),
门禁在每次运行的开头和结尾都断言这一点。
性能 A/B
在真实的约 90 MB 会话日志上,用后端自己的写入路径追加一个真实批次,再计量紧随其后
的那次读(也就是宿主每轮都要做的那次读),各跑 5 轮取中位数:
三次独立运行的中位数(同一份 ~90 MB 真实会话,每次 5 轮):
| 追加后的一次读 | 运行 1 | 运行 2 | 运行 3 |
|---|
| 原始后端 | 2 928 ms | 4 232 ms | 5 972 ms |
| 挂插件但关闭常驻(对照组) | 5 244 ms | 6 436 ms | 6 493 ms |
| 挂插件 | 474 ms | 410 ms | 477 ms |
即 6×–12.5×。对照组就是这个断言自己的证明:同一段循环、同一个
后端类、同一个文件,只关掉常驻——它一点加速都没有,说明这个断言能真的变红。
注意:这里比的不是「同一个文件读两遍」。后端自己那份两条缓存已经能让重复读很快,
那样比什么都证明不了。真正会卡的是读一份刚刚变长的日志,所以 A/B 测的就是这个。
实机
单独冷启一份 profile 到另一个端口(不动正在用的宿主),什么参数都不给:
{
"backend": "session-persistence-jsonl",
"installed": ["readStoredLog","decodeStoredLog","persistBatch","persistHeader","truncateTornTail"],
"failures": {}
}
启动日志里零条插件错误。然后在界面上纯 UI 操作来回切换真实会话:
hits=4 ext=0 cold=1 ← 第一次打开
hits=5 ext=0 cold=2 ← 打开另一个会话
hits=9 ext=1 cold=3 ← 回到已打开的会话:靠「扩展常驻解析」服务,没有重新冷读
hits=11 ext=2 cold=4 ← 再一轮:又两次扩展
关键在最后两行:extensions 涨了,coldParses 没跟着涨。
限制
- 快速路径只覆盖「按整帧增长」和「完全没变」。尾部撕裂、改写、截断、帧表重建都会回退。
- 常驻是进程级的,宿主重启后失效;插件会在挂载时重新安装。
- 第一次读永远是冷的(那一刻还没有常驻结果可用)。
- 后端自己那份两条的缓存不受影响,插件另有自己的字节数与条目数预算。
许可
MIT