DeepSeek Harness 最后一步只有 reasoning,turn 为何被判 completed?
如果模型的最后一次响应只有 reasoning、finish.reason 是 stop,而界面既没有正文也没有工具调用,回合却显示 completed,那这不是模型「想完了就收工」,而是判定链把「没有回答」当成了「任务完成」。 据讨论 #9990,这类回合会静默进入 idle,用户必须再发一条消息才能推动它继续。下面按判定链、三条处理路径和复现方法拆开讲。
DeepSeek Harness 最后一步只有 reasoning 时,界面会看到什么
三个特征同时出现,基本可以锁定这一条,而不是普通的模型停住。 先看现象,再去翻会话记录:
- 回合结束状态是
completed,预期不是错误、也不是aborted。 - 最后一条 assistant 消息只有
reasoning块,预期没有text,也没有tool-call。 - 界面上的最终回答为空,预期任务实际没做完,比如文件没有创建、命令没有执行。
- 用户再发一句「继续」,预期模型能接着做完,说明能力和上下文都在,缺的只是收尾判定。
DeepSeek Harness 调用链:两个条件把「没有回答」当成「任务完成」
据讨论中社区核对源码,问题不在一个适配器,而在「适配器判空太窄 + loop 完全不看内容」的叠加。 agent loop 的整体结构可以对照 DeepSeek Harness 一轮对话链路 看,这里只留两步:
llm-pi-ai的mapStopReason只在message.content.length === 0时返回EMPTY_RESPONSE,预期只有 reasoning 块的响应长度不为 0,于是被当成正常stop放行。agent-loop在agent.ts的步骤收尾处只过滤tool-call,数量为 0 就返回{ kind: 'completed' },预期完全不检查有没有可见文本。- 结果是「没有输出、没有动作」和「模型真的做完了」在状态上无法区分,预期重试机制也不会被触发。
DSH插件修法:改判定、steer 续跑还是前端重试
三条路都能让空回答不再被当成完成,差别在改的是 provider 路由、插件事件还是前端展示。 只想修当前 provider 就走路径一,不想动 core 就走路径二,临时兜底走路径三。
路径一:把 reasoning-only 的 stop 并入 EMPTY_RESPONSE
这是改动最小、覆盖面只限 pi-ai 路由的做法:把「无 text 且无 tool-call」当作空响应,复用已有的重试策略。
- 在
packages/llm/llm-pi-ai/src/stream.ts的mapStopReason里找到case "stop",预期能定位到 stop 分支。 - 把判空条件从
content.length === 0改为「没有 text 块且没有 tool-call 块」,预期 reasoning 块不再计入可见内容。 - 保持原有
EMPTY_RESPONSE_CODE与 failure 结构不变,预期上层重试策略原样接管。 - 重跑同一条只有 reasoning 的响应,预期它被标成可重试错误,而不是正常 stop。
case "stop": {
const visible = message.content.some(
(b) => (b.type === "text" && b.text.trim() !== "") || b.type === "tool-call",
)
if (!visible) {
return { kind: "error", failure: { code: EMPTY_RESPONSE_CODE } }
}
return { kind: "stop" }
}
路径二:用 turn-stopping 插件 steer 续跑
不想动 core,就挂在 agent/turn-stopping 上,在回合真正收尾前注入一次「继续」。 据讨论,该事件在 agent.ts 的收尾路径派发,插件有机会看到最后一条消息并调用 agent.steer。
- 新建一个本地插件,注册
agent/turn-stopping监听,预期监听器会在回合收尾时被调用。 - 取本轮最后一条 assistant 消息,判断是否既无
tool-call又无非空text,预期能区分出空回答。 - 命中时调用
agent.steer,注入一条继续提醒,来源标为stall-guard、形式用notice,预期提醒带着指定来源和形式发出。 - 用计数器把每轮续跑限制在 2 次,超过就放行,预期不会无限空转。
- 用同样的故障代理重跑,预期运行从 1 步继续到第 5 步,完成文件创建并给出正常最终回答。
// 逻辑骨架,事件载荷字段以本机 dsh-agent-loop 的类型定义为准
if (!hasToolCall && !hasVisibleText && steered < 2) {
steered += 1
await agent.steer("上一步没有输出也没有动作,请继续完成任务", {
source: { kind: "stall-guard" },
form: "notice",
})
}
路径三:前端提示重试,不动 core
如果暂时不能改运行时,至少把静默失败变成用户看得见、点得动的状态。 这是可见性补丁,不是根修:
- 前端在 turn 结束时同时检查结束原因和最终文本,预期两个值在渲染时都可用。
- 结束原因为
completed且最终文本为空时,不要渲染成正常完成,预期空回合不会被当成成功。 - 展示一条明确提示,例如「模型停止但没有输出」,并提供「重试」入口,预期用户看到可操作的提示。
- 用户点击后以新消息触发下一轮,预期模型重新生成而不是停留在空回答;这条路径不会自动续跑,也不改变会话记录里的 completed 状态。
DeepSeek Harness 怎么用代理稳定复现
要验证三条路径是否真的生效,需要能稳定复现,而不是等模型偶尔犯错。 讨论里给了一个确定性做法:
- 在 OpenAI-Responses 端点前放一个小代理,只处理第一个
/v1/responses请求,预期只有首个请求被改写。 - 从 SSE 流里删掉
message、function_call项以及output_text、content_part事件,只保留 reasoning,预期流里只剩推理内容。 - 把一个
api: openai-responses的 provider 指向该代理,使用带推理的模型,预期请求会打到代理。 - 发一条「创建 a.txt 并报告完成」的任务,预期看到 1 步、
turn_end为completed、最终文本为空、文件未创建。 - 换上任一条修复路径后重跑,预期完成步数与文件结果发生变化。
DeepSeek Harness 空回答的边界与验证
不是所有空回答都值得续跑,判定要卡在「最后一步、无文本、无动作」这三个条件上。 否则会把正常收尾也变成循环:
- 最后一步有 text 但很短,属于正常回答,预期不触发续跑。
- 最后一步有 tool-call,说明还在执行,预期交给工具结果推动下一轮。
- 用户主动中断导致的结束,预期不应被插件 steer 拉回来。
- 同一轮已经续跑两次仍无输出,预期停下来暴露问题,而不是继续加次数。
- 如果 reasoning 里出现泄漏的工具调用标记,那是另一类适配器问题,预期不能只靠续跑掩盖。
来源:Discussion #9990。
常见问题
DeepSeek Harness 里判定链只看两个条件,都不检查可见文本。据讨论 #9990 中社区核对源码,llm-pi-ai 的 mapStopReason 只在 content.length === 0 时判 EMPTY_RESPONSE;content 里有 reasoning 块就返回正常 stop,agent-loop 又只按有没有 tool-call 决定是否结束,于是空回答被记成 completed。
DeepSeek Harness 里不会,因为改的是「没有可见文本且没有工具调用」这一种情况,而不是所有带 reasoning 的步骤。正常步骤只要最终有 text 块或 tool-call 块就仍然走 stop;只有思考完既没写正文也没发起动作时,才进入已有的可重试错误路径。
DeepSeek Harness 的 turn-stopping 插件在 agent/turn-stopping 事件里检查本轮最后一条 assistant 消息,如果没有 tool-call 也没有非空 text,就调用 agent.steer 注入一条继续提醒。据讨论里的可用实现,提醒以 stall-guard 来源、notice 形式发出,每轮最多 2 次,避免无限循环;同一条故障代理下可以跑到第 5 步并完成文件创建。
DeepSeek Harness 里可以,但这只是可见性修复,不会让当前回合自动继续。前端在收到 final 文本为空、turn 结束原因为 completed 时,展示「模型停止但没有输出」并提供重试入口,用户点一次会以新消息触发下一轮;它不能替代 loop 层的内容判定,只是把静默失败变成可操作状态。
相关术语
- EMPTY_RESPONSE
- EMPTY_RESPONSE 是 DSH 对「模型这一步没有产生任何内容」的可重试错误码。当前实现只在 content 数组长度为 0 时触发,只有 reasoning 块的响应不会进入该分支。— https://github.com/deepseek-ai/deepseek-harness/discussions/9990
- turn(回合)
- turn 是 DSH agent loop 中从接收用户消息到判定结束的一次完整执行。结束原因由最后一步是否产生 tool-call 决定,completed 表示循环认为任务已经收尾。— https://github.com/deepseek-ai/deepseek-harness/discussions/9990
- reasoning block(推理块)
- reasoning block 是模型返回内容中不直接展示给用户的思考片段。它可以单独构成一步响应,因此 content 非空并不等于用户看到了回答。— https://github.com/deepseek-ai/deepseek-harness/discussions/9990
- steer(续跑注入)
- steer 是 DSH 允许插件在回合结束前向 agent 注入提醒并继续执行的机制。turn-stopping 事件在 agent.ts 的回合收尾路径上派发,插件因此能在判定 completed 之前介入。— https://github.com/deepseek-ai/deepseek-harness/discussions/9990
来源
- deepseek-harness Discussion #9990:Turn silently ends as completed when the final step contains only reasoning blocks· deepseek-ai(GitHub Discussions)