DeepSeek Harness 报 tool_addition 400:中途换工具为何永久失效
会话跑得好好的,中途装了个插件或者 MCP 热加载了两个工具,下一轮开始就再也没成功过——400 INVALID_REQUEST: messages.N: tool_addition blocks can only be in system messages,位置永远不变,换 request_id 也换不掉。 这不是一次性的网络抖动,是一条不合法的内容块被写进会话历史后,每轮请求原样重发(#10553)。按时间线拆成三段,你会看到它是怎么一步步被钉死的。
DeepSeek Harness 发生前:能力声明写在模型条目上,不写在端点上
toolUpdate 是从模型条目的 compat.supportsMidConvoToolChanges 推导出来的,投影只读这个声明值,不管对面的端点收不收这类块。
- 打开当前会话使用的模型条目,看有没有
toolUpdate: "addition-only"或compat.supportsMidConvoToolChanges。 - 记住这条:
dsh-llm-pi-ai的conversationUpdates()(lib/index.js:789)用它推导toolUpdate: 'in-history'。 - 消费这个值的是
projectToolUpdates(packages/llm/llm/src/content.ts:424),它按声明执行,不会去探测端点能力。 - 所以同一个模型条目指向自建兼容端点时正常、指向官方端点时 400——变的不是条目,是端点。
这是整条链的第一块多米诺:能力描述的对象错了。它描述的是端点,却被声明在模型上。
DSH插件 发生时:工具表一变,块就被写进历史
触发点不是「你手动改了什么」,而是「工具表在会话中途变了一次」:插件追加工具、MCP 热加载、技能目录刷新,都会让 harness 生成一条带 tool-addition 的 developer/message。
- 插件场景:第一轮之后,
billion-context这类插件把 6 个工具注册进同一会话(acp_cache、compress、search_context等)。 - MCP 场景:第 9 轮,某个 MCP server 热加载了 2 个新工具,harness 追加一条
source: tool-registry的 developer/message。 - 技能场景:客户端收到「available skill catalog changed」这类提示,runtime context 随之变化。
- 这条 developer/message 被持久化进
session.v4.jsonl.zstd,里面是若干tool-addition块。 - 下一轮请求由序列化器转成线上格式,官方端点在这个位置拒绝:
messages.N: tool_addition blocks can only be in system messages。
值得注意的反证:社区另一个安装上,两个会话分别做过 116 次和 12 次中途工具新增,零失败。同样操作不一定复现,说明中间还有一层与 in-history system 消息交互的逻辑在决定最终形状。
DeepSeek Harness 发生后:块留在历史里,每轮重发
一旦这条 developer/message 落盘,它就成了会话历史的一部分;之后每轮请求都会把它重新序列化发出,所以失败是会话级的、重试不可恢复的。
- 第 2 轮失败后,第 3、4 轮即使强行注入输入,错误也不变——会话事件日志里不再产生新的 developer/message,也不再产生新的 request/header。
- 错误里的
messages.2或messages.28、messages.340是请求数组的 0-based 下标,下标稳定说明每次发的都是同一份历史。 - 重启 harness 无效:历史在磁盘上,不在内存里。
- 社区核对结论:两个能发出这类块的序列化器都拒绝而不是搬移——
dsh-llm-pi-ai在lib/index.js:1215抛UNSUPPORTED_CONTENT,dsh-llm-deepseek对 developer 消息之外的 tool-change 块同样拒绝。
所以「把角色改回 system」这种修法不成立:harness 手上没有一条会主动降级角色的路径,问题出在「该不该发」的判定,而不是「发成什么形状」。
DeepSeek插件 止损路径:从最省事到最彻底
按代价从低到高:换一个不声明中途工具变更的模型 → 新开会话 → 挂路由插件把请求重投影。
- 换模型:把该会话切到不声明
toolUpdate的模型(社区用的是deepseek-v4-pro),块不再被发出,会话立刻可用。原历史不动,只是这一轮换个口径发。 - 新开会话:最干脆,坏历史留在磁盘里不管。适合手上没有备用模型的场景。
- 挂社区路由插件:
dsh-tool-update-route-guard(v0.1.0,commit54ecf0e),安装方式是npm install github:argszero/dsh-tool-update-route-guard。 - 在 patch 里加一条挂载项,
config.routes列出不能承载这些块的 provider/model 通配。 - 先开
audit: true只观察不改请求,确认命中范围后再关掉审计。 - 插件对命中路由用核心自己的 unsupported-route 投影重新派发,持久历史不动、会话恢复可用,随时可摘。
插件作者的声明很克制:它只在你点名的路由上抑制症状,真正的修复属于核心——要么从端点解析能力,要么在端点拒绝后允许路由丢掉这个声明。
DeepSeek Harness 两条触发路径的差别:DeepSeek 与 Anthropic
同样是 400,DeepSeek 端点和 Anthropic 官方端点的机制不完全一样,判断时别混。
| 路径 | 能力来源 | 关键差别 |
|---|---|---|
| DeepSeek Messages | 模型条目声明 toolUpdate: addition-only | beta 头由 dsh-llm-deepseek 在体里带块时自加 |
| Anthropic 官方端点 | compat.supportsMidConvoToolChanges 推 in-history | pi-ai 路径只在「所有工具都是 deferred」时才重整形(lib/index.js:1949),混合工具表走另一条路 |
混合工具表这一点很关键:不是所有请求都会带上 mid-conversation-tool-changes-2026-07-01,所以同一个会话某些轮能过、某些轮必挂,看起来就像随机。
DSH插件 怎么确认这个会话还能不能救
判断标准只有一条:把请求换成不声明中途工具变更的模型后,下一轮能不能过。能过说明历史本身没坏,只是发送口径与端点不匹配。
- 先复制一份会话目录,
session.v4.jsonl.zstd是后面所有判断的依据。 - 在 UI 里把该会话的模型切成不声明
toolUpdate的条目。 - 发一条最短的消息,比如「继续」,看这一轮是否还报同样的 400。
- 过了,说明会话可救;仍 400 且
messages.N下标不变,说明块被写到了别的位置,转去查会话历史。 - 想留在原模型,就挂路由插件后重复第 3 步,并把
audit: true打开对比请求差异。
社区核对时用捕获式 fetch 做过逐字节对比:未加保护的请求里是 role: user, system 加一个 tool_addition 块和 beta 头,加了保护的请求这两样都没有,而凭据头、用户轮次与工具声明都还在。这也是「插件只改发送口径、不动历史」的直接证据。
注意事项
- 不要靠重试。每轮重发同一份历史,重试次数只增加耗时。
- 不要为了绕开去全局关掉 toolUpdate。你可能同时失去正常的中途工具新增能力。
- 记下
messages.N的下标。它稳定不变,是最有力的「历史被污染」证据。 - 插件缓解不等于修复。核心未修,升级后请重新确认行为。
- 备份会话目录再动手。
~/.dsh/sessions/.../session.v4.jsonl.zstd是唯一证据,清理前先复制一份。
如果你的错误文案不是 tool_addition,而是 UNSUPPORTED_CONTENT 或其他块类型,那是同一族里的另一个问题,别把这套结论套上去。相关排查见 DeepSeek Harness 工具全被 defer_loading:恢复会话 400 怎么修。
常见问题
重试没用:这些块已经写进 DeepSeek Harness 的会话历史,每次请求都会重发同一条,所以错误位置稳定、request_id 每轮不同但结果一样。要么换一个不声明中途工具变更的模型,要么新开会话。
常见三种,都会让 DeepSeek Harness 在运行中途改工具表:插件在第一轮之后把工具注册进同一会话、MCP 服务热加载新工具、技能目录发生变化。社区两次复现分别来自插件追加 6 个工具和 MCP 热加载 2 个工具,Web UI 手工操作也挡不住。
因为 DeepSeek Harness 的能力声明写在模型条目上,不写在端点上。社区核对 dsh-llm-pi-ai 的 conversationUpdates() 是从 compat.supportsMidConvoToolChanges 推导 toolUpdate: 'in-history',投影只看这个声明值,不管对面端点收不收。
不能,改写角色修不好 DeepSeek Harness 的这个会话:社区核对后指出两个序列化器都选择拒绝而不是搬移,dsh-llm-pi-ai 在 lib/index.js:1215 抛 UNSUPPORTED_CONTENT,dsh-llm-deepseek 对 developer 消息之外的 tool-change 块同样拒绝。问题在声明与端点的错配。
不是官方修复,dsh-tool-update-route-guard 是社区给 DeepSeek Harness 做的插件(v0.1.0,commit 54ecf0e),可用 npm install github:argszero/dsh-tool-update-route-guard 安装。在 patch 里把不能承载这些块的路由写进 config.routes,先开 audit: true 只报告不改请求。
相关术语
- tool_addition
- tool_addition 是 Anthropic Messages 协议里表示会话中途新增工具的内容块,只能出现在 system 角色消息中。它一旦被写进会话历史,每轮请求都会重发,位置不合法就是永久 400。— https://github.com/deepseek-ai/deepseek-harness/discussions/10553
- toolUpdate
- toolUpdate 是 DeepSeek Harness 模型条目上的能力声明,取值如 addition-only 或 in-history,决定是否把中途工具变更写进会话历史。它描述的是模型能力,但实际发不发取决于序列化器与端点。— https://github.com/deepseek-ai/deepseek-harness/discussions/10563
- supportsMidConvoToolChanges
- supportsMidConvoToolChanges 是模型条目 compat 下的布尔声明,harness 通过它推导 toolUpdate 是否为 in-history。声明为真时,投影会按这个值处理工具变更,不再检查目标端点是否接受。— https://github.com/deepseek-ai/deepseek-harness/discussions/10563
- mid-conversation-tool-changes-2026-07-01
- mid-conversation-tool-changes-2026-07-01 是 Anthropic 的 beta 请求头,用于启用会话中途工具变更。`dsh-llm-deepseek` 在请求体带这类块时会自己加这个头,走 pi-ai 路径的请求是否带它取决于工具表形态。— https://github.com/deepseek-ai/deepseek-harness/discussions/10563
来源
- #10553 — Mid-conversation tool change places tool_addition blocks in a non-system message· deepseek-ai(GitHub Discussions)
- #10563 — tool_addition blocks can only be in system messages(Anthropic 官方端点永久 400)· deepseek-ai(GitHub Discussions)
- deepseek-ai/deepseek-harness(源码仓库)· GitHub