DeepSeek Harness 只思考不输出:空 stop 被判成功与 EMPTY_RESPONSE 重试排查
「思考完成但什么都没说」不是一个故障,而是两个不同的回合终态,而且两个都不会触发重试。 一种是只有 reasoning 的 stop,被适配器当成正常完成;另一种是输出预算耗尽后的 max-tokens,被当成回合结果。前者是判定条件太窄,后者是归类不在失败码里,修法也完全不同(Discussion #9893)。
DeepSeek Harness 两个空输出终态与 turn/end 判据
两种空输出对应两条代码路径,混在一起排查会得出错误结论。 拆开看:
- 只有 reasoning 的完成。
llm-pi-ai的stream.ts:96-99在case 'stop'里只检查message.content.length === 0,llm-deepseek的translate.ts:149只检查blocks.size === 0。 - 思考块也计入了 content。pi-ai 的
thinking_start/thinking_delta/thinking_end与文本共用contentIndex计数,DeepSeek 的content_block_start把所有块(含 reasoning)都放进blocks。 - 于是「一大段思考后停止」的块集合非空,被映射成普通
{ kind: 'stop' },loop 见不到工具调用就返回completed。 - 另一种是
stop_reason: 'length',finish 变成max-tokens,重试插件根本看不到它,因为它只挂agent/request-error,而该事件只对error与aborted开放。
据讨论,仓库里还有一个测试专门钉住了第一种行为,名称是「keeps a thinking-only stop successful (any block counts as content)」,这能说明它是当前设计而非偶然。
判别只需要看持久化日志里的 turn/end.reason.kind,不要猜。 步骤:
- 打开出问题会话的日志,定位到那一轮的
turn/end。预期结果:能看到{ turn, reason }。 - 如果
reason.kind === 'max-tokens',归为预算耗尽。预期结果:最后一条assistant/message通常被截断。 - 如果
reason.kind === 'completed',再看最后一条assistant/message的内容块。预期结果:只有 reasoning 块、没有文本与工具调用,就是第一种。 - 搜
llm/retry与llm/retry-started事件。预期结果:整段日志里一条都没有,说明重试路径从未被触发。
第 4 步很关键:它把「重试没生效」与「压根没进入重试」区分开。后者才是这两个终态的共同点。
DSH插件路径一:装 silent-completion-guard 改写终止帧
这是不动 core、当天就能用的一条路,副作用是空回合会变成可见错误;插件版本与兼容性可以在 DSH Plugin Hub 核对。 思路与步骤:
- 理解插件做的事:包住
llm/stream返回的 chunk 流,观察终止 finish。预期结果:不改模型请求,只改响应流的最后一帧。 - 当 finish 是
stop且这一轮没有文本块、也没有工具调用块时,把它替换成携带EMPTY_RESPONSE的 error finish。预期结果:loop 走assistant/attempt而不是追加一条空 assistant 消息。 - 让默认重试策略接手:五次重试、500 毫秒到 10 秒退避。预期结果:模型第二次答出来就正常结束;五次都空则回合明确失败。
- 在长会话上复现一次。预期结果:要么有正文,要么转录里出现真实错误,不再是「分析完成」配空气泡。
它不改变「模型为什么会只思考」,只保证这个状态不会再被静默吞掉。据讨论,该插件由回复者冻结为 @argszero/dsh-silent-completion-guard;同一作者还有一个 @argszero/dsh-empty-response-retry,处理的是不可分类 provider 错误码吞掉瞬时失败的相邻场景。
DeepSeek Harness 路径二:在适配器里把空 stop 改判 EMPTY_RESPONSE
适合能改源码、希望从根上修正归类的人,两个适配器要分别改。 步骤:
- 把 pi-ai 的判定从「没有任何 content 块」放宽到「没有文本块且没有工具调用块」。预期结果:只有 reasoning 的 stop 不再算成功。
- 对 DeepSeek Messages 路径做同样的放宽,别只改一边。预期结果:两条 provider 路径行为一致。
- 保留
max-tokens的独立归类,不要把它并进EMPTY_RESPONSE。预期结果:预算耗尽可能仍需要单独提示用户。 - 更新那个钉住旧行为的测试,改成断言空 stop 会抛
EMPTY_RESPONSE。预期结果:测试与实现同步,后续不会回退。 - 用长会话与短会话各跑一轮。预期结果:短会话不受影响,长会话的空回合进入重试。
另一条上游形状是新增一个「已结算但没有可见输出」的 outcome kind,让重试策略能看见它。这属于设计问题,改之前最好先在讨论里对齐语义。
DeepSeek Harness 路径三:模型侧调低 reasoning 与上下文压力
这是降低触发率,不是修复判定逻辑,适合暂时不能改代码的部署。 可做的三件事:
- 把 reasoning 档位调低,或换一个不强制思考的模型。预期结果:每轮思考占用的 token 变少,空正文的回合明显减少。
- 缩短单会话长度,长任务拆成新会话或先压缩。预期结果:上下文压力下降,模型更倾向于输出正文。
- 在系统提示里明确要求最终回答必须包含可见正文。预期结果:能减少一部分「想完就停」,但不能保证。
据讨论,报告者的会话已经跑到 51 轮、405 步、上下文压力 48%,这种规模下空回合会反复出现。调低 reasoning 能让它变少,但只要判定条件不变,就仍有概率发生。
三条路的代价对照:
| 路径 | 是否改代码 | 空回合的结局 | 主要代价 |
|---|---|---|---|
| silent-completion-guard | 不改 | 重试或明确报错 | 依赖第三方插件 |
| 适配器改判 | 改 | 进入默认重试 | 需同步两条 provider 路径 |
| 调低 reasoning | 不改 | 概率下降,仍可能空 | 思考质量可能变差 |
如果只是偶尔遇到,路径三够用;如果长会话里稳定复现,直接上路径一,别再靠提示词碰运气。
DeepSeek Harness 界面为什么写「分析完成」而不是报错
前端没有撒谎,它忠实展示了一个「结束但没东西可展示」的步骤。 机制是:
- 步骤运行时有一行实时状态,文案是「正在分析请求」,带 shimmer 动效。
- 步骤组关闭后,若里面只有 reasoning,标题变成「分析完成」。
- assistant 节点本身不画任何东西,因为渲染器要求处于 streaming、interrupted,或存在非工具调用的块;reasoning 属于过程折叠区,不算正文。
- 所以空气泡不是缺指示器,而是缺一个能表达「结算了但没说话」的终态。
想从界面判断,别只看气泡,展开过程区确认里面是否只有思考块。
DeepSeek Harness 处理这类空回合的注意事项
先读 turn/end,再决定改哪一层,别一上来就调模型参数。 四点:
retryPolicy: 'always'不会覆盖这两个终态,它不是万能开关。- 重试预算是按 step 计算的,
llmRetry投影在step/start与turn/end时清空,所以五次重试只管一个步骤。 max-tokens对整个回合有粘性,一旦置上,后续步骤也不会自动恢复。- 与「最后一步只有 reasoning 就判回合完成」的相邻问题要分开处理,那条属于回合完成判定,不是 provider 的空响应判定。
把「有思考、无正文」当成一次需要显式失败的事件,比继续在提示词里加「请输出正文」更可靠。
来源:Discussion #9893、deepseek-ai/deepseek-harness。长会话上下文压力的相邻问题见 上下文窗口溢出。
常见问题
DeepSeek Harness 把只有 reasoning 的 stop 当成正常完成。思考块本身占一个 content 块,EMPTY_RESPONSE 的判定条件却是「一个块都没有」,所以有思考、没正文的结束被映射成普通 stop,agent loop 见不到工具调用就判 completed。
DeepSeek Harness 的 retryPolicy always 不能覆盖这两个终态,重试插件只挂在 agent/request-error 上,而 error 与 aborted 才会走到那里;只思考的 stop 是正常完成,max-tokens 是回合结果,两者都不是失败码。always 只是放宽模型请求失败的尝试次数,不改变这两个终态的归类。
DeepSeek Harness 的 silent-completion-guard 包住 llm/stream 返回的 chunk 流,观察终止帧,如果 finish 是 stop 且这一轮既没有文本块也没有工具调用块,就替换成携带 EMPTY_RESPONSE 的 error finish。下游 loop 会把这次尝试记为 assistant/attempt,默认策略重试五次,重试耗尽后明确报错。
DeepSeek Harness 调低 reasoning 不能根治,只能降低触发概率,它减少每轮思考占用的输出预算与上下文压力,让空正文的回合变少,但判定逻辑没变,遇到长会话仍可能出现。要稳定必须在适配器或插件层把空 stop 归类为失败。
相关术语
- EMPTY_RESPONSE
- EMPTY_RESPONSE 是 DSH 的重试错误码之一,表示模型正常结束但没有任何内容,默认重试策略会对它重试五次并采用 500 毫秒到 10 秒的退避。— https://github.com/deepseek-ai/deepseek-harness/discussions/9893
- reasoning-only stop
- reasoning-only stop 指模型只输出思考块、没有文本块和工具调用块就结束的一轮,它在当前适配器里被当作正常完成而不是空响应。— https://github.com/deepseek-ai/deepseek-harness/discussions/9893
- turn/end
- turn/end 是会话日志里标记回合结束的持久事件,携带 turn 与 reason,reason.kind 可取 completed、aborted、blocked、error、max-tokens 或 interrupted。— https://github.com/deepseek-ai/deepseek-harness/discussions/9893
- max-tokens
- max-tokens 是回合结束原因的一种,表示输出预算被用完,它不是失败码,因此不会进入重试策略,且对整个回合具有粘性。— https://github.com/deepseek-ai/deepseek-harness/discussions/9893
来源
- deepseek-harness Discussion #9893:长会话中 agent 反复「思考完成但无输出」· deepseek-ai(GitHub Discussions)
- deepseek-ai/deepseek-harness 官方仓库· GitHub