---
title: "谁把大模型的标签切断了？记一次流式 HTML\"川剧变脸\"的破案现场"
date: 2026-07-07
description: "AI 结构化日程流式吐字时，Day2/Day4 标题前偶现脏字符，流结束后也没有消失——根因是 SSE 切包正好切断 <div class=\"day-section\"> 标签，残缺片段被写入累加 HTML 并经 v-html 解析进 DOM，浏览器 HTML 错误恢复将其降级为纯文本后持久残留。本文复盘抓包定位、排查弯路、三层排查方法论，以及后端语义缓冲与前端精准修复的兜底方案。"
canonical_url: "https://yijinlee.com/articles/article-59"
tags:
  - "AI Coding"
  - SSE
  - Vue
  - v-html
  - 流式渲染
  - "HTML Parser"
  - "AI 流式日程"
  - 排障方法论
topic: "AI 外骨骼"
---

METRICS: SSE 切包 · v-html 中间态 · 偶发残留 · 精准修复

在做 AI 结构化日程业务的前端同学，大概率都经历过一种被大模型支配的恐惧。

某天下午，测试同学突然提了个极其诡异的 Bug："AI 在一行行吐字的时候，Day2 和 Day4 的标题前面总是会闪过一串残缺的标签碎片。更糟的是，等 AI 完全说完了，这串脏字符也没有消失，就赖在页面上不走了。"

研发第一反应：啊？流式渲染还会演川剧变脸呢？

更绝的是，这个问题是**偶发的**。你盯着它的时候它丝滑无比，你刚转过头去喝口水，它就跳出来恶心你一下——而且一旦中招，终态页面也会带着这串"骨灰"。

今天就来复盘一下这个"过程痛苦、终态亦脏"的流式富文本大坑。

### 一、案发现场：那串突如其来的"骨灰"

乍看之下，大模型最终拼出来的 HTML 结构似乎没问题——缺的是被切包拦腰截断的那半截标签。问题出在**切包时机与累加字符串**：残缺片段一旦写入 Content，就会跟着 v-html 一路渲染到终态。

我们顺着数据管道一路往下摸，终于在某次偶发抓包里，逮到了一个正在赶路的 SSE 数据切片（Chunk）：

```chunk
// 某一个刚从网络蹦出来的 SSE Data Chunk
... </div>day-section"><strong>Day2 ...
```

这感觉就像是：后端大哥拿着一把西瓜刀，在大模型疯狂输出 Token 的时候冷酷地一刀切了下去，正好切在了 `<div class="day-section">` 的大腿上。

前一半留在上一个数据包里了，前端手里现在只拿到了后半截：`day-section"><strong>Day2`。

| 完整 HTML 片段 | 切包位置 | 前端实际收到 | 丢失部分 |
| :--- | :--- | :--- | :--- |
| `</div><div class="day-section"><strong>Day2` | `class="` 与 `day-section"` 之间 | `</div>day-section"><strong>Day2` | `<div cla` |
| `</p><div class="day-section"><strong>Day4` | `day-` 与 `section">` 之间 | `</p>section"><strong>Day4` | `<div class="day-` |

💡 **关键洞察**：这不是 JSON 转义失败，也不是 Vue 的 Bug——是**网络切包时机**与**实时 HTML 解析**的碰撞。

### 二、浏览器：对不起，我尽力了！

前端拿到这半截断头文本，由于用了 Vue 的 `v-html`，二话不说就直接塞给了浏览器内核。

这时候，浏览器的 HTML 解析器（Parser）看着这串东西，估计也是一脸懵逼。但作为老牌工业级内核，它有一套傲娇的**"错误恢复机制"（Error Recovery）**：

| 步骤 | 解析器看到的输入 | 解析器行为 | 页面表现 |
| :---: | :--- | :--- | :--- |
| 1 | `day-section"` | 残缺片段不符合完整标签结构 | 降级为普通文本节点 |
| 2 | `<strong>Day2` | 标准开标签 | 正常解析为加粗标题 |
| 2 | `>` | 孤立的大于号 | 继续输出为文本 → `">` |
| 3 | `<strong>Day2` | 标准标签，解析器熟悉 | 正常加粗 → **Day2** |

```parserflow
残缺 token 降级|day-section" 等残缺片段|Parser 将其当作纯文本节点输出
孤立符号保留|> 无法配对成合法标签|继续以文本形式渲染在页面上
标准标签恢复|<strong>Day2 结构完整|正常进入 DOM 树，加粗显示
累加字符串固化|message_end 到达|残缺片段仍留在 Content 中，脏字符持久残留
```

当 `message_end` 到达时，流式推送确实结束了，但累加器里的 HTML 字符串仍含着 `day-section"><strong>Day2` 这类被切坏的片段。v-html 每次重渲染都会让 Parser 再跑一遍错误恢复，残缺片段就会作为文本节点稳稳钉在 Day 标题前面——不会自己消失，只能等人来修。

### 三、那些年，我们在排查时走过的弯路

在揪出这个真凶之前，我们在群里和后端、算法的小伙伴反复拉扯，也踩了不少坑：

| 弯路 | 表面现象 | 我们当时的怀疑 | 真实原因 |
| :--- | :--- | :--- | :--- |
| 弯路一 | 以为流结束会自愈 | 前端累加器截断 / Vue 响应式慢 | 残缺片段已固化进 Content，错在切包时机 |
| 弯路二 | 控制台日志刷爆 | 用 `/day-section/` 搜整条消息 | 只要 Day1 渲染成功，后续每次追加都在刷 Log，Day4 淹没在噪音里 |
| 弯路三 | Console 里看到 `\"` | JSON 转义失败 | 那只是 JSON 字符串在控制台的字面量表现，`JSON.parse` 后就是合法双引号 |

- **弯路一：过早陷入"谁的锅"的防御心理**。我们一度以为流结束后页面会自愈，开始怀疑是不是前端累加器把字符串截断了，或者是 Vue 渲染响应式太慢。跟后端同学 battle 了几个来回，最后发现大家都没错——错在网络切包时机，残缺片段一旦写入累加字符串就会持久留污，不会随 message_end 自动消失。

- **弯路二：控制台日志打太多，直接被噪音淹没**。最开始图省事，用 `/day-section/` 去搜整条消息，结果只要 Day1 顺利渲染出来，后续流式追加的每一次，控制台都在疯狂刷日志。真正翻车的 Day4 淹没在成千上万条 Log 里，根本看不见。

- **弯路三：跟控制台里的反斜杠 `\"` 较劲**。盯着 Console 里的 `\"` 琢磨了半天是不是转义问题，后来一拍大腿才反应过来：那只是 JSON 字符串在控制台的字面量表现形式，`JSON.parse` 之后人家就是合法的双引号，跟这个脏字符半毛钱关系没有！

### 四、摸鱼是不可能摸鱼的，只能总结排查方法论

为了以后不在这类流式 Bug 上通宵，我们总结了一套**"三步走"**的速通秘籍：

| 排查层级 | 检查对象 | 查什么 | 责任归属 |
| :--- | :--- | :--- | :--- |
| L1 · 原始网络 | SSE EventStream Chunk | 切包是否落在 HTML 标签内部 | 后端 / 网关 |
| L2 · 前端累加 | Content 拼接结果 | 字符串拼接逻辑是否截断或重复 | 前端 |
| L3 · 浏览器渲染 | v-html 终态 DOM | Parser 错误恢复导致的文本降级与持久残留 | 前端防御层 |

```pipeline
原始网络 Chunk|查切包边界|Chrome Network 存 EventStream 为文本，静态模拟流式追加
前端累加器 Content|查拼接逻辑|精准条件断点，仅在高危特征出现时触发
浏览器 v-html 渲染|查容错表现|确认终态 DOM 是否仍残留脏字符文本节点
```

- **别瞎猜，先抓原始快照**：直接从 Chrome Network 把整个 EventStream 存成文本文件。别在本地乱试，用静态文本模拟流式一点点追加，100% 干净复现。

- **写精准的条件断点**：拒绝无脑 `console.log`。只有当字符里同时出现 `/(day-section|"><strong>Day)/` 这种高危特征时，才让控制台尖叫，这样一逮一个准。

- **分层看数据**：Chunk 错了找后端，Chunk 没错、拼接错了找前端，拼接没错、渲染错了去查浏览器的标签容错。

### 五、终极救砖指南：这锅怎么补？

| 方案 | 实施位置 | 核心思路 | 优点 | 风险 / 注意 |
| :--- | :--- | :--- | :--- | :--- |
| 后端语义缓冲（治本） | 流式推送网关 | Token 处于 `< ... >` 内部时暂存，遇 `>` 闭合后整段发送 | 源头不切断，前端零改动 | 需网关改造，排期可能紧张 |
| 前端防腐层（兜底） | v-html 注入前 | 精准 regex 修复被切断的 Day 标题 | 快速上线，改动面小 | 禁止全局 replace，避免误伤正文 |

#### 后端：语义微型缓冲区

其实最完美的解法在后端。后端的流式推送网关可以加个**"语义微型缓冲区"**。当发现大模型吐出来的 Token 正处于 `< ... >` 标签内部时，先别急着往外发，憋一下，等遇到 `>` 标签闭合了，再把这一整段当作一个 Chunk 发给前端。源头不切断，前端不翻车。

#### 前端：暖心的"防腐层"

如果后端兄弟业务排期太满，前端也得有自己作为纯爷们的担当。我们选择在数据喂给 `v-html` 之前的最后一步，加一层轻量的动态清洗函数：

```typescript
/**
 * 暖心小棉袄：专门修复流式传输中被网络切断的 Day 标题
 */
export function repairBrokenTripDayTitle(rawText: string): string {
  if (!rawText) return '';

  // 严苛匹配：只有在 </div> 或 </p> 后面，紧跟被切断的类名和 Day 标题时才出手
  // 比如专门拦截：</div>day-section"><strong>Day3
  const brokenTagRegex = /(</div>|</p>)(day-section)?"><strong>Day(d+)/g;

  return rawText.replace(brokenTagRegex, (match, closeTag, _, dayNumber) => {
    // 悄悄把被切掉的 <div class="day-section"> 给它补上
    return `${closeTag}<div class="day-section"><strong>Day${dayNumber}`;
  });
}
```

⚠️ **避坑警告**：千万别在页面里写全局 `replace` 去硬删可疑碎片！万一用户正文里恰好出现相似字符序列，你咔嚓一下把人家内容给吞了，那就成了新的生产事故。

### 六、写在最后

折腾了大半天，这个 Bug 最终被我们优雅地按死在了沙滩上。

大模型时代的流式交互，看着很酷炫，但实际上是在完全无状态的网络流上，去强行构建有状态的 DOM 树。这中间的各种网络切包、长文本截断，无时无刻不在考验着前端管道的鲁棒性。

作为前端，我们不仅是把数据画在页面上的"切图仔"，在这种时候，我们更像是守在数据链路最后一公里的**"清道夫"**。多留个心眼，加一层防御性思考，下班的钟声就能敲得更响亮一些。

## 参考文章：相关链接

- [HTML Living Standard · Parsing](https://html.spec.whatwg.org/multipage/parsing.html)（权威引文）— HTML 解析器如何「修好」残缺标签，解释川剧变脸。
- [MDN · DOMParser](https://developer.mozilla.org/en-US/docs/Web/API/DOMParser)（官方文档）— 解析与安全插入的浏览器 API，流式 HTML 重构抓手。
- [流式传输占位符泄露重构](https://yijinlee.com/articles/article-49)（站内深度）— 流式标记泄漏的姊妹案。
- [流式 FAQ 首尾截断架构重构](https://yijinlee.com/articles/article-56)（站内深度）— 用分层与代际令牌保证终态收敛。

