Repository navigation
[Bug] 历史里的空 reasoning 块会序列化成空 thinking 块,导致已有会话切到严格的 Anthropic Messages 网关后永久失败 #9239
Replies: 3 comments
|
serialize 层加守卫(跳过空 reasoning 块)是对的上游修法。给已经被卡住的会话补一条立即恢复路径——坏的是 assistant/message
两点延伸:
(会话日志的行级/块级手术我在 #7257/#7824/#8084/#8352 给过同套四步法——定位、小改、局部重编、校验,这类"脏数据被严格消费者引爆"的形态基本都能救回。) |
|
Confirmed, and the mechanism you located is exactly right — Dropping the block is not enough: the replay envelope must be pruned in step
Three places enforce that, and they agree:
So the shape to watch for is: a The good news is core already demonstrates the correct form. replay: kept === undefined || blocks.length === all.length
? envelope
: { response: envelope.response, blocks: envelope.blocks.filter((_, position) => kept[position]) },That is the invariant to preserve: A sibling hole in the same switchWhile confirming your anchor I checked the neighbouring case, and case 'text': return { type: 'text', text: block.text }An empty assistant text block becomes What makes it convincing as a pattern rather than an oversight is that the same file already applies the empty-drops-out rule in the other direction — developer/input content, case 'text': return block.text.length === 0 ? [] : [{ type: 'text', text: block.text }]and core applies it to salvage as well ( On recovery for already-poisoned sessionsYou are right that a serializer guard cannot help a session whose history already contains the block, and the suggestion in the thread (offline block deletion, keeping neighbouring blocks and frame bytes) is the right shape — block removal needs no renumbering. I would add one constraint to it, from Note also that the request path is not a place a plugin can fix this for you: history is handed to adapters frozen, deliberately, so listeners read it and never rewrite it. The prevention half is the only half reachable from a plugin, and it has to happen on the response side. A published sibling for the same familyYour cross-reference to #9215 is apt — it is the same shape (one malformed block poisons a session permanently) and it has a delivered preventive half: That seam is provider-agnostic and reusable for this shape too: an assistant content block whose text is empty is visible in the chunk stream ( Two boundaries I would state up front rather than discover later: it prevents and does not cure (an already-durable block stays durable), and it can only see blocks the stream actually declares — anything an adapter adds to a message outside its chunk stream is out of reach. Thank you for the six-row gateway matrix; "只有空值这一种过不去" is the kind of narrowing that makes this fixable at all. |
|
The preventive half is now a published plugin, built on the seam described above.
npm install @argszero/dsh-empty-block-guard@0.1.0- insert:
- id: empty-block-guard
name: '@argszero/dsh-empty-block-guard'It wraps the returned chunk stream of the The pairing is the part worth reviewingRemoving the block is not the whole fix, and the naive version of it is worse than the bug. I measured the requirement against the shipped code:
Which chunks go
The fourth row is the one a "drop the block" rule gets wrong: a close is authoritative, so keeping it poisons the session and throws away text the model sent. It also covers the sibling hole I flagged in Boundaries, stated rather than discovered
ctx.get('emptyBlockGuard').snapshot()
// { counters: { streams, verdicts: {keep, drop}, reasons: {…}, envelopes: {untouched, pruned, unaligned} },
// recent: [{ provider, model, sessionId, index, type, verdict, reason }], notCovered: [ … ] }Verification, so the claim is checkable rather than asserted: 65 tests, 34/34 mutation arms caught (0 silent), and the peer range Thank you for the reproduction and the gateway matrix — the observation, translated from the Chinese original, that "only the empty value is the one that does not get through" is what made this narrow enough to turn into a guard instead of a guess. |
Uh oh!
There was an error while loading. Please reload this page.
Summary
会话历史里只要存在
text为空的 assistantreasoning块,Messages 序列化就会输出{"type":"thinking","thinking":""}。会校验 thinking 块的 Anthropic Messages 兼容网关会直接拒掉整个请求,于是一个已经跑过若干轮的会话再也切不过去,报错位置固定在同一个 message 下标,每轮都失败,只能新开会话。
这不是偶发个案。我在自己的多个会话里都复现过,历史越长越容易混进空块,一旦混进去就无法回到网关那条路由。
Reproduction
deepseek-official,@deepseek-ai/dsh-llm-deepseek-api-key适配器)的baseURL指到任意会校验 thinking 块的 Anthropic Messages 兼容网关。
text为空的reasoning块。账号路由(
deepseek-account)上可以稳定观察到这种现象,形态都是「空 reasoning + tool-call」。只要历史里有空块,100% 复现,而且后续每一轮都失败。
Current behavior
请求在模型开始生成之前就被拒绝:
我按序列化后的实际形态直接打网关,逐个形态验证过,只有空值这一种过不去:
{"type":"thinking","thinking":""}(带签名){"type":"thinking","thinking":""}(无签名){"type":"thinking","thinking":null}同一份历史里把空块换成非空块,整段历史(含无签名 thinking、tool_use / tool_result 链)能完整重放并跑通,
所以问题范围就限定在空值这一个点上。
Expected behavior
这一轮正常跑起来。空的 reasoning 不携带任何信息,序列化时丢掉即可。
定位
packages/llm/llm-deepseek/lib/types/serialize.js:block.text为空时没有兜底。两个放大的因素:readReplay()只在response.model === model时才返回 replay 元数据(注释写的是 cross-model signaturesare not portable),所以跨模型重放时签名一并丢失,发出去的是「空且无签名」的块。顺带一个观察:部分兼容网关
回传的 model 名和请求的不一致(例如请求名带变体后缀、回传的是基础名),这些情况即使不换模型名也会走到丢签名的
分支——实测 thinking 非空时不影响使用,但这条路会让空值更早暴露。
packages/llm/llm-pi-ai/lib/index.js是同一套写法(content.push({ type: "thinking", thinking: block.text })),所以换适配器绕不过去。
Environment
@deepseek-ai/dsh-llm-deepseek0.2.0-rc.2)deepseek-official,适配器@deepseek-ai/dsh-llm-deepseek-api-key,配置了自定义baseURLdeepseek-account(账号路由),形态固定为「空 reasoning + tool-call」包含非空内容,DSH 这边不该把空块发出去
建议的修法
序列化时跳过空的 reasoning 块,注意保持
replay[index]的索引对齐:再在 map 之后过滤掉
undefined;llm-pi-ai同步处理。如果认为「没有正文的 reasoning 块」本就不该落盘,那在写入会话时拦掉可能更彻底——这样存量会话的历史也干净。
不过对已经生成的历史,序列化侧兜底仍然是必要的。
影响面
任何跑过账号路由、历史里混进空块的会话都无法切到严格的 Anthropic Messages 网关。用户能看到的只是
「切了模型就报错」,从界面上完全看不出跟历史里的某个块有关;唯一的绕过办法是新开会话,而这一点在 UI 上没有任何提示。
相关
那边的处理思路可以对照参考
All reactions