AI Coding · 流式 HTML 川剧变脸排障
在做 AI 结构化日程业务的前端同学,大概率都经历过一种被大模型支配的恐惧。
某天下午,测试同学突然提了个极其诡异的 Bug:"AI 在一行行吐字的时候,Day2 和 Day4 的标题前面总是会闪过一串残缺的标签碎片。更糟的是,等 AI 完全说完了,这串脏字符也没有消失,就赖在页面上不走了。"
研发第一反应:啊?流式渲染还会演川剧变脸呢?
更绝的是,这个问题是偶发的。你盯着它的时候它丝滑无比,你刚转过头去喝口水,它就跳出来恶心你一下——而且一旦中招,终态页面也会带着这串"骨灰"。
今天就来复盘一下这个"过程痛苦、终态亦脏"的流式富文本大坑。
案发现场:那串突如其来的"骨灰"
乍看之下,大模型最终拼出来的 HTML 结构似乎没问题——缺的是被切包拦腰截断的那半截标签。问题出在切包时机与累加字符串:残缺片段一旦写入 Content,就会跟着 v-html 一路渲染到终态。
我们顺着数据管道一路往下摸,终于在某次偶发抓包里,逮到了一个正在赶路的 SSE 数据切片(Chunk):
标签被拦腰截断的瞬间
// 某一个刚从网络蹦出来的 SSE Data Chunk ... </div>day-section"><strong>Day2 ...
这感觉就像是:后端大哥拿着一把西瓜刀,在大模型疯狂输出 Token 的时候冷酷地一刀切了下去,正好切在了 <div class="day-section"> 的大腿上。
前一半留在上一个数据包里了,前端手里现在只拿到了后半截:day-section"><strong>Day2。
完整 HTML vs 网络切包后前端实际收到
| 完整 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- |
浏览器:对不起,我尽力了!
前端拿到这半截断头文本,由于用了 Vue 的 v-html,二话不说就直接塞给了浏览器内核。
这时候,浏览器的 HTML 解析器(Parser)看着这串东西,估计也是一脸懵逼。但作为老牌工业级内核,它有一套傲娇的"错误恢复机制"(Error Recovery):
残缺 token 如何变成页面上的脏字符
| 步骤 | 解析器看到的输入 | 解析器行为 | 页面表现 |
|---|---|---|---|
| 1 | day-section" | 残缺片段不符合完整标签结构 | 降级为普通文本节点 |
| 2 | <strong>Day2 | 标准开标签 | 正常解析为加粗标题 |
| 2 | > | 孤立的大于号 | 继续输出为文本 → "> |
| 3 | <strong>Day2 | 标准标签,解析器熟悉 | 正常加粗 → Day2 |
解析器容错 · 四阶段流转
Parser 将其当作纯文本节点输出
继续以文本形式渲染在页面上
正常进入 DOM 树,加粗显示
残缺片段仍留在 Content 中,脏字符持久残留
当 message_end 到达时,流式推送确实结束了,但累加器里的 HTML 字符串仍含着 day-section"><strong>Day2 这类被切坏的片段。v-html 每次重渲染都会让 Parser 再跑一遍错误恢复,残缺片段就会作为文本节点稳稳钉在 Day 标题前面——不会自己消失,只能等人来修。
那些年,我们在排查时走过的弯路
在揪出这个真凶之前,我们在群里和后端、算法的小伙伴反复拉扯,也踩了不少坑:
表面现象 vs 真实原因
| 弯路 | 表面现象 | 我们当时的怀疑 | 真实原因 |
|---|---|---|---|
弯路一 | 以为流结束会自愈 | 前端累加器截断 / 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 上通宵,我们总结了一套"三步走"的速通秘籍:
Chunk → 拼接 → 渲染,分层定位责任
切包是否落在 HTML 标签内部
字符串拼接逻辑是否截断或重复
Parser 错误恢复导致的文本降级与持久残留
三步走速通秘籍
原始网络 Chunk
Chrome Network 存 EventStream 为文本,静态模拟流式追加
前端累加器 Content
精准条件断点,仅在高危特征出现时触发
浏览器 v-html 渲染
确认终态 DOM 是否仍残留脏字符文本节点
别瞎猜,先抓原始快照
直接从 Chrome Network 把整个 EventStream 存成文本文件。别在本地乱试,用静态文本模拟流式一点点追加,100% 干净复现。
写精准的条件断点
拒绝无脑 console.log。只有当字符里同时出现 /(day-section|"><strong>Day)/ 这种高危特征时,才让控制台尖叫,这样一逮一个准。
分层看数据
Chunk 错了找后端,Chunk 没错、拼接错了找前端,拼接没错、渲染错了去查浏览器的标签容错。
终极救砖指南:这锅怎么补?
治本 vs 兜底,各取所长
| 方案 | 实施位置 | 核心思路 | 优点 | 风险 / 注意 |
|---|---|---|---|---|
后端语义缓冲(治本)治本 | 流式推送网关 | Token 处于 < ... > 内部时暂存,遇 > 闭合后整段发送 | 源头不切断,前端零改动 | 需网关改造,排期可能紧张 |
前端防腐层(兜底)兜底 | v-html 注入前 | 精准 regex 修复被切断的 Day 标题 | 快速上线,改动面小 | 禁止全局 replace,避免误伤正文 |
后端:语义微型缓冲区
其实最完美的解法在后端。后端的流式推送网关可以加个"语义微型缓冲区"。当发现大模型吐出来的 Token 正处于 < ... > 标签内部时,先别急着往外发,憋一下,等遇到 > 标签闭合了,再把这一整段当作一个 Chunk 发给前端。源头不切断,前端不翻车。
前端:暖心的"防腐层"
如果后端兄弟业务排期太满,前端也得有自己作为纯爷们的担当。我们选择在数据喂给 v-html 之前的最后一步,加一层轻量的动态清洗函数:
/**
* 暖心小棉袄:专门修复流式传输中被网络切断的 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}`;
});
}写在最后
折腾了大半天,这个 Bug 最终被我们优雅地按死在了沙滩上。
大模型时代的流式交互,看着很酷炫,但实际上是在完全无状态的网络流上,去强行构建有状态的 DOM 树。这中间的各种网络切包、长文本截断,无时无刻不在考验着前端管道的鲁棒性。
作为前端,我们不仅是把数据画在页面上的"切图仔",在这种时候,我们更像是守在数据链路最后一公里的"清道夫"。多留个心眼,加一层防御性思考,下班的钟声就能敲得更响亮一些。
本文属于 AI 实用主义流派 的第 43 篇肉身实战。
发表评论
分享你的想法和反馈