
现在但凡做过一点 AI 应用开发的人都会注意到一个现象ChatGPT、Claude、文心一言这些产品在回答问题时文字不是啪一下全出来的而是一个字一个字往外蹦。这个体验背后其实藏着一套相当精巧的技术方案。很多人第一次接触这个概念时会觉得不就是打字机效果吗但真到自己动手做的时候才发现从后端到前端整条链路里有不少门道——为什么用 fetch 而不是 EventSourceReadableStream 到底怎么读用户点了停止按钮之后后端还在跑怎么办这些问题不踩一遍坑是很难搞清楚的。我自己在做一个内部知识库问答工具的时候完整地把这套流式响应的链路从零搭了一遍中间踩了不少坑也积累了一些文档里不太会写的经验。这篇文章就把整个实现过程拆开来讲从 HTTP 协议层面的数据格式到前端 fetch ReadableStream 的逐字解析再到 AbortController 的中断处理尽量把每个环节的为什么讲清楚。不管你是刚接触流式响应的新手还是已经用过 EventSource 但想搞清楚底层原理的开发者应该都能从里面找到有用的东西。1. 流式响应到底解决了什么问题1.1 从等十秒到立刻有反应的体验差异先想一个最朴素的场景。你问 AI 一个问题后端拿到请求后调用大模型大模型生成完整回答大概需要 8 到 15 秒。如果按照传统的 HTTP 请求-响应模式用户点完发送之后界面上只能显示一个 loading 转圈整整十几秒什么都看不到然后突然一大段文字全部出现。这个体验其实非常糟糕——用户不知道系统是不是卡死了也不知道回答有多长甚至可能以为请求失败了直接刷新页面。流式响应要解决的核心问题就是这个让首字节到达的时间从十几秒缩短到几百毫秒。大模型生成 token 是一个一个往外吐的每生成一个 token 大概几十毫秒。如果我们不等它全部生成完而是生成一个就往前端推一个那么用户几乎在点击发送后立刻就能看到第一个字出现然后文字像打字一样持续输出。虽然总时长没变但感知上的等待时间从十几秒变成了几乎为零。这里有个关键认知流式响应并没有让模型变快它改变的是数据的传输时机。传统的响应是攒够了再发流式是有一个发一个。这个思路在 HTTP 协议里其实早就有了只是大模型场景把它推到了前台。1.2 流式传输和普通请求的本质区别要理解流式得先理解普通 HTTP 响应是怎么工作的。在标准模式下服务器会把整个响应体准备好计算好 Content-Length然后一次性发出去。客户端收到完整的响应后才认为这次请求结束。整个过程中连接虽然可能保持但数据是整块交付的。流式传输打破了这个模式。服务器发送响应头的时候通常不设置 Content-Length而是用Transfer-Encoding: chunked分块传输编码。这意味着响应体被切成一块一块地发送每块自带长度信息客户端收到一块就能处理一块不需要等全部到齐。连接会一直保持打开状态直到服务器主动发送一个长度为 0 的块表示结束。对于 AI 问答来说这个机制完美契合模型每生成一段文本后端就把它包装成一个数据块推出去前端收到就渲染。整个过程就像水管一样水龙头开着水就持续流出来而不是等满一桶再倒给你。1.3 为什么大模型场景非流式不可有人可能会问我能不能用轮询前端每隔一秒问一次后端生成好了没生成好了就取回来。技术上可行但体验很差轮询有延迟你不知道下一秒会不会有新内容轮询请求本身有开销每次都要建立连接、带认证信息而且轮询的粒度很粗做不到逐字的效果。也有人想用 WebSocket。WebSocket 确实是全双工的长连接理论上更适合实时通信。但对于 AI 问答这种一问一答、单向推送的场景WebSocket 有点杀鸡用牛刀。它需要额外的握手升级、心跳维护、断线重连逻辑而且很多基础设施比如 CDN、反向代理对 WebSocket 的支持不如普通 HTTP 那么顺滑。相比之下SSEServer-Sent Events基于普通 HTTP实现简单天然支持自动重连是 AI 问答场景更务实的选择。所以结论很清晰AI 问答的流式响应主流方案就是基于 HTTP 的 SSE。接下来我们就从协议格式开始一层层往下拆。2. SSE 协议格式流式数据的信封2.1 SSE 的数据帧长什么样SSE 全称 Server-Sent Events是 HTML5 规范里定义的一种服务器推送技术。它的数据格式非常简单就是纯文本用特定的字段名和换行来组织。一个典型的 SSE 数据帧长这样data: {content: 你} data: {content: 好} data: {content: } data: [DONE]每一行以字段名开头冒号后面跟值。常见的字段有四个data消息内容最常用event事件类型默认是 messageid消息 ID用于断线重连时定位retry重连等待时间毫秒每个数据帧以两个换行符\n\n结束。这个双换行是 SSE 的帧分隔符解析时必须靠它来切分。注意单个\n只是字段内的换行两个连续的\n才表示一个完整消息结束。2.2 为什么用双换行做分隔符这个设计其实挺讲究的。SSE 是基于文本流的服务器可能一次发送多个帧也可能一个帧分多次发送受 TCP 分片影响。客户端拿到的是字节流必须有一个明确的边界标记才能正确切分。双换行在文本内容里几乎不会自然出现除非内容本身包含空行所以作为分隔符既安全又简单。但这里有个坑TCP 是流式的不保证消息边界。服务器发了一个完整的 SSE 帧客户端可能一次收到半个帧也可能一次收到两个半帧。所以前端解析的时候不能假设每次 read 拿到的就是一个完整帧必须自己维护一个缓冲区按\n\n切分切出来完整的就处理剩下的留在缓冲区等下次数据。2.3 后端如何构造 SSE 响应后端要返回 SSE响应头必须设置正确否则浏览器不会按流式处理Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive X-Accel-Buffering: noContent-Type: text/event-stream是 SSE 的标识浏览器看到这个类型就知道是事件流。Cache-Control: no-cache防止中间层缓存。X-Accel-Buffering: no是给 Nginx 看的告诉它不要缓冲这个响应——这个头非常关键很多人本地测试正常一上生产就变成一次性输出十有八九是 Nginx 在缓冲。后端代码大致是这样以 Node.js 为例res.setHeader(Content-Type, text/event-stream); res.setHeader(Cache-Control, no-cache); res.setHeader(Connection, keep-alive); res.setHeader(X-Accel-Buffering, no); // 调用大模型逐 token 推送 for await (const token of modelStream) { res.write(data: ${JSON.stringify({ content: token })}\n\n); } res.write(data: [DONE]\n\n); res.end();注意res.write而不是res.send前者是流式写入后者会结束响应。每写一个帧就 flush 一次确保数据立刻到达客户端。3. 前端为什么选 fetch 而不是 EventSource3.1 EventSource 的便利与局限提到 SSE很多人第一反应是用浏览器原生的EventSourceAPI。它确实简单const es new EventSource(/api/chat); es.onmessage (e) { console.log(e.data); };自动重连、自动解析帧、事件分发全都帮你搞定了。但它在 AI 问答场景里有个致命缺陷只支持 GET 请求不能自定义请求头不能带请求体。AI 问答的请求通常需要 POST因为要把用户的问题、对话历史、模型参数放在请求体里。而且往往需要带Authorization头做鉴权。EventSource 这两样都做不到。你可能想用 URL 参数传问题但对话历史一长URL 长度就爆了而且把敏感信息放 URL 里也不安全。3.2 fetch ReadableStream 的完整控制力fetch就没这些限制。它支持 POST、支持自定义头、支持请求体而且返回的response.body是一个ReadableStream可以手动读取字节流。这就把控制权完全交给了开发者const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${token} }, body: JSON.stringify({ question, history }), signal: abortController.signal }); const reader response.body.getReader(); const decoder new TextDecoder(); while (true) { const { done, value } await reader.read(); if (done) break; const text decoder.decode(value, { stream: true }); // 处理 text }这段代码是流式响应的核心。getReader()拿到读取器read()返回一个 Promise每次解析出一个数据块。done为 true 表示流结束。TextDecoder负责把字节转成字符串注意{ stream: true }这个参数——它告诉解码器后面还有数据这样遇到多字节字符比如中文被 TCP 分片切断时解码器会缓存半个字符等下一块数据到了再拼起来。如果不加这个参数中文很容易出现乱码。3.3 两种方案的对比与选型建议维度EventSourcefetch ReadableStream请求方法仅 GET任意方法自定义请求头不支持完全支持请求体不支持支持自动重连内置需手动实现中断控制close()AbortController解析工作自动手动解析适用场景简单推送AI 问答、复杂交互选型结论很明确AI 问答场景一律用 fetch ReadableStream。EventSource 更适合那种服务端主动推送通知、不需要客户端传复杂参数的场景。多写几十行解析代码换来的是完整的控制力这笔账很划算。4. 逐字解析的完整实现链路4.1 缓冲区管理与帧切分前面提到TCP 不保证消息边界所以前端必须自己维护缓冲区。核心逻辑是每次读到新数据追加到缓冲区然后按\n\n切分能切出完整帧的就处理最后一段留在缓冲区。let buffer ; function processChunk(text) { buffer text; const frames buffer.split(\n\n); // 最后一段可能不完整留在缓冲区 buffer frames.pop(); for (const frame of frames) { if (!frame.trim()) continue; // 解析 frame const lines frame.split(\n); for (const line of lines) { if (line.startsWith(data: )) { const data line.slice(6); if (data [DONE]) { // 流结束 return; } const parsed JSON.parse(data); // 渲染 parsed.content } } } }这里有个细节frames.pop()取出的最后一段可能是完整的也可能是不完整的。如果这次数据刚好以\n\n结尾那 pop 出来的是空字符串没问题如果没结尾pop 出来的就是半个帧留在缓冲区等下次。这个逻辑必须写对否则会出现偶尔丢字或JSON 解析报错的问题。4.2 TextDecoder 处理中文乱码的关键中文乱码是流式解析里最常见的坑之一。原因在于 UTF-8 编码的中文占 3 个字节如果这 3 个字节被 TCP 分片切成了 12 或者 21单独解码任何一半都会得到乱码。TextDecoder的{ stream: true }参数就是解决这个问题的。它会让解码器在遇到不完整的字节序列时把剩余字节缓存起来等下次 decode 时再拼接。所以正确的写法是const decoder new TextDecoder(utf-8); // 每次 read 后 const text decoder.decode(value, { stream: true }); // 流结束时flush 剩余字节 const finalText decoder.decode();最后那次不带参数的decode()是 flush 操作把缓冲区里可能残留的字节吐出来。虽然正常情况下不会有残留但加上更保险。4.3 从 token 到 DOM 的渲染策略拿到 token 之后怎么渲染到界面上也有讲究。最直接的做法是每次收到 token 就更新 DOMelement.textContent token;但这样有个性能问题如果 token 来得很快比如每秒几十个频繁操作 DOM 会导致页面卡顿而且浏览器的重排重绘开销不小。更好的做法是用一个队列缓冲 token然后用requestAnimationFrame批量更新let pending ; let rafId null; function appendToken(token) { pending token; if (!rafId) { rafId requestAnimationFrame(() { element.textContent pending; pending ; rafId null; }); } }这样每帧最多更新一次 DOM既保证了流畅度又不会丢字。实测下来即使用户快速滚动页面文字输出也不会卡。5. AbortController让停止生成真正停下来5.1 用户点停止后发生了什么AI 问答界面上通常有个停止生成按钮。用户点了之后前端要做的第一件事是停止读取流第二件事是通知后端停止调用模型。如果只做第一件事后端还在傻乎乎地生成 token白白消耗算力。AbortController就是干这个的。它提供一个signal对象传给 fetch 之后调用abort()就能中断请求const controller new AbortController(); fetch(/api/chat, { signal: controller.signal, // ... }); // 用户点停止 controller.abort();调用abort()后fetch 的 Promise 会 reject 一个AbortErrorreader.read()也会抛出异常。所以读取循环要包在 try-catch 里try { while (true) { const { done, value } await reader.read(); if (done) break; // 处理 } } catch (err) { if (err.name AbortError) { console.log(用户主动停止); } else { console.error(其他错误, err); } }5.2 中断信号如何传递到后端前端 abort 之后浏览器会关闭这个 HTTP 连接。后端如果监听连接的close事件就能感知到客户端断开了从而停止模型调用req.on(close, () { // 客户端断开停止模型生成 modelStream.cancel(); });这一步非常关键。很多实现只做了前端中断后端还在跑结果就是用户点了停止服务器还在烧钱。正确的做法是前后端联动前端 abort 关闭连接后端监听 close 事件取消模型流。5.3 中断后的状态清理与重试中断之后还有一堆状态要清理reader 要释放、缓冲区要清空、loading 状态要复位、停止按钮要隐藏。如果用户中断后马上又发新问题还得确保旧的流不会干扰新的流。我的做法是给每次请求分配一个唯一的 requestId所有回调都检查 requestId 是否匹配当前请求不匹配就忽略。这样即使旧流的回调延迟到达也不会污染新请求的界面。let currentRequestId 0; async function send(question) { const myId currentRequestId; // ... while (true) { const { done, value } await reader.read(); if (myId ! currentRequestId) return; // 已被新请求取代 // ... } }这个模式在处理用户连续快速提问的场景时特别有用能避免界面出现两个回答混在一起的情况。6. 生产环境里那些文档不会写的坑6.1 Nginx 缓冲导致的假流式这是最经典的坑。本地开发一切正常逐字输出很流畅一部署到生产环境就变成等半天然后全部出现。原因几乎可以肯定是 Nginx 在缓冲响应。Nginx 默认会对上游响应做缓冲攒够一定大小才发给客户端。对于 SSE 这种需要实时推送的场景必须关掉缓冲。两个办法location /api/chat { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_set_header Connection ; proxy_http_version 1.1; chunked_transfer_encoding off; }或者在应用层设置X-Accel-Buffering: no响应头Nginx 看到这个头会自动关闭缓冲。我一般两个都做双保险。6.2 代理和网关的超时设置流式连接是长连接可能持续几十秒甚至几分钟。中间任何一层Nginx、负载均衡、API 网关如果超时时间设得太短连接就会被掐断用户看到的就是回答到一半突然停了。需要检查的超时参数层级参数建议值Nginxproxy_read_timeout300sNginxproxy_send_timeout300s负载均衡idle_timeout300s应用keep-alive timeout300s具体值根据业务调整但一定要比最长回答时间长。另外可以在流中定期发送心跳比如每 15 秒发一个注释帧: heartbeat\n\n保持连接活跃。6.3 错误处理与降级方案流式请求比普通请求更容易出错网络抖动、模型超时、后端崩溃都可能导致流中断。前端必须能优雅处理这些情况。我的做法是如果流在收到[DONE]之前就断了就把已收到的内容保留在末尾加一个生成中断点击重试的提示。同时记录错误日志方便排查。如果连续多次失败就降级到非流式接口至少保证功能可用。let receivedDone false; // ... 读取循环中 if (data [DONE]) receivedDone true; // 循环结束后 if (!receivedDone) { showRetryHint(); }6.4 移动端和弱网环境的特殊处理移动端网络切换WiFi 转 4G会导致连接断开这是流式响应的一个大敌。处理办法是监听visibilitychange和online事件在页面重新可见或网络恢复时检查流的状态必要时自动重连。另外弱网环境下 token 到达会不均匀可能几秒没动静然后突然来一大串。这时候 UI 上最好有个正在思考的动画让用户知道系统没死。我一般会在超过 2 秒没收到新 token 时显示一个脉冲动画收到就隐藏。7. 性能优化与进阶思路7.1 减少 JSON 解析开销每个 token 都包一层 JSON 再解析其实有开销。如果 token 量大可以考虑简化格式比如直接用纯文本帧用特殊分隔符区分内容和元数据。不过 JSON 的可扩展性更好方便加 token 计数、finish_reason 等字段所以除非性能瓶颈明显否则不建议过早优化。真要优化的话可以在后端把多个 token 合并成一个帧发送比如每 5 个 token 发一次。这样减少了帧数量前端解析次数也少了。代价是首字延迟略微增加需要权衡。7.2 背压处理当渲染跟不上接收如果模型生成速度很快而前端渲染慢比如要跑 Markdown 解析、代码高亮就会出现接收快、渲染慢的情况token 在内存里堆积。这时候需要背压机制当待渲染队列超过一定长度时暂停读取流等渲染追上再继续。if (pendingQueue.length 100) { await new Promise(r setTimeout(r, 50)); }虽然ReadableStream本身有背压支持但手动加一层缓冲控制更直观。7.3 多轮对话中的流式管理多轮对话场景下每次提问都是一次新的流式请求。要管理好这些请求的生命周期新请求发出时中断旧请求、维护对话历史、处理并发。我一般用一个数组维护消息列表每条消息带状态pending/streaming/done/aborted渲染时根据状态显示不同的 UI。这套机制搭好之后整个问答体验就非常顺滑了。用户看到的是文字流畅地蹦出来点停止立刻停网络抖动也能优雅恢复。这些细节堆起来才是一个真正可用的 AI 问答产品。最后分享一个我踩过的坑有一次线上突然大量用户反馈回答不完整排查了半天发现是某个中间层代理的缓冲区大小设成了 4KB超过就截断。所以流式响应上线前一定要把链路上每一层的缓冲和超时都检查一遍任何一层没配对整个流式体验就废了。这个教训让我养成了一个习惯新环境部署流式服务先用 curl 直接打后端确认流式正常再逐层加上代理测试一层层排除比盲目猜快得多。