2026/9/12 18:02:14

大模型流式输出选型与实战:SSE如何替代WebSocket

大模型流式输出选型与实战:SSE如何替代WebSocket 先给结论大模型项目的实时输出本质上不是“要不要用 WebSocket”的问题而是“用 HTTP 长连接 流式文本”还是“用 WebSocket 双向通道”的问题。我最近在公司内部把一整套 LLM 流式输出链路从自研 WebSocket 改成了 SSE效果直接体现在三个方面服务端代码量几乎砍半、浏览器侧不用再维护心跳逻辑、对接 CDN 和网关也省了一大堆事。这篇文章就把我从协议选型、前端解析、Agent 场景事件流设计到线上踩坑的完整过程拆开讲清楚适合正在对接大模型流式输出的前端同学也适合后端想了解 SSE 该暴露什么接口的同事。先说清楚这里的“两种姿势”不是对立关系而是递进关系。第一种姿势是传输层协议的选择也就是 SSE 替代 WebSocket第二种姿势是业务层数据结构的组织也就是面对 LLM大语言模型逐 token 生成文本这种场景前端究竟该用怎样的流式事件去承载它。很多教程只讲了一半要么告诉你text/event-stream长什么样就结束要么丢给你一个 ChatGPT 风格的“打字机效果” Demo。但实际接一个 Agent 应用时你会发现流里不仅有文本还有工具调用、状态变化、中间结果这些都需要一套比“打字机”更健壮的事件协议。1. 为什么大模型场景都在用 SSE1.1 从“首 token 延迟”说起很多人第一次接触流式输出是被 LLM 调用的体验逼出来的。大模型推理是一个自回归过程模型一个 token 一个 token 地往后预测如果按传统 HTTP 请求的玩法前端必须等模型把所有文字生成完服务器一次性把完整回复返回。在较长的回答场景里这个等待时间轻松超过 10 秒甚至 1 分钟用户盯着一个转圈菊花体验极其糟糕。但更关键的是“首 token 延迟”这一指标。模型从收到请求到吐出第一个 token可能需要几百毫秒甚至几秒取决于模型大小、显存、推理框架但一旦开始吐字速度往往很快。流式输出让用户看到先头部队既掩盖了等待焦虑也让人感觉系统“响应很快”。在实际测试中同样一个 RAG 问答接口非流式接口 p95 延迟 15 秒流式接口的首 token p95 只有 2.8 秒用户主观体感的差异是巨大的。所以 LLM 场景对流式传输几乎是刚需而 SSE 就是为这种“服务端单向推送、文本为主、基于 HTTP”的场景量身定做的协议。它不是新技术HTTP 协议里早就定义了真正让它翻红的正是大模型这股浪潮。1.2 轮询、WebSocket、SSE 三选一实现“服务端主动往客户端推数据”前端方案其实就三条路定时轮询、WebSocket、SSE。轮询是最笨的办法每隔几秒发一次请求问“答案生成完了没”。优点是没有协议门槛缺点也明显延迟高最多落后一个轮询周期、浪费流量大量请求在反复确认“还没好”、服务器压力大。而且 LLM 场景的生成时间波动极大短的可能几秒长的能到分钟级轮询间隔根本没法调。调短了空转太频繁调长了用户觉得卡顿。所以轮询直接被排除。WebSocket 是目前各类实时应用的默认选择比如聊天、协同编辑、游戏。它建立一条 TCP 长连接支持客户端和服务端双向实时通信也可传输二进制数据。听起来 WebSocket 好像“全面碾压”SSE但代价是它是另一个协议不是 HTTP这意味着它有自己的握手逻辑、帧格式、连接生命周期管理前后端都要维护额外的心跳、重连、协议解析代码。SSE 则完全是另一条思路它继续使用 HTTP服务端把响应体的 Content-Type 设置成text/event-stream之后以一种特定的文本格式持续向客户端推送数据。这个数据流是单向的只有服务端能推客户端不能通过这条连接往回发内容。一旦找到“LLM 流式输出是单向推送”这个事实你就会发现 SSE 和这个场景是天作之合。因为整个对话流程里用户请求内容一次发完就可以了剩下的全是模型在往客户端推文字。下面用一张表把这三种方式的关键差异列清楚方便选型时直接对照维度定时轮询WebSocketSSE通信方向请求-响应双向服务端单向推送传输协议HTTP独立协议握手后升级基于 HTTP数据格式任意文本或二进制帧纯文本自动重连无需手动实现EventSource 原生支持请求头自定义支持支持EventSource 不支持fetch 支持浏览器兼容性全兼容全兼容现代浏览器全兼容IE 不支持实现复杂度低高低典型场景低频状态查询双向聊天、游戏、实时协作通知、订阅、LLM 流式输出1.3 SSE 的天然优势与使用边界在实际项目里使用 SSE还有几个 WebSocket 不具备的优势。第一它跑在 HTTP 上意味着 Nginx、CDN、网关、各种负载均衡组件都能直接理解和转发它不需要单独为 WebSocket 做协议升级、连接保持的配置。第二SSE 的降级方案很自然大不了就是“一次性返回完整文本”同一个 URL 同一个返回体客户端只要做“响应式展示”和“流式展示”两种模式即可WebSocket 则不好做这种降级。第三SSE 原生支持断线重连浏览器自带的 EventSource 对象会自动重连还会通过 Last-Event-ID 把断点补上这些逻辑如果用 WebSocket 全部要自己写。但我也得说清楚 SSE 的边界。如果你的业务确实是强双向交互比如用户边生成边发送指令或者要实时传输音频帧、二进制文件那就别硬上 SSE。另外低版本 IE 不支持 SSEC 端老古董浏览器场景需要谨慎。不过对纯 LLM 流式输出这种明确单向、纯文本的场景SSE 确实是最优解这也是目前市面主流大模型 API 不约而同采用 SSE 的原因。2. SSE 协议拆解一个文本协议打天下2.1 SSE 的 MIME 类型与数据格式SSE 的本质是响应体被当成一个持续输出文本的流。服务端在响应头里设置Content-Type: text/event-stream然后不断地往响应体里写特定格式的文本块。每个事件块由若干“字段行”组成字段名和字段值用冒号加空格分隔事件块之间用一个空行\n\n分隔。最常用的字段有四个data事件携带的数据内容可以有多行多行会在客户端被拼成一个字符串中间用换行符连接。event事件类型默认是message你也可以自定义比如tool_call、status。id事件编号配合断线重连使用客户端重连时会把这个 id 通过 Last-Event-ID 请求头发给服务端。retry告诉客户端断线后多久重试一次单位毫秒。还有一类特殊的行以冒号开头比如: keep-alive。这种行是注释客户端会直接忽略服务端可以利用它来让代理层和浏览器保持连接活跃防止长连接被闲置超时切断。这在后文排查超时的时候会派上大用场。一个典型的 SSE 事件长这样event: message id: 42 data: {delta:你好,index:0}注意结尾必须有一个空行这个空行才是事件的分隔符少了它客户端会一直等下一个事件块“拼进来”。2.2 用 Node.js 搭一个最简 SSE 接口知道了格式服务端代码其实非常简单。我用原生 Node.js 的 http 模块写一个最小可运行的 SSE 接口模拟 LLM 分片吐字的过程const http require(http); const server http.createServer((req, res) { res.writeHead(200, { Content-Type: text/event-stream; charsetutf-8, Cache-Control: no-cache, no-transform, Connection: keep-alive, X-Accel-Buffering: no, // 阻止 Nginx 缓冲下文会讲 }); let index 0; const timer setInterval(() { index 1; const delta 第 ${index} 次推送这是模拟的 LLM 增量内容。; res.write(id: ${index}\n); res.write(data: ${JSON.stringify({ delta })}\n\n); if (index 10) { res.write(data: ${JSON.stringify({ done: true })}\n\n); clearInterval(timer); res.end(); } }, 200); req.on(close, () { clearInterval(timer); }); }); server.listen(3000);关键是几个响应头Content-Type必须是text/event-streamCache-Control: no-cache避免中间层缓存响应Connection: keep-alive告诉连接不要被关闭。X-Accel-Buffering: no是给 Nginx 看的后面踩坑章节细说。注意data字段。我习惯把数据序列化成 JSON 字符串放进去比如data: {delta:...}而不是直接塞一段明文。原因是结构化的数据在业务层更好解析也能避免文案里出现换行、冒号带来的歧义。这里要特别强调如果 data 后面的内容本身包含换行SSE 要求你把这一行拆成多个data:行客户端再拼成一个字符串。但因为我们的内容是 JSON 序列化后的JSON.stringify 会把字符串里的换行转成\n转义符不会产生真实换行所以“单行 data 放 JSON”是最稳的做法。2.3 心跳、断线重连与 Last-Event-IDSSE 长连接如果长时间没有数据容易被中间的网络设备尤其是各种代理、网关判定为闲置连接并被掐断。所以实际生产环境里服务端需要定期往连接里写心跳数据。常见的做法是写一个注释行比如每 15 秒写一个: ping\n\n因为注释会被客户端忽略不会污染业务数据又能让连接“看起来活跃”。断线重连这块如果用的是浏览器原生 EventSource它天然支持自动重连而且会把上一次收到的id通过Last-Event-ID请求头发给服务端。服务端收到这个头之后应该把从断点之后的数据重新推一遍实现“断点续传”的效果。但如果你用 fetch 自建 SSE 客户端后面会细讲那断线重连就要自己实现捕获异常、等待重试时间、带上 Last-Event-ID 重新请求。我通常的做法是把“解析器”和“连接器”拆开连接器负责 fetch、断线重连解析器负责把字节流转成事件这样两条逻辑互不干扰。3. 前端实现 LLM 流式输出的完整套路3.1 为什么我不用 EventSource 而是用 fetch浏览器原生有一个 EventSource 对象专门消费 SSE 流用法极简const es new EventSource(/api/llm/stream); es.onmessage (e) { console.log(e.data); };为什么不直接用原因有四个全是我在实际项目中撞上的。第一EventSource 只能用 GET 方法请求但一般的大模型服务端接口多半需要 POST尤其是私有化部署的推理服务经常要求把超长 prompt 放在 body 里GET 的 URL 长度根本装不下。第二EventSource 无法自定义请求头很多场景下后端要求带Authorization: Bearer xxxEventSource 做不到只能退而求其次用 cookie 或 query 参数传 token既难看又容易在日志里泄露。第三EventSource 无法拿到 HTTP 状态码。当请求失败比如鉴权 401、限流 429时EventSource 触发的是 error 事件你根本不知道具体是哪种错误。第四EventSource 默认行为不可控它会自动重连但业务上有些错误比如 token 失效根本不该重连还得想办法把它停掉。所以我强烈建议在 LLM 流式输出场景里统一用fetch ReadableStream自己解析 SSE。它既能 POST、又能自定义 header还能拿到完整响应状态等于把 EventSource 缺的能力全补上了代价只是多写几十行解析代码。3.2 一个可以直接抄走的 fetch 版 SSE 解析器下面这段代码我直接在业务里用了很久核心思路是用response.body.getReader()拿到字节流再通过TextDecoder把字节解码成字符串然后按 SSE 的规范切分成事件块逐条处理async function fetchSSE(url, { method POST, headers {}, body, signal, onEvent, onError }) { let response; try { response await fetch(url, { method, headers: { Content-Type: application/json, ...headers }, body: body ? JSON.stringify(body) : undefined, signal, }); } catch (err) { // 网络层异常可能是断网或者 AbortError onError?.(err); return; } if (!response.ok) { onError?.(new Error(HTTP ${response.status} ${response.statusText})); return; } 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 }); const blocks buffer.split(\n\n); // 最后一段可能是不完整的事件留到缓冲区等下一次读取 buffer blocks.pop() ?? ; for (const block of blocks) { const event parseSSEBlock(block.trim()); if (event) onEvent?.(event); } } // 流结束后如果还有残余数据强制解析一次 if (buffer.trim()) { const event parseSSEBlock(buffer.trim()); if (event) onEvent?.(event); } } function parseSSEBlock(block) { const lines block.split(\n); let event message; let data ; let id ; for (const line of lines) { if (line.startsWith(:)) continue; // 注释行直接忽略 if (line.startsWith(event:)) event line.slice(6).trim(); else if (line.startsWith(data:)) data line.slice(5).trim(); else if (line.startsWith(id:)) id line.slice(3).trim(); // retry 字段这里先不处理需要的话同理解析 } if (!data) return null; let parsedData data; try { parsedData JSON.parse(data); } catch (e) { // 不是 JSON 就原样返回字符串保持兼容 } return { event, id, data: parsedData }; }这个解析器有四个细节值得说。第一buffer是必须的。TCP 流是分块的一个完整 SSE 事件可能被拆成两半到达也可能一次到达好几个事件split(\n\n)之后最后一块大概率是半截必须留在缓冲区等下一次reader.read()再接着拼。第二decoder.decode(value, { stream: true })里的stream: true不能漏否则中文等多字节字符在跨 chunk 时可能被截断产生乱码。第三data 里的 JSON 字符串我用JSON.parse尝试解析一次解析成功就用对象失败就保留字符串这样兼容纯文本和结构化数据两种服务端设计。第四SSE 规范里多个 data 行在客户端应按换行拼接成完整 data我在上面用了data line.slice(5).trim()的简写实测业务里多做 JSON 序列化极少出现多行 data如果需要严格遵守规范可以把这行改成data (data ? \n : ) line.slice(5)。3.3 增量渲染、全文拼接与中断控制拿到事件之后前端要把流式内容渲染到界面上。这里有一个很容易踩的坑不要拿到一个 delta 就整体重设一次完整内容而要用“增量累计 一次性赋值”的方式。以我们现在最常用的 React 为例const [content, setContent] useState(); const abortRef useRef(null); const handleSseEvent useCallback(({ event, data }) { if (event message) { setContent(prev prev data.delta); } else if (event done) { // 整个回答结束可以关闭 loading 状态 } }, []); const startStream async () { const controller new AbortController(); abortRef.current controller; setContent(); await fetchSSE(/api/llm/chat, { method: POST, headers: { Authorization: Bearer ${token} }, body: { prompt: 讲个冷笑话 }, signal: controller.signal, onEvent: handleSseEvent, onError: (err) { if (err.name AbortError) return; // 用户主动打断不算错误 console.error(err); }, }); }; const stopStream () { abortRef.current?.abort(); };用函数式更新prev prev data.delta的好处是即使事件触发频率很高、React 批量渲染有延迟也能保证最终内容是完整拼接的不会因为闭包里的旧值互相覆盖而丢字。中断控制是我当时最容易忽略、但线上问题最多的环节。用户在流式输出过程中点击“停止生成”实际上要做三件事前端 abort 掉 fetch 请求服务端感知到连接断开后终止大模型的推理任务模型推理侧的算力资源被释放。如果只管前端 abort服务端可能还在傻乎乎地继续调模型 API既浪费 token 又占用显存这在生产环境是不可接受的。3.4 兼容 OpenAI 风格 chunk 的小改造市面上很多 LLM 网关、自建推理框架都默认兼容 OpenAI 的流式格式长这样data: {id:chatcmpl-xxx,object:chat.completion.chunk,choices:[{delta:{content:你好},index:0}]} data: [DONE]你会发现它本身就是一个简化版的 SSE没有 event 字段所有事件都是默认 message最后用一个data: [DONE]表示流结束。上面这个解析器直接就能处理这种格式唯一需要特殊对待的是[DONE]这个特殊标记它不是合法 JSONJSON.parse会失败但我们的解析器会把data原样返回成字符串[DONE]所以只要在业务层加一个判断if (data [DONE]) { // 流已结束 return; } const content data.choices?.[0]?.delta?.content ?? ; if (content) setContent(prev prev content);这里还有一个我自己踩过的坑OpenAI 的delta有时包含role字段而不含content比如流的第一块。如果无脑拼接会把undefined拼到文本里。所以拿到 delta 后必须先判空只追加有content的块。到这一步一个能正常“打字”的 LLM 流式前端链路就已经通了。但生产环境通常不止这么简单尤其是你做的不是单轮问答而是带工具调用、多步骤推理的 Agent 应用流的格式就得再设计一层。4. Agent 场景下的“复杂流式输出”4.1 LLM 流式输出不只有文本如果你只做 ChatGPT 那种纯文本对话上一章就够了。但现实是现在的前端 LLM 应用越来越多是 Agent 形态用户抛一个问题模型先分析意图然后决定调用某个工具搜网页、查数据库、调外部 API拿到工具结果后再继续推理最后汇总成回答。整个过程里用户需要看到的不只是最终文本还包括当前在做什么、调用了什么工具、中间结果是什么甚至哪一步失败了。这种场景下如果你还是在流里只推文本 delta前端就只能看到模型光速吐字或者干等几十秒完全不知道系统在干什么。用户会焦虑会反复点“重试”体验非常差。解决办法是把流式事件结构化、类型化让前端能区分“这是文本”“这是工具调用”“这是状态更新”。我自己的做法是基于 SSE 的event字段做了一套简单的事件协议以下是我在一个 Agent 项目里实际用过的设计。4.2 一个可落地的流式事件协议协议核心就一个原则所有事件都用 JSON 作为 data 的载体事件类型用event字段区分。常用的类型有这些event 类型data 关键字段含义statusstatus、messageAgent 生命周期状态变化比如 thinking、running、finishmessagedelta给用户看的增量文本正常打字机输出reasoningdelta思考过程增量文本可选展示给用户tool_callid、name、arguments模型决定调用某个工具tool_resultid、result工具执行完成后的结果errorcode、message流程中出现的错误donefinish_reason、usage整个流程结束附带 token 消耗信息一个典型的 Agent 事件流长这样event: status data: {status:thinking,message:正在理解你的问题...} event: message data: {delta:我来帮你查一下北京的天气。} event: tool_call data: {id:call_001,name:search_web,arguments:{\query\:\北京今天天气\}} event: tool_result data: {id:call_001,result:北京晴气温 25 摄氏度微风。} event: message data: {delta:根据查询结果北京今天晴气温 25 度。} event: done data: {finish_reason:stop,usage:{prompt_tokens:120,completion_tokens:80}}这里有两个设计细节很关键。第一tool_call里的arguments是一个 JSON 字符串而不是一个 JSON 对象。因为模型工具调用的参数在流式输出过程中是逐步拼出来的一开始可能只是半个 JSON强行让服务端解析成对象再推给前端解析失败就尴尬了。直接把原始字符串推给前端前端需要用的时候自己 parse 一次即可。第二事件顺序不一定稳定。Agent 可能先推 message 再推 tool_call也可能并列推多条 message前端不要对顺序做硬编码假设而是根据 event 类型走对应的处理函数。4.3 前端如何消费结构化事件流有了事件协议前端代码很容易组织成一个“分发器 状态机”的结构const [uiState, setUiState] useState({ status: idle, message: , tools: [] }); function handleEvent({ event, data }) { switch (event) { case status: setUiState(prev ({ ...prev, status: data.status, message: data.message })); break; case message: setContent(prev prev (data.delta ?? )); break; case tool_call: setTools(prev [...prev, { id: data.id, name: data.name, status: running }]); break; case tool_result: setTools(prev prev.map(t t.id data.id ? { ...t, status: done, result: data.result } : t)); break; case done: setUiState(prev ({ ...prev, status: finish })); break; case error: setUiState(prev ({ ...prev, status: error, message: data.message })); break; default: break; } }UI 层面我通常把界面分成三块顶部状态栏展示当前 Agent 在干什么thinking / running / finish中间渲染工具调用卡片底部滚动输出最终文本。tool_call事件到达时前端先渲染一个“正在调用工具”的占位卡片等tool_result到达后再填充结果。这种交互比纯文本流更有掌控感用户能随时知道系统卡在哪一步。这里要提醒一句事件里的status字段和组件里的status字段容易重名建议前端状态机里的字段统一叫phase避免和事件里的 status 混淆我当初就是没注意这个在状态流转判断时绕了好大一圈。5. 实战踩坑超时、缓冲、鉴权和连接数5.1 报错“idle timeout waiting for SSE”到底是谁的问题这个错误我印象太深了。线上某个 LLM 接口从 12 秒开始必挂浏览器控制台直接报 “before completion: idle timeout waiting for SSE”。一开始我们以为是服务端代码问题反复查日志却发现生成任务明明还在跑响应流也正常写但客户端就是收不到数据。后来定位才发现问题出在接入层网关。它对上游 SSE 响应设置了 idle timeout也就是说在这个超时窗口内如果服务端没有写入任何字节网关就会主动切断连接。我们的模型思考阶段可能有十几秒没有输出任何内容正好撞上这个阈值整个连接就被掐了。解决方案有两层。第一层服务端要定期发心跳我刚在协议部分讲过就是每隔 10 到 15 秒发一行: ping\n\n注释让网关知道连接还活着。第二层反向代理的超时时间要调大比如 Nginx 里的proxy_read_timeout默认 60 秒对某些慢思考模型来说根本不够要调到 300 秒以上。排查时最直接的办法是用 curl 模拟客户端curl -N -H Accept: text/event-stream https://your-api.example.com/llm/stream-N表示关闭 curl 的缓冲让输出实时打印。如果 curl 能稳定输出而浏览器控制台报超时那就基本锁定是代理层或网关层的问题而不是后端代码的问题。5.2 Nginx 把流式响应“吞”了第二个高频坑是 Nginx 缓冲。默认情况下Nginx 作为反向代理会缓冲上游响应等攒够一定量再一次性发给客户端。这在普通接口没什么毛病但对 SSE 是致命的——本来应该逐 token 推送的内容被 Nginx 攒着前端还是一等十几秒没动静最后攒完才哗啦啦全出来流式的意义完全消失。针对 SSE location 的 Nginx 配置要这样写location /llm/stream { proxy_pass http://backend; proxy_http_version 1.1; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; proxy_set_header Connection ; }proxy_buffering off是关键它告诉 Nginx 不要对响应做缓冲。此外服务端在响应头里加X-Accel-Buffering: no也能起到同样的效果哪怕你没有权限改 Nginx 配置也能通过这个响应头让 Nginx 对当前请求关闭缓冲。我一般两个都加双保险。还有一个隐藏坑是 gzip。如果 Nginx 对 SSE 的响应启用了 gzip 压缩流式推送的每块小数据都要等压缩缓冲凑够同样会延迟数据到达。SSE 场景里最好对该 location 关闭 gzip或者在响应头里加Cache-Control: no-transform明确告诉中间层不要对响应做任何转换。5.3 SSE 鉴权怎么做鉴权是我被问得最多的问题。原生 EventSource 不能自定义 header 这件事把很多人卡在“接口要 Authorization但 EventSource 带不了”的尴尬里。绕来绕去方案无非三种。Cookie 鉴权如果你们的服务端是 Cookie-Session 模式浏览器会自动带上 CookieEventSource 也能正常工作这个方案最省事但受限于同域。Query 参数传 token把 token 拼在 URL 上比如/api/llm/stream?tokenxxx。能用但丑而且 token 会记录在服务端访问日志、代理日志里存在泄露风险只建议用在短期有效的场景。先用普通请求换“流式专用 token”前端 POST 一个正常请求拿鉴权服务端返回一个 30 秒内有效的临时 stream-token然后再用这个 token 去建立 SSE 连接。这个方案安全性和体验都兼顾了但实现起来最重。而如果改用我前面讲的 fetch 方案这个纠结完全不存在headers里直接写Authorization: Bearer xxx和普通请求一样传鉴权信息不需要 cookie、不需要在 URL 里塞 token。这也是我坚持前端不要用 EventSource 的一个核心理由。5.4 HTTP/1.1 连接数打满页面全部卡死这不是流式协议本身的问题是 HTTP 连接管理的经典坑。浏览器对同一个域名下的并发连接数有限制HTTP/1.1 一般最多 6 个。SSE 是一个常驻长连接一个页面同时在跑多个对话流、多个 Agent 任务时6 个连接很快就会被占满此时页面里任何其他 HTTP 请求都会排队等待表现就是“图片加载不出来、按钮点了没反应”。遇到这个问题第一选择是把服务端和网关升级到 HTTP/2。HTTP/2 的多路复用让同域名下的并发请求不再受 6 个连接限制允许在一个 TCP 连接上同时跑很多请求“连接打满”的情况基本消失。第二选择是把 SSE 接口放到独立的子域名下这样它能享用另一个域名的 6 个连接额度和主站互不干扰。用 HTTP/2 时还要注意一点长连接场景下服务端如果想让一个事件立刻到达客户端依然要保证不经过缓冲HTTP/2 的帧机制本身不会破坏 SSE但代理层缓冲的问题在 HTTP/2 下依然存在该关闭缓冲还是要关。5.5 页面关闭后服务端还在烧 token最后一个坑我愿称之为“钱坑”。用户打开对话页面点了一个很长的请求看了一半感觉不对直接把页面关了。前端页面销毁fetch 请求被浏览器取消但服务端如果没感知到连接关闭会继续把大模型的 token 流式拉取完。Node.js 服务端监听req.on(close)事件可以感知客户端断开const server http.createServer(async (req, res) { const controller new AbortController(); req.on(close, () { // 客户端断开连接 if (!res.writableEnded) { controller.abort(); } }); try { const stream await callLLMStream({ signal: controller.signal }); for await (const chunk of stream) { if (res.writableEnded) break; res.write(...); } } catch (err) { // 如果是因为 abort 导致的错误静默处理即可 } });把AbortController.signal一路传给底层的大模型调用 SDK连接断开时立刻终止上游请求。我见过不少项目只处理了前端 abort服务端退出逻辑漏掉了一个月多烧掉几千块 token 费用。建议把这个点写进服务端代码评审的 checklist 里。6. 从项目实践里总结的几点体会做了大半年 LLM 流式输出的接入工作我个人的最大体会是SSE 虽然是个很老的协议但在大模型流式输出场景里依然是最合适、最省心的选择前提是你理解了它的边界并用对姿势。用 fetch 而不是 EventSource、把 data 统一设计成 JSON、协议上兼容 OpenAI 风格、对代理层缓冲和超时做预处理这几件事能在上线阶段帮你少踩一大半坑。最后再分享一个我最近在做的优化方向流式输出加“可恢复性”。目前我们的前端重连逻辑是断线后从头开始重新请求虽然服务端支持 Last-Event-ID但业务层的对话上下文还没有完善地把断点和状态一起恢复。下一步我想把事件 id 设计成单调递增的序号配合服务端缓存最近事件让断线重连后客户端可以补齐缺失的部分而不是让用户重新等一遍完整回答。这个方向要是跑通了长回答场景的稳定性还能再上一个台阶。