DSH plugin 工具参数失控吃满输出预算?思考空转与碎片 runaway 排查与兜底做法

故障排查发布于 2026-09-12作者: DeepSeek Plugin 插件市场
DeepSeek HarnessDSH plugin思考空转参数 runawayOutput token limit reached
DeepSeek Harness 长跑时回合零产出、只刷 thinking,或工具参数被反复追加直到吃满输出预算,最终只报 Output token limit reached。识别要靠重复句式与碎片数,而非时间阈值;社区 guard 插件可按阈值 warn/steer/cancel。

DeepSeek Harness 长跑时出现两类「输出预算被吃光」的失控:一类是回合全程只刷 thinking、零工具零正文的纯思考空转,另一类是工具调用参数被无限追加的 runaway。两者最终都可能只给你一句 Output token limit reached,但它们既不是上下文溢出,也不能靠调大 maxTokens 或加时间超时解决——识别必须靠重复句式与按调用分桶的碎片/字节计数,社区 guard 插件已可按阈值逐级 warn / steer / cancel。

DSH plugin 两类输出预算失控表现

同一句报错文案下是两种不同的失控形状,先分清形状才能选对兜底判据。 社区实测记录:

  1. 纯思考空转:回合内零工具调用、零回复文本,全部输出为 thinking 增量块;思考内容陷入退化循环,反复生成「好。执行。好。(输出工具调用)」式自我催促,模型一度自纠「嗯,我要停止循环,直接输出工具调用」后继续循环。两次记录分别持续约 2 分 15 秒 / 约 6.7 万条事件、约 16 秒 / 约 2 千条事件,全部为 thinking 增量,只能手动中止(#5976)。
  2. 触发门槛比想象中低:另一位用户在 Windows + dsh web、同一个 deepseek-v4.1-flash-expires-on-0910 模型上,只是跑一个普通的稍长任务就撞上;界面上只显示「思考中」,不点开 thinking 块看不出它卡在重复里,很容易被当成「模型变慢」(#5976)。
  3. 参数 runaway:父会话成功调用 subagent 后,下一步模型发起了 job_output 调用——这对可续传子代理 id 本就是错误的控制方式;流式 JSON 以正确的 id 开头,随后把后缀重复了数千次:{"job_id":"a72944e6-...717af96a717af96a..."}。会话记录到 4,096 输出 token、stopReason: "length"max-tokens 回合结束,组装出的助手消息没有任何可用内容(#6059)。
  4. 前端信息被降级:Web 端只显示 Output token limit reached,最初看起来像普通的回答过长问题;真正的成因要看持久化的流——一组 tool-call-delta 序列在为 job_output 累积失控参数,直到 block-end
  5. 修法上有明确的反例:把 maxTokens 调大不是修复,只是让这次失败更慢更贵;而上下文长度也不是主因——压缩到约 32.2 万 token 后仍然复现(#5976)。

DeepSeek Harness 纯思考空转与碎片数 runaway 机制

两条失控链都能精确定位到源码,而且都落在「插件可挂载」的接缝上。 逐层拆解:

  1. 回合循环不会因「没产出」结束:agent-loop 的 turn 循环(packages/core/agent-loop/src/agent.ts:274)只有在 turnEnds 非空(completed / max-tokens / blocked / aborted / error)且 inbox 清空时才 break;而 step() 返回的 StepEndReason 只取 completed / max-tokens 两型(agent.ts:50)。模型持续产出 thinking 增量、既无 text 也无 tool-call 时,stepEnd 一直是「未完成」,turnEnds 永不置位——循环永不结束。
  2. 现有 guard 是工具调用中心guard/timeout-policytools/execute,只在工具被调用且声明 timeoutMs 时挂 deadline;guard/repeat-tool-remindertools/post-execute,检测连续相同 tool call 链。零工具调用时两者都不触发。
  3. 可用的挂载面是流事件agent/assistant-stream 每帧带 frame.chunk: StreamChunk,而 StreamChunk 明确区分 reasoning-delta / text-delta / tool-call-delta 三型,因此插件可以按 (agent, turn, step) 统计各型 chunk 占比。反应能力也是公开的:agent.steer(input) 可注入「你正在重复自己,停止并行动」,agent.cancel(cause) 可硬停。
  4. 参数 runaway 的接缝在 llm/streampackages/llm/llm/src/index.ts:72 定义了 llm/stream 这个 Cordis waterfall,插件可以包一层迭代器;StreamChunk 类型(llm/llm/src/types.ts:388-394)里的 { type: 'tool-call-delta'; index; id; name?; argumentsDelta: string } 正是要按调用分桶累计的字段。必须按 index 分桶,不能按 id ——上游可能把同一个 id 复用给不同调用。
  5. 真实事故的形状推翻了字节预算:录制流显示一个 index: 0、稳定 id、名为 job_output 的调用,在约 115 秒里产生 4,074 个碎片、总共只有 4,658 ASCII 字节(直方图 3,492×1、581×2、1×4 字节),JSON 字符串始终没有闭合,上游最终发 { type: 'finish', reason: { kind: 'max-tokens' } }。这说明它是碎片数 runaway,而不是大参数 runaway:24 KiB 的默认字节上限永远不会触发,4,096 字节的设置要到第 3,583 个碎片才拦到,而 1,024 碎片的上限在第 1,173 字节就能拦下。
  6. 为什么「发 error finish」真的能阻止执行:agent loop 是先按 finish reason 分支、再 filter 助手内容里的 tool-call 块——当 finish.kind === 'error' || 'aborted' 时,它 settle 这次 attempt 并派发 agent/request-error,根本走不到后面 .filter(block => block.type === 'tool-call') 那一步;且流校验器显式允许 error / aborted finish 携带未闭合的 block index,所以这个协议合法。
  7. 持久化事件名两代不重叠assistant/chunk(会话格式 v1,约 ≤0.1.2-rc.1)与 assistant/attempt(v2,约 ≥0.1.5)没有交集,所以任何基于持久化事件的读取都只能覆盖一代;llm/stream 才是两代都在的那个缝。顺带一个实现级坑:早期版本用 argumentsDelta.length 计的是 UTF-16 code units 而非文档承诺的 UTF-8 字节,对非 ASCII 参数会少算。

用 DSH plugin 按重复句式与碎片阈值兜底

两个缺口各有一个插件形态的兜底,装法与阈值都可配;这是当前 DSH插件 生态里最常用的做法,也是不少 DeepSeek插件 的通用模式:插件负责「检测 + 切断」,core 负责「不执行 + 结构化报错」。 做法如下:

  1. 思考空转@argszero/cordis-plugin-thinking-loop-guard 订阅流事件逐帧统计 chunk 组成——某 step 一旦出现 text-deltatool-call-delta 即视为有产出并清零计数;全程只有 reasoning-delta 且长度超阈值才计入连续空转步数,按 escalate 逐级反应(warn 注入下一 pre-step 提示 → steer 发「停止空转、立即行动」→ cancel('thinking-loop') 硬停)。默认参数偏保守(maxThinkingSteps: 3minReasoningChars: 2048repeatRatio: 0.5escalate: 'steer'),避免误伤正常的长思考。
  2. 参数 runaway@argszero/cordis-plugin-llm-tool-call-guard 包一层 llm/stream,按每个 tool-call block index 独立累计 argumentsDeltaUTF-8 字节(用 TextEncoder),越界即停止消费上游并发出带稳定路由码的终端 error finish。三个互补闸门分别对应三种形状:单块字节 maxArgsBytesTOOL_CALL_ARGUMENTS_TOO_LARGE)、整请求聚合 maxTotalArgsBytes..._TOTAL_TOO_LARGE,「call 太多」而非「一个 call 太大」)、单块碎片数 maxArgsFragments..._TOO_MANY_FRAGMENTS,兜住上面 4,074 碎片那条真实事故):
yaml
- set:
    - id: llm-tool-call-guard
      config:
        maxArgsBytes: 8192        # 单块字节上限
        maxArgsFragments: 1024    # 单块碎片数上限;0 关闭
        maxTotalArgsBytes: 32768  # 整请求聚合上限;0 关闭
        fail: true                # 越界发 error finish,不执行该调用
  1. 用你自己的会话文件标定阈值:思考空转插件随包附带离线复跑工具,用插件运行时同一个 detector 复跑 session jsonl,直接输出每步的 reasonChars / textChars / verdict / fired,本地读取、不上传:
sh
node node_modules/@argszero/cordis-plugin-thinking-loop-guard/tools/analyze-session.mjs \
  <你的 session.jsonl> --similarity 0.6 --threshold 3 --min-chars 512
  1. 装插件走插件市场:在 DSH Plugin Hub 的「设置 → 插件市场」安装与更新这类 guard 插件,比手工往 profile 里加 mount 行更安全(失败会回滚 manifest)。注意 peer range 陷阱:semver 中每个 comparator 只放行与自身 major.minor.patch 元组相同的 prerelease,所以 >=0.1.2-rc.1 <0.2.0 只放行 0.1.2-rc.1 一个版本,跨 0.1.20.1.5 两条线要写成 >=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0;自查用 npm view <pkg>@<range> version 返回空即死。
  2. 本地规避一条:把委派改成一次性/前台返回的子代理,同一条助手回复里发出的多个独立调用仍会并发执行,但结果直接返回、无需 id 轮询,从而避开这条本地模型的触发路径(#6059)。
  3. 别把这类报错当普通输出上限治Output token limit reached 的其它成因与处置另见 输出上限排查

DSH plugin 排查注意事项

识别不能靠时长,只能靠内容特征——正常长推理同样长时间没有文本与工具输出,纯时间阈值必然误伤。 六条要点:

  1. 时长不是判据:正常长推理同样长时间没有文本与工具输出,纯时间阈值必然误伤。
  2. 重复句式是可靠特征:CJK 无空格文本(「好。执行。」)要用语言无关的 repeated-gram 覆盖率,早期按空白分词的做法判不出来。
  3. 按 index 分桶而不是按 id:同一个 id 可能被复用给不同调用。
  4. 字节与碎片是两种互补信号:字节闸门拒真正的大 payload,碎片闸门拒碎片风暴,缺一个就有盲区。
  5. maxTokens 不是解法:小 maxTokens 也可能被一个 runaway call 整段吃掉,且没有 per-call 信号。
  6. 插件只能反应、不能事后否决:契约上插件无法 veto 已发生的 step,但 steer / cancel 与 error finish 已足够阻断。
DSH Plugin Hub 插件市场:安装 guard 类插件、查看版本与更新

来源:Discussion #5976Discussion #6059cordis-plugin-thinking-loop-guardcordis-plugin-llm-tool-call-guard

常见问题

为什么 DeepSeek Harness 的回合会一直「思考中」,零工具调用、零正文,只能手动中止?

DeepSeek Harness 的回合循环只在结束原因落位且 inbox 清空时才 break,而 step 的结束原因只有 completed / max-tokens 两型,所以零产出的回合会一直空转下去。模型持续吐 thinking 增量、既不落文本也不落 tool-call 时,stepEnd 始终「未完成」,循环永远不结束。这正是「纯思考空转」的机制,与上下文长度无必然关系——有用户在 /compact 把上下文从约 51.5 万 token 压到约 32.2 万 token 后,新回合仍然同样空转(来源:Discussion #5976)。

为什么 DeepSeek Harness 里现有的 guard 插件都没能拦住零工具调用的纯思考空转,这究竟是漏检还是本就不覆盖这一失败面?

DeepSeek Harness 里现有的 guard 插件都以工具调用为中心,因此都不覆盖零工具调用的空转这一失败面。guard/timeout-policy 只在工具被调用且声明了 timeoutMs 时挂 deadline,guard/repeat-tool-reminder 检测的是「连续相同 tool call 链」;零工具调用场景下前者根本不触发、后者无链可检测。这不是漏检,而是它们本就不覆盖「纯思考空转」这一失败面(来源:Discussion #5976)。

DeepSeek Harness 的工具参数 runaway 为什么最后只看到 Output token limit reached?

DeepSeek Harness 里失控的 tool-call-delta 会一直被允许流式追加,直到模型把整段输出预算耗尽,所以前端最后只显示通用的输出上限提示。回合随后以 stopReason: "length"max-tokens 结束,组装出的助手消息也没有可用内容。真实事故里一个 job_output 调用在约 115 秒内累积了 4,074 个碎片、总共只有 4,658 字节——这是「碎片数 runaway」,24 KiB 这类字节预算永远不会触发(来源:Discussion #6059)。

在 DeepSeek Harness 中,能不能靠调大 maxTokens 或加时间超时来解决输出预算被吃光的问题?

在 DeepSeek Harness 中,调大 maxTokens 或加时间超时都不能解决这个问题。调大 maxTokens 只会让这次失败更慢更贵;而纯时间阈值会误伤正常的长推理——正常的长思考同样长时间没有文本与工具输出,时长不是有效特征。可靠识别必须靠内容特征(重复句式 / repeated-gram)与按 call 分桶的碎片、字节计数,这也是两个社区 guard 插件各自采用的判据(来源:Discussion #6059)。

相关术语

reasoning-only step(纯思考空转)
`reasoning-only step` 是某个 step 全程只产出 reasoning-delta、没有 text-delta 也没有 tool-call-delta 的状态。它不满足 step 的两种结束原因,因此回合循环不会自行退出。https://github.com/deepseek-ai/deepseek-harness/discussions/5976
repeated-gram coverage(重复片段覆盖率)
`repeated-gram coverage` 是一种语言无关的重复判据,统计思考文本中重复片段的占比。CJK 无空格的「好。执行。」式空转实测退化度约 0.99,连贯的长推理约 0.0,计算复杂度 O(n)。https://github.com/argszero/cordis-plugin-thinking-loop-guard
fragment-count runaway(碎片数 runaway)
`fragment-count runaway` 是单个工具调用的参数被切成极多小碎片持续追加、但总字节数并不大的失控形态。它绕开按字节计的预算,只能用 per-call 碎片数上限兜住。https://github.com/deepseek-ai/deepseek-harness/discussions/6059

来源