Repository navigation
Replies: 1 comment
|
This is the same mechanism as #9239 meeting a different provider dialect, and your reading of the asymmetry holds at // packages/llm/llm/src/assembler.ts:137
const kept = this.finish.kind === 'max-tokens'
? all.map((block) => block.type !== 'tool-call')
: void 0
const blocks = kept === undefined ? all : all.filter((_, position) => kept[position])against // packages/llm/llm/src/assembler.ts:169 — interruptedBlocks()
… .filter((block) => … && block.text.trim() !== '')and the serializer that turns it back into a rejected request is still unguarded: // packages/llm/llm-deepseek/src/serialize.ts:31
case 'reasoning': return {
type: 'thinking', thinking: block.text,
...replay?.[index]?.signature === undefined ? {} : { signature: replay[index].signature },
}So there is a community-side plugin for this already, published after #9239: Its boundary, stated plainly, because it matters for your four sessions: that seam prevents an empty block from landing. It cannot heal a log that already contains one — the request is rebuilt from the durable log every turn, so those 119/122 blocks keep being replayed no matter what a plugin does on the response side. Your upstream fix is the cure, not the mitigation. Your caveat about the naive One note on the narrower fix you list: skipping empty Thanks for the three analysis scripts, and for naming the |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
上游缺陷:空的
reasoning块被持久化并作为空thinking回放,导致 HTTP 400组件:
@deepseek-ai/dsh-llm(组装)+@deepseek-ai/dsh-llm-deepseek(序列化)已验证版本:
0.2.0-rc.2(实际观察到)与0.2.1-alpha.2(仍然存在)严重程度: 会破坏请求,且反复出现。不会损坏会话数据。
现象
请求被直接拒绝。对话日志完好无损,因此重试/继续通常能继续进行——但它会反复出现,因为触发它的条件会反复出现。
根本原因
每当模型对某次响应没有产生任何推理内容时,DSH 仍会持久化一个
text: ""的 assistantreasoning块。DeepSeek 适配器会把每一个reasoning块无条件地序列化为原生thinking字段,于是这个空块被作为thinking: ""发送,而 API 会拒绝它:在 thinking 模式下,提供方要求此前返回的 thinking 内容被如实回放,而空块并不是如实回放。同一个文件中的两个函数,对于“空的 text/reasoning 块是否应该在组装后存活”这件事的判定并不一致。
interruptedBlocks()——中断流路径——会显式丢弃它们(
dsh-llm/lib/index.js:1088-1094):assembled()——正常完成路径——没有这样的过滤(
dsh-llm/lib/index.js:1051-1071):kept只在max-tokens情况下计算,而且即便那时也只丢弃 tool call。因此,一个空的reasoning块会被发出、被持久化,并在之后每一个请求中被回放。随后适配器把它原样映射到线上报文(
dsh-llm-deepseek/lib/index.js:1547-1550):而该提供方被文档描述为执行“原生 thinking 回放”
(
dsh-llm-deepseek/lib/index.js:2069)。注意
assembled()自身的注释已经说明了设计意图:保留/丢弃规则是存在的;只是在这条路径上没有应用到空 reasoning 块上。
证据
解码真实的会话产物(
session.v4.jsonl.zstd,使用来自dsh-session-persistence-jsonl的规则遍历拼接的 zstd 帧),并统计每一条assistant/message上的内容块类型:reasoning块的 assistant 消息数被持久化的块在结构上与正常的块完全相同,只是内容为空:
{"type":"reasoning","text":""}整个会话的分布:225 个非空 vs 119 个空。该故障并不罕见,这与观察到的反复出现相符。
复现方法:让任意 thinking 模式会话运行足够久,直到至少有一次 assistant 响应不带任何推理内容(只有 tool call 的轮次是常见情形),然后继续该对话。
建议的修复
扩展已有的共享
kept判定,使其同样丢弃空 reasoning 块,与interruptedBlocks()保持一致。有一点值得明确指出,因为那个显而易见的改法是错的:
kept的max-tokens分支同时会丢弃 tool call。一个天真的||会让普通完成也开始丢弃 tool call。这两种丢弃理由必须保持相互独立:回放重对齐路径(第 1060-1069 行)已经处理了过滤后的情形:当信封与
all不匹配时,它会把replay置为 null;否则就用同一个kept数组过滤envelope.blocks。那里应该不需要进一步改动。另一种更窄的修复方案是:在适配器的
assistant()序列化器中跳过空的 reasoning 块。这样能止住 400,但会把空块留在持久化日志中。为什么禁用 thinking 不能绕过该问题
assistant()(dsh-llm-deepseek/lib/index.js:1539)不接收任何 thinking 策略——它的签名是(message, model, onReplayDegrade)——并且无论请求的 thinking 设置如何,都会序列化每一个reasoning块。因此设置reasoningEffort: "off"并不能阻止历史上的空块被发送出去。只有全新会话才能避开该问题,而且也只能撑到它第一次出现无推理响应之前。(另一条相关的配置约束,供尝试此方案的人参考:
thinking: "disabled"要求reasoningEffort为"off"或未设置,否则适配器会抛错——dsh-llm-deepseek/lib/index.js:393。)相关报告
其他客户端也撞上了同一个 API 约束,这说明在客户端侧做防御性处理是值得的:
leechen298/cc-useissue #1 — “DeepSeek Thinking Mode Error:content[].thinkingMust BePassed Back to the API” — DeepSeek Thinking Mode Error:
content[].thinkingMust Be Passed Back to the API leechen298/cc-use#1ddaedeb— “fix(core): inject thinking blocks for DeepSeekanthropic-compatible” — QwenLM/qwen-code@ddaedeb
复现脚本
用于产出上述数据的只读分析脚本位于本仓库的
analysis/目录下:analysis/show-empty-reasoning.mjs— 打印空reasoning块的确切结构,以及整个会话的结构直方图。analysis/trace-guard.mjs— 按会话统计带/不带空 reasoning 块的 assistant 消息数量,外加 surface-op 与注入的计数。analysis/diagnose-thinking.mjs— 每条 assistant 消息的内容块类型,以及会话事件序列的尾部。这三个脚本都以结构化方式遍历 zstd 帧来解码会话产物,因为 Node 的
zstdDecompressSync对于拼接产物只会返回第一帧。All reactions