Replies: 2 comments
|
另记一个不相关的独立缺陷(已单独开帖,便于跟进):新建会话 / “在新窗口继续”产生的会话不会 → #8405 |
0 replies
|
根因分析很完整(那个按码元 slice 的惯用法确实是常见来源)。补一条给已经被锁死会话的用户的恢复路径——坏的是一行
另外给排查者的一个提醒:provider 侧差异可以当临时逃生门——DeepSeek 的 JSON 解析器拒孤立代理转义,但不是所有 provider 都拒(同族的 #3315 有跨 provider 实测)。不过换 provider 只是绕过,日志里那颗雷还在,下次换回来还会炸,文件级清理才是根治。 上游修复方向你文末应该已经隐含了: |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
环境
0.1.5-rc.1(Windows 11),providerdeepseek-official,modeldeepseek-flash0.2.0-rc.2(2026-09-29):同样没有相关处理(见文末)现象
一个约 50 轮的长会话,从某一轮开始,之后每一轮都在 200–300ms 内失败,UI 只显示:
只发「你好」也一样失败,刷新页面、换措辞都无效——会话实际已不可用,且报错完全没有指向性。
根因
工具输出(
tool/result)文本里混入了一个未配对的 UTF-16 代理项。来源是很常见的写法——对含 emoji 的字符串按码元截断:
这个孤立代理随
tool/result写入会话后:JSON.stringify把它写成\ud83d转义;Content-Type: text/plain;dsh-llm-deepseek的错误处理只从JSON.parse(body).error.message取文案(0.2.0-rc.2 里是const text = await response.text(); try { raw = JSON.parse(text) } catch {}),遇到 text/plain 就退化成通用文案,用户看不到真正原因;压缩也救不了:压缩/摘要请求本身同样带着这段历史,而且 web profile 里
compaction-basic、command-compact、tool-result-pruner默认是 disabled。最小复现(不依赖 DSH)
实际返回:
对照组(成对代理、合法 emoji)返回 200,说明只与「未配对」有关,与 emoji 本身无关:
{"model":"deepseek-flash","messages":[{"role":"user","content":"A\ud83d\udd34B"}],"max_tokens":16,"stream":false} → 200 application/json为什么值得修
slice(0, N)截断工具输出),不是用户误操作;dsh-tools里有LONE_SURROGATE = /[\ud800-\udfff]/gu;dsh-compaction-tool-result-pruner专门按 Unicode 码点切片并注明 "so a retained boundary cannot split a surrogate pair"。缺的只是 append / transport 层的兜底;建议修复(两处,改动都很小)
dsh-util-values的walkJsonValue对typeof current === "string"是原样 assign。而该函数的文档说明它会拒绝「非无损 JSON」(BigInt、循环、稀疏数组、-0、特殊原型)——未配对代理正好是无法经 UTF-8 往返的字符串,属于同一不变式的漏项。建议在字符串分支把未配对代理规范化为U+FFFD。规范化比拒绝更安全:不会让工具调用直接失败,且改这一处之后会话日志、日志导出、所有 provider 一起干净。DeepSeek API error (HTTP 400): <body 前 N 字符>),否则任何网关/解析层的 400 都会退化成同一句无用文案。text/plain而非 JSON 错误体,客户端无法结构化解析,建议官方一并看看。临时绕过(已验证)
向会话日志追加一条 replacement 事件即可恢复:复制原
tool/result的data,把文本里的未配对代理换成U+FFFD,以新 seq 追加,并带{ "sourceEventSeqs": [<原 seq>], "surfaceOp": { "op": "replace", "startSeq": <原 seq>, "endSeq": <原 seq> } }(与
tool-result-pruner使用的机制一致。)重新打开会话后即可继续。我这边用这个方法把那个 50 轮会话救回来了,并回放验证过修复后的请求返回 200。影响版本
已核对
0.2.0-rc.2的dsh-llm-deepseek与dsh-util-values:grepsurrogate|D800|FFFD|isWellFormed均 0 命中,问题仍在。TL;DR (English)
An unpaired UTF-16 surrogate anywhere in a
tool/resulttext (e.g. produced bystr.slice(0, N)cutting an emoji in half) is serialized as a lone\uD83Descape. The DeepSeek endpoint rejects the whole body with HTTP 400 and a text/plain body, and sinceproviderError()only reads a JSONerror.message, the UI collapses it toDeepSeek API error (HTTP 400). Because the poisoned event stays on the session surface, every later turn in that session fails permanently. Fix suggestion: normalize unpaired surrogates to U+FFFD indsh-util-values's string branch (the same lossless-JSON invariant it already enforces for BigInt/circular/-0), and surface non-JSON error bodies. Still present in 0.2.0-rc.2.All reactions