news 2026/9/26 12:39:36

LLM流式对话架构设计与实战:SSE、WebSocket选型及前后端实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LLM流式对话架构设计与实战:SSE、WebSocket选型及前后端实现

1. 通用 LLM 流式对话的架构选型与设计思路

1.1 为什么流式输出是对话类产品的分水岭

做过对话机器人的朋友大概都有这个体会:非流式接口跑通之后,本地测试一切正常,一上线就被用户吐槽“卡”。原因很简单,大模型生成一段三百字的回答,从请求发出到完整响应返回,中间可能要等五到十五秒。这段时间前端界面如果什么都不动,用户会以为程序挂了,反复点击发送按钮,结果又触发一堆重复请求,体验直接崩掉。

流式输出解决的就是这个“等待焦虑”。它的核心思路是把大模型逐 token 生成的结果,通过长连接一点一点推给前端,前端收到一个片段就渲染一个片段。用户看到文字像打字机一样冒出来,心理上会觉得“它在思考、它在回答”,哪怕总耗时没变,感知速度也快了好几倍。

从工程角度看,流式对话涉及三个层面的配合:模型侧的增量生成能力、服务端的流式转发能力、前端的增量渲染能力。这三者缺一不可,任何一环做了缓冲或聚合,流式效果就会退化成分段返回甚至一次性返回。我见过不少项目,后端明明用了流式接口,但中间加了一层网关做响应聚合,结果前端还是等半天才看到内容,排查了半天才发现是网关配置的问题。

1.2 前后端联接方案对比:SSE、WebSocket 还是轮询

流式对话的前后端联接方式,主流有三种选择,各有适用场景,不能一概而论。

方案通信方向实现复杂度适用场景主要短板
SSE服务端单向推送低文本流式对话、通知推送不支持客户端主动发消息
WebSocket全双工中高实时协作、语音对话、多轮交互需要心跳保活、连接管理复杂
长轮询客户端反复请求低兼容性要求极高的老系统延迟高、服务端压力大

对于纯文本的 LLM 流式对话,SSE(Server-Sent Events)是性价比最高的选择。原因有三点:第一,它基于标准 HTTP 协议,浏览器原生支持 EventSource,服务端用普通的 HTTP 响应流就能实现,不需要引入额外的协议栈;第二,它是单向推送,正好匹配“客户端发一次请求、服务端持续推流”的对话模式;第三,它对代理和网关的兼容性比 WebSocket 好,很多企业内网环境对 WebSocket 的升级握手有限制,但 SSE 走的是普通 HTTP 响应,基本不会被拦。

WebSocket 更适合需要双向实时通信的场景,比如语音对话中要随时打断模型输出、或者多人在线协作。如果你的产品只是“用户发一句、模型答一段”的问答模式,上 WebSocket 属于杀鸡用牛刀,连接管理和断线重连的复杂度会让你多写不少代码。

长轮询则是最后的兜底方案,只有在客户端环境完全不支持 SSE 的情况下才考虑。它的本质是客户端每隔一小段时间问一次“有新内容吗”,延迟和资源消耗都不理想。

1.3 整体数据流设计:从用户输入到逐字渲染

把整个链路拆开看,一次流式对话的数据流大致是这样的:

  1. 用户在输入框敲完问题,点击发送,前端把消息通过 POST 请求发给后端对话接口。
  2. 后端接收到请求,做参数校验、会话上下文组装、鉴权检查。
  3. 后端调用大模型的流式接口,拿到一个可迭代的响应流。
  4. 后端对模型返回的每个数据块做解析,提取出增量文本,按 SSE 格式封装后写入 HTTP 响应流。
  5. 前端通过 EventSource 或 fetch 的流式读取能力,持续接收数据块,每收到一块就追加到消息气泡里。
  6. 流结束时,后端发送一个结束标记,前端关闭连接,完成本轮对话。

这个链路里最容易出问题的是第 4 步和第 5 步。后端如果对模型返回的数据块解析不干净,可能把协议头、心跳包、空数据块也推给前端;前端如果没处理好分块边界,可能把半个字符渲染出来,出现乱码。这些细节后面会展开讲。

2. 服务端流式接口的核心实现细节

2.1 接口协议设计:请求体与响应格式约定

服务端的流式接口,请求体设计要兼顾扩展性和简洁性。一个典型的请求体包含这几个字段:

{ "sessionId": "sess_20250101_abc123", "message": "帮我解释一下什么是流式输出", "model": "default", "stream": true, "maxTokens": 2048, "temperature": 0.7 }

sessionId用于关联多轮对话的上下文,后端根据它去查历史消息记录。stream字段是一个开关,同一个接口既能处理流式请求也能处理非流式请求,方便调试和降级。maxTokens和temperature是模型参数,建议给默认值,前端不传也能正常工作。

响应格式遵循 SSE 规范,每个数据块以data:开头,以两个换行符结束。内容部分建议用 JSON 封装,而不是直接推纯文本,这样后续要加字段(比如 token 用量、引用来源、工具调用信息)时不用改协议:

data: {"type":"delta","content":"流式"} data: {"type":"delta","content":"输出"} data: {"type":"delta","content":"是指"} data: {"type":"done","usage":{"promptTokens":15,"completionTokens":42}}

用 JSON 封装的好处是扩展性强。我见过有的项目直接推纯文本,后来要加“引用文档来源”的功能,只能另开一个接口,前端要同时监听两个流,维护起来很痛苦。一开始就用 JSON 结构,后面加字段就是顺手的事。

2.2 模型调用的流式适配:不同厂商接口的差异处理

不同大模型厂商的流式接口返回格式不完全一样,这是实际开发中必须面对的现实。有的返回 SSE 格式,有的返回 JSON Lines,有的在数据块里嵌套了多层结构。后端需要做一层适配,把各家格式统一成内部的标准事件。

以常见的 OpenAI 兼容格式为例,模型返回的每个数据块长这样:

{ "choices": [ { "delta": { "content": "流式" }, "index": 0 } ] }

而有些厂商的格式可能是:

{ "output": { "text": "流式", "finish_reason": null } }

适配层的做法是定义一个内部事件模型,比如DeltaEvent、DoneEvent、ErrorEvent,然后为每个厂商写一个转换器,把原始响应映射成内部事件。这样上层业务代码只处理内部事件,换模型厂商时只需要改转换器,不用动业务逻辑。

注意:有些厂商的流式接口在最后一个数据块里才返回 token 用量统计,前面的块里没有。如果你的业务需要计费或用量监控,要在适配层里把最后一个块的特殊字段提取出来,单独处理。

2.3 流式响应的缓冲与刷新控制

服务端写 SSE 流时,有一个非常容易被忽略的坑:输出缓冲。很多 Web 框架和服务器默认会对响应做缓冲,攒够一定大小才真正发给客户端。这会导致你明明写了流式代码,前端却还是等好几秒才收到第一批数据。

解决方法是显式关闭缓冲,并强制刷新。以常见的 Java 生态为例,需要在响应头里设置:

Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive X-Accel-Buffering: no

X-Accel-Buffering: no这个头是给 Nginx 看的,告诉它不要缓冲这个响应。如果你用的是其他反向代理,也要查一下对应的缓冲配置。我踩过一次坑,本地直连服务端流式效果很好,一放到 Nginx 后面就变成一次性返回,排查了半天才发现是 Nginx 的proxy_buffering默认开着。

除了响应头,代码层面每次写入数据后要调用 flush:

response.getWriter().write(sseData); response.getWriter().flush();

不 flush 的话,数据可能留在缓冲区里,等攒够一批才发出去,流式就变成了“批量式”。

2.4 连接生命周期管理与异常中断处理

流式连接的生命周期比普通请求长,可能持续几十秒甚至几分钟,这期间各种异常都可能发生:客户端主动断开、网络抖动、模型服务超时、服务端线程池耗尽。

客户端断开检测是必须做的。用户可能在模型还在生成时关掉页面或点击“停止生成”,这时服务端如果继续跑,既浪费算力又占着连接。检测方法因框架而异,常见的是注册一个回调,当输出流抛出 IO 异常时,说明客户端已断开,此时应该取消模型调用。

超时控制要分两层:一层是连接空闲超时,如果超过一定时间没有任何数据产出,主动关闭连接并返回错误事件;另一层是总时长超时,防止某个请求无限期占用资源。建议空闲超时设 30 秒,总超时设 5 分钟,具体数值根据业务调整。

资源清理同样重要。流式请求通常要占用一个线程或一个异步任务,如果异常路径没有正确释放,跑一段时间后线程池就会被占满。用 try-finally 或者在响应式框架里用 doFinally 钩子,确保无论正常结束还是异常退出,都能释放资源、更新会话状态、记录日志。

3. 前端流式接收与渲染的完整实现

3.1 用 fetch 还是 EventSource:两种接收方式的选择

前端接收 SSE 流,有两条路:EventSource和fetch配合流式读取。

EventSource的优点是简单,浏览器原生支持自动重连,代码量少:

const es = new EventSource('/api/chat/stream?sessionId=xxx'); es.onmessage = (event) => { const data = JSON.parse(event.data); appendToBubble(data.content); }; es.onerror = () => { es.close(); };

但它有两个硬伤:第一,只支持 GET 请求,没法在请求体里传复杂的 JSON 参数;第二,不能自定义请求头,如果你的鉴权靠 Header 里的 token,EventSource 就无能为力了。

fetch方式更灵活,支持 POST、自定义 Header、请求体传参,代价是要自己处理流的读取和解析:

const response = await fetch('/api/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, body: JSON.stringify({ sessionId, message, stream: true }) }); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // 按 SSE 格式切分数据块 const lines = buffer.split('\n\n'); buffer = lines.pop(); for (const line of lines) { if (line.startsWith('data: ')) { const data = JSON.parse(line.slice(6)); appendToBubble(data.content); } } }

实际项目中我更推荐 fetch 方式,因为对话场景基本都需要 POST 传参和鉴权头。EventSource 适合那种参数简单、不需要鉴权的公开流。

3.2 分块数据的解析与边界处理

流式数据到达前端时,不是按你期望的“一个完整数据块”来的。TCP 传输会把数据切成任意大小的片段,你可能收到半个 JSON、两个半数据块、或者一个数据块被拆成三次到达。所以必须有一个缓冲区来拼接和切分。

上面代码里的buffer就是干这个的。每次收到新数据,先追加到 buffer,然后按 SSE 的分隔符(两个换行)切分。切出来的最后一段可能是不完整的,留在 buffer 里等下一批数据。这个逻辑看起来简单,但如果不做,就会出现 JSON.parse 报错、文字乱码、消息重复等问题。

还有一个细节是TextDecoder的stream: true参数。中文字符在 UTF-8 里占三个字节,如果一批数据正好在字符中间切断,不加这个参数就会解码出乱码。加上之后,decoder 会把不完整的字节序列缓存起来,等后续字节到了再一起解码。

提示:如果你的数据块里包含多行内容(比如模型返回的代码块里有换行),SSE 的data:行需要把内部换行转义,或者用多个data:行表示。前端解析时要把同一个事件的多行 data 拼接起来。这个细节在返回 Markdown 格式内容时特别容易踩坑。

3.3 打字机效果的渲染节奏控制

收到数据就立刻渲染,理论上最快,但视觉上不一定最好。如果模型返回速度很快,文字会“唰”地一下全出来,反而失去了流式的感知优势。如果返回速度不均匀,文字会一顿一顿地跳。

比较舒服的做法是加一个渲染队列,收到的数据先入队,然后用一个定时器以固定频率(比如每 16 毫秒,约 60 帧)从队列里取字符渲染。这样无论数据到达速度如何,视觉上都是平滑的打字机效果。

const queue = []; let rendering = false; function enqueue(text) { queue.push(...text); if (!rendering) renderLoop(); } function renderLoop() { rendering = true; const step = () => { if (queue.length === 0) { rendering = false; return; } const char = queue.shift(); bubble.textContent += char; requestAnimationFrame(step); }; requestAnimationFrame(step); }

用requestAnimationFrame而不是setTimeout,可以跟浏览器的刷新节奏对齐,避免不必要的重绘。每次只取一个字符可能太慢,可以根据队列长度动态调整每次取的字符数,队列长就多取几个,队列短就少取几个,保证整体进度跟得上。

3.4 中断、重试与错误状态的界面反馈

用户点击“停止生成”时,前端要做三件事:调用reader.cancel()中断流读取、通知后端取消模型调用、把当前消息标记为“已中断”。后端收到取消信号后,应该停止向模型请求后续内容,并把已经生成的部分保存到会话历史里。

网络异常导致流中断时,不要静默失败。界面上要给出明确提示,比如在消息气泡下方显示“生成中断,点击重试”。重试的逻辑要区分情况:如果是连接建立阶段失败,可以直接重发请求;如果是流传输中途失败,已经生成的部分内容要不要保留、重试时是重新生成还是续写,这些产品决策要提前想清楚。

我个人的经验是,中途失败时保留已生成内容,重试时把已生成内容作为上下文的一部分发给模型,让它接着写。这样用户不会看到内容突然从头开始,体验更连贯。当然这要求后端支持“续写”模式,实现上稍微复杂一点,但对长回答场景很值得。

4. 联调排查与性能优化的实战经验

4.1 流式效果退化的常见原因速查

流式对话上线后,最常见的反馈就是“怎么不流式了”。下面这张表是我实际排查中总结的高频原因,按出现频率排序:

现象可能原因排查方法
前端一次性收到全部内容反向代理开启了响应缓冲检查 Nginx 的 proxy_buffering 配置
服务端日志显示逐块输出,前端却批量收到框架层或网关层做了聚合用 curl 直连服务端验证
前几块正常,后面突然批量到达缓冲区大小阈值触发检查 flush 调用是否每次都有
本地正常,部署后异常环境差异导致缓冲策略不同对比本地和线上的代理配置
偶发不流式,刷新后恢复连接被中间设备缓存检查 Cache-Control 头是否正确

排查的基本方法是逐层剥离:先用 curl 直接请求服务端接口,看输出是不是逐块到达;如果服务端正常,再在代理层加日志,看转发是否及时;最后检查前端接收逻辑。这样一层层排除,很快就能定位到问题所在。

4.2 高并发下的连接数与资源控制

流式连接是长连接,每个连接占用的资源比普通请求多。如果并发用户量大,服务端的连接数和线程数会成为瓶颈。

连接数控制方面,要设置合理的最大并发流数,超过阈值的新请求要么排队要么直接拒绝并返回友好提示。不要指望无限扩容,资源总是有限的,提前做好限流比事后救火强。

线程模型方面,传统的“一个请求一个线程”模型在流式场景下很吃亏,因为线程大部分时间在等待模型返回。用异步非阻塞模型(比如响应式框架、协程)可以用少量线程支撑大量并发连接。如果技术栈限制只能用同步模型,那线程池要开得比普通接口大一些,同时做好超时回收。

模型侧并发也要考虑。大模型服务的并发能力通常有限,如果后端无限制地把请求转发给模型,可能触发模型侧的限流。建议在后端加一个信号量或队列,控制同时向模型发起的请求数,超出的请求排队等待。

4.3 首字节延迟与整体吞吐的优化取舍

流式对话有两个关键指标:首字节延迟(从用户发送到看到第一个字的时间)和整体吞吐(每秒能处理多少轮对话)。这两个指标有时候是矛盾的。

降低首字节延迟的关键是让模型尽快开始输出。可以做的事情包括:精简系统提示词(提示词越长,模型处理越慢)、关闭不必要的预处理步骤、让模型调用和上下文组装并行执行。我实测下来,把系统提示词从两千字精简到五百字,首字节延迟能减少将近一秒。

提升整体吞吐则要关注资源利用率。模型调用是主要耗时,如果后端在等待模型返回时占着线程不放,吞吐就上不去。用异步方式调用模型,等待期间释放线程去处理其他请求,能显著提升并发能力。

实际项目中要根据产品定位做取舍。面向 C 端的对话产品,首字节延迟更重要,用户等三秒没反应就跑了;面向 B 端的批量处理场景,吞吐更重要,延迟几秒无所谓。优化方向不同,技术选型也会有差异。

4.4 鉴权信息与敏感数据的防护要点

流式接口的鉴权跟普通接口一样,token 放在 Header 里,不要放在 URL 参数里。URL 会被记录到访问日志、浏览器历史、代理日志中,泄露风险高。用 Header 传递,配合 HTTPS,基本能保证传输安全。

服务端调用大模型时,API Key 绝对不能下发到前端。所有模型调用都经过后端中转,前端只跟自己的后端通信。我见过有的项目为了“减少后端压力”,让前端直接调模型接口,把 Key 写在前端代码里,这等于把钥匙挂在门上。

日志记录也要注意脱敏。对话内容可能包含用户隐私,记录日志时要么不记内容,要么做脱敏处理。调试用的详细日志在上线前要关掉或降级,避免敏感信息落盘。

注意:如果对话内容会展示给其他用户(比如分享功能),要在服务端做内容安全过滤,不能依赖前端过滤。前端过滤可以被绕过,服务端过滤才是最后一道防线。

5. 从能跑到好用:几个容易被忽略的工程细节

5.1 会话上下文的截断策略

多轮对话需要把历史消息一起发给模型,但模型的上下文窗口是有限的。对话轮次多了之后,必须做截断,否则要么报错,要么被模型静默丢弃早期内容。

截断策略有几种:按轮次截断(只保留最近 N 轮)、按 token 数截断(从最新往回累加,超过阈值就停)、按重要性截断(用摘要或向量检索保留关键信息)。简单场景用按 token 数截断就够了,复杂场景可以结合摘要,把早期对话压缩成一段概述。

截断时要注意保留系统提示词和最近一轮用户消息,这两个是必须的。中间的助手回复可以优先丢弃,因为用户当前的问题通常跟最近的上下文关系最大。

5.2 流式与非流式的接口复用

同一个对话接口,最好同时支持流式和非流式两种模式,通过请求参数切换。这样做的好处是:调试时用非流式,方便看完整响应;前端某些场景(比如生成摘要后要二次处理)可能也需要非流式;降级时如果流式通道出问题,可以临时切到非流式保证可用。

实现上,业务逻辑层统一处理“获取模型响应”,流式和非流式的差异只在最外层的响应封装。流式把响应逐块写出,非流式等全部生成完再一次性返回。这样代码复用度高,维护成本低。

5.3 模型切换与降级预案

生产环境不能只依赖一个模型服务。模型服务可能超时、限流、故障,需要有降级预案。常见的做法是配置多个模型源,主模型不可用时自动切换到备用模型。切换逻辑可以基于错误率、响应时间等指标触发。

切换时要注意响应格式的兼容性。如果备用模型的输出格式跟主模型不同,适配层要能正确处理。另外,切换对用户应该是透明的,前端不需要知道当前用的是哪个模型,除非产品上要展示模型标识。

我一般会在配置里维护一个模型列表,每个模型带优先级和健康状态。请求进来时按优先级选可用的模型,调用失败则标记该模型不健康一段时间,自动降级到下一个。这套机制不复杂,但能大幅提升服务的稳定性。

5.4 流式场景下的日志与监控

流式请求的日志跟普通请求不一样,不能等请求结束才记一条。要在关键节点打点:请求开始、模型调用开始、首字节产出、流结束、异常发生。这些时间戳能帮你算出首字节延迟、总耗时、生成速度等指标。

监控方面,重点关注几个指标:流式请求的成功率、首字节延迟的 P95 和 P99、平均生成速度、异常中断率。这些指标能反映系统的健康状态,出问题时也能快速定位是模型侧慢还是网络侧慢。

日志里记录 sessionId 和请求 ID,方便把一次对话的多个环节串起来排查。但注意不要把完整的对话内容记进日志,只记元数据和长度信息,保护用户隐私。

6. 写在最后的一些个人体会

流式对话这个功能,从技术原理上讲并不复杂,无非是把一次性响应拆成多次推送。但真正把它做稳、做好用,需要在很多细节上下功夫。我前后在几个项目里实现过这套东西,每次都会遇到新的问题,有的是框架层面的,有的是网络环境的,有的是产品需求变化带来的。

最大的体会是:流式效果的好坏,往往不取决于你用了多高级的技术,而取决于你有没有把每一层的缓冲都关掉、把每一个边界都处理好。一个 Nginx 配置没改,就能让精心写的流式代码退化成批量返回;一个分块边界没处理,就能让中文变成乱码。这些细节看起来琐碎,但恰恰是区分“能跑”和“好用”的关键。

另外,不要过度设计。如果产品就是简单的问答,SSE 加 fetch 足够了,没必要上 WebSocket 和复杂的连接管理。技术方案要匹配业务需求,够用就好,把省下来的精力放在打磨用户体验和排查实际问题上,收益更大。

最后分享一个小技巧:联调阶段,在服务端的每个数据块里加一个递增的序号和时间戳,前端收到后打印出来。这样一眼就能看出数据是逐块到达的还是批量到达的,排查流式问题时特别管用。上线前把这个调试字段去掉或者降级为 debug 级别日志就行。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 12:37:54

n8n接入Fastgpt MCP:构建超强RAG工作流

/* 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 12:35:32

AI增强电机瞬态仿真:从加速计算到物理推演

1. 这不是“用AI跑个仿真”——而是重新定义电机研发的临界点电机瞬态动力学仿真,这个词组里藏着两个硬核世界:一个是传统电机工程师熬了十几年才摸清门道的物理场耦合、非线性材料建模、多时间尺度耦合求解;另一个是最近两年突然闯进实验室的…

作者头像 李华
网站建设 2026/9/26 12:34:41

Python 常用内置函数

所谓内置函数(Built-in)是预先定义好的函数, 可以直接使用它们, 不需要编写导入语句。它们为人们提供了一种非常方便的途径, 可以用来处理那些常见的任务事项, 这样一来, 代码的清晰程度得到了很大的提升, 也让开发者们省去了不少花费在开发上面的时间成…

作者头像 李华
网站建设 2026/9/26 12:33:23

工业控制板EMI辐射超标整改实录:从PCB布局到滤波电路设计

这块工业控制板送测第三天下午,测试工程师把频谱截图甩过来的时候,我心里其实早就有预感。第一版做主功能验证时只图跑得快,EMI整机测试完全是“先点亮再说”的思路,结果一到半电波暗室,辐射发射直接来了个下马威&…

作者头像 李华
网站建设 2026/9/26 12:32:27

压缩感知与OMP:毫米波大规模MIMO信道估计的稀疏重构方案

简介:面向毫米波通信与压缩感知研究者的Matlab源码包,聚焦第五代/第六代无线系统中基于正交匹配追踪的稀疏信道估计问题。包内共八个脚本文件,压缩后仅6KB,包含主程序、改进版正交匹配追踪函数、波束空间信道建模、离散傅里叶变换…

作者头像 李华
网站建设 2026/9/26 12:30:47

移动数据处理策略全解析:从端侧采集到数仓与实时链路

做数据架构的同行应该都有过这种体验:服务端的数据链路无论搭得多复杂,只要日志格式统一、字段齐全,后面再难也有章可循。但一旦换成移动端数据——App里的埋点日志、用户行为事件、位置上报——整套架构的脆弱点就全暴露出来了。我这两年接手…

作者头像 李华