ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

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

LLM流式对话架构设计与实战:SSE、WebSocket选型及前后端实现 1. 通用 LLM 流式对话的架构选型与设计思路1.1 为什么流式输出是对话类产品的分水岭做过对话机器人的朋友大概都有这个体会非流式接口跑通之后本地测试一切正常一上线就被用户吐槽“卡”。原因很简单大模型生成一段三百字的回答从请求发出到完整响应返回中间可能要等五到十五秒。这段时间前端界面如果什么都不动用户会以为程序挂了反复点击发送按钮结果又触发一堆重复请求体验直接崩掉。流式输出解决的就是这个“等待焦虑”。它的核心思路是把大模型逐 token 生成的结果通过长连接一点一点推给前端前端收到一个片段就渲染一个片段。用户看到文字像打字机一样冒出来心理上会觉得“它在思考、它在回答”哪怕总耗时没变感知速度也快了好几倍。从工程角度看流式对话涉及三个层面的配合模型侧的增量生成能力、服务端的流式转发能力、前端的增量渲染能力。这三者缺一不可任何一环做了缓冲或聚合流式效果就会退化成分段返回甚至一次性返回。我见过不少项目后端明明用了流式接口但中间加了一层网关做响应聚合结果前端还是等半天才看到内容排查了半天才发现是网关配置的问题。1.2 前后端联接方案对比SSE、WebSocket 还是轮询流式对话的前后端联接方式主流有三种选择各有适用场景不能一概而论。方案通信方向实现复杂度适用场景主要短板SSE服务端单向推送低文本流式对话、通知推送不支持客户端主动发消息WebSocket全双工中高实时协作、语音对话、多轮交互需要心跳保活、连接管理复杂长轮询客户端反复请求低兼容性要求极高的老系统延迟高、服务端压力大对于纯文本的 LLM 流式对话SSEServer-Sent Events是性价比最高的选择。原因有三点第一它基于标准 HTTP 协议浏览器原生支持 EventSource服务端用普通的 HTTP 响应流就能实现不需要引入额外的协议栈第二它是单向推送正好匹配“客户端发一次请求、服务端持续推流”的对话模式第三它对代理和网关的兼容性比 WebSocket 好很多企业内网环境对 WebSocket 的升级握手有限制但 SSE 走的是普通 HTTP 响应基本不会被拦。WebSocket 更适合需要双向实时通信的场景比如语音对话中要随时打断模型输出、或者多人在线协作。如果你的产品只是“用户发一句、模型答一段”的问答模式上 WebSocket 属于杀鸡用牛刀连接管理和断线重连的复杂度会让你多写不少代码。长轮询则是最后的兜底方案只有在客户端环境完全不支持 SSE 的情况下才考虑。它的本质是客户端每隔一小段时间问一次“有新内容吗”延迟和资源消耗都不理想。1.3 整体数据流设计从用户输入到逐字渲染把整个链路拆开看一次流式对话的数据流大致是这样的用户在输入框敲完问题点击发送前端把消息通过 POST 请求发给后端对话接口。后端接收到请求做参数校验、会话上下文组装、鉴权检查。后端调用大模型的流式接口拿到一个可迭代的响应流。后端对模型返回的每个数据块做解析提取出增量文本按 SSE 格式封装后写入 HTTP 响应流。前端通过 EventSource 或 fetch 的流式读取能力持续接收数据块每收到一块就追加到消息气泡里。流结束时后端发送一个结束标记前端关闭连接完成本轮对话。这个链路里最容易出问题的是第 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: noX-Accel-Buffering: no这个头是给 Nginx 看的告诉它不要缓冲这个响应。如果你用的是其他反向代理也要查一下对应的缓冲配置。我踩过一次坑本地直连服务端流式效果很好一放到 Nginx 后面就变成一次性返回排查了半天才发现是 Nginx 的proxy_buffering默认开着。除了响应头代码层面每次写入数据后要调用 flushresponse.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?sessionIdxxx); es.onmessage (event) { const data JSON.parse(event.data); appendToBubble(data.content); }; es.onerror () { es.close(); };但它有两个硬伤第一只支持 GET 请求没法在请求体里传复杂的 JSON 参数第二不能自定义请求头如果你的鉴权靠 Header 里的 tokenEventSource 就无能为力了。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 级别日志就行。
返回列表