1. 为什么流式对话是 LLM 应用的分水岭做过 LLM 应用的人都有一个共识非流式对话和流式对话完全是两个产品。前者是你问我答的批处理模式用户敲完问题、点发送、盯着转圈图标等上十几秒然后一大段文字啪地砸在屏幕上后者是边想边说的实时模式用户刚发完问题回答就一个字一个字地往外蹦像真人打字一样。这个差别看起来只是体验问题实际上决定了你的应用能不能真正落地。我见过太多团队模型接得没问题、Prompt 调得也不错但就是因为没做流式用户留存率惨不忍睹。原因很简单大模型的推理延迟天然存在一个 500 字的回答首 token 可能要等 2 到 5 秒全量生成完可能要 15 到 30 秒。让用户对着空白屏幕等 20 秒跟让他看着文字一点点长出来心理感受完全是两码事。这篇内容要聊的就是通用 LLM 流式对话的前后端完整联接方案。所谓通用是指不绑定某一家模型厂商OpenAI 兼容协议、国内主流大模型 API、本地部署的推理服务都能用同一套前后端架构接进来。所谓完整是指从后端的流式响应封装、SSE 协议处理、异常兜底到前端的流式接收、逐字渲染、中断控制、Markdown 实时解析一条链路全部打通。适合谁看如果你正在做对话机器人、AI 助手、知识库问答这类产品后端用 Java、Python、Node 都行前端用 React、Vue 都行只要涉及让大模型的回答实时显示出来这个需求这篇内容里的思路和代码都能直接抄。哪怕你是刚接触 LLM 应用开发的新手我也会把 SSE、流式协议这些基础概念用生活化的方式讲清楚保证你能看懂、能复现。先说一个我踩过的坑很多人以为流式就是前端加个EventSource就完事了结果上线后发现断流、乱码、内存泄漏、中断不生效一堆问题。流式对话真正的难点不在能流而在流得稳、流得对、流得可控。下面我按后端到前端、从原理到实操的顺序把整条链路拆开讲。2. 流式对话的整体架构与技术选型2.1 三种主流流式方案对比在动手之前得先搞清楚流式传输到底有几种做法。目前业界主流就三种SSEServer-Sent Events、WebSocket、HTTP 分块传输Chunked Transfer。很多人一上来就想用 WebSocket觉得双向通信听起来更高级其实在 LLM 对话场景里往往是过度设计。方案通信方向协议复杂度断线重连适用场景我的推荐度SSE服务端单向推送低基于 HTTP浏览器自动重连LLM 流式输出强烈推荐WebSocket全双工高需握手升级需手动实现实时协作、游戏特定场景Chunked服务端分块低无原生支持文件下载、简单流不推荐为什么 LLM 对话首选 SSE核心原因是LLM 对话本质上是一问一答的单向流。用户发一次请求服务端持续推送 token直到结束。这个过程中客户端不需要持续往服务端发消息除非做中断而中断用普通 HTTP 请求就能实现。SSE 基于标准 HTTP天然穿透大部分代理和网关浏览器原生支持自动重连实现成本最低。WebSocket 的优势在于双向实时通信但代价是要维护长连接状态、处理心跳、自己实现重连逻辑服务端还要考虑连接数上限。我实测过一个项目同样的并发量下SSE 方案的服务端内存占用只有 WebSocket 的三分之一左右。除非你要做多轮实时打断、语音对话这种强交互场景否则 SSE 是性价比最高的选择。2.2 前后端职责边界划分选好传输方案后要明确前后端各自负责什么。这块边界划不清楚后期联调会非常痛苦。我的划分原则是后端负责流得对前端负责流得稳。后端要做的事把模型返回的原始流可能是 OpenAI 的data: {...}格式也可能是其他厂商的自定义格式统一转换成标准 SSE 事件处理 token 拼接、结束标记、错误事件做好超时控制、异常捕获、连接释放关键统一不同模型厂商的流式协议差异前端要做的事建立 SSE 连接监听消息事件逐块接收内容并实时渲染处理 Markdown 增量解析这是个大坑后面细讲提供中断按钮能主动停止生成处理断流、错误、重连这个划分的核心逻辑是后端屏蔽模型差异前端屏蔽传输细节。这样换模型时前端不用动换前端框架时后端不用动两边解耦。2.3 通用性的关键协议适配层设计通用 LLM这个目标难点在于不同厂商的流式格式五花八门。OpenAI 的格式是data: {choices:[{delta:{content:你}}]}国内某厂商可能是data: {output:{text:你}}本地部署的推理服务可能又是另一种结构。如果前端直接对接这些格式换一个模型就要改一次前端这是灾难。我的做法是在后端加一个协议适配层把各家格式统一成内部标准事件。标准事件就三种event: message data: {content: 增量文本, index: 0} event: done data: {finish_reason: stop, usage: {...}} event: error data: {code: xxx, message: 错误描述}适配层针对每个厂商写一个转换器输入是厂商原始流输出是标准事件。前端只认这三种事件永远不用改。这个设计我用了两年多接过七八家模型前端代码一行没动过非常省心。3. 后端流式接口的核心实现细节3.1 SSE 响应的正确打开方式后端返回 SSE最容易出错的地方是HTTP 响应头。很多人写完发现前端收不到流或者收到一堆乱码八成是响应头没设对。正确的响应头必须包含这几项response.setContentType(text/event-stream;charsetUTF-8); response.setCharacterEncoding(UTF-8); response.setHeader(Cache-Control, no-cache, no-store, must-revalidate); response.setHeader(Connection, keep-alive); response.setHeader(X-Accel-Buffering, no);逐条解释为什么text/event-stream是 SSE 的 MIME 类型不设这个浏览器不会按事件流解析charsetUTF-8必须显式声明否则中文会乱码这个坑我踩过Cache-Control要禁止缓存否则某些代理会缓存整个响应流式就变成一次性返回了X-Accel-Buffering: no是给 Nginx 看的告诉它不要缓冲这个响应。这一条极其关键很多人在本地测试流式正常一上生产就变成等半天一次性出来就是 Nginx 默认开了缓冲注意如果你用了 Spring Boot 的SseEmitter它内部会帮你设置部分响应头但X-Accel-Buffering还是得手动加。用原生HttpServletResponse写的话所有头都要自己设。3.2 流式数据的写入与刷新设置好响应头只是第一步真正把数据推出去还要注意写入和刷新的时机。SSE 的格式规范是每条消息以data:开头以两个换行符\n\n结尾。少一个换行前端就解析不出来。PrintWriter writer response.getWriter(); // 发送一条消息 writer.write(event: message\n); writer.write(data: jsonPayload \n\n); writer.flush(); // 必须 flush否则数据留在缓冲区这里有个细节flush()的调用频率。如果每收到一个 token 就 flush 一次网络开销会比较大如果攒一批再 flush延迟又会增加。我的经验是每个 token 都 flush因为 LLM 的 token 生成速度本身就不快每秒几十个flush 的开销可以忽略但延迟的体感差异很明显。还有一个隐藏问题连接超时。SSE 是长连接如果模型生成很慢中间可能有几十秒没有数据某些网关会认为连接空闲而断开。解决办法是定期发送心跳注释// 每 15 秒发一次心跳注释行以冒号开头 writer.write(: heartbeat\n\n); writer.flush();注释行不会被前端当作消息处理但能保持连接活跃。这个技巧在跨网关部署时几乎是必备的。3.3 异常处理与资源释放流式接口的异常处理比普通接口复杂得多因为响应头一旦发出就没法再改 HTTP 状态码了。也就是说如果流已经开始推送中途模型报错你不能再返回 500只能在流里发一个 error 事件。try { // 开始流式推送 streamModelResponse(writer, request); } catch (Exception e) { // 流已开始只能通过 error 事件通知前端 writer.write(event: error\n); writer.write(data: buildErrorJson(e) \n\n); writer.flush(); } finally { // 无论如何都要关闭资源 writer.close(); }资源释放这块我见过最典型的内存泄漏是用户中途关闭页面但后端还在傻傻地生成。因为 SSE 连接断了后端writer.write()会抛异常但如果你没捕获线程就一直挂着。正确做法是监听连接断开事件或者用writer.checkError()判断连接是否还活着一旦断开立即停止模型调用释放资源。实操心得模型调用本身也要支持取消。很多 SDK 提供了cancel()或abort()方法在检测到客户端断开时调用能省下不少 token 费用。我有个项目就是没做这个用户频繁刷新页面一天多烧了几十块钱的 API 费用。4. 前端流式接收与实时渲染实战4.1 EventSource 与 fetch 流式读取的选择前端接收 SSE有两条路原生EventSource和fetch ReadableStream。很多人默认用EventSource因为它简单但它在 LLM 场景下有个致命缺陷只支持 GET 请求不能带请求体。LLM 对话的参数消息历史、模型名、温度等通常是一大坨 JSON用 GET 塞进 URL 既不优雅也有长度限制。所以我的建议是用fetchReadableStream手动解析 SSE。虽然代码多一点但灵活性和可控性完全不是一个级别。const response await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages, model: xxx }), signal: abortController.signal // 用于中断 }); const reader response.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 }); // 按 \n\n 切分事件 const events buffer.split(\n\n); buffer events.pop(); // 最后一段可能不完整留到下次 for (const event of events) { parseAndRender(event); } }这段代码里有个极其关键的细节buffer的处理。网络传输是分块的一个 SSE 事件可能被拆成两个 chunk 到达如果你直接对每个 chunk 解析就会解析失败。正确做法是维护一个缓冲区按\n\n切分最后一段不完整的留在 buffer 里等下一个 chunk。这个坑我调了大半天才找到原因症状是偶尔丢字。4.2 逐字渲染的性能优化收到增量文本后怎么渲染到界面上最朴素的做法是每收到一个字就setState一次。但这样在 React 里会触发大量重渲染回答长了之后页面会卡。我的优化方案是批量更新 节流把短时间内的多个 token 攒起来用requestAnimationFrame或定时器统一更新一次。实测下来把更新频率控制在每秒 30 到 60 次视觉上完全看不出差别但性能提升非常明显。let pendingText ; let rafId null; function appendText(text) { pendingText text; if (rafId) return; rafId requestAnimationFrame(() { setContent(prev prev pendingText); pendingText ; rafId null; }); }另一个优化点是避免整段重新渲染。如果每次更新都把整个回答重新解析 Markdown长文本下开销很大。更好的做法是只对新增部分做增量处理或者用虚拟滚动只渲染可视区域。不过对于大多数对话场景回答长度在几千字以内批量更新已经够用了。4.3 Markdown 增量解析的坑LLM 的回答通常是 Markdown 格式包含代码块、列表、表格。流式渲染 Markdown 有个天然矛盾Markdown 语法是成对的但流式到达时是不完整的。比如代码块用包裹流到一半时只有一个解析器会懵。我试过几种方案方案一每收到增量就全量重新解析。简单但长文本下性能差而且未闭合的语法会导致渲染闪烁。方案二只在流结束后解析 Markdown流式过程中显示纯文本。体验割裂用户看到的是先纯文本后突然变格式。方案三增量解析 容错处理。对未闭合的代码块、列表做特殊处理先按纯文本渲染闭合后再格式化。我最终采用的是方案三的简化版流式过程中检测到未闭合的代码块就临时补一个闭合标记再解析渲染完再把补的标记去掉。这样代码块能实时高亮视觉体验最好。列表和表格的未闭合问题相对轻微容忍度高一些。提示如果你用的是现成的 Markdown 渲染库如 marked、markdown-it记得开启breaks和gfm选项并且做好 XSS 防护。LLM 的输出不可信直接innerHTML是危险的一定要用 DOMPurify 之类的库过滤。5. 中断控制与连接管理5.1 用户主动中断的实现流式对话必须支持中断这是刚需。用户看到回答跑偏了想立刻停下来重新问如果只能干等体验极差。中断的实现依赖AbortControllerconst abortController new AbortController(); // 发起请求时传入 signal fetch(url, { signal: abortController.signal, ... }); // 用户点中断按钮 function handleStop() { abortController.abort(); }前端 abort 后fetch 会抛出一个AbortError你需要捕获它并优雅处理不要弹错误提示因为这是用户主动行为。同时后端要能感知到连接断开及时停止模型调用。前面提到的writer.checkError()就是干这个的。这里有个容易忽略的点中断后已生成的内容要保留。用户中断不代表要清空已经显示的文字应该留在界面上只是停止继续生成。这个细节很多产品做得不好一中断就清空用户还得重新看一遍。5.2 断线重连与状态恢复SSE 原生支持自动重连但用fetch手动实现的话重连要自己写。不过对于 LLM 对话我的建议是不做自动重连而是给用户一个重新生成按钮。原因是LLM 的生成是有状态的断线后从中间续上很难保证一致性不如让用户重新发起。但有一种情况必须处理网络抖动导致的短暂断流。如果只是几秒的网络问题可以让前端等待一下如果连接恢复就继续超过阈值再提示用户。这个逻辑用fetch的reader.read()抛异常来触发配合重试计数。5.3 多轮对话的上下文管理流式对话通常不是单轮的用户会连续追问。上下文管理有两个关键点消息历史的存储和token 数量的控制。消息历史我建议存在前端或前端 后端双存每次请求把完整历史发给后端。后端不保存会话状态做成无状态的这样水平扩展很容易。token 控制方面历史消息不能无限增长超过模型上下文窗口就得截断。我的策略是保留最近 N 轮 系统提示词N 根据模型窗口大小动态调整。function buildMessages(history, maxTokens) { const systemPrompt history[0]; const recent history.slice(1).slice(-10); // 保留最近 10 条 // 更精细的做法是按 token 数截断这里简化处理 return [systemPrompt, ...recent]; }实操心得截断历史时不要从中间截断一轮对话比如只保留用户问题不保留助手回答否则模型会困惑。要么保留完整的一轮要么整轮丢弃。我见过有人图省事直接slice(-5)结果把一问一答拆散了模型回答质量明显下降。6. 常见问题排查与避坑清单6.1 流式不生效的排查思路流式对话最让人抓狂的问题是本地好好的上线就不流了。我整理了一个排查清单按顺序检查基本能定位现象可能原因排查方法完全收不到流响应头 Content-Type 不对看浏览器 Network 面板的 Response Headers等很久一次性返回Nginx 缓冲未关闭检查 X-Accel-Buffering 和 proxy_buffering中文乱码字符编码未声明确认 charsetUTF-8偶尔丢字前端 buffer 处理不当检查是否按 \n\n 切分并保留残片流到一半断掉网关超时检查网关的 read timeout 配置中断按钮无效AbortController 未传入 fetch确认 signal 正确传递其中Nginx 缓冲是最常见的元凶。默认配置下Nginx 会等后端响应完整了再转发给客户端流式就废了。解决办法是在 location 配置里加location /api/chat/stream { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_set_header Connection ; proxy_http_version 1.1; chunked_transfer_encoding off; }6.2 密钥泄露的防范热词里提到了使用 LLM 时如何防止密钥等鉴权信息泄露这个问题在流式场景下尤其重要。核心原则只有一条API 密钥永远不能出现在前端。所有模型调用必须经过你自己的后端中转前端只跟你的后端通信。后端持有密钥通过环境变量或配置中心注入绝不硬编码在代码里。我见过有人图方便把密钥写在前端直接调模型 API结果密钥被扒出来盗刷账单直接爆炸。另外后端转发时要注意日志脱敏。请求日志里如果打印了完整的请求头密钥就泄露到日志系统了。建议对Authorization这类头做掩码处理。6.3 并发与限流流式连接是长连接占用的服务端资源比普通请求多。如果不做限流一个用户开几十个标签页同时对话服务端线程池很快就被打满。我的做法是单用户并发限制同一用户同时最多 N 个流式连接超过就拒绝全局连接数上限用信号量控制保护后端超时强制释放设置最大生成时长超过就主动断开这些限制要在网关层和业务层双重保障不能只靠一层。7. 我在实际项目中的几点体会流式对话这套东西原理不复杂但魔鬼全在细节里。我做了几个 LLM 应用之后最大的体会是不要追求一步到位先把能流跑通再逐步优化流得稳。一开始就想着把所有边界情况都处理完美反而容易卡住。另一个体会是日志要打全。流式链路的排查比普通接口难得多因为问题往往出现在中间某一环。我在关键节点都加了日志请求进入、模型调用开始、首个 token 到达、流结束、异常发生。有了这些日志定位问题快很多。最后分享一个小技巧在开发阶段用一个假模型来测试流式链路。写一个 mock 接口每隔 100 毫秒返回一个字把你好我是一个测试回答这句话慢慢吐出来。这样你可以在不消耗 API 费用、不受模型波动影响的情况下专心调试前后端的流式逻辑。等链路完全通了再换成真实模型。这个 mock 我到现在还留着每次改流式相关代码都先用它验证一遍省了不知道多少调试时间。
企业数字化 ERP 产品动态
相关推荐
忻州商用厨具电磁灶案例:成立多年服务商行业全景分析 走在忻州的街头,从老城区飘着刀削面香气的社区面馆,到新建商圈里热闹的连锁火锅店,从县域中学的学生食堂,到机关单位的职工后勤厨房,商用厨具的影子藏在每一处烟火背后。
近年来忻州餐饮行业持续扩容,不少新… · 2026/9/26 2:11:43
ArcGIS属性表导出Excel三种方式对比:复制粘贴、CSV与Table To Excel /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 2:11:43
如何升级老mac系统 1.查看自己的老mac最多支持更新到什么系统版本 https://eshop.macsales.com/guides/Mac_OS_X_Compatibility (如果上面的网站需要检测是否真人,而你的电脑无法支持,那么可以试试这个网址)
从上面的网站可以看到最多支持到Big Sur… · 2026/9/26 2:11:43
【深度学习新浪潮】代码不再是稀缺品:大模型时代,软件交付和维护的真正瓶颈在哪里? 摘要:大模型把"写代码"这一环节的成本压到了接近于零,但过去三年(2023—2026)的大规模行业数据却显示一个反直觉的结果:个人产出在涨,团队交付在波动,系统稳定性在下降。本文汇集 DORA、METR、LinearB、Faros、GitClear 等多方研究证据,论证一个核心判断——… · 2026/9/26 2:49:51
鱼香ROS一键安装:Ubuntu 22.04部署ROS 2 Humble的工程化方案 1. 项目概述:为什么“鱼香ROS一键安装”成了Ubuntu下ROS 2 Humble部署的默认选项你刚装好Ubuntu 22.04,打开终端敲下sudo apt update,心里盘算着:今天得把ROS 2 Humble跑起来——毕竟实验室新买的UR5e机械臂、自研的差速底盘小车、… · 2026/9/26 2:49:51
二维码扫进来不知道用户从哪来:小程序场景值与渠道参数追踪实战 二维码扫进来不知道用户从哪来:小程序场景值与渠道参数追踪实战适用读者:做过带参二维码投放、被运营追着问「这批扫码用户到底从哪个渠道来的」的小程序开发者;正在设计渠道归因表的后端;以及所有被 scene 参数坑过的同行。TL;DR… · 2026/9/26 2:49:45
【Python3基础】10-类与对象 ▒ 目录 ▒🛫 导读本篇知识路线1️⃣ 类和对象类和对象的定义Python 的类和对象相对其它语言增加的额外特性2️⃣ 构造函数和访问控制构造函数访问控制3️⃣ 一切皆对象isinstance 函数type 函数4️⃣ 类的职责要单一🛬 文章小结🛫 导读 面向… · 2026/9/26 2:49:39
GEO品牌可见度监测系统:多AI平台模拟提问与引用源溯源架构 技术摘要
本文从系统架构视角拆解GEO品牌可见度监测系统。2026年GEO市场从2.5亿增长到约30亿,企业需要持续监测品牌在豆包、DeepSeek、Kimi、元宝等AI平台的回答表现。文章给出多AI平台采集层、品牌提及率计算、情感分析引擎、引用源溯源、竞品对标五个核心模块的数… · 2026/9/26 2:49:39
换了 Mac 之后,我最舍不得的居然是这个截图工具? 各位伙伴们,大家中秋节快乐,我是中秋节还加班的顾北!最近这两天不是刚换了 MacBook 嘛,所以这段时间一直在倒腾各种 Mac 上的提效工具。前几天也给大家分享了几个我自己觉得还不错的:刘海工具:Atoll给 Dock… · 2026/9/26 2:49:33
数据库课后习题答案别硬背:当测试用例集刷,效率翻倍 简介:万常选版《数据库原理与设计》课后习题答案资源,覆盖第2至6章及第9章,适合正在学习关系模型、数据库建模、关系数据理论与模式求精的本科生、自学者作为复习与自测材料。压缩包共7个文件,含3个doc参考答案、2个sql示例脚本、… · 2026/9/26 0:00:21
OpenClaw 替代品?Hermes Agent 踩坑实录:macOS 飞书接入 TaoToken 配置 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 0:00:40
向下兼容与向上兼容:接口设计中的兼容性策略与工程实践 一次版本升级事故,是很多团队绕不过去的坎。线上环境里,服务端明明已经上线了新版接口,老的移动端还在照着旧文档传参数。请求一到网关,校验直接拒绝,用户操作失败,客服群炸了锅,开发群里开始互… · 2026/9/26 0:00:46