Repository navigation
[Bug] 工具结果里一个孤立的 UTF-16 代理项(半个 emoji)会让会话永久 400(INVALID_REQUEST) #11831
Replies: 3 comments
|
补充:同一个会话在修复后 2 小时再次复发,可以确认这是稳定可复现的,不是一次性事故。 第二次触发(2026-11-02 12:26,本机时间)
console.log(r.lines.map(l=>`${l.number}\t${l.text.slice(0,80)}`).join("\n"))
两次都发生在同一个动作上:读取同一张 markdown 表格(每行以 🔴 / 🟡 结尾)→ 截断后打印。截断点只要落在行尾的 emoji 上就必然复现。 这次的修复方式仍然是手工把日志里的 再补充一个判断: |
|
TL;DR — Same root cause as #10713 (web_fetch) and #11476 (read). The correct 同族报告
三个不同用户、三个入口、同一个根因:一次 UTF-16 码元切把代理对劈开,孤立代理项 这个问题 9 月已经修过一半
// packages/util/output-retention/src/index.ts:456
export function truncateWithoutSplittingSurrogatePair(text: string, maxChars: number): string {
if (text.length <= maxChars) return text
const capped = text.slice(0, maxChars)
return /[\uD800-\uDBFF]$/.test(capped) ? capped.slice(0, -1) : capped
}当时接上的是三个调用点:
// packages/fs/tool-fs/src/read-render.ts:69
function truncateLine(line: string, maxLineLength: number): string {
return line.length > maxLineLength
? `${line.substring(0, maxLineLength)}... (line truncated to ${maxLineLength} chars)`
: line
}
建议的最小修复(二选一)
function truncateLine(line: string, maxLineLength: number): string {
if (line.length <= maxLineLength) return line
const capped = line.slice(0, maxLineLength)
const safe = /[\uD800-\uDBFF]$/.test(capped) ? capped.slice(0, -1) : capped
return `${safe}... (line truncated to ${maxLineLength} chars)`
}为什么建议按 hotfix 看待这类缺陷是不可恢复且没有自助手段的:孤立代理项一旦进日志,之后每个请求都会被拒, |
补充实测:0.2.2-rc.1 上仍可复现;两个客户端侧自救方案,一个失败、一个有效承接上一条( 1. 载体不止
|
Uh oh!
There was an error while loading. Please reload this page.
环境
deepseek-account,模型deepseek-flash(后来也试过deepseek-v4-pro)现象
一个跑了 88 轮的长会话,从第 77 轮第 11 步开始,之后每一次请求都失败:
换模型(flash → v4-pro)、调推理强度(high/max)、重开对话都无效。之后连续 12 次尝试,每次都在约 250ms 内返回 400。
根因(已定位到具体字符)
模型在
run_code里写了l.text.slice(0, 90)打印一段中文表格,第 90 个 UTF-16 码元正好落在 🔴(U+1F534 =\uD83D\uDD34)中间,于是工具结果里留下一个孤立的高位代理项 U+D83D。这个字符串原样写入会话日志(JSONL 里是
\ud83d转义),之后每个请求都会把它带给服务端;严格的 JSON 解析器拒绝这个不成对的代理项转义,返回 400。而响应体不是标准的{"error":{"message":...}}结构,providerError()只能回退成笼统的DeepSeek Messages request failed (400),用户看不到任何可操作的细节。证据
tool/result;此前 0 次,此后 12 次请求全部 400。tool_result、无重复tool_callid、无空content。...落不成秒数 | \ud83d\n52\t| **D7**...。同类风险:DSH 自带的截断逻辑本身就能造出这个字符
@deepseek-ai/dsh-tool-fs的truncateLine():READ_MAX_LINE_LENGTH默认 2000。只要被截断的行第 2000 个码元落在代理对中间,read工具自己就会产出孤立代理项。复现(纯字符串,不涉及文件):值得注意的是,
@deepseek-ai/dsh-output-retention里已经有truncateWithoutSplittingSurrogatePair(),注释写得很明确:"An unpaired surrogate is not well-formed text — a strict JSON reader rejects a durable Session log that carries one." 说明这条不变量团队是知道的,只是没有覆盖到持久化和请求边界。建议
toWellFormed();truncateLine()复用truncateWithoutSplittingSurrogatePair();(400);INVALID_REQUEST时提示"某条历史消息包含非法字符"。用户侧临时规避:不要对可能含 emoji 的文本用
.slice(),改用Array.from(s).slice(0, n).join('');已经坏掉的会话可以手工把日志里那个\ud83d换成\ufffd(这次就是这么救回来的,改完 1653 帧 / 3789 条事件全部可解析)。English summary
A single unpaired UTF-16 surrogate (half of an emoji) inside one tool result permanently poisons a session: every later Messages request is rejected with 400 INVALID_REQUEST, and neither switching models nor restarting helps. The string is persisted verbatim (
\ud83d) in the session JSONL and re-sent on every turn. The same defect is reachable from the shippedtruncateLine()in@deepseek-ai/dsh-tool-fs(a plainsubstringcut), even though@deepseek-ai/dsh-output-retentionalready shipstruncateWithoutSplittingSurrogatePair(). Suggested fixes: sanitize at the persistence and request boundaries, make truncation surrogate-safe, and surface the provider error body.All reactions