DSH plugin 工具参数失控吃满输出预算?思考空转与碎片 runaway 排查与兜底做法
DeepSeek Harness 长跑时出现两类「输出预算被吃光」的失控:一类是回合全程只刷 thinking、零工具零正文的纯思考空转,另一类是工具调用参数被无限追加的 runaway。两者最终都可能只给你一句 Output token limit reached,但它们既不是上下文溢出,也不能靠调大 maxTokens 或加时间超时解决——识别必须靠重复句式与按调用分桶的碎片/字节计数,社区 guard 插件已可按阈值逐级 warn / steer / cancel。
DSH plugin 两类输出预算失控表现
同一句报错文案下是两种不同的失控形状,先分清形状才能选对兜底判据。 社区实测记录:
- 纯思考空转:回合内零工具调用、零回复文本,全部输出为 thinking 增量块;思考内容陷入退化循环,反复生成「好。执行。好。(输出工具调用)」式自我催促,模型一度自纠「嗯,我要停止循环,直接输出工具调用」后继续循环。两次记录分别持续约 2 分 15 秒 / 约 6.7 万条事件、约 16 秒 / 约 2 千条事件,全部为 thinking 增量,只能手动中止(#5976)。
- 触发门槛比想象中低:另一位用户在 Windows +
dsh web、同一个deepseek-v4.1-flash-expires-on-0910模型上,只是跑一个普通的稍长任务就撞上;界面上只显示「思考中」,不点开 thinking 块看不出它卡在重复里,很容易被当成「模型变慢」(#5976)。 - 参数 runaway:父会话成功调用
subagent后,下一步模型发起了job_output调用——这对可续传子代理 id 本就是错误的控制方式;流式 JSON 以正确的 id 开头,随后把后缀重复了数千次:{"job_id":"a72944e6-...717af96a717af96a..."}。会话记录到 4,096 输出 token、stopReason: "length"、max-tokens回合结束,组装出的助手消息没有任何可用内容(#6059)。 - 前端信息被降级:Web 端只显示
Output token limit reached,最初看起来像普通的回答过长问题;真正的成因要看持久化的流——一组tool-call-delta序列在为job_output累积失控参数,直到block-end。 - 修法上有明确的反例:把
maxTokens调大不是修复,只是让这次失败更慢更贵;而上下文长度也不是主因——压缩到约 32.2 万 token 后仍然复现(#5976)。
DeepSeek Harness 纯思考空转与碎片数 runaway 机制
两条失控链都能精确定位到源码,而且都落在「插件可挂载」的接缝上。 逐层拆解:
- 回合循环不会因「没产出」结束: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永不置位——循环永不结束。 - 现有 guard 是工具调用中心:
guard/timeout-policy包tools/execute,只在工具被调用且声明timeoutMs时挂 deadline;guard/repeat-tool-reminder挂tools/post-execute,检测连续相同 tool call 链。零工具调用时两者都不触发。 - 可用的挂载面是流事件:
agent/assistant-stream每帧带frame.chunk: StreamChunk,而StreamChunk明确区分reasoning-delta/text-delta/tool-call-delta三型,因此插件可以按(agent, turn, step)统计各型 chunk 占比。反应能力也是公开的:agent.steer(input)可注入「你正在重复自己,停止并行动」,agent.cancel(cause)可硬停。 - 参数 runaway 的接缝在
llm/stream:packages/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 复用给不同调用。 - 真实事故的形状推翻了字节预算:录制流显示一个
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 字节就能拦下。 - 为什么「发 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/abortedfinish 携带未闭合的 block index,所以这个协议合法。 - 持久化事件名两代不重叠:
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 负责「不执行 + 结构化报错」。 做法如下:
- 思考空转:
@argszero/cordis-plugin-thinking-loop-guard订阅流事件逐帧统计 chunk 组成——某 step 一旦出现text-delta或tool-call-delta即视为有产出并清零计数;全程只有reasoning-delta且长度超阈值才计入连续空转步数,按escalate逐级反应(warn注入下一 pre-step 提示 →steer发「停止空转、立即行动」→cancel('thinking-loop')硬停)。默认参数偏保守(maxThinkingSteps: 3、minReasoningChars: 2048、repeatRatio: 0.5、escalate: 'steer'),避免误伤正常的长思考。 - 参数 runaway:
@argszero/cordis-plugin-llm-tool-call-guard包一层llm/stream,按每个 tool-call block index 独立累计argumentsDelta的 UTF-8 字节(用TextEncoder),越界即停止消费上游并发出带稳定路由码的终端errorfinish。三个互补闸门分别对应三种形状:单块字节maxArgsBytes(TOOL_CALL_ARGUMENTS_TOO_LARGE)、整请求聚合maxTotalArgsBytes(..._TOTAL_TOO_LARGE,「call 太多」而非「一个 call 太大」)、单块碎片数maxArgsFragments(..._TOO_MANY_FRAGMENTS,兜住上面 4,074 碎片那条真实事故):
- set:
- id: llm-tool-call-guard
config:
maxArgsBytes: 8192 # 单块字节上限
maxArgsFragments: 1024 # 单块碎片数上限;0 关闭
maxTotalArgsBytes: 32768 # 整请求聚合上限;0 关闭
fail: true # 越界发 error finish,不执行该调用
- 用你自己的会话文件标定阈值:思考空转插件随包附带离线复跑工具,用插件运行时同一个 detector 复跑 session jsonl,直接输出每步的
reasonChars/textChars/verdict/fired,本地读取、不上传:
node node_modules/@argszero/cordis-plugin-thinking-loop-guard/tools/analyze-session.mjs \
<你的 session.jsonl> --similarity 0.6 --threshold 3 --min-chars 512
- 装插件走插件市场:在 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.2与0.1.5两条线要写成>=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0;自查用npm view <pkg>@<range> version返回空即死。 - 本地规避一条:把委派改成一次性/前台返回的子代理,同一条助手回复里发出的多个独立调用仍会并发执行,但结果直接返回、无需 id 轮询,从而避开这条本地模型的触发路径(#6059)。
- 别把这类报错当普通输出上限治:
Output token limit reached的其它成因与处置另见 输出上限排查。
DSH plugin 排查注意事项
识别不能靠时长,只能靠内容特征——正常长推理同样长时间没有文本与工具输出,纯时间阈值必然误伤。 六条要点:
- 时长不是判据:正常长推理同样长时间没有文本与工具输出,纯时间阈值必然误伤。
- 重复句式是可靠特征:CJK 无空格文本(「好。执行。」)要用语言无关的 repeated-gram 覆盖率,早期按空白分词的做法判不出来。
- 按 index 分桶而不是按 id:同一个 id 可能被复用给不同调用。
- 字节与碎片是两种互补信号:字节闸门拒真正的大 payload,碎片闸门拒碎片风暴,缺一个就有盲区。
maxTokens不是解法:小maxTokens也可能被一个 runaway call 整段吃掉,且没有 per-call 信号。- 插件只能反应、不能事后否决:契约上插件无法 veto 已发生的 step,但
steer/cancel与 error finish 已足够阻断。

来源:Discussion #5976、Discussion #6059、cordis-plugin-thinking-loop-guard、cordis-plugin-llm-tool-call-guard。
常见问题
DeepSeek Harness 的回合循环只在结束原因落位且 inbox 清空时才 break,而 step 的结束原因只有 completed / max-tokens 两型,所以零产出的回合会一直空转下去。模型持续吐 thinking 增量、既不落文本也不落 tool-call 时,stepEnd 始终「未完成」,循环永远不结束。这正是「纯思考空转」的机制,与上下文长度无必然关系——有用户在 /compact 把上下文从约 51.5 万 token 压到约 32.2 万 token 后,新回合仍然同样空转(来源:Discussion #5976)。
DeepSeek Harness 里现有的 guard 插件都以工具调用为中心,因此都不覆盖零工具调用的空转这一失败面。guard/timeout-policy 只在工具被调用且声明了 timeoutMs 时挂 deadline,guard/repeat-tool-reminder 检测的是「连续相同 tool call 链」;零工具调用场景下前者根本不触发、后者无链可检测。这不是漏检,而是它们本就不覆盖「纯思考空转」这一失败面(来源:Discussion #5976)。
DeepSeek Harness 里失控的 tool-call-delta 会一直被允许流式追加,直到模型把整段输出预算耗尽,所以前端最后只显示通用的输出上限提示。回合随后以 stopReason: "length" 与 max-tokens 结束,组装出的助手消息也没有可用内容。真实事故里一个 job_output 调用在约 115 秒内累积了 4,074 个碎片、总共只有 4,658 字节——这是「碎片数 runaway」,24 KiB 这类字节预算永远不会触发(来源:Discussion #6059)。
在 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
来源
- deepseek-harness Discussion #5976:超长上下文 + max reasoning effort 下思考退化循环,回合零产出、无自动熔断· deepseek-ai(GitHub Discussions)
- deepseek-harness Discussion #6059:Runaway tool-call arguments consume the full output budget before validation· deepseek-ai(GitHub Discussions)
- cordis-plugin-thinking-loop-guard:按 repeated-gram 覆盖率识别纯思考空转(含离线复跑工具 analyze-session.mjs)· GitHub(argszero)
- cordis-plugin-llm-tool-call-guard:在 llm/stream 接缝按 index 卡字节/碎片/聚合预算· GitHub(argszero)