罗德岛通讯与技术部

截断问题

TRUNCATION LESSONS

TCP 会把一个中文 UTF-8 字符拆成两个 Uint8Array 帧。decoder.decode(value, {stream:true}) 虽然内部缓冲了不完整字节,但 split('\n') 之后取出来的一行,字符序列已经被重组了——在 TCP 切割点上的字符间隔不是真实的 SSE 语义边界。

状态:🟢 现行 · 最后核对 2026-06-19

2026-06-15 ~ 2026-06-18 | 最终修复:f2eaea3


走过的弯路(按时间顺序)

我们以为的原因 修了什么 结果
thinking filter 吞文字 </thinking> 之后文字被丢弃 修了一处
parseAndRenderMessage 拆 DOM 关掉了 修了一处
trust_parse 策略3 误匹配 限制最后3行 修了一处
innerHTML 在流式中每 chunk 解析 改成流式后一次性 innerHTML 没修好
Chromium 渲染 bug 改成 textContent 没修好
TCP 帧切割 UTF-8 字节 split('\n')split('\n\n') SSE 事件层解析 修了传输层——但还是截断
innerHTML 中文+<i> 浏览器解析 bug 纯 DOM API——createTextNode + createElement('i') 修好了

真正的根因(两个)

根因 1:split('\n') 按行拆 SSE 事件 —— 语义丢失

TCP 会把一个中文 UTF-8 字符拆成两个 Uint8Array 帧。decoder.decode(value, {stream:true}) 虽然内部缓冲了不完整字节,但 split('\n') 之后取出来的一行,字符序列已经被重组了——在 TCP 切割点上的字符间隔不是真实的 SSE 语义边界。

修法split('\n\n')。SSE 协议的最小语义单元是事件,不是行。等 \n\n 到了再处理,配合 decoder({stream:true}) 的不完整字节缓冲。

根因 2:innerHTML + 中文 + <i> 标签 —— 浏览器解析不可靠

即使文本是完整的、HTML 标签是闭合的,Chromium 的 HTML 解析器在中文和 <i> 标签混合的某些字符序列上,会自作主张地"修正"DOM——丢掉中间的内容。这是浏览器底层行为,无法通过代码修复。

修法:不经过 innerHTML。用 DOM API 直接操作——textContent 设全文(安全),正则找到 (...) 位置,createTextNode 放普通文字,createElement('i') 放括号内容,appendChild 组装。浏览器从头到尾不触发 HTML 解析。

最终架构

后端: chat → SSE 流式
前端: reader.read() → decoder.decode({stream:true}) → split('\n\n') SSE事件层
      ↓
      流式中: textSpan.textContent = fullResponse(纯文本)
      ↓
      [DONE]后: createTextNode + createElement('i') + appendChild(DOM操作,不经过innerHTML)

核心教训

  1. SSE 用 \n\n 拆事件,不用 \n 拆行。 前者是协议语义单元,后者是 TCP 碎片的偶然边界。
  2. innerHTML + 中文 + 行内标签是不可靠的。 不论你怎么保证标签闭合——浏览器解析器就这个行为。
  3. DOM API 比 innerHTML 安全一百倍。 慢一点,但不会丢内容。
  4. 先关新功能再排查。 截断问题反复三周,很大原因是我们每次修了一个又引入另一个。正确的排查顺序:关新功能 → 二分排查 → 确认根因 → 一处修复。