DeepSeek Harness web_search 卡死整轮:声明的工具超时为什么不生效
一个 5.5 小时的挂起,起因是一次 web_search:tool/call 事件写进去了,tool/result 永远没来,会话状态一直显示 running,排队的用户消息一条都没被读到——重启进程才恢复。 报告者最初的判断是「缺超时」,被社区当场纠正:预算存在,只是不可执行。沿着「声明 → 传递 → IO」这三段看,你会看到它是在哪一步断掉的(#11258、#11371)。
DeepSeek Harness 第一段:预算确实声明了
web_search 与 web_fetch 都带着默认 30 秒的 timeoutMs,这是写进代码的声明,不是用户漏配。
- 在
packages/web/tool-web/src/index.ts里,applyWebSearchTool把timeoutMs = searchTimeoutMs挂到工具定义上,默认30_000。 search.ts的注释写明这个参数是「协作式工具调用预算」,交给@deepseek-ai/dsh-tool-call-timeout-policy执行;web_fetch在fetch.ts里是同一形状。- 你可以在运行时确认:会话事件里的
tool/call之后,如果 30 秒内没有tool/result,就说明预算没有生效。 - 这一段的结论要记牢:不是没有预算,是预算传不到 IO。
DSH插件 第二段:策略只通知,不抢跑
timeout-policy 在 exec.signal 上装好 deadline,然后 await next();只有工具 promise 结算之后,它才把结果替换成 TOOL_TIMEOUT。promise 不结算,它就永远等下去。
- 打开
packages/guard/timeout-policy/src/index.ts,第 4 行的自述是「把自身到期转成TOOL_TIMEOUT,不 race、不放弃工具 promise」。 - 这条注释不是谦虚,是设计:它给出的上限只约束「等答案的公告时间」,不约束「这一步实际跑多久」。
- 包 README 把这个缺口写成已知限制:wrapper 始终停在
await next()内部。 - 对比
packages/util/timeout的idleWatchdog.next:它明确用Promise.race保护自己,注释写得很直白——transport 可能让被中止的读取永久挂起。 - 同一个仓库里,一个组件防了,另一个没防,这就是缺陷的位置。
| 层 | 有没有 30s 预算 | 到期会不会强制结算 |
|---|---|---|
| 工具声明 | 有 | 不会,只是标注 |
timeout-policy | 有 | 不会,只替换结果文案 |
transport / fetch | 没有 | 不会,signal 不是 deadline |
DeepSeek Harness 第三段:IO 层不读 signal
dispatchDeepSeekWeb 直接 await fetch(),请求只带着调用方的 signal,没有融合任何 deadline;Node 的 fetch 默认也没有超时。
- 看
packages/web/web-deepseek-api/src/transport.ts:请求参数是...signal !== undefined ? { signal } : {},没有任何阶段性的 deadline。 - 三个阶段全都没有边界:凭据解析、请求发出、响应体读取。
- 一旦 abort 落在进行中的 body read 上、半开连接又不再发声,数据和错误都不会到;undici 默认的
headersTimeout/bodyTimeout(300 秒)也覆盖不了这种形态。 - 社区实测的形态是:
ss里 socket 已经消失,promise 仍 pending。 - 对照
packages/web/web-fetch-http/src/provider.ts:那里已经有deadline(signal, this.limits.timeoutMs, 'WEB_FETCH_TIMEOUT'),注释说明一个 signal 同时停请求和 body 读取。同一个仓库已经有正确写法。
DeepSeek插件 复现:一条不响应的端点就够
不需要真实网络故障,一个只接受连接、永不响应的服务就能稳定复现。
- 把
web-deepseek-api.baseURL指向一个net.createServer(() => {})起的服务。 - 发起一轮会调用
web_search的对话。 - 观察事件流:
tool/call出现,tool/result永不出现,phase 一直停在 turn 1 / step 1,状态 running。 - 按停止或调用
agent.cancel(),步骤依旧不结束。 - 另一个真实形态:端点很快返回
HTTP 402 Insufficient Balance,但该轮早已挂住,遗留的调用在整个会话里都没消失。
容器环境里这一点更容易误判:连接被 NAT 或代理静默丢弃时,socket 会先消失,错误却不会回来,表现和「端点很慢」一模一样。
DeepSeek Harness 修法一:核心补丁,把 deadline 融进 transport
正确的修复是在 provider 请求上融合一个覆盖全阶段的 deadline,到期就立即以独立错误码结算,而不是继续等。
- 给
@deepseek-ai/dsh-web-deepseek-api加timeoutMs设置,默认 30 000 ms,按操作解析。 - 用仓库已有的
@deepseek-ai/dsh-timeout把 deadline 与调用方 signal 融合,请求和 body 读取共用同一个 signal。 - 到期时返回新的
WEB_PROVIDER_TIMEOUT:不是WEB_ABORTED(没人取消),也不是WEB_PROVIDER_ERROR(端点没配错,只是慢)。 - 调用方主动取消仍然赢下 race,归类为
WEB_ABORTED;正常完成的操作用using自动解除计时器。 - 社区补丁在 fork 分支
fix/web-search-hang(commit203ae5e3),8 个文件、+177/−14,包测试 132 项通过。
DeepSeek Harness 修法二:插件缓解,让这一轮先能继续
插件不改核心,只在工具调用外面再 race 一次:到期就把 TOOL_DEADLINE_EXCEEDED 交给模型,步骤继续往下走。
- 安装:
dsh plugin --profile <profile> add "github:argszero/cordis-plugin-tool-deadline-guard#v0.2.2"。 - 配置
applyTo: all。默认值是undeclared,会刻意放过自己声明了预算的工具,web_search正好在放过名单里。 - 配置
onExpiry: replace,这是真正会抢跑的那条分支。 - 配置
perTool: { web_search: 30000, web_fetch: 30000 }。在all模式下插件执行的是自己的预算,不继承工具声明值。 - 社区实测的四组对照:不装插件 800 ms 后仍 pending;默认配置仍 pending;
all + replace到期结算为TOOL_DEADLINE_EXCEEDED;再加perTool后按每工具预算结算。
这个缓解方案的代价
replace 结束的是「调用」,不是「请求」:被放弃的 fetch 还在后台跑,模型重试可能在同一端点上产生重复副作用。
- 这是双重包装配置:基础包本来就挂了
timeout-policy,两个 wrapper 抢同一个预算,可能给一次调用产出两个互相矛盾的结果码。 - 所以默认值是
undeclared而不是all——选all是有意识的取舍,不是第二个 bug。 - 插件用的是自己的
TOOL_DEADLINE_EXCEEDED,不和核心的TOOL_TIMEOUT混,方便你区分是哪一层到期的。 - v0.2.1 在
applyTo: all下会输出「该工具没有声明 timeoutMs」的错误文案,对web_search是假话;v0.2.2 已修正,请直接装新 tag。
注意事项
- 别把「停止按钮无效」当成 UI bug。当前实现里没有任何代码去 race 那个不结算的 promise,取消只改 signal。
- 别只调大超时。数值再大也不改变「只通知不强制」这一事实。
- 重启后先复现一次。确认是搜索端点的问题,再决定要不要挂插件。
- 插件不是修复。它让部署先不丢整轮,但真正的修复仍在 transport 里。
- 同族还有别的形态。网关 413 被误判成上下文溢出,是同一类「拿到无法处理的失败,却走了一条解决不了它的恢复路径」。
你的日志里如果连 tool/call 都没有,那不属于这一条,去查工具是否根本没被调用。相关排查见 DeepSeek Harness web 面板永久 loading:keepAliveTimeout 超时排查。
常见问题
因为 30 秒在 DeepSeek Harness 里只是声明,不是强制。timeout-policy 在 exec.signal 上装 deadline,然后 await next(),只有工具 promise 结算后才替换成 TOOL_TIMEOUT;transport 吞掉 abort、promise 永不结算时,等待就没有终点。
在当前实现下是预期结果,DeepSeek Harness 并没有让停止按钮强制结束卡住的工具调用。取消只改变 exec.signal,没有任何代码去 race 那个永不结算的 promise,所以步骤不会结束,排队的用户消息也永远读不到。社区补丁的做法是把 deadline 自己的到期也纳入 race。
是设了不生效,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 的彻底修复。它用 applyTo: all 加 onExpiry: replace 在到期时直接给模型返回 TOOL_DEADLINE_EXCEEDED,让这一轮继续;但被放弃的 fetch 仍在后台运行,模型重试可能产生重复副作用。
先在 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
来源
- #11258 — tool-call timeout is notify-only: a declared timeoutMs can hang the whole session· deepseek-ai(GitHub Discussions)
- #11371 — web_search can hang forever instead of failing: one stalled request wedges the whole turn· deepseek-ai(GitHub Discussions)
- deepseek-ai/deepseek-harness(源码仓库)· GitHub