ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

AI对话流式输出实战:SSE传输、Markdown增量渲染与Nginx配置

AI对话流式输出实战:SSE传输、Markdown增量渲染与Nginx配置 1. 从逐字蹦出的观感说起打字机效果到底难在哪第一次做 AI 对话界面的人几乎都会卡在同一个地方后端明明已经把整段回答生成好了前端却只能等全部内容返回后一次性渲染出来用户盯着空白屏幕转圈十几秒体验非常糟糕。而市面上那些成熟的对话产品回答是一个字一个字往外蹦的像老式打字机一样这种观感背后其实是一整套从传输协议到渲染层的配合。我最早做这块的时候也走过弯路一开始想的是前端定时器模拟把整段文本切片后按固定间隔塞进 DOM。这个方案在演示阶段看着还行真上线就露馅了模型生成速度是不均匀的遇到长回答时前端演的速度和真实生成速度对不上用户刷新页面就全丢了而且首字延迟一点没改善。后来才转向真正的流式方案也就是让数据在生成过程中就持续推给前端。这套东西拆开来看是三层传输层负责把服务端生成的内容实时送到浏览器主流做法是 SSE渲染层负责把不断追加的 Markdown 文本正确显示出来涉及增量解析和未闭合标签处理网关层负责让流式数据穿过 Nginx 这类反向代理时不被缓冲、不被超时切断。三层里任何一层出问题用户看到的就是卡住不动或者内容粘连成一坨。这篇文章面向的是正在做或准备做 AI 对话界面的开发者不管你用的是 Vue、React 还是原生 JS不管你后端是 Java、Python 还是 Node这套思路都是通用的。我会把 SSE 的协议细节、Markdown 增量渲染的坑、Nginx 的关键配置逐层拆开讲并且给出可以直接抄的配置和代码。读完你应该能独立搭出一套稳定的流式对话链路并且知道每个参数为什么这么设。2. SSE 流式传输为什么是它而不是 WebSocket 或轮询2.1 三种方案的取舍逻辑做流式推送摆在面前的选择其实就三个短轮询、WebSocket、SSE。短轮询是最笨但最省事的前端每隔一秒发一次请求问生成完了吗服务端返回当前进度。它的致命问题是延迟和资源浪费一秒的间隔意味着首字延迟至少一秒而且每次请求都要重新建立连接、携带完整上下文服务端压力随用户数线性上涨。我实测过一个两百人同时在线的场景轮询方案下 QPS 直接飙到四百多纯属浪费。WebSocket 是双向全双工能力最强但它对 AI 对话来说属于杀鸡用牛刀。对话场景的数据流向是单向的服务端推、客户端收。用 WebSocket 你得自己定义消息格式、自己做心跳保活、自己处理重连而且很多网关和负载均衡对 WebSocket 的支持需要额外配置。更麻烦的是WebSocket 连接是有状态的水平扩展时需要考虑会话粘滞运维复杂度上一个台阶。SSE 恰好卡在中间它基于普通 HTTP单向服务端推送浏览器原生支持EventSource断线自动重连穿过 Nginx 只需要改几个配置。对于服务端持续推文本给前端这个需求它是成本最低、最贴合的选择。这也是为什么主流大模型 API 的流式接口几乎清一色用 SSE。方案首字延迟服务端压力双向能力网关友好度适用场景短轮询高等于轮询间隔高无极好兼容性兜底WebSocket低低强一般双向实时交互SSE低低单向好服务端单向推送2.2 SSE 协议格式那些必须记死的细节SSE 的报文格式看着简单但有几个细节不搞清楚就会踩坑。它的响应头必须是Content-Type: text/event-stream并且要带上Cache-Control: no-cache和Connection: keep-alive。数据体的格式是纯文本每条消息由若干字段行组成字段和值之间用冒号加空格分隔消息之间用空行分隔。data: 你好 data: 世界 data: 第二段消息上面这段里第一个消息块的两行data会被拼接成你好\n世界然后遇到空行触发一次message事件。注意data:后面如果只有一个空格那个空格是分隔符不算内容如果内容本身以空格开头需要写两个空格。这个细节很多人第一次写会搞错导致输出莫名其妙多一个空格。除了data还有几个字段值得知道event用来指定事件类型默认是messageid用来标记消息序号断线重连时浏览器会通过Last-Event-ID请求头带上最后收到的 id服务端可以据此续传retry用来指定重连间隔毫秒数。对于 AI 对话id字段其实很有用配合服务端的会话状态可以实现断点续传不过大多数场景下前端直接重新发起一次请求更简单。2.3 为什么原生 EventSource 常常不够用浏览器原生的EventSource用起来很省心但它有两个硬伤。第一它只支持 GET 请求没法带请求体。AI 对话的 prompt 往往很长塞进 URL 查询参数既不优雅也可能超长。第二它不能自定义请求头没法带认证 token只能靠 Cookie 或者把 token 塞 URL 里安全性差。所以实际项目里更常见的是用fetch配合ReadableStream手动解析 SSE或者用microsoft/fetch-event-source这个库。后者封装了重连、事件解析、AbortController 取消等逻辑用起来比手写省心。手写的话核心就是拿到response.body.getReader()然后循环read()把拿到的Uint8Array用TextDecoder解码再按\n\n切分消息块。async function streamChat(prompt, onChunk, signal) { const resp await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ prompt, stream: true }), signal }); const reader resp.body.getReader(); const decoder new TextDecoder(utf-8); 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.trim(); if (!line.startsWith(data:)) continue; const payload line.slice(5).trim(); if (payload [DONE]) return; onChunk(payload); } } }这里有个关键点decoder.decode(value, { stream: true })的stream: true参数不能省。因为一个 UTF-8 中文字符占三个字节网络分片很可能把一个字符切成两半不加这个参数就会解码出乱码。同理buffer的保留逻辑也不能省因为一个 SSE 消息块可能被网络分片切断必须等下一片到了再拼起来解析。提示判断流结束不要只看done很多服务端会在最后发一条data: [DONE]作为结束标记前端收到后应主动关闭连接避免连接悬挂。3. Markdown 增量渲染未闭合标签才是真正的坑3.1 为什么不能每来一段就整体重渲染最朴素的做法是每收到一个 chunk就把累积的全文丢给 Markdown 解析器重新渲染一遍。这个方案在短回答下没问题但回答一长就崩了。假设回答最终有 3000 字分 300 个 chunk 到达那么总解析量是 12...300 个 chunk 的文本量接近 45000 个 chunk 的解析开销而且每次都要重建整个 DOM浏览器主线程会被打满滚动会卡顿输入框会掉帧。正确的做法是增量渲染已经渲染完成的部分不再动只处理新增的内容。但这里有个矛盾Markdown 是上下文相关的语法一个**加粗标记可能跨 chunk一个代码块可能刚开始还没闭合你不能简单地把新 chunk 直接 append 到 DOM 里。我的处理策略是分段提交维护一个未提交缓冲区每次新内容进来后追加到缓冲区然后尝试解析。解析时只把确定完整的部分提交到已渲染区剩下的留在缓冲区等下次。判断确定完整的规则是以空行段落边界为切分点最后一个空行之前的内容视为完整段落可以提交最后一个空行之后的内容留在缓冲区。3.2 未闭合语法的处理清单即便按段落切分仍然会遇到各种未闭合的情况。下面这张表是我在实际项目里总结的常见场景和处理方式。未闭合场景表现处理方式加粗**只出现一个后续文字全变粗缓冲区检测到奇数个**则不提交该段行内代码未闭合后续文字变代码样式同上检测反引号配对代码块 未闭合后续全部进代码块检测 出现次数奇数则整段挂起链接[text](未闭合显示原始文本检测括号配对未闭合则挂起表格分隔行未到表格渲染成普通文本检测到 列表项跨 chunk列表断裂以空行为界列表项通常在同一段内代码块是最麻烦的因为它的内容可能很长如果一直等闭合用户会看到代码区迟迟不出现。我的做法是检测到 开始后即使未闭合也先渲染成一个代码块容器只是标记为进行中等闭合后再补上语言高亮。这样用户至少能看到代码在逐行出现而不是一片空白。3.3 一个可用的增量渲染实现下面这段逻辑是我在 Vue 项目里用的简化版核心思路是缓冲区 完整性检测 分段提交。class MarkdownStreamRenderer { constructor(container) { this.container container; this.committed ; // 已提交渲染的文本 this.buffer ; // 待提交缓冲区 } push(chunk) { this.buffer chunk; const { safe, rest } this.splitSafe(this.buffer); if (safe) { this.committed safe; this.buffer rest; this.render(); } } // 找到最后一个可安全提交的边界 splitSafe(text) { // 优先以空行切分 const idx text.lastIndexOf(\n\n); if (idx -1) return { safe: , rest: text }; const candidate text.slice(0, idx 2); const rest text.slice(idx 2); // 检查候选区是否有未闭合语法 if (this.hasUnclosed(candidate)) { return { safe: , rest: text }; } return { safe: candidate, rest }; } hasUnclosed(text) { const fences (text.match(//g) || []).length; if (fences % 2 ! 0) return true; const bolds (text.match(/\*\*/g) || []).length; if (bolds % 2 ! 0) return true; return false; } render() { // 已提交部分整体渲染缓冲区部分作为预览追加 this.container.innerHTML marked.parse(this.committed) (this.buffer ? div classstreaming${escapeHtml(this.buffer)}/div : ); } }这个实现有个取舍已提交部分每次都是整体重渲染。如果回答特别长比如上万字整体重渲染仍然有开销。进一步优化可以只渲染新增的 safe 部分并 append 到容器但那样需要处理跨段落的列表、引用等结构复杂度会上升。对于绝大多数对话场景回答长度在几千字以内整体重渲染完全够用实测下来很稳。注意缓冲区里的内容用escapeHtml转义后显示不要直接丢给 Markdown 解析器否则未闭合的语法会污染整个渲染结果。3.4 换行和空格的显示问题Markdown 有个反直觉的规则单个换行符在渲染时会被当成空格只有空行才产生新段落。但 AI 生成的文本经常用单换行来分行比如列举要点时。如果严格按标准 Markdown 渲染用户看到的会是一大坨挤在一起的文字。解决办法是开启软换行转硬换行也就是把单个\n渲染成br。marked有breaks: true选项markdown-it有breaks: true开启后单换行就会断行。这个选项对 AI 对话场景几乎是必开的否则排版会很难看。但要注意开了之后代码块内的换行不受影响因为代码块是独立解析的。另一个常见问题是连续空格被折叠。HTML 默认会把多个空格合并成一个如果 AI 输出里有对齐用的空格比如 ASCII 图就会错位。这种情况需要用white-space: pre-wrap或者把内容包在代码块里。我的经验是凡是涉及对齐的内容都建议 AI 用代码块输出前端就不用操心了。4. Nginx 反向代理流式数据为什么会被粘住4.1 缓冲才是流式最大的敌人本地开发时流式效果好好的一部署到线上就变成等半天然后一次性全出来十有八九是 Nginx 的缓冲在作怪。Nginx 作为反向代理默认会把上游响应先缓冲到自己的缓冲区攒够一定大小或者上游关闭连接后才转发给客户端。这个设计对普通请求是优化对 SSE 就是灾难因为 SSE 的特点就是小包、持续、长时间不关闭正好触发缓冲。要关掉缓冲核心是proxy_buffering off。但光这一条还不够还有几个相关配置需要一起改。下面是我在生产环境用的配置片段。location /api/chat { 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_set_header X-Accel-Buffering no; # 超时设置SSE 连接可能持续很久 proxy_read_timeout 300s; proxy_send_timeout 300s; proxy_connect_timeout 60s; }逐条解释一下为什么这么设。proxy_http_version 1.1是必须的因为 HTTP/1.0 不支持分块传输SSE 就没法流式。proxy_set_header Connection 是为了清掉默认的Connection: close保持长连接。proxy_buffering off是核心关掉响应缓冲。X-Accel-Buffering no这个响应头是给 Nginx 看的即使全局开了缓冲这个头也能让 Nginx 对当前响应禁用缓冲双保险。4.2 超时参数那个让人抓狂的 idle timeout热词里有个报错很典型stream disconnected before completion: idle timeout waiting for sse。这个错误的根源就是超时。SSE 连接建立后如果模型思考时间较长比如推理模型要思考十几秒这段时间内没有数据推送Nginx 的proxy_read_timeout一到就会切断连接前端就报这个错。proxy_read_timeout默认是 60 秒对于普通请求够用对于 SSE 太短。我一般设成 300 秒甚至更长具体看业务场景。但要注意这个值不能无限大因为如果客户端异常断开而服务端没感知连接会一直挂着占资源。合理的做法是配合心跳服务端每隔 15 到 30 秒发一个注释行以冒号开头的行如: keepalive既能让 Nginx 知道连接活跃也能让前端知道连接没断。# 全局或 server 块中 proxy_read_timeout 300s; proxy_send_timeout 300s; # 如果用了 upstream还要注意 keepalive upstream backend { server 127.0.0.1:8080; keepalive 32; }心跳这个机制值得单独说。SSE 协议里以冒号开头的行是注释会被浏览器忽略但会重置超时计时器。所以服务端在等待模型输出的间隙定时发一个:\n\n就能有效防止 idle timeout。这个技巧在推理模型场景下几乎是必备的因为推理模型的思考阶段可能长达几十秒没有任何输出。4.3 负载均衡和压缩的坑如果后端是多实例部署前面挂了负载均衡SSE 会遇到会话粘滞问题。因为一次对话的流式响应必须由同一个后端实例处理如果请求被轮询到不同实例上下文就对不上了。解决办法是在负载均衡层配置基于 Cookie 或者基于客户端 IP 的粘滞会话。Nginx 的ip_hash是最简单的方案但在 NAT 环境下会导致负载不均更好的做法是用sticky模块或者在上层用一致性哈希。另一个坑是 gzip 压缩。Nginx 默认可能对text/event-stream开启 gzip压缩会引入缓冲破坏流式效果。虽然text/event-stream通常不在默认的 gzip 类型列表里但如果你手动配了gzip_types包含它就要小心。我的建议是明确排除或者在 SSE 的 location 里直接gzip off。location /api/chat { gzip off; # ... 其他配置 }还有proxy_request_buffering这个是缓冲请求体的对 SSE 响应没影响但如果你的 prompt 很大关掉它可以减少首字延迟。不过关掉后请求体会直接透传给后端后端要能处理流式请求体一般场景下保持默认即可。5. 前后端配合的完整链路与实测经验5.1 服务端怎么把内容推出来后端这块不同语言的写法差异很大但核心都是拿到模型返回的流逐块转发给客户端。以 Java 为例如果用 Spring 的SseEmitter要注意它默认会在一个线程里同步发送如果模型返回慢会阻塞。更好的做法是用WebFlux的FluxServerSentEvent天然支持背压和异步。Python 的话FastAPI 的StreamingResponse配合生成器函数是最顺手的。关键点是生成器里要yield出符合 SSE 格式的字符串并且设置正确的响应头。from fastapi import FastAPI from fastapi.responses import StreamingResponse app FastAPI() async def event_generator(prompt: str): async for chunk in call_llm_stream(prompt): # 注意chunk 里如果有换行要拆成多个 data 行 for line in chunk.split(\n): yield fdata: {line}\n yield \n yield data: [DONE]\n\n app.post(/api/chat) async def chat(prompt: str): return StreamingResponse( event_generator(prompt), media_typetext/event-stream, headers{ Cache-Control: no-cache, X-Accel-Buffering: no, } )这里有个细节如果 chunk 内容里包含换行符直接yield fdata: {chunk}\n\n会导致 SSE 解析错乱因为换行会被当成消息分隔。正确做法是把内容里的换行拆开每行单独加data:前缀。上面代码里的for line in chunk.split(\n)就是干这个的。5.2 前端渲染的性能优化前端这块除了前面说的增量渲染还有几个性能点值得注意。第一是滚动跟随新内容进来时如果用户没有手动上滑应该自动滚到底部如果用户上滑了就不要强制拉回否则用户没法回看。判断逻辑是检测滚动条是否在底部附近比如距底部小于 50px。第二是节流渲染如果 chunk 到达频率很高比如每秒几十个每个 chunk 都触发一次渲染会导致频繁重排。可以用requestAnimationFrame做节流把一帧内的多个 chunk 合并成一次渲染。let pending false; function scheduleRender() { if (pending) return; pending true; requestAnimationFrame(() { renderer.render(); pending false; }); }第三是代码高亮的延迟代码块在流式过程中不断变化如果每次都重新高亮开销很大。我的做法是流式过程中代码块用纯文本显示等整个回答结束后再统一做一次高亮。这样用户看到的是代码在逐行出现结束后瞬间变成彩色体验上反而更自然。5.3 那些只有踩过才知道的坑说几个我在实际项目里踩过的坑都是文档里不会写的。第一个是中文乱码。前面提过TextDecoder的stream: true但还有一个隐蔽的点如果服务端在yield时把中文字符按字节切开了前端解码就会出问题。Java 的OutputStream写入时如果没指定 UTF-8 编码默认用平台编码在 Windows 上就是 GBK前端按 UTF-8 解码必然乱码。所以服务端一定要显式指定response.setCharacterEncoding(UTF-8)。第二个是代理层的自动重试。有些云厂商的负载均衡或者 API 网关会对失败的请求自动重试但 SSE 是长连接重试会导致重复请求用户看到回答重复。解决办法是在请求头里加一个唯一标识服务端做幂等处理或者在网关层关掉自动重试。第三个是浏览器连接数限制。HTTP/1.1 下同一域名最多 6 个并发连接。如果用户开了多个标签页都在对话加上页面其他请求很容易把连接数占满导致新请求排队。解决办法是升级到 HTTP/2多路复用不受这个限制。Nginx 配 HTTP/2 需要 HTTPS本地开发可以用自签名证书。第四个是移动端后台断连。手机浏览器切到后台后SSE 连接可能被系统挂起或断开。前端要监听visibilitychange事件切回前台时检查连接状态断了就重新发起。但重新发起意味着要重新生成所以更好的做法是服务端支持基于Last-Event-ID的续传把已生成的部分缓存起来。5.4 一套可复用的检查清单最后给一份排查清单遇到流式不工作时按顺序查。检查项预期值常见错误响应 Content-Typetext/event-stream写成 application/jsonNginx proxy_bufferingoff默认 on 导致攒包Nginx proxy_read_timeout大于最长思考时间默认 60s 导致 idle timeout服务端编码UTF-8平台默认编码导致乱码前端解码TextDecoder stream:true漏参数导致中文乱码消息分隔空行 \n\n用单换行导致消息粘连结束标记data: [DONE]不发送导致连接悬挂心跳15-30s 一次注释行不发导致代理超时断连这套链路我从最早的轮询方案一路迭代过来中间踩的坑基本都在这了。SSE 本身不复杂复杂的是它要穿过浏览器、网关、负载均衡、后端框架这一整条链路每一层都有自己的默认行为而这些默认行为往往和流式的需求相冲突。理解了每一层为什么这么设计配置起来就不会是照抄而是知道改哪个参数、为什么改。代码高亮延迟处理那个技巧是我在一个长代码回答场景下被用户投诉代码一直在闪之后才加上的效果立竿见影。如果你也在做类似的东西建议一开始就把这个策略定下来省得后面返工。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表