ChatGPTClaudeGrok / X / CursorGemini

DeepSeek Harness web_search 卡死整轮:声明的工具超时为什么不生效

故障排查发布于 2026-10-30作者: DeepSeek Plugin 插件市场
DeepSeek HarnessDSHweb_search超时TOOL_TIMEOUTabort工具调用
DeepSeek Harness 里 web_search 声明了 30 秒预算,却能把整轮挂到几小时,连停止都停不下来。按声明、传递、IO 三段链路看:预算只是通知,transport 吞掉 abort 就永远不结算,并给出补丁与插件两条路。

一个 5.5 小时的挂起,起因是一次 web_search:tool/call 事件写进去了,tool/result 永远没来,会话状态一直显示 running,排队的用户消息一条都没被读到——重启进程才恢复。 报告者最初的判断是「缺超时」,被社区当场纠正:预算存在,只是不可执行。沿着「声明 → 传递 → IO」这三段看,你会看到它是在哪一步断掉的(#11258、#11371)。

DeepSeek Harness 第一段:预算确实声明了

web_search 与 web_fetch 都带着默认 30 秒的 timeoutMs,这是写进代码的声明,不是用户漏配。

  1. 在 packages/web/tool-web/src/index.ts 里,applyWebSearchTool 把 timeoutMs = searchTimeoutMs 挂到工具定义上,默认 30_000。
  2. search.ts 的注释写明这个参数是「协作式工具调用预算」,交给 @deepseek-ai/dsh-tool-call-timeout-policy 执行;web_fetch 在 fetch.ts 里是同一形状。
  3. 你可以在运行时确认:会话事件里的 tool/call 之后,如果 30 秒内没有 tool/result,就说明预算没有生效。
  4. 这一段的结论要记牢:不是没有预算,是预算传不到 IO。

DSH插件 第二段:策略只通知,不抢跑

timeout-policy 在 exec.signal 上装好 deadline,然后 await next();只有工具 promise 结算之后,它才把结果替换成 TOOL_TIMEOUT。promise 不结算,它就永远等下去。

  1. 打开 packages/guard/timeout-policy/src/index.ts,第 4 行的自述是「把自身到期转成 TOOL_TIMEOUT,不 race、不放弃工具 promise」。
  2. 这条注释不是谦虚,是设计:它给出的上限只约束「等答案的公告时间」,不约束「这一步实际跑多久」。
  3. 包 README 把这个缺口写成已知限制:wrapper 始终停在 await next() 内部。
  4. 对比 packages/util/timeout 的 idleWatchdog.next:它明确用 Promise.race 保护自己,注释写得很直白——transport 可能让被中止的读取永久挂起。
  5. 同一个仓库里,一个组件防了,另一个没防,这就是缺陷的位置。
层有没有 30s 预算到期会不会强制结算
工具声明有不会,只是标注
timeout-policy有不会,只替换结果文案
transport / fetch没有不会,signal 不是 deadline

DeepSeek Harness 第三段:IO 层不读 signal

dispatchDeepSeekWeb 直接 await fetch(),请求只带着调用方的 signal,没有融合任何 deadline;Node 的 fetch 默认也没有超时。

  1. 看 packages/web/web-deepseek-api/src/transport.ts:请求参数是 ...signal !== undefined ? { signal } : {},没有任何阶段性的 deadline。
  2. 三个阶段全都没有边界:凭据解析、请求发出、响应体读取。
  3. 一旦 abort 落在进行中的 body read 上、半开连接又不再发声,数据和错误都不会到;undici 默认的 headersTimeout/bodyTimeout(300 秒)也覆盖不了这种形态。
  4. 社区实测的形态是:ss 里 socket 已经消失,promise 仍 pending。
  5. 对照 packages/web/web-fetch-http/src/provider.ts:那里已经有 deadline(signal, this.limits.timeoutMs, 'WEB_FETCH_TIMEOUT'),注释说明一个 signal 同时停请求和 body 读取。同一个仓库已经有正确写法。

DeepSeek插件 复现:一条不响应的端点就够

不需要真实网络故障,一个只接受连接、永不响应的服务就能稳定复现。

  1. 把 web-deepseek-api.baseURL 指向一个 net.createServer(() => {}) 起的服务。
  2. 发起一轮会调用 web_search 的对话。
  3. 观察事件流:tool/call 出现,tool/result 永不出现,phase 一直停在 turn 1 / step 1,状态 running。
  4. 按停止或调用 agent.cancel(),步骤依旧不结束。
  5. 另一个真实形态:端点很快返回 HTTP 402 Insufficient Balance,但该轮早已挂住,遗留的调用在整个会话里都没消失。

容器环境里这一点更容易误判:连接被 NAT 或代理静默丢弃时,socket 会先消失,错误却不会回来,表现和「端点很慢」一模一样。

DeepSeek Harness 修法一:核心补丁,把 deadline 融进 transport

正确的修复是在 provider 请求上融合一个覆盖全阶段的 deadline,到期就立即以独立错误码结算,而不是继续等。

  1. 给 @deepseek-ai/dsh-web-deepseek-api 加 timeoutMs 设置,默认 30 000 ms,按操作解析。
  2. 用仓库已有的 @deepseek-ai/dsh-timeout 把 deadline 与调用方 signal 融合,请求和 body 读取共用同一个 signal。
  3. 到期时返回新的 WEB_PROVIDER_TIMEOUT:不是 WEB_ABORTED(没人取消),也不是 WEB_PROVIDER_ERROR(端点没配错,只是慢)。
  4. 调用方主动取消仍然赢下 race,归类为 WEB_ABORTED;正常完成的操作用 using 自动解除计时器。
  5. 社区补丁在 fork 分支 fix/web-search-hang(commit 203ae5e3),8 个文件、+177/−14,包测试 132 项通过。

DeepSeek Harness 修法二:插件缓解,让这一轮先能继续

插件不改核心,只在工具调用外面再 race 一次:到期就把 TOOL_DEADLINE_EXCEEDED 交给模型,步骤继续往下走。

  1. 安装:dsh plugin --profile <profile> add "github:argszero/cordis-plugin-tool-deadline-guard#v0.2.2"。
  2. 配置 applyTo: all。默认值是 undeclared,会刻意放过自己声明了预算的工具,web_search 正好在放过名单里。
  3. 配置 onExpiry: replace,这是真正会抢跑的那条分支。
  4. 配置 perTool: { web_search: 30000, web_fetch: 30000 }。在 all 模式下插件执行的是自己的预算,不继承工具声明值。
  5. 社区实测的四组对照:不装插件 800 ms 后仍 pending;默认配置仍 pending;all + replace 到期结算为 TOOL_DEADLINE_EXCEEDED;再加 perTool 后按每工具预算结算。

这个缓解方案的代价

replace 结束的是「调用」,不是「请求」:被放弃的 fetch 还在后台跑,模型重试可能在同一端点上产生重复副作用。

  1. 这是双重包装配置:基础包本来就挂了 timeout-policy,两个 wrapper 抢同一个预算,可能给一次调用产出两个互相矛盾的结果码。
  2. 所以默认值是 undeclared 而不是 all——选 all 是有意识的取舍,不是第二个 bug。
  3. 插件用的是自己的 TOOL_DEADLINE_EXCEEDED,不和核心的 TOOL_TIMEOUT 混,方便你区分是哪一层到期的。
  4. v0.2.1 在 applyTo: all 下会输出「该工具没有声明 timeoutMs」的错误文案,对 web_search 是假话;v0.2.2 已修正,请直接装新 tag。

注意事项

  1. 别把「停止按钮无效」当成 UI bug。当前实现里没有任何代码去 race 那个不结算的 promise,取消只改 signal。
  2. 别只调大超时。数值再大也不改变「只通知不强制」这一事实。
  3. 重启后先复现一次。确认是搜索端点的问题,再决定要不要挂插件。
  4. 插件不是修复。它让部署先不丢整轮,但真正的修复仍在 transport 里。
  5. 同族还有别的形态。网关 413 被误判成上下文溢出,是同一类「拿到无法处理的失败,却走了一条解决不了它的恢复路径」。

你的日志里如果连 tool/call 都没有,那不属于这一条,去查工具是否根本没被调用。相关排查见 DeepSeek Harness web 面板永久 loading:keepAliveTimeout 超时排查。

ChatGPTClaudeGrok / X / CursorGemini

常见问题

DeepSeek Harness 的 web_search 声明了 30 秒超时,为什么还能把整轮挂住几个小时?

因为 30 秒在 DeepSeek Harness 里只是声明,不是强制。timeout-policy 在 exec.signal 上装 deadline,然后 await next(),只有工具 promise 结算后才替换成 TOOL_TIMEOUT;transport 吞掉 abort、promise 永不结算时,等待就没有终点。

工具卡住时点停止按钮没用、只能重启进程,DeepSeek Harness 这样算正常吗?

在当前实现下是预期结果,DeepSeek Harness 并没有让停止按钮强制结束卡住的工具调用。取消只改变 exec.signal,没有任何代码去 race 那个永不结算的 promise,所以步骤不会结束,排队的用户消息也永远读不到。社区补丁的做法是把 deadline 自己的到期也纳入 race。

DeepSeek Harness 的 web_search 到底是没设超时,还是设了却不生效?

是设了不生效,DeepSeek Harness 的 web_search 声明了 30 秒预算但没有把它传进 IO。packages/web/tool-web/src/index.ts 里 timeoutMs 默认 30_000,search.ts 也注明这是给 dsh-tool-call-timeout-policy 用的协作式预算;而 transport.ts 的 fetch 只收到调用方的 signal,没有任何 deadline 融合进去。

dsh-tool-deadline-guard 插件能彻底解决 DeepSeek Harness 的工具挂死吗?

不能,dsh-tool-deadline-guard 只是缓解,不是 DeepSeek Harness 的彻底修复。它用 applyTo: all 加 onExpiry: replace 在到期时直接给模型返回 TOOL_DEADLINE_EXCEEDED,让这一轮继续;但被放弃的 fetch 仍在后台运行,模型重试可能产生重复副作用。

工具挂死拖住整轮时,怎么先止损让 DeepSeek Harness 的会话继续干活?

先在 DeepSeek Harness 里重启进程清掉卡住的步骤,再给搜索工具挂 deadline guard 插件并把 perTool 写成 { web_search: 30000 }。长期做法是等核心在 provider transport 里融合 deadline,并返回独立的超时错误码。

相关术语

TOOL_TIMEOUT
TOOL_TIMEOUT 是 DeepSeek Harness 工具调用超时策略返回的结构化结果码,用来告诉模型这次调用超出预算。当前实现只在工具 promise 结算后替换结果,无法中断一个不结算的调用。— https://github.com/deepseek-ai/deepseek-harness/discussions/11258
exec.signal
这个信号是工具执行体收到的取消通道,即 exec.signal,超时策略通过它通知工具停止。它只能传递取消意图,不能强制一个不读 signal 的 fetch 结束。— https://github.com/deepseek-ai/deepseek-harness/discussions/11371
idleWatchdog
idleWatchdog 是 packages/util/timeout 里的空闲看门狗,它用 Promise.race 防止 transport 让被中止的读取永久挂起。仓库注释明确记录了这个失败模式,工具超时策略却没有采用同样写法。— https://github.com/deepseek-ai/deepseek-harness/discussions/11258
WEB_PROVIDER_TIMEOUT
WEB_PROVIDER_TIMEOUT 是社区补丁建议新增的错误码,用于把「provider 太慢」与「被调用方取消」和「端点配置错误」区分开。它的目的是让模型选择重试或升级处理,而不是把它当配置错误反复重试。— https://github.com/deepseek-ai/deepseek-harness/discussions/11371

来源