DeepSeek Harness 工具输出按字节截断出现 U+FFFD?中文首词乱码与修复路径
DeepSeek Harness 的截断只应该删内容,不应该改写留下来的内容;这里的实现改写了。 子进程输出超过收集器的保留上限时,缓冲区头部被按字节丢掉,切点没有考虑字符边界,于是模型拿到的文本开头出现命令从未打印过的 U+FFFD。据讨论,报告者在已发布包上实测,社区成员随后核对了 subprocess-local 的 output.ts(讨论原文)。
DeepSeek Harness 子进程输出截断的现象:一个小上限就能稳定切出两个替换字符
只要切点落在多字节序列内部,文本就会多出字符,而且后面的内容完全正确,看起来特别可信。 用讨论里的脚本复现:
import { OutputCollector } from '@deepseek-ai/dsh-subprocess-local/lib/output.js'
const c = new OutputCollector(23, 'stdout', undefined)
c.push(Buffer.from('编译完成:3 个文件 ✅ done\n', 'utf8'))
console.log(JSON.stringify(c.finalize().text))
// "��3 个文件 ✅ done\n" 两个输入里没有的字符
console.log(JSON.stringify(c.readFrom(0).text))
// "��3 个文件 ✅ done\n" 前台读取,同样如此
复现时按下面顺序核对,避免把别的问题混进来:
- 把上限设为明显小于输出的值,预期
truncated为true。 - 打印
finalize().text,预期开头出现一个或两个 U+FFFD。 - 再打印
readFrom(0).text,预期同样损坏——说明不是某一条读取路径特有的。 - 找到同一次运行的 spill 文件,预期内容字节精确,没有替换字符。
损坏发生在 DeepSeek Harness 的缓冲区,不在解码器
真正的动作是 this.chunks[0] = head.subarray(excess):一次裸字节切片,文件里没有任何 UTF-8 边界处理。 之后的 finalize()、snapshot()、readFrom(0) 解码的都是这段已经被切坏的字节。社区核对的位置是 output.ts:167-181:
while (this.bytes > this.maxBytes) {
const head = this.chunks[0] as Buffer
const excess = this.bytes - this.maxBytes
if (head.length <= excess) {
this.bytes -= head.length
// 整块丢弃
} else {
this.chunks[0] = head.subarray(excess)
this.bytes -= excess
}
}
由此可以推出三件事:
- 损坏发生在缓冲阶段,所有下游读者都会继承,包括主路径上的前台读取。
- 只修解码器不够,因为交到解码器手里的字节已经缺了前导字节。
truncated: true无法区分「在字符边界上干净地丢头」和「从字符中间丢头」,两种情况共用一个值。
改法一:把 DeepSeek Harness 的切点推进到下一个 UTF-8 首字节
这是改动最小、语义最稳的修法:保留字节预算,只把切点往后挪到字符起点,最多多丢 3 个字节。 步骤:
- 把裸切片换成一个裁剪函数,入参是原始缓冲区和要丢掉的字节数。
- 从
excess开始向后走,只要当前字节满足(byte & 0xC0) === 0x80就继续前进。 - 用新的偏移量做
subarray,并把实际丢掉的字节数写回this.bytes。 - 边界保护:如果一直走到缓冲区末尾,说明整块都不可解码,按整块丢弃处理。
- 用第 1 节的脚本重跑,预期开头是完整中文,
truncated仍为true。
示意实现(不是仓库原代码):
let cut = excess
while (cut < head.length && (head[cut] & 0xc0) === 0x80) cut++
this.chunks[0] = head.subarray(cut)
this.bytes -= cut
改法二:保留 DeepSeek Harness 的字节精确窗口,读取端流式解码
如果下游必须拿到「正好 maxBytes 个字节」,那就别在缓冲区里修,改成每个读者用流式解码器自己处理残缺序列。 步骤:
- 保持
subarray(excess)不变,明确记录这块缓冲区可能以连续字节开头。 - 把
finalize()、snapshot()、readFrom()里的toString('utf8')换成共享的TextDecoder,开启流式模式。 - 对窗口开头单独做一次「跳到首字节」的预处理,或在解码前标记跳过不完整序列。
- 回归三条读取路径,预期三者输出一致,且都没有 U+FFFD。
- 检查 spill 路径没有跟着改——它写的是原始块,不需要解码。换 DSH插件 或换终端都改不了这段裁剪逻辑,修在收集器本身。
两种改法的取舍要写清楚:改法一保证「存下来的缓冲是合法文本」,改法二保证「上限恰好是 maxBytes」,在不知道编码的前提下无法同时满足。另一条相邻缺陷是 spill 短写,见 DeepSeek Harness spill 短写丢字节排查。
改法三:调高 DeepSeek Harness 的输出上限,让截断不发生
提高上限只是把概率降下来,不是修复;它适合作为止血,不能当作结案。 步骤:
- 找到当前输出收集器的上限配置,确认它是按字节计的硬上限。
- 按实际命令的输出分布把上限调到能覆盖绝大多数场景,预期常规构建日志不再触发
truncated。 - 用一个明显超限的输出再测一次,预期仍会出现替换字符——证明这只是缓解。
- 同时在结果里补上「省略量按实际返回字节计算」的口径,避免上限与返回值对不上。
DeepSeek Harness 修复后的验证:三类文本加两个对照
验收不能只看一段中文,必须覆盖 2、3、4 字节码点,并带上必定失败与必定成功的对照。 步骤:
- 准备混合中文、拉丁字母与 emoji 的样本,循环取不同上限,统计开头出现 U+FFFD 的比例,修复后预期为 0。
- 保留一份纯 ASCII 样本,预期任何时候都不出现替换字符。
- 保留一份已知必然切在字符中间的样本,修复前预期 100% 命中,修复后预期 0 命中——这证明检测器本身有效。
- 对比 spill 文件与内存文本,预期修复后两者开头一致;
truncated的语义仍只表示「内容被省略」。
常见问题
因为 DeepSeek Harness 的收集器在缓冲区超过上限时按字节丢弃头部,head.subarray(excess) 的切点落在多字节字符中间,留下的第一段是没有前导字节的连续字节。解码时这段字节被还原成 U+FFFD,所以它来自截断,不是命令输出。
DeepSeek Harness 的 spill 文件写入的是原始数据块,没有经过内存缓冲区那次字节裁剪,因此字节精确。模型拿到的是裁剪后的内存尾部,两者在同一次运行里内容不一致,而 truncated 在两种情形下都是 true。
DeepSeek Harness 把按字节截断改成按字符截断能消除这类替换字符,但会破坏字节预算的确定性。更稳的做法是保留字节预算,在 subarray 之后跳过 10xxxxxx 连续字节,最多多丢 3 个字节,代价可控且不引入新的编码假设。
DeepSeek Harness 调大输出上限能降低触发概率,但不能消除。只要输出仍会超过上限,切点就有概率落在字符中间;把上限调到内存无法承受也不现实。上限只能作为缓解,边界处理仍需在截断点完成。
相关术语
- U+FFFD(replacement character)
- U+FFFD 是 Unicode 的替换字符,解码器遇到无法解释的字节序列时用它占位。它是合法字符,能序列化、能被接受,因此不会触发任何校验错误。— https://www.unicode.org/glossary/#replacement_character
- UTF-8 continuation byte
- UTF-8 continuation byte 是以 `10xxxxxx` 开头的后续字节,必须跟在一个前导字节后面才能组成完整码点。单独出现在缓冲区开头的连续字节无法解码,通常变成 U+FFFD。— https://www.rfc-editor.org/rfc/rfc3629
- TextDecoder streaming mode
- TextDecoder 的流式模式允许把分块的字节序列连续解码,内部会保留不完整的多字节序列等到下一块到达。它解决的是解码边界问题,不修复已经被裁掉的字节。— https://encoding.spec.whatwg.org/#textdecoder
- spill file(溢出文件)
- spill file 是子进程输出超过内存保留量时落盘的完整副本,通常按原始数据块写入。它与内存尾部可能内容不一致,是排查此类问题的关键对照。— https://github.com/deepseek-ai/deepseek-harness/discussions/10345
来源
- deepseek-harness Discussion #10345:按字节截断子进程输出会在读取前改变文本内容· deepseek-ai(GitHub Discussions)
- deepseek-harness Discussion #3251:终端与子进程输出解码边界的既有审计· deepseek-ai(GitHub Discussions)