Skip to content

DeepSeek Harness 0.2.2-rc.3:一件 4 行,一件 3000 行 ​

2026 年 11 月 13 日 15:56(UTC+8),DeepSeek 放出 dsh-v0.2.2-rc.3,距 rc.2 只有 44 小时。

上一篇写 rc.2 时,我在结尾下了个注:按这个节奏,rc.3 大概率是收尾版,而不是又一个功能包。现在可以对照了——赌对了,而且收得比我猜的更狠。 整个发布说明只有两条:一个实验性的会话导入插件,一个 Windows 桌面端的更新修复。

但"只有两条"不等于"没什么可看"。这一版恰好把两件平时看不见的事摆到了台面上:一件是 Windows 上一个平常没人注意的进程属性,能让整次更新失败;另一件是把另一个产品的聊天记录,翻译成本产品的会话日志——而这项工作的难点几乎全在"什么不翻译"。

项目内容
版本dsh-v0.2.2-rc.3(Pre-release)
发布2026-11-13 15:56(UTC+8),距 rc.2 44 小时
规模发布说明 2 条(新增 1 / 修复 1);6 个提交、300 个文件,+8,880 −450
实质改动3 条:导入插件、Windows 更新修复、CI 的 bubblewrap 缓存(未写进说明)
运行时Node.js ≥ 24.9.0(沿用 rc.1,非本版新增)
仓库MIT,TypeScript,280,929 star / 34,688 fork(11-14 读数)
npm周下载 759,921;latest 仍指向 0.2.2-rc.1,本版挂在 next 下

两条,和一份对不上的账 ​

先看这 300 个文件是怎么构成的。

300 个变更文件里 196 个只是 package.json 的版本号,实质代码 66 个;两条改动体量相差三个数量级:Windows 修复 4 行,导入插件 3,092 行源码加 3,050 行测试

这张图的读法是减法:300 个文件里,196 个是各子包的 package.json 把 0.2.2-rc.2 改成 0.2.2-rc.3——一个 monorepo 发版的固定开销。剩下的 104 个里,35 个是文档与多语言 i18n(导入插件要写设计说明,中英各 266 行),3 个是 CI,剩 66 个才是源码、测试与语言包。

再把 6 个提交摊开,账就更清楚了:

提交性质在发布说明里吗
feat(experimental) 导入 DeepSeek 官网对话导出 #8342功能✅
Merge #8385(带回 Windows 重启状态修复)修复✅
fix(ci) 缓存校验过的 bubblewrap 归档基础设施❌
Merge #8383 / Merge #8397合并—
release(dsh): 0.2.2-rc.3版本号—

所以更准确的说法不是"两条藏在 diff 里",而是:这版只有三条实质改动,发布说明写了其中两条。 漏掉的是 CI——对用户没意义,所以不写,这个取舍我认同。

真正值得注意的是体量:同一版里的两件事,一个改了 4 行,一个改了 6,142 行(源码加测试),差三个数量级。 而发布说明给它们的篇幅一样,都是一句话。

4 行:你自己的状态窗,挡住了自己的更新 ​

先讲小的那条,因为它更锋利。

rc.2 那条"改善 Windows 更新安装期间的反馈",引入了一个独立进程:更新确认重启后,弹一个可拖动的状态提示窗,告诉用户"正在装"。实现方式是安装器用 rundll32.exe 加载自己的一个 DLL,把窗口跑起来。

问题就出在这个"跑起来"上。CreateProcessW 创建进程时,如果不指定工作目录,子进程继承父进程的当前目录——也就是安装目录。而 Windows 的语义是:任何进程把这个目录当工作目录,目录就不能被重命名。

安装器更新时要做的事,恰恰是把旧目录换掉(测试里用一次改名来模拟这个过程)。

修复前状态窗以安装目录为工作目录,安装器改名安装目录时被拒;修复后改以系统目录为工作目录,目录得以释放

于是形成一条完整的失败链:状态窗开着 → 安装目录被钉住 → 目录替换失败 → 更新失败 → 应用没能自动重开。用户看到的是"更新失败、还得手动打开一次",根因是他自己屏幕上那个提示窗。

修法是把 CreateProcessW 的第 8 个参数 lpCurrentDirectory 传成 GetSystemDirectoryW()。代码里的注释写得很直白:

An inherited installation directory would prevent its rename while the status window is alive.

改动 restart-status.h +4 −1,一共 4 行。

比修复本身更值得看的是测试。它没有断言"这个参数被传了系统目录"——那种断言只能锁住实现,锁不住目的。它做的是:让状态窗活起来,然后真的把安装目录改名两次(改走再改回),中途检查窗口是否还活着;窗口提前退出就判失败。先中断窗口、再中断宿主进程,两条路径都跑一遍。

这才是这个 bug 的正确回归测试:断言的是"目录可以被替换",而不是"我们传了哪个参数"。

一句话修复、三天定位,这类 bug 在桌面端很常见。也提醒一件事:发布说明里"修复应用内更新可能失败"这种平淡表述,背后往往是一条没人会想到的因果链。

3000 行:把另一个产品的历史,翻译成本产品的日志 ​

另一半是 @deepseek-ai/dsh-deepseek-chat-import,官方定位是"实验性"。

它做的事听起来很轻:DeepSeek 官网(chat.deepseek.com)支持"导出全部会话",产物是一个 deepseek_data-*.zip;装了这个插件,就能把里面的历史会话导进你选的工作区,打开后能看到原来的提问、思考、搜索卡片和回答,还能接着聊。

实际难点是四种结构上的"对不上":

  • 树 vs 线性。官网一个会话的 messages 是靠 parent_id 连起来的树——重新生成、编辑提问都会分叉;而 DSH 的 Session 是只追加的线性日志。
  • id 是局部的。消息 id 只在单个会话里自增,跨会话不可比;inserted_at 在同一会话里也不单调——assistant 常常比配对的 user 早几毫秒。
  • 过程表达方式不同。官网把思考、联网搜索、打开网页、正文这些块交错放在 fragments 里;DSH 用 step 内的 assistant/message 加 tool/call / tool/result 表达同一个过程。
  • 有些内容不能进模型上下文。模板回答、失败状态、加密的链接正文、界面提示条,导出文档明确要求排除。

官网导出的分叉消息树与 DSH 的线性日志:导入器从 current_message_id 回溯到根再反转,另一条分支整条丢弃

线性化的规则很克制:从 current_message_id(用户最后看到的那条)沿 parent_id 回溯到根,反转成一条链,其他分支整条丢弃——一个官网会话只产出一个 Session,因为 DSH 的日志里没有"分叉"这个概念。字段缺失时退化成"取 id 最大的消息",这里有个漂亮的推理:子消息 id 恒大于父消息,所以 id 最大的必然是叶子,不会切到半截。

三个不显然的选择 ​

上传走浏览器,不走 host 路径。 最初的设计是从 host 的绝对路径读 ZIP,或者读一个已解压的目录。被否掉的理由很实际:远程 Host 根本访问不到用户本地下载的文件,而浏览器上传对本地和远程的行为一致。

压缩包只在内存里,从不落盘。 代价是必须有上限:单次上传 512 MiB、单个条目 256 MiB、最多导入 1000 个会话。收益是不用写清理逻辑、不会留崩溃残留、Host 上不会多一份私有数据。服务同时只有一个导入槽位,第二个请求直接回 busy;只有 owner 能调,共享会话的访客永远没权限。

两次流式扫描,而不是"先全解开再校验"。 第一次只解压选中会话的图片(chat_sessions/{id}_files/…),在任何会话被解析之前先把附件存好;第二次按压缩包顺序逐个转换、写入,未选中的条目根本不解压,每 64 KiB 让一次事件循环。放弃的方案是"先全部解压校验、确认无误再写",那样要同时把所有会话和转换后的日志留在内存里,而且整段时间事件循环是堵的。

进度用 NDJSON 逐行流式回写:先是若干 progress 行,最后恰好一行 outcome。关对话框就中止请求,卸载插件会等正在写的会话停下来再完成卸载。

什么不搬,比搬什么更重要 ​

这是我认为这版最值得读的部分。

fragment 的三类去向:进模型上下文、只留一条固定错误码、直接丢弃;丢弃的大多是官网界面的装饰

三类去向,判断标准只有一条:它是不是模型真的说过的话。

REQUEST / THINK / RESPONSE / TOOL_SEARCH / TOOL_OPEN / READ_LINK 会被映射成事件,进上下文。TIP(提示横幅)、CONTENT_RISK(免责声明)、TOOL_FIND(页内查找)、TOOL_IMAGE_SEARCH(图搜)、PARTIAL_FILE_READ 这些全部丢弃——它们是官网界面的装饰,搬进上下文就变成了模型从没说过的话。

中间那一类最有意思:被排除的回答,事实留下,内容删掉。

  • 状态是 FAILED / TIMEOUT / CONTEXT_LENGTH_EXCEEDED 的回答,不写 assistant/message,只在 turn 末尾写一条 turn/end 错误,码是固定字符串 DEEPSEEK_EXPORT_FAILED 之类。
  • 模板回答(TEMPLATE_RESPONSE)整条排除,错误信息就是模板正文。
  • 被内容过滤撤回的一轮(CONTENT_FILTER),提问和回答一起丢,界面上什么都不显示——因为官网也是这么显示的,用户回头看到的和当初看到的一致。

为什么非要多写一个错误码,而不是干脆什么都不写?因为"这里断了"本身是事实,删掉就等于伪造了一段连续的对话。为什么错误码要用固定字符串?因为来自导出的文本只出现在说明里——这些码也不在"可继续生成"的集合内,所以界面上不会冒出那个会再次失败的"继续生成"按钮。

被日志格式逼出来的两个细节 ​

有两个实现细节,看着像 hack,其实是被校验规则逼出来的。

第一个:第一个 turn 的 step/start 后面紧跟一条空的 system/message。 存储格式只接受"作为 surface 节点 0 或紧随这种头之后"的 system 消息,每次读取日志都会检查这一点。有了这个空头,恢复时 Agent 会用真实的系统提示替换它,而不是在导入的历史之后追加一条系统消息——后者会让整段历史被当成"用户后来补充的指令"。

第二个:assistant/message 的 stream 写空数组。 导出里没有逐 token 的流记录,而现有的校验要求"流能精确重建内容",伪造一份流通不过。空数组能过,Web 就从 message.content 渲染。

这两处的共同点:导入器是照着日志的不变量写的,不是绕着校验走的。 一个外来写入方愿意付这个成本,说明这套存储格式的不变量是真的有用的。

代价写在 Consequences 里 ​

很少见到一个功能的设计文档,把"代价"写得比"功能"还长。挑几条我觉得真正会咬人的:

  • 重复导入会产生重复会话。 同一份导出导两次,写两遍。导入器不记录"哪些已经导过"。
  • 存储里有"孤儿"会话。 会话写完、加入工作区失败时,SessionPersistence 没有删除接口,这条会话会留在存储里、不属于任何工作区,只在 skipped 里报出 id。
  • 抓取卡片的状态码是推断的。 导出只记"页面打开成功",没有 HTTP 状态码,卡片按 statusCode: 200 渲染。
  • 外来的 reasoning 会重新发给模型。 导入的思考内容按"无签名 thinking"发送,恢复后首个请求的输入 token 会明显变多——这是一次真实的计费增量。
  • token 计量是估算值。 导出里的 accumulated_token_usage 不导入。
  • 只针对一种导出结构(2026-11-12 那版)。更旧的 v3 归档仍能导入,但链接行能写出的内容更少。
  • 图片会留下没人引用的副本。 被撤回的轮次、被丢弃的分支、种子校验失败的会话,图片都已经存进附件库了,而附件存储同样没有删除。

这些不是"以后再说"的 TODO,是设计时就写下来的已知损失。把成本列清楚的文档,比只列收益的文档可信得多。

那条没进发布说明的 CI 改动 ​

第三条实质改动是 fix(ci): cache the verified bubblewrap archive and bound its download——一条回填(backport)到 release 分支的修复。

内容不复杂:CI 每次跑测试都要下载并校验 bubblewrap 0.12.0 的 deb 包,之前每次从 Launchpad 拉。现在把校验过的归档缓存到 ~/.cache/dsh-ci/bubblewrap,命中就免网;下载加了上限,避免卡住的主机把一次 CI 拖成几十分钟;SHA-256 工具先探测再联网——缺 sha256sum 时直接报"没有摘要工具",而不是把"工具缺失"误报成"校验不匹配"从而丢掉一份好缓存。

注释里有一句很诚实的自我暴露:摘要校验失败的缓存会被重新下载,但不会重新保存(key 已存在)——也就是说一份被投毒的缓存会活到被驱逐为止,"这需要蓄意的缓存投毒才会发生"。

这条对用户零影响,但对一个每天发版的仓库来说,它决定了发版流程本身跟不跟得上。rc.3 之所以能 44 小时就出,靠的正是这类没人看的改动。

冷静看 ​

先说证据等级。

  • 版本信息、两条发布说明、提交与文件数:来自 GitHub Release 与 compare API(文件列表撞上 300 条上限,实际可能更多——但版本号文件已占绝大多数,不影响结论)。
  • 行数(3,092 / 3,050 / 4):按 compare API 的 additions 逐目录累加,不含 apps/web 那 174 行 e2e。
  • 设计细节(选择规则、fragment 映射、代价清单):来自仓库里的设计说明 .agents/notes/implemented/feature/2026-11-11-deepseek-chat-export-import.zh.md。这是官方自己写的实现记录,属于设计自述,不是第三方复现。
  • "工作目录导致改名失败"这条因果链:由 README 的一句话("状态进程以 Windows 系统目录为工作目录,避免在替换期间占用安装目录")加代码注释推出,没有官方逐条解释。
  • npm、star 数:11 月 14 日直接读 registry 和 API 的结果,会变。

四个判断。

  1. Windows 桌面用户必升。 这版就是修你的;如果你遇到"更新完了要自己再点一次图标",升完应该就没了。
  2. macOS / Linux / 纯 Web 用户基本没有收益。 另一条是实验性插件,默认不挂载。可以等 rc.4 或正式版。
  3. 插件作者这版完全没有活。 没有接口变更,没有迁移项,也没有默认值调整——和 rc.2 那 20 条迁移清单不是一个量级。
  4. 想试导入功能,别用 npx @deepseek-ai/dsh。 这是本文最实用的一条:npm 上 latest 标签还停在 0.2.2-rc.1(11 月 4 日),next 才是 0.2.2-rc.3。默认命令装到的是九天前的候选版,而且里面没有这个插件。要试就写 npx @deepseek-ai/dsh@0.2.2-rc.3 web。

最后是节奏。

88 天里 39 个发布,全部是 pre-release;npm latest 停在 11 月 4 日的 0.2.2-rc.1

88 天、39 个版本、0 个正式版。0.1.0 走到 rc.8,0.2.1 走到 rc.5——这个仓库不忌讳把候选版排到第五个以后。

所以"rc.3 是收尾"这句话,我上一版的赌注其实只对了一半:它确实是收尾的形状(没有新功能),但按它自己的历史,rc.3 之后再出一个 rc.4 也完全正常。对外部用户来说,判断标准不该是版本号,而是"我关心的那条修没修"。

这版真正给出的信号是另一件事:当一版发布只剩一个实验功能和一句 Windows 修复时,说明 0.2.2 的功能面已经冻结了。 接下来的 rc 大概只会修 bug。这对插件作者是好消息——接口可以按 rc.2 的样子定下来开始写了。

参考 ​