Replies: 1 comment
|
你这份报告的分析是准确的,我在 0.1.5 线的 master( 一、比"错误地报告成功"更严重:它同时关掉了重试
const DEFAULT_RETRYABLE_CODES = Object.freeze([
EMPTY_RESPONSE_CODE, 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT',
])而 所以 二、你建议的谓词已在同一棵树里,而且没有任何消费者
export function chunkHasVisibleText(chunk: StreamChunk): boolean {
if (chunk.type === 'text-delta') return hasNonWhitespace(chunk.text)
return chunk.type === 'block-end' && chunk.block.type === 'text' && hasNonWhitespace(chunk.block.text)
}它比"排除 reasoning"更严:连纯空白的 text-delta 也不算内容( 也就是说:修 三、同一个形状在 pi-ai 里也存在(顺带修正一处)
如果你要对上游提补充,值得把这条一起带上:它不是 DeepSeek 适配器独有的笔误,而是两个适配器共享的同一个谓词错误("块数"当"内容"用),修法也是同一个。 四、一个可用的止血方案(已发布)在你等 core 修复期间,我已经把这个缺口做成了插件形态,刚发布到 npm: npm install @argszero/cordis-plugin-empty-response-guard- insert:
- id: empty-response-guard
name: '@argszero/cordis-plugin-empty-response-guard'在公开的
仓库:https://github.com/argszero/cordis-plugin-empty-response-guard(20 条测试,含一条把" 五、边界(说清楚)这是接缝侧的止血,不是 core 修复 —— 真正的修法仍在 |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Summary
A provider completion that contains only
reasoning_contentand ends withstopis reported as a successful turn. No assistant text block and no tool call are produced, so the turn terminates with no visible output, no error, and no retry.From the user side this looks like the model "thinking halfway and then stopping". The session log records
turn/endwithreason.kind = "completed".Root cause
@deepseek-ai/dsh-llm-deepseek/lib/index.js— thetranslate()consumer:So as soon as the model emits any
reasoning_content,order.length >= 1for the remainder of the stream, and theEMPTY_RESPONSEbranch at L1239 can never be taken. A genuine degenerate completion — reasoning only, no text, no tool call — is forwarded as a normalstop.The JSDoc immediately above states the intended contract:
The intent is correct; the predicate merely counts a block kind that must not count as content.
A correct predicate already exists elsewhere in the codebase
@deepseek-ai/dsh-llm/lib/index.js:function assistantStreamHasVisibleText(stream)dsh-llmtherefore already defines "visible" as text-only and explicitly excludes reasoning. The DeepSeek adapter's degeneracy guard usesorder.lengthinstead, so the two definitions disagree on whether a reasoning block is content.Suggested fix
Test for the absence of visible content rather than the absence of any block:
or reuse the
assistantStreamHasVisibleTextdefinition. Note that reasoning followed by a tool call is a legitimate completion and must not be classified as empty.Evidence from a local session log
Read-only inspection of
session.jsonl.zstd(5.12 MB compressed → 21 MB, 4564 events); metadata only, no prompts, tool arguments, or tool outputs.The failure rate is not uniform within the session, but the split is by model, not by reasoning effort. The session's 27
request/headersnapshots showreasoningEffort: maxandmaxTokens: 65536on every request — neither value ever changed:deepseek-v4.1-flash-expires-on-0910deepseek-flashThe model was swapped at 21:44 when the older, date-limited experimental model reached its expiry date. Two
model/selectionrecords three seconds apart (21:43:51high, 21:43:54max) are easy to misread as a settings change, but they are the new model's default being written and then immediately overridden: the first request on the new model already carriesmax.All 7 affected turns fall in the latter part of the new model's run (37-51); turns 29-36 on the same model were clean. So the model change is an aggravating factor, not the defect itself — it raises how often the model emits reasoning-only completions, while the guard predicate is what converts that into a silent success. The defect is upstream of any particular model or effort setting.
For each of the 7 affected turns, per-turn
output_tokensequalsreasoning_tokens. No text tokens were billed at all — consistent with reasoning-only output.All 7 turns ended with
reason.kind = "stop". There is nochunk.reason === "length"anywhere in the session, and the largest single output was 11,214 tokens against a 65,536 budget — output truncation is excluded.No
llm/retryevent was emitted for any of the 7 turns. Because the finish surfaced asstoprather thanEMPTY_RESPONSE, the retry policy never saw an empty-response failure it could act on.In at least one affected turn the model had already composed the next tool invocation in its reasoning but stopped before emitting it; the reasoning text is present in the log while the tool call is absent.
Attempted workarounds
Attempts were made at two levels before the defect was isolated. None resolved the symptom, which is itself consistent with the root cause: no caller-side setting can help, because the degenerate completion is never classified as a failure in the first place.
Configuration level
maxTokens32768->65536deepseek-v4.1-flash-expires-on-0910->deepseek-flashat 21:44, because the former carries its expiry date in its own nameA note on
reasoningEffort, since it is the obvious suspect: it was not varied. It ismaxon everyrequest/headersnapshot in the session. Raising it is not a fix, and the model swap above is not evidence of a model-specific bug either — the guard predicate is wrong for any model that can open a reasoning block and then stop.Process level
The affected work consisted of long, multi-step tool chains, which is the shape that triggers the symptom most often. These measures were adopted while the defect was still unexplained:
These reduced exposure but did not eliminate it — every affected turn (37 through 51) occurred while the process measures were already in place. That is the strongest argument for fixing the classification in the adapter rather than expecting callers to work around it.
Also ruled out by direct inspection of the session log (4,564 events):
chunk.reason === "length"anywhere in the session; the largest single output was 11,214 tokens against a 65,536 budget.llm/retryevents in the session predate the affected range and belong to an unrelated older model configuration.compaction/pruneevents ran normally.Local workaround
A small out-of-tree plugin (
dsh-llm-empty-guard) resolves the symptom on the affected installation without patching the provider package. It is included here as evidence that the defect is addressable at the adapter boundary, and in case it helps anyone else hitting the same symptom.Design - delegate, do not reimplement
The plugin deliberately does not implement retry logic.
EMPTY_RESPONSEis already the first entry ofDEFAULT_RETRYABLE_CODESin@deepseek-ai/dsh-llm, and the stock policy (5 attempts, exponential backoff) already handles it correctly. The only thing missing is that the failure is never raised.So the plugin does exactly one thing - in the
llm/streamwaterfall it rewrites a reasoning-onlystopfinish into theEMPTY_RESPONSEfinish the adapter should have emitted, yields the rewritten chunk, and lets the stock retry path do the rest:Why it does not misfire
chunkHasVisibleTextrather than a third, private notion of "content" - deliberately avoiding a repeat of the two-definitions disagreement described above.Verification - 27/27 assertions pass across unit and waterfall-integration tests. The integration harness drives the real waterfall against a stubbed downstream and asserts the four critical paths: reasoning-only -> rewritten to
EMPTY_RESPONSE; reasoning-then-text -> untouched; reasoning-then-tool-call -> untouched; non-agent-loop stream -> untouched.Expected behavior
Either:
stopmaps toEMPTY_RESPONSEand therefore enters the normal empty-response retry path, orEnvironment
@deepseek-ai/dsh0.1.5-rc.1deepseek-official/deepseek-flashreasoningEffort:maxwhen observedAll reactions