SSE流式传输与Markdown渲染:AI对话打字机效果全链路实践

SSE流式传输与Markdown渲染:AI对话打字机效果全链路实践 1. 从逐字蹦出的体验说起打字机效果到底难在哪第一次做 AI 对话界面的人几乎都会卡在同一个地方后端明明已经把整段回答生成好了前端却只能等它全部返回再一次性渲染用户盯着空白屏幕转圈体验非常割裂。而市面上那些成熟的对话产品回答是一个字一个字蹦出来的像老式打字机一样有节奏感。这个效果看起来简单真动手做才发现牵扯的东西一点都不少。我前后在三个项目里实现过这套东西踩过的坑从流式数据粘包到Markdown 渲染到一半标签断裂再到线上 Nginx 把流式响应缓冲成一坨每一个都够写一篇排查记录。这篇就把整条链路拆开讲清楚SSE 流式传输怎么把数据一段段推给前端Markdown 组件怎么在流式过程中安全渲染Nginx 反向代理为什么会让流式粘连以及断线重连、超时这些边界情况怎么处理。适合谁看如果你正在做 AI 对话、实时日志、流式报表这类需要边生成边展示的功能或者你已经做出来了但线上表现和本地不一样这篇应该能帮你少走弯路。我会尽量把每一步的为什么讲透而不是只丢一段配置让你抄。先说结论性的判断打字机效果的本质不是动画而是数据分片到达 前端增量渲染。动画只是表象真正决定体验的是数据怎么切、怎么传、怎么拼、怎么渲染。这四件事任何一环出问题用户看到的要么是卡顿要么是乱码要么是转圈半天突然全出来。2. SSE 流式传输为什么它是 AI 对话的首选通道2.1 SSE 和 WebSocket 的选择逻辑很多人第一反应是用 WebSocket觉得全双工听起来更高级。但在 AI 对话这个场景里WebSocket 其实是过度设计。原因很简单AI 对话的数据流向是单向的——服务端持续推、客户端只管收用户的下一条消息是另起一个请求。这种服务端单向推送的模型SSEServer-Sent Events天生就是为它设计的。SSE 基于普通 HTTP 长连接协议格式极简服务端只要按data: xxx\n\n的格式往响应体里写数据浏览器端的EventSource就能自动解析。对比一下维度SSEWebSocket通信方向服务端单向推送全双工协议基础纯 HTTP独立协议需升级握手自动重连浏览器原生支持需自己实现代理兼容性好走标准 HTTP部分代理需额外配置实现复杂度低中高适用场景推送、流式输出双向实时交互我实测下来的经验是只要你的场景是请求一次、持续接收优先选 SSE。代码量少一半调试也简单用curl就能直接看流。WebSocket 留给真正需要双向高频通信的场景比如协同编辑、游戏。2.2 SSE 的数据格式与分帧规则SSE 的协议格式看着简单但细节不注意就会踩坑。一条完整的 SSE 消息由若干字段组成字段之间用换行分隔消息之间用空行两个连续换行分隔data: 第一段内容 data: 第二段内容 data: 第三段内容关键规则有这么几条我逐条解释为什么每条data:后面跟的内容会被拼接如果一条消息里写了多个data:行浏览器会把它们用换行符连起来。所以想推一段带换行的文本要么用多个data:行要么在内容里编码换行符。空行是消息结束标志这是最容易出错的地方。如果你推的内容里本身包含空行而没做转义浏览器会误以为消息结束了后面的内容就丢了。data:冒号后建议加一个空格规范里这个空格会被忽略但加上更符合惯例也避免某些解析器把空格当成内容的一部分。服务端推送时一个典型的 Node.js 写法是这样的res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no // 关键告诉 Nginx 不要缓冲 }); // 逐段推送 for (const chunk of chunks) { res.write(data: ${JSON.stringify({ text: chunk })}\n\n); } res.write(data: [DONE]\n\n); res.end();注意那个X-Accel-Buffering: no响应头这是后面 Nginx 章节的伏笔先记住它。2.3 前端 EventSource 的用法与局限浏览器端用EventSource接收最省事const es new EventSource(/api/chat/stream?q你好); es.onmessage (event) { if (event.data [DONE]) { es.close(); return; } const { text } JSON.parse(event.data); appendToUI(text); }; es.onerror (err) { console.error(SSE 连接异常, err); // EventSource 会自动重连但重连会重新发起请求 };但EventSource有两个硬伤做 AI 对话时几乎一定会遇到第一它只支持 GET 请求不能带请求体。而 AI 对话往往需要把对话历史、模型参数一起发过去用 URL 传参既丑陋又有长度限制。解决办法是用fetchReadableStream手动解析 SSE这样就能用 POST 带 bodyconst response await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages, model: xxx }) }); const reader response.body.getReader(); const decoder new TextDecoder(); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); // 按空行切分消息 const parts buffer.split(\n\n); buffer parts.pop(); // 最后一段可能不完整留到下次 for (const part of parts) { const line part.replace(/^data: /, ); if (line [DONE]) return; appendToUI(JSON.parse(line).text); } }第二EventSource的自动重连会重发整个请求。对于 AI 对话这意味着模型会重新生成一遍用户看到内容重复。所以生产环境我基本都用fetch手动控制重连逻辑而不是依赖EventSource的默认行为。2.4 断线重连怎么做到接着上次继续断线重连是流式场景里最容易被低估的部分。用户网络抖一下、服务端超时、代理断开都会触发重连。如果处理不好要么内容重复要么内容丢失。我的做法是在服务端维护一个可续传的会话状态核心是给每个流分配一个streamId并记录已经推送到的位置offset 或 chunk 序号。重连时前端带上streamId和lastOffset服务端从断点继续推// 服务端伪代码 const sessions new Map(); app.post(/api/chat/stream, (req, res) { const { streamId, lastOffset } req.body; const session sessions.get(streamId); if (session lastOffset ! null) { // 续传从 lastOffset 之后继续 for (let i lastOffset 1; i session.chunks.length; i) { res.write(data: ${JSON.stringify({ text: session.chunks[i], offset: i })}\n\n); } } else { // 新会话 // ... 正常生成逻辑 } });前端在每次收到消息时记录offset重连时带上。这样即使断了几次用户看到的内容也是连续不重复的。注意续传状态不能只放内存多实例部署时要用 Redis 之类的共享存储否则重连打到另一个实例就找不到会话了。这是我在一个多副本部署的项目里踩过的坑本地单实例测试完全正常一上生产就乱套。3. Markdown 流式渲染标签没闭合时怎么办3.1 流式渲染的核心矛盾AI 返回的内容通常是 Markdown 格式包含代码块、表格、列表、加粗等。如果等全部内容接收完再渲染打字机效果就没了如果每收到一小段就渲染又会遇到标签不完整的问题。举个最典型的例子模型正在输出一个代码块当前收到的内容是这是示例代码 python def hello(此时只出现了开头结尾还没到。如果直接丢给 Markdown 解析器它会把后面所有内容都当成代码块页面直接乱掉。等结尾的到了又会重新渲染一遍用户看到内容跳了一下。这个矛盾的本质是Markdown 是块级语法需要完整结构才能正确解析而流式数据是逐段到达的天然不完整。3.2 三种处理策略的取舍我试过三种方案各有适用场景方案一延迟渲染等块完整再渲染。维护一个缓冲区检测到当前块比如一个代码块、一个表格闭合了才提交渲染。优点是渲染结果稳定不会跳缺点是代码块很长时用户要等很久才看到内容打字机效果在代码块内失效。方案二容错渲染不完整时按纯文本显示。检测到未闭合的语法标记就把这部分当纯文本渲染等闭合后再切换成 Markdown。优点是即时性好缺点是内容会变形从纯文本突然变成带样式的代码块视觉上跳变明显。方案三增量补全渲染时临时补上闭合标记。比如检测到python开了但没关渲染前临时补一个让解析器能正确解析已到达的部分。等真正的结尾到了再用完整内容重新渲染。这是我现在主要用的方案兼顾了即时性和稳定性。方案三的实现关键是在渲染前对文本做一次补全预处理function completeMarkdown(text) { // 统计未闭合的代码块 const fenceCount (text.match(/^/gm) || []).length; if (fenceCount % 2 ! 0) { text \n; } // 未闭合的行内代码 const inlineCount (text.match(/(?!)(?!)/g) || []).length; if (inlineCount % 2 ! 0) { text ; } // 未闭合的加粗 const boldCount (text.match(/\*\*/g) || []).length; if (boldCount % 2 ! 0) { text **; } return text; }这个函数不追求完美只处理最常见的几种情况。实测下来代码块和加粗覆盖了 90% 以上的跳变问题。3.3 代码高亮的性能陷阱流式渲染时每次收到新片段都重新解析整个 Markdown 并高亮代码性能会迅速崩掉。我做过测试一段 2000 字的回答如果每 20 字渲染一次就是 100 次全量解析 高亮在中低端手机上明显卡顿。优化思路有两个一是节流渲染。不要每收到一个 chunk 就渲染而是用requestAnimationFrame或固定 50ms 的节流把多次更新合并成一次渲染。用户感知不到 50ms 的延迟但渲染次数能降一个数量级。二是增量更新 DOM。对于已经渲染好的部分不要整体替换只追加新增的内容。这需要 Markdown 渲染器支持增量输出或者自己维护已渲染块和待渲染块的边界。实现复杂一些但长回答场景下体验提升明显。let pending false; function scheduleRender(text) { if (pending) return; pending true; requestAnimationFrame(() { renderMarkdown(completeMarkdown(text)); pending false; }); }3.4 表格和数学公式的特殊处理表格是流式渲染里最麻烦的。Markdown 表格需要表头、分隔行、数据行都到齐才能正确解析。如果只到了表头解析器会把它当普通文本等分隔行到了又变成表格。这个跳变比代码块更明显。我的处理方式是检测到表格开始连续两行都有|就暂时不渲染等表格结束出现空行或非表格行再一次性渲染。表格通常不会太长延迟几百毫秒用户能接受。数学公式$...$和$$...$$同理未闭合时先按纯文本显示闭合后再用 KaTeX 或 MathJax 渲染。这里要注意公式渲染是异步的流式过程中频繁调用渲染器会有性能问题建议同样做节流。4. Nginx 反向代理流式响应为什么会被粘连4.1 现象本地正常线上一坨这是最经典的环境差异问题。本地开发时前端直连后端服务流式效果丝滑一部署到线上经过 Nginx 反向代理就变成转圈半天然后整段内容一次性出现。打字机效果完全消失。第一次遇到这个问题时我排查了很久一度怀疑是前端代码问题。后来用curl直接请求后端端口发现流是正常的再curl请求 Nginx 端口就变成了一次性返回。问题定位到 Nginx。4.2 根因Nginx 的响应缓冲机制Nginx 默认开启proxy_buffering它会把后端返回的响应先缓冲到内存或磁盘攒够一定大小默认proxy_buffer_size4k/8k或者响应结束后才转发给客户端。这个设计对普通请求是优化——减少后端连接占用时间但对流式响应是灾难——它把流变成了批。除了proxy_buffering还有几个相关配置会影响流式配置项默认值对流式的影响proxy_bufferingon开启时缓冲响应流式失效proxy_cacheoff开启时缓存响应流式失效proxy_buffer_size4k/8k缓冲区大小影响首次输出延迟proxy_buffers8 4k/8k缓冲区数量和大小chunked_transfer_encodingon分块传输流式需要开启proxy_read_timeout60s读超时长连接需调大4.3 正确的 Nginx 配置针对 SSE 流式接口我的配置模板是这样的location /api/chat/stream { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection ; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; # 关闭缓冲这是流式的关键 proxy_buffering off; proxy_cache off; # 分块传输 chunked_transfer_encoding on; # 长连接超时按业务调整 proxy_read_timeout 300s; proxy_send_timeout 300s; # 禁用 gzip避免压缩缓冲 gzip off; }逐条解释为什么proxy_buffering off核心配置关闭响应缓冲后端写一段 Nginx 就转发一段。proxy_http_version 1.1Connection SSE 需要长连接HTTP/1.0 默认短连接会断。Connection 是清空这个头避免 Nginx 主动关闭连接。chunked_transfer_encoding on分块传输编码让响应可以边生成边发送。proxy_read_timeout默认 60 秒AI 生成慢的时候容易超时断开调大到 300 秒或更长。gzip offgzip 压缩需要攒够数据才能压缩会引入缓冲流式场景直接关掉。提示如果后端已经设置了X-Accel-Buffering: no响应头Nginx 会自动对该响应关闭缓冲可以不用在 Nginx 里配proxy_buffering off。但两个都配上更保险尤其是 Nginx 配置不由你控制的时候。4.4 超时与心跳让连接活着即使配置对了长连接还是会因为各种超时被断开。常见的有三类Nginx 的proxy_read_timeout后端多久没数据就断开。负载均衡器的空闲超时云厂商的 LB 通常有 60 秒空闲超时。浏览器/操作系统的 TCP 超时。解决办法是发心跳。在流式过程中如果后端暂时没有内容可推比如模型在思考定期发送注释行: heartbeat\n\n。SSE 规范里以冒号开头的行是注释会被客户端忽略但能保持连接活跃const heartbeat setInterval(() { res.write(: heartbeat\n\n); }, 15000); // 结束时清理 res.on(close, () clearInterval(heartbeat));15 秒是个比较稳妥的间隔小于大多数 LB 的 60 秒空闲超时又不会太频繁浪费带宽。5. 完整链路的联调与线上验证5.1 用 curl 逐层验证排查流式问题curl是最趁手的工具。它能直接看到数据到达的时间分布判断是真流式还是假流式# 加 -N 禁用 curl 自己的缓冲-i 显示响应头 curl -N -i http://localhost:3000/api/chat/stream?q你好如果输出是一行一行逐渐出现的说明流式正常如果卡几秒后一次性全出来说明中间有缓冲。逐层测试的顺序是直连后端 → 经过 Nginx → 经过 LB → 经过 CDN哪一层开始变成一次性问题就在哪一层。5.2 前端如何判断流是否真的在流前端也可以做自检记录每次onmessage的时间戳如果所有消息的时间戳几乎相同说明是被缓冲后一次性到达的如果时间戳均匀分布说明是真流式。const timestamps []; es.onmessage (e) { timestamps.push(Date.now()); // ... }; // 结束后分析间隔这个自检在联调阶段非常有用能快速区分是前端渲染问题还是传输问题。5.3 常见故障对照表把我在实际项目里遇到过的故障整理成表方便对照排查现象可能原因排查方向内容一次性出现Nginx 缓冲未关检查proxy_buffering连接 60 秒断开读超时太短调大proxy_read_timeout内容重复重连重发请求检查续传逻辑和 offset代码块渲染错乱标签未闭合检查补全预处理中文乱码分片切断多字节字符用TextDecoder的 stream 模式首字节延迟高缓冲区太大调小proxy_buffer_size移动端卡顿渲染过于频繁加节流其中中文乱码这个坑特别隐蔽。UTF-8 的中文占 3 个字节如果网络分片正好切在一个字的中间直接decode就会出乱码。正确做法是用TextDecoder的{ stream: true }选项它会把不完整的多字节序列缓存到下次const decoder new TextDecoder(utf-8); // 每次 decode 都带 stream: true buffer decoder.decode(value, { stream: true });这个细节我在两个项目里都踩过第一次排查了半天才想到是编码问题。6. 几个容易被忽略的工程细节6.1 服务端的背压处理流式推送时如果客户端消费慢比如网络差而服务端还在拼命res.write数据会堆积在内存里严重时 OOM。Node.js 的res.write返回false表示缓冲区满了此时应该暂停推送等drain事件再继续if (!res.write(chunk)) { await new Promise(resolve res.once(drain, resolve)); }这个背压机制在正常网络下几乎不会触发但线上总会有网络差的用户加上它能让服务更稳。6.2 客户端主动取消用户点了停止生成前端要能真正取消请求而不是只停止渲染。用AbortControllerconst controller new AbortController(); fetch(/api/chat/stream, { signal: controller.signal, ... }); // 用户点停止 controller.abort();服务端要监听req.on(close)及时停止模型生成释放资源。否则用户取消了后端还在跑白白消耗算力。6.3 多实例部署的会话一致性前面提过续传状态要共享存储这里再强调一次。如果你的服务是多副本部署用户重连时可能打到另一个实例如果会话状态只在本地内存续传就会失败。用 Redis 存streamId - { chunks, offset }设置合理的过期时间比如 5 分钟既保证续传可用又不会无限占用内存。6.4 日志与可观测性流式接口的日志和普通接口不一样一次请求可能持续几十秒。建议记录这几个指标首字节时间TTFB、总时长、chunk 数量、是否发生重连、是否被取消。这些指标能帮你快速定位是生成慢还是传输慢还是渲染慢。我在一个项目里就是靠 TTFB 指标发现某段时间首字节延迟突然从 200ms 涨到 3 秒最后定位到是 Nginx 的proxy_buffer_size被改大了导致要攒够更多数据才转发。这种问题不看指标很难发现。7. 写在最后的一点个人体会这套东西我从零搭过也在别人的项目上改过最大的感受是打字机效果的技术难点不在打字机而在流的可靠性。动画部分随便找个库都能做真正花时间的是那些边界情况——断线了怎么办、标签没闭合怎么办、代理缓冲了怎么办、多字节字符被切断了怎么办。我的建议是做这类功能时先在本地把直连链路跑通确认数据是真流式然后逐层加上 Nginx、LB每加一层都用curl -N验证一次最后再处理前端渲染的容错。不要一上来就全链路联调出了问题根本不知道是哪一层。还有一个经验把流式当成一个独立的传输层能力来设计而不是塞在业务代码里。我现在的做法是封装一个通用的流式客户端负责分片解析、重连、心跳、取消业务层只管收到一段文本就渲染。这样换模型、换后端、换部署环境时改动面小很多。至于 Markdown 渲染的补全逻辑别追求完美覆盖代码块、加粗、行内代码这几种高频情况就够了。剩下的边角情况用户其实感知不到过度设计反而增加维护成本。