[Bug] read 工具按 UTF-16 码元截断会切开代理对,产生非法 JSON 并使会话永久 400 #8846
Replies: 5 comments
你这条可复现性强、机制清楚——而且最该修的位置不在截断,而在持久化边界1. 常量我核到了
export const READ_MAX_LINE_LENGTH = 2000 // :11
export const READ_MAX_BYTES = 50 * 1024 // :14⇒ 你说的"按 2000 个 UTF-16 码元"这个量级就是这里的 2. 机制为什么必然成立(语言层面,无需复现即可确认一半)JavaScript 的字符串下标是码元(UTF-16 code unit),因此任何 3.
|
|
补两样(PerryLink 要的)。 1. 最小复现(自包含,不需要 DSH)# 第 2000 个 UTF-16 码元正好切开一个代理对
line = "a" * 1999 + "\U0001F600" # 😀 = U+1F600 = \ud83d\ude00
truncated = line[:2000] # 与 read-render.ts 的 line.substring(0, 2e3) 等价
assert truncated[-1] == "\ud83d" # 语言层保证:以孤立高代理项结尾
import json
body = json.dumps({"messages": [{"role": "user", "content": truncated}]})
print(body[-20:]) # 以 \ud83d 结尾 —— 语法合法、语义非法
body.encode("utf-8") # UnicodeEncodeError: surrogates not allowed把探针字符放在下标 2. 400 响应原文两端各验过一次,互为对照:
第二行同时说明:这个 400 完全由这一个字符决定。定位过程中我逐一排除过 3. 版本基线
4. 影响面与现状同一台机器上 4 个会话 / 5 个会话文件被污染,每个都每轮 400、无法自行恢复。处置方式是把孤立代理项转义替换为 5. 关于你提的修法前移同意"真正该修的是持久化边界"这一判断,而且理由比我原报告的更充分:孤立代理项的来源不止 |
你的复现我跑过了——并补上了机制里最关键的一环(我原先的猜测是错的)1. 我本机模拟的结果(逐条)const line = 'a'.repeat(1999) + '\u{1F600}' // 码元数 2001
const t = line.substring(0, 2000)
t.charCodeAt(t.length - 1) === 0xD83D // ✅ 末位是孤立高代理项⇒ 你"把探针放在下标 1999 就必现"这一句成立,我这边一次即中。 2.
|
|
机制侧你们已经挖得很全了。给作者补一句最急的:你那 4 个被污染的会话能救回来——这和 #8352 是完全同族的形态(孤立代理持久化进 上游修复的落点参考:#8352 文末建议的「 |
你给的恢复配方是对的——而我能补一句"为什么必须这样做"的实证1. 我用模拟验证过:语法检查抓不到它我本机跑过这条链( const t = ('a'.repeat(1999) + '\u{1F600}').substring(0, 2000)
t.charCodeAt(1999) === 0xD83D // ✅ 末位是孤立高代理项
JSON.stringify({content: t}).slice(-14) // → '…aaaa\\ud83d"}' (ASCII 转义!)
JSON.parse(body) // ✅ 成功
new TextDecoder('utf-8',{fatal:true}) // ✅ 通过⇒ 两个直接后果,正好解释了你那套配方为什么必须那样写:
2. 你引的
|
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
@deepseek-ai/dsh-tool-fs的 read 工具在截断超长行时按 UTF-16 码元 下标切割。当第 2000 个码元落在一个代理对(astral 平面字符:emoji、CJK 扩展 B 等)中间时,结果以孤立的高代理项结尾。这段文本被持久化进会话日志后,JSON.stringify会把它写成\ud83d—— 语法合法、语义非法的 JSON。此后该会话每一次请求都会被 DeepSeek Messages 端点整体拒绝(HTTP 400)。因为脏数据永远留在历史里,会话不可恢复:后续每个 turn 都失败,UI 上没有任何出路。我这边一台机器上有 4 个会话 / 5 个会话文件被污染。
Reproduction
用 read 工具读取一个含超长行的文件,且该行第 2000 个 UTF-16 码元正好切在一个 astral 字符中间。
触发点(
@deepseek-ai/dsh-tool-fs/lib/index.js):String.prototype.substring按 UTF-16 码元索引,不感知代理对。工具结果写入会话日志,其中包含孤立的
\ud83d转义。之后该会话的任意一次模型请求都返回 400。
Current behavior
请求体被整体拒绝:
两个加重问题的细节:
① 该 400 的响应体不是标准错误信封(不是
{"error":{"message":...}}),所以dsh-llm-deepseek的providerError()取不到error.message,最终只显示兜底文案:真实原因(
invalid unicode code point)对用户完全不可见,极难自行定位。② 脏数据持久化后会被一直重发,所以会话永久卡死 —— 每个 turn 都是同一个 400。
用最小请求可以稳定复现:
对照实验:把同一段会话历史里的孤立代理项替换成
U+FFFD后,整段历史重放返回 HTTP 200。也就是说会话数据本身没问题,卡点纯粹是这个字符。机制补充:"非法"到底在哪一层(PerryLink 实测,2026-10-05)
这一节修正了一个容易走错的直觉:问题不在编码层。
即:孤立代理项被
JSON.stringify写成 6 个 ASCII 字符\ud83d;字节流是合法 UTF-8;严格解码通过、JSON.parse也通过。服务端成功解析 JSON 后,该转义解码为一个不属于任何 Unicode 标量值的孤立代理项,内容校验因而拒绝整个请求 ⇒ 400。这条结论直接改变修法优先级:任何"这一行能不能
JSON.parse"的检查都是无效的(脏行是合法 JSON)。定位只能在解析后按码元扫,或直接在原始文本里找转义序列。临时处置:已污染会话的恢复配方
来自同族先例 #8352(
xiaoyuyu6420提供),经PerryLink复核并补充三条前提:JSON.parse后按 code unit 扫孤立代理,定位坏行;U+FFFD或删除该字符;效率提示:坏值在文件里是可见的 ASCII 文本(
\ud83d这 6 个字符),所以可以先用正则粗筛\ud[89abAB]…而后面没有配对的\ud[c-fC-F]…,再逐行确认——比全量解析快得多。本次 4 个被污染的会话正是这么定位并修复的。Expected behavior
建议按下列三级收紧(第一级根治,第二级覆盖面最大):
第一级 · 不再制造孤立代理项(根治):截断按码点而非码元。
按码元切是语言保证会切坏的(JS 字符串下标=码元);改成码点感知可从源头消除本工具的制造源。
第二级 · 持久化/发送边界兜底(成本最低、覆盖最广):
放在"写会话日志"与"序列化请求体"这两条边界上。理由是孤立代理项的来源不止截断一处(用户脚本按码元 slice、第三方工具输出、外部导入、粘贴都可能),边界兜底一次性覆盖;只改 read 的截断只堵一个口。这一层是本报告最希望被采纳的一项。
第三级 · 让已污染会话可救:脏数据留在历史里就永远 400。要么在读取时清洗,要么提供一个显式修复入口(例如"清理本会话中的非法字符")。原报告写"没有任何出路"——这一级就是给出出路。
此外建议读取/发送路径遇到非法标量时能指出是哪一条,否则下次仍只能靠"逐行扫"来定位(见上面配方)。
Environment
@deepseek-ai/dsh0.2.0-rc.2(dsh-tool-fs同版本)deepseek-official,Messages 协议,modeldeepseek-flashAll reactions