LLM 流式输出用 SSE 时那些会乱码卡顿的字节级坑
如果你负责把 LLM 的流式响应从后端送到浏览器,最难复现的故障往往不在本地。功能在开发环境跑得好好的:模型一个字一个字往外蹦,浏览器里是标准的打字机效果。部署到测试环境后,前端同事报了个诡异现象——请求发出去之后界面卡住不动,大约二三十秒后,整段回答"啪"地一次性糊在屏幕上。更晚一点,又有人反馈中文偶尔会冒出一个黑色问号菱形。这两个问题一个来自代理层的缓冲,一个来自字节流的切割,根子都在流式输出这条链路上,而它们恰恰是本地开发几乎碰不到的坑。
现象:三类反复出现的故障
把线上流式输出的报障归一下类,基本逃不出三种。
第一种是"伪流式":后端明明在逐 token 往下写,前端却收不到中间态,要么等全部生成完一次性到达,要么每隔几秒才成批刷一下。打字机效果消失,首字延迟(用户看到第一个字的时间)从几百毫秒退化成十几秒,体验上跟没做流式没区别,甚至更差——因为连接一直挂着,超时风险还更高。
第二种是乱码:大部分是 ASCII,一切正常,但中文、emoji 或其它非 ASCII 字符会零星出现�(U+FFFD 替换字符)。规律是它只在流式模式下出现,同一个 prompt 用非流式接口拿到的整段响应完全正常。
第三种是断连与"续传幻觉":网络抖动或代理超时导致连接中断,前端要么静默停在半句话,要么触发自动重连、结果模型从头又生成了一遍,用户看到内容重复。
原理:SSE 是纯文本帧协议,而 token 是字节流
要讲清这几个现象,得先回到 SSE(Server-Sent Events)本身的定义。它不是什么二进制协议,而是一段带Content-Type: text/event-stream的长连接文本响应,靠约定的换行来分帧。一个最小事件长这样:
data: 你好 data: 世界规则很简单但很致命:字段以字段名:开头,单个\n分隔字段行,而两个连续换行\n\n才表示一个事件结束。OpenAI 兼容接口在此之上再加一层约定:每个data:后面跟一段 JSON,流末尾发一个data: [DONE]作为终止哨兵。
理解了分帧规则,三个坑的成因就清楚了。
乱码来自 UTF-8 的多字节切割。一个汉字在 UTF-8 里占 3 个字节,emoji 常占 4 个字节。而 HTTP 的 chunked 传输、以及底层 TCP,都是按字节切块的,块边界完全不保证落在字符边界上。当一个汉字的 3 个字节被切成"前 2 字节在这一块、第 3 字节在下一块",如果你的解码逻辑对每一块单独调用一次"字节转字符串",那半个字符就会被解码成�。这不是模型的问题,是解码器在字节没收齐时就急着解释造成的。
伪流式则来自链路上任意一层的缓冲。反向代理(Nginx 最典型)默认会把上游响应先攒进缓冲区,攒够一批或攒完整个响应再转发给客户端——这对普通网页是优化,对 SSE 是灾难,因为它把"逐个到达"重新变回了"一次到达"。据 Nginx 文档,proxy_buffering默认是开启的,这也是线上伪流式最高频的单一原因。
而断连续传的幻觉,源于对 SSE 重连语义的误解:浏览器原生EventSource断线后会自动重连并带上Last-Event-ID头,但服务端要真正做到"接着上次那个字往下发",必须自己维护生成状态。LLM 的一次生成通常是不可从中间点续的,所以简单重连的结果就是重新生成、内容重复。
在动手改之前,建议把流式这条链路当成一个独立的上线项来对待,像走一份覆盖代理、编码、超时与重连的上线前检查清单那样逐项过一遍,而不是等用户报障了再一层层扒——因为这类问题在本地和单元测试里几乎复现不出来,它们只在真实的代理和网络条件下暴露。
落地:三处必须改对的地方
其一,服务端写 data 帧时必须转义换行。这是一个容易被忽略、后果却很严重的坑。模型输出本身包含换行符,如果你直接把 token 拼进data:后面,一旦 token 里带\n,就会被 SSE 解析器当成字段分隔甚至事件结束——轻则分帧错乱,重则形成事件注入(h3 框架曾就"未转义换行导致 SSE 注入"发过安全通告)。正确做法是把内容 JSON 编码后再放进单个data:字段:
importjsonfromfastapiimportRequestfromfastapi.responsesimportStreamingResponseasyncdefsse_stream(request:Request,token_source):asyncdefgen():asyncfortokenintoken_source:# token_source: 上游模型的异步生成器ifawaitrequest.is_disconnected():# 客户端断开就停止,别再空转烧算力breakpayload=json.dumps({"delta":token},ensure_ascii=False)yieldf"data:{payload}\n\n"# JSON 编码天然把 \n 转义成 \\nyield"data: [DONE]\n\n"returnStreamingResponse(gen(),media_type="text/event-stream",headers={"Cache-Control":"no-cache, no-transform","Connection":"keep-alive","X-Accel-Buffering":"no",# 关键:显式告诉 Nginx 别缓冲这条响应},)X-Accel-Buffering: no这个响应头是 Nginx 识别的信号,比起改全局配置,它随响应下发、作用域精确,是优先选择。
其二,前端解码要用带状态的流式解码器。浏览器原生EventSource只能发 GET、不能带请求体和自定义头,而 LLM 调用几乎都要 POST 一段 JSON 和鉴权头,所以实践中通常改用fetch读ReadableStream。这里的核心是用TextDecoder的stream: true模式,它会在内部把没凑齐的多字节序列暂存,等下一块字节到了再拼,从根上消除半个汉字变�的问题:
constresp=awaitfetch("/api/chat",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({prompt}),});constreader=resp.body.getReader();constdecoder=newTextDecoder("utf-8");letbuffer="";while(true){const{value,done}=awaitreader.read();if(done)break;buffer+=decoder.decode(value,{stream:true});// stream:true 保住被切断的字节constevents=buffer.split("\n\n");// 按事件边界切buffer=events.pop();// 最后一段可能不完整,留到下一轮for(constevtofevents){constline=evt.replace(/^data:/,"");if(line==="[DONE]")return;render(JSON.parse(line).delta);}}注意buffer.split("\n\n")后把最后一段留回缓冲区,这一步和TextDecoder的stream是一对孪生逻辑:前者处理事件被 chunk 切断,后者处理字符被 chunk 切断,两个层级的边界问题都要各自兜住。
其三,代理层配置。应用已经下发X-Accel-Buffering: no时,先确认 Nginx 没有用proxy_ignore_headers把这个响应头忽略掉;也可以在 SSE 专用的 location 里明确关掉响应缓冲:
location /api/chat { proxy_http_version 1.1; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; proxy_pass http://app_backend; }proxy_read_timeout不是越大越好,应高于业务可接受的最长无数据间隔,并配合心跳帧与客户端取消。不要为了 SSE 统一关闭 HTTP chunked 传输;真正需要验证的是应用是否及时 flush、每一跳是否继续缓冲。CDN、API 网关、Service Mesh sidecar 都可能有自己的缓冲与空闲超时,排查伪流式要沿链路逐跳确认。
边界与取舍
SSE 不是唯一选择,但对 LLM 单向下推 token 这个场景是合适的:它跑在普通 HTTP 上,天然穿透多数代理,重连语义内建,比 WebSocket 轻。代价是它是单向的——如果你需要生成过程中双向交互(中途打断、边生成边追加上下文),SSE 就不够,得上 WebSocket。
还有两个容易忽视的约束。一是 HTTP/1.1 下浏览器对同一域名的并发连接数有限(常见是 6 个),原生EventSource每条占一个连接,多开几个标签页就可能把连接池占满、后续请求被阻塞;切到 HTTP/2 多路复用可以缓解。二是保活:长时间没有 token 产出(比如模型在思考或调工具)时,中间设备可能因空闲把连接判死,需要服务端定期发注释行(以:开头的心跳帧)维持。
关于续传,务实的结论是:大多数场景不要试图"从断点续生成"。LLM 单次生成的中间状态难以精确恢复,与其做复杂且不可靠的续传,不如把已生成部分落库,断连后让用户显式选择"重新生成"或"基于已有内容继续",把不确定性交还给用户判断。
技术结论
流式输出的绝大多数线上故障,不在模型、也不在业务代码,而在"字节流 → 文本帧 → 字符"这三次转换的边界上,以及链路每一跳的缓冲开关上。落到可执行的动作:服务端对 token 做 JSON 编码以吞掉换行、显式下发X-Accel-Buffering: no;前端用TextDecoder({stream:true})加事件缓冲双层兜住切割;代理层沿链路关掉 buffering 并放宽超时。这三处对齐了,打字机效果、非 ASCII 字符完整性和连接稳定性基本就都稳了。这类问题的共性是本地测不出、真机才现形,所以把它当成一个需要独立验证的上线项,比事后扒日志划算得多。