1. 从"逐字蹦出"的体验说起:打字机效果到底难在哪
第一次做 AI 对话界面的人,几乎都会卡在同一个地方:后端明明已经把整段回答生成好了,前端却只能等它全部返回再一次性渲染,用户盯着空白屏幕转圈,体验非常割裂。而市面上那些成熟的对话产品,回答是一个字一个字"蹦"出来的,像老式打字机一样有节奏感。这个效果看起来简单,真动手做才发现牵扯的东西一点都不少。
我前后在三个项目里实现过这套东西,踩过的坑从"流式数据粘包"到"Markdown 渲染到一半标签断裂",再到"线上 Nginx 把流式响应缓冲成一坨",每一个都够写一篇排查记录。这篇就把整条链路拆开讲清楚:SSE 流式传输怎么把数据一段段推给前端,Markdown 组件怎么在流式过程中安全渲染,Nginx 反向代理为什么会让流式"粘连",以及断线重连、超时这些边界情况怎么处理。
适合谁看?如果你正在做 AI 对话、实时日志、流式报表这类需要"边生成边展示"的功能,或者你已经做出来了但线上表现和本地不一样,这篇应该能帮你少走弯路。我会尽量把每一步的"为什么"讲透,而不是只丢一段配置让你抄。
先说结论性的判断:打字机效果的本质不是动画,而是数据分片到达 + 前端增量渲染。动画只是表象,真正决定体验的是数据怎么切、怎么传、怎么拼、怎么渲染。这四件事任何一环出问题,用户看到的要么是卡顿,要么是乱码,要么是"转圈半天突然全出来"。
2. SSE 流式传输:为什么它是 AI 对话的首选通道
2.1 SSE 和 WebSocket 的选择逻辑
很多人第一反应是用 WebSocket,觉得"全双工"听起来更高级。但在 AI 对话这个场景里,WebSocket 其实是过度设计。原因很简单:AI 对话的数据流向是单向的——服务端持续推、客户端只管收,用户的下一条消息是另起一个请求。这种"服务端单向推送"的模型,SSE(Server-Sent Events)天生就是为它设计的。
SSE 基于普通 HTTP 长连接,协议格式极简,服务端只要按data: xxx\n\n的格式往响应体里写数据,浏览器端的EventSource就能自动解析。对比一下:
| 维度 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 服务端单向推送 | 全双工 |
| 协议基础 | 纯 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 传参既丑陋又有长度限制。解决办法是用fetch+ReadableStream手动解析 SSE,这样就能用 POST 带 body:
const 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_buffering | on | 开启时缓冲响应,流式失效 |
proxy_cache | off | 开启时缓存响应,流式失效 |
proxy_buffer_size | 4k/8k | 缓冲区大小,影响首次输出延迟 |
proxy_buffers | 8 4k/8k | 缓冲区数量和大小 |
chunked_transfer_encoding | on | 分块传输,流式需要开启 |
proxy_read_timeout | 60s | 读超时,长连接需调大 |
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.1+Connection '':SSE 需要长连接,HTTP/1.0 默认短连接会断。Connection ''是清空这个头,避免 Nginx 主动关闭连接。chunked_transfer_encoding on:分块传输编码,让响应可以边生成边发送。proxy_read_timeout:默认 60 秒,AI 生成慢的时候容易超时断开,调大到 300 秒或更长。gzip off:gzip 压缩需要攒够数据才能压缩,会引入缓冲,流式场景直接关掉。
提示:如果后端已经设置了
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 客户端主动取消
用户点了"停止生成",前端要能真正取消请求,而不是只停止渲染。用AbortController:
const 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 渲染的补全逻辑,别追求完美,覆盖代码块、加粗、行内代码这几种高频情况就够了。剩下的边角情况,用户其实感知不到,过度设计反而增加维护成本。