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

发布时间:2026/11/11 12:29:54

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 字符完整性和连接稳定性基本就都稳了。这类问题的共性是本地测不出、真机才现形,所以把它当成一个需要独立验证的上线项,比事后扒日志划算得多。
延伸阅读

更多相关文章

2026/11/7 9:17:13

三分钟搞定!免费开源中文字体霞鹜文楷终极安装使用指南

三分钟搞定!免费开源中文字体霞鹜文楷终极安装使用指南 【免费下载链接】LxgwWenKai An unprofessional open-source Chinese font derived from Fontworks Klee One. 一款非专业的开源中文字体,基于 FONTWORKS 出品字体 Klee One 衍生。 项目地址: h…

2026/11/11 12:29:20

上海低压电工证试题库怎么查?工地太忙根本没时间复习考试

上海低压电工证试题库怎么查?工地太忙根本没时间复习考试 工地太忙根本没时间复习考试,你是不是也经常这样想?特别是在赶工期、抢进度的时候,谁还有精力去翻那些厚厚的教材?别急,我来帮你搞定 上海低压电工证试题库 ,顺便教你怎么 网上查询…

2026/11/11 12:29:20

珠海电工证有哪些种类保姆级教程,学历不高也能考

珠海电工证有哪些种类保姆级教程,学历不高也能考 你是不是也担心学历不高,考不了电工证?别急,今天我就给你讲清楚珠海电工证有哪些种类,报名条件、考试流程、备考技巧全都有,照着做, 学历不高也能考 ,而且还能 轻松通过…

2026/11/11 12:29:20

之前考的证在外省能不能转过来?北京通州考电工证网上查询全攻略

之前考的证在外省能不能转过来?北京通州考电工证网上查询全攻略 之前考的证在外省能不能转过来?这是很多在外地工作多年的电工同行最关心的问题。如果你正在北京通州找工作,或者想在本地从事电工相关工作,却发现手上那张外省的电工证不能直接用,那你一定…

2026/11/10 8:12:38

电工证丢失还能补吗?千万别踩坑,丢了证等于失业

电工证丢失还能补吗?千万别踩坑,丢了证等于失业 你正在找工作,但手里的电工证突然丢了,这下怎么办?别急,先别慌, 电工证丢失还能补吗 ?答案是肯定的,但 千万别踩坑 ,补办流程如果不了解,可能会耽误你上岗、甚至影响到你的收入。…

2026/11/11 0:02:26

考低压电工证通过率考试真题与备考技巧

低压电工证通过率高不高?学历不高也能考,值不值得考? 你是不是总听说电工证能帮你找高薪工作,但又担心自己学历不够,考不上?其实,低压电工证考试门槛并没有你想象中那么高,关键是方法对不对。这篇文章就告诉你, 考低压电工证通过率到底有多高 ,…

2026/11/11 0:02:26

低压电工证学时千万别踩坑,濮阳毕业生怎么避坑报名

低压电工证学时千万别踩坑,濮阳毕业生怎么避坑报名 你是不是也和我一样,刚毕业就听说低压电工证学时必须达标才能上岗?可一打听,发现报名机构五花八门,价格参差不齐, 不知道去哪报名怕被中介坑…

2026/11/10 20:08:15

面向复杂光照与角度的宠物面部识别鲁棒性增强策略

在智慧养宠时代,基于视觉的宠物身份识别技术正逐步从实验室走向家庭应用。然而,与配合度高的人类不同,宠物是典型的“非合作生物识别对象”——它们不会主动配合摄像头调整姿态,更无法控制光照环境。实际应用中,我们常…

2026/11/10 10:29:37

SSM电子书店源码全解析:结构、原理、部署与排错实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑