LLM 流式输出用 SSE 时那些会乱码卡顿的字节级坑

LLM 流式输出用 SSE 时那些会乱码卡顿的字节级坑
如果你负责把 LLM 的流式响应从后端送到浏览器,最难复现的故障往往不在本地。功能在开发环境跑得好好的:模型一个字一个字往外蹦,浏览器里是标准的打字机效果。部署到测试环境后,前端同事报了个诡异现象——请求发出去之后界面卡住不动,大约二三十秒后,整段回答啪地一次性糊在屏幕上。更晚一点,又有人反馈中文偶尔会冒出一个黑色问号菱形。这两个问题一个来自代理层的缓冲,一个来自字节流的切割,根子都在流式输出这条链路上,而它们恰恰是本地开发几乎碰不到的坑。现象:三类反复出现的故障把线上流式输出的报障归一下类,基本逃不出三种。第一种是伪流式:后端明明在逐 token 往下写,前端却收不到中间态,要么等全部生成完一次性到达,要么每隔几秒才成批刷一下。打字机效果消失,首字延迟(用户看到第一个字的时间)从几百毫秒退化成十几秒,体验上跟没做流式没区别,甚至更差——因为连接一直挂着,超时风险还更高。第二种是乱码:大部分是 ASCII,一切正常,但中文、emoji 或其它非 ASCII 字符会零星出现(UFFFD 替换字符)。规律是它只在流式模式下出现,同一个 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():# 客户端断开就停止,别再空转烧算力breakpayloadjson.dumps({delta:token},ensure_asciiFalse)yieldfdata:{payload}\n\n# JSON 编码天然把 \n 转义成 \\nyielddata: [DONE]\n\nreturnStreamingResponse(gen(),media_typetext/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模式,它会在内部把没凑齐的多字节序列暂存,等下一块字节到了再拼,从根上消除半个汉字变的问题:constrespawaitfetch(/api/chat,{method:POST,headers:{Content-Type:application/json},body:JSON.stringify({prompt}),});constreaderresp.body.getReader();constdecodernewTextDecoder(utf-8);letbuffer;while(true){const{value,done}awaitreader.read();if(done)break;bufferdecoder.decode(value,{stream:true});// stream:true 保住被切断的字节consteventsbuffer.split(\n\n);// 按事件边界切bufferevents.pop();// 最后一段可能不完整,留到下一轮for(constevtofevents){constlineevt.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 字符完整性和连接稳定性基本就都稳了。这类问题的共性是本地测不出、真机才现形,所以把它当成一个需要独立验证的上线项,比事后扒日志划算得多。