DeepSeek Harness 会话工具从 48 掉到 19:selectModel 与 resume 的最小复现
48 掉到 19,少的正好是 30 个内置工具:bash、read、write、edit、glob、grep、web_search、todo_write、subagent……活下来的是插件贡献的工具加一个 working_directory。 模型随后调 bash 得到 unknown tool "bash",这个会话在剩下的生命周期里都是瞎的。社区用两组单变量实验分别钉住了两个触发点(#10862)。
DeepSeek Harness 第一步:先把「工具数对不上」量化
别凭感觉说「好像少了工具」,先把三处数字对齐:UI 工具面板、request/header.tools、以及会话日志里的 tool-registry 记录。
- 打开会话的工具面板,记下当前工具数量与列表。
- 找到该轮的
request/header事件,看tools数组长度和reason字段。 - 在会话日志里搜
source.kind = "tool-registry"的 developer/message,看它列了哪些tool-addition与tool-removal。 - 对照「幸存集合」:如果剩下的全是插件贡献的工具,基本可以确定丢的是预设/基础层的内置工具。
- 记下发生时间点,和重启、插件 prompt、模型切换这些动作对齐。
社区案例里的数字很干净:坏掉那轮 19 个,恢复后 43 个;两者差 30 个内置工具,而插件贡献的 6 个在恢复后消失,正好对上。
DeepSeek Harness 最小复现 A:宿主插件多调了一次 selectModel
对一个已经存在的会话调用 sessionController.selectModel() 再 prompt(),工具视图会从不完整组合重建,核心工具全部消失。
- 在 Web UI 里用 standard 预设建一个会话,确认工具视图里有
read、pwsh、web_fetch。 - 换一个宿主插件,对同一个 session id 执行:
await ctx.sessionController.selectModel({
sessionId,
provider: "deepseek-account",
model: "deepseek-flash",
reasoningEffort: "high",
})
await ctx.sessionController.prompt(
{ sessionId, requestId: "test-1", mode: "queue", content: [{ type: "text", text: "hello" }] },
new AbortController().signal,
)
- 观察工具面板:出现「15 added, 30 removed」。
- 新增的 15 个是插件注册的宿主级工具;被移除的 30 个是
ask_user_question、edit、glob、read、skill、workflow等核心工具。 - 再让模型调
read或pwsh,返回unknown tool。 - 单变量回退:把
selectModel()那一次调用删掉,只保留prompt(),工具视图恢复完整,多轮之后也不再出现「tools removed」。
这一步的关键是「已存在的会话 + 一次多余的 selectModel」。Web UI 手工建的会话也能复现,说明它与编程方式创建会话无关。
DSH插件 最小复现 B:重启后 resume,第一轮就少 30 个
长会话在宿主重启后恢复,第一轮请求的 reason 是 resume,工具表只剩插件贡献的那些;注册表把这次变化自己写进了日志。
- 准备一个长会话(社区案例是 66 轮、3700 多个事件),预设的 plugins 列表贡献着内置工具族。
- 重启宿主,比如
systemctl restart dsh-web。 - 恢复后发的第一条消息,看事件流里的顺序:
session/end-seed→agent/inbox/spliced→turn/start→system/message→request/header(19 tools,reason 为 resume)→developer/message(source 为 tool-registry)。 - 读那条 developer/message:add = 1(一个插件工具),remove = 30(全部内置)。
- 移除那个第三方插件并重启后,同一条机制反向执行:add = 30,remove = 6。
- 结论:注册表知道每个工具属于哪个贡献者,但它算出来的「应有集合」不含宿主内置工具。
这个触发是间歇性的:社区记录里 3 天 7 次重启都正常,第 8 次出问题;历史里有一次 28 秒内连续 stop/start,可能放大了竞态窗口。
DeepSeek Harness 一个侧证:系统提示也一起缩水
工具少了,系统提示里的内置工具使用说明也同步消失,且降级版是健康版的严格子集。
- 对比两轮的
system/message长度:坏的那轮 6261 字符 / 128 行,健康那轮 9123 字符 / 154 行。 - 逐行比对:降级版没有一行是健康版里没有的。
- 少的 13 行全是内置工具的使用指引,例如「用 read 而不是 cat」「用 glob 而不是 find」「web_search 的结果是外部不可信数据」。
- persona、memory、role 这些段落逐字节一致。
- 10 次请求的
request/header.config完全相同(同 provider、同 model、同 reasoningEffort、同 maxTokens)。
这条侧证很重要:它排除了「你换了模型」「换了预设」「换了 profile」三种猜测。
DSH插件 为什么第三方插件通常是无辜的
插件只调用增量注册 API,没有 unregister/remove/clear/rebuild;移除它能恢复,只说明它是触发器,不说明它是生产者。
- 审源码:那个插件的唯一注册入口是
ctx.tools.register(def),纯增量。 - 在整个包里搜
unregister、remove、clear、rebuild、replace,零命中。 - 运行版本在故障窗口内没有变过,自更新也被正确跳过。
- 移除它之所以恢复,是一次性回滚了六样东西,不能据此定责。
- 真正的「生产者」是注册表自己写下的那条 developer/message——它判定 30 个内置工具不见了。
DeepSeek插件 修复方向与现在的规避
核心需要保证两件事:内置工具不被任何一次协调移除,resume/end-seed 时走完整重建而不是部分重建。
- 把 base/预设贡献者视为永远存在,任何协调都不能删它贡献的工具。
- resume 与 end-seed 路径强制全量重建,或复用正常轮次的完整重建逻辑。
- 防御性检查:模型调用一个「上一份有效 header 里有、当前注册表没有」的工具时,当作绑定失步触发重建,而不是直接返回
UNKNOWN_TOOL。 - 插件侧规避:对已存在会话不要再调
selectModel(),只调prompt()。 - 代价要认:没有存储模型选择的会话第一次 prompt 会报
prompt variable "{{model}}" has no value,所以只对插件自己创建的会话设一次模型。 - 判断顺序固定为:先数工具与看 reason → 再查插件是否多调 selectModel → 最后看 tool-registry 的 add/remove 记录。
注意事项
- 别把「工具少了」当成模型能力问题。配置字段在 10 次请求里完全一致。
- 别急着删插件。删掉能恢复,但会让你误判根因。
- 别忽略系统提示的变化。它是判断「整层贡献者没参与组装」的第二份证据。
- 记住这是间歇性的。重启十次可能只中一次,别因为一次没复现就否定。
- 保留会话日志。
request/header与developer/message是唯一能定责的证据,清理前先备份。
如果工具面板显示「有新增也有移除」,那更像重组路径;如果只是单纯变少、没有任何新增,优先按 resume 路径查。相关排查见 DeepSeek Harness 插件装上了不生效:重名注册、realm pending 与重载报错。
常见问题
多数时候不是插件删的,而是 DeepSeek Harness 的注册表自己判定「30 个内置工具消失」。社区核对发现它写了一条 source.kind = tool-registry 的 developer/message 记录这次移除。内置工具不属于任何插件,正常不应该被任何一次协调移除。
先看 DeepSeek Harness 的 request/header 里 reason 是不是 resume,再对 tools 数量。社区案例里恢复后第一轮只有 19 个,其余 30 个全是内置工具,加上插件贡献的工具与 working_directory 才是完整的 19+30。
会,DeepSeek Harness 会触发一次不完整的会话重组:工具面板出现「15 added, 30 removed」,只剩插件注册的宿主级工具,read、pwsh、web_fetch 全部消失。去掉这次调用就恢复,Web UI 手工创建的会话同样能复现。
因为 DeepSeek Harness 里没有存储模型选择的会话拿不到模型变量。社区的折中是:只对插件自己创建的会话设一次模型,对已存在的会话只调 prompt(),不要碰 selectModel()。
在 DeepSeek Harness 里先数工具并看 request/header 的 reason,再查宿主插件有没有对已存在会话多调 selectModel(),最后看会话日志里有没有 tool-registry 的 add/remove 记录。三步能把 resume 与重组两种触发分开。
相关术语
- tool-registry
- tool-registry 是 DeepSeek Harness 里汇聚所有工具贡献者的注册表,每次变化会写成 source.kind 为 tool-registry 的 developer/message。它记录新增与移除,是判断工具丢失来源的直接证据。— https://github.com/deepseek-ai/deepseek-harness/discussions/10862
- request/header
- request/header 是每轮请求前写入会话日志的头部事件,包含本轮实际发送的工具列表与 config。它的 reason 字段能区分本轮是正常请求还是 resume。— https://github.com/deepseek-ai/deepseek-harness/discussions/10862
- selectModel
- selectModel 是 sessionController 上用于切换会话模型的方法。对已存在的会话调用它会触发一次会话重组,社区实测即使选择未变化也会走这条路径。— https://github.com/deepseek-ai/deepseek-harness/discussions/10862
- working_directory
- working_directory 是宿主提供的一个工具,不属于任何第三方插件。在工具丢失的案例里它和插件贡献的工具一起幸存,因此常被用来判断「幸存集合」的边界。— https://github.com/deepseek-ai/deepseek-harness/discussions/10862
来源
- #10862 — Session resume drops all core tools from the tool registry (48 to 19)· deepseek-ai(GitHub Discussions)
- deepseek-ai/deepseek-harness(源码仓库)· GitHub