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

相关新闻

【2027最新】基于SpringBoot+Vue的校园周边美食探索及分享平台管理系统源码+MyBatis+MySQL

【2027最新】基于SpringBoot+Vue的校园周边美食探索及分享平台管理系统源码+MyBatis+MySQL

💡实话实说:C有自己的项目库存,不需要找别人拿货再加价。博主介绍:🎓 江南大学计算机科学与技术专业在读研究生 | CSDN博客专家 | Java技术爱好者 在校期间积极参与实验室项目研发,现为CSDN特邀作者、掘金优…

2026/10/17 0:30:59 阅读更多 →
Python深度学习实战:从神经网络基础到CNN与LSTM应用

Python深度学习实战:从神经网络基础到CNN与LSTM应用

1. 神经网络与机器学习基础概述 第一次接触神经网络时,我被它的生物类比深深吸引——就像人脑神经元之间的连接。在Python中实现第一个感知器模型后,这种兴奋感更加强烈。现代深度学习框架让神经网络的实现变得异常简单,但真正理解其数学本质…

2026/10/17 0:31:04 阅读更多 →
3大技术突破:国产AI硬件生态下的实时语音合成架构演进

3大技术突破:国产AI硬件生态下的实时语音合成架构演进

3大技术突破:国产AI硬件生态下的实时语音合成架构演进 【免费下载链接】Fun-CosyVoice3-0.5B-2512 提供在昇腾平台上使用vllm进行语音模型推理的完整流程,包含镜像加载、容器启动、代码部署及权重下载,测试RTF≈0.27,便于快速体验…

2026/10/16 16:44:51 阅读更多 →

最新新闻

Java后端网络面试题:从TCP握手到云原生调试的全链路解析

Java后端网络面试题:从TCP握手到云原生调试的全链路解析

1. 这不是一份“背了就能过”的题库,而是一张网络协议世界的导航图你手头这份标着“2021Java后端面试题整理”的文档,标题里那个“计算机网络相关面试题解”,表面看是求职路上的应试材料,但实际它是一把钥匙——一把打开后端系统底…

2026/10/18 3:00:06 阅读更多 →
10天三千万的AI编程神话拆解:工具链、开源运营与商业化

10天三千万的AI编程神话拆解:工具链、开源运营与商业化

拿到这个标题的时候,我第一反应是朋友圈最近疯转的那条“AI代写代码焦虑帖”。大学生、GitHub全球第一、AI辅助编程、10天、三千万,这几个词组合在一起,怎么看都像一条博眼球的营销稿。但把公开能看到的材料认真捋过一遍之后,我改…

2026/10/18 3:00:06 阅读更多 →
没有感情的人是不可能成为神的

没有感情的人是不可能成为神的

很多地方都说这样一个错误认识:神仙是不能有感情的。其实我认为这是完全错误的:作者本来是想要说:我这么优秀的人,为了事业不能有感情。但是其实他错了:一个没有感情的人就会:视人命如草芥,杀人…

2026/10/18 3:00:06 阅读更多 →
SpringBoot+Thymeleaf后台管理CRUD实战:从数据流转到表单校验

SpringBoot+Thymeleaf后台管理CRUD实战:从数据流转到表单校验

1. 动手前先想清楚的事:这套方案到底解决什么问题“增删改查”这四个字,在简历上出现频率很高,但真正能用SpringBootThymeleaf把它做成一个可交付、可扩展、页面好看的后台管理模块,很多人其实是卡住的。问题往往不在“会不会写代…

2026/10/18 3:00:06 阅读更多 →
浏览器输入URL后发生了什么?全链路技术拆解与面试指南

浏览器输入URL后发生了什么?全链路技术拆解与面试指南

后端岗位的面试题里,有一道题目出现的频率高得出奇,基本可以算作“必考题”:在浏览器地址栏输入一个网址,按下回车到网页完整显示出来,中间到底发生了什么。做了十几年后端,我自己被问过,也面试…

2026/10/18 3:00:06 阅读更多 →
C#+OpenCV二维码识别:图像预处理与透视矫正实战解析

C#+OpenCV二维码识别:图像预处理与透视矫正实战解析

这个项目看起来很直接,但实际落地时坑不少。标题是“C#OpenCV识别图片二维码”,拆开看就是三件事:C#做宿主程序、OpenCV做图像处理、二维码识别作为最终目标。很多人一上来就找OpenCV的二维码识别模块,结果发现识别率惨不忍睹&…

2026/10/18 2:59:06 阅读更多 →

日新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/18 0:00:55 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/18 0:00:55 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/18 0:00:55 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/18 0:00:55 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/18 0:00:55 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/18 0:00:55 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/18 0:55:02 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/17 17:22:31 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/16 14:23:36 阅读更多 →