
1. 流式响应不是“快”而是“不等”从用户敲下回车那一刻说起你有没有试过在某个AI聊天页面里刚输入问题、按下回车光标还没来得及闪烁第二下第一行字就跳出来了不是整段返回不是弹窗提示“加载中”而是一字一字、像打字员在你眼前实时敲出来——“你好”、“我”、“是”、“一”、“个”……这种体验我们叫它流式响应Streaming Response。它和传统HTTP请求最根本的区别不在于速度多快而在于服务端不再攒着所有答案一起发而是边生成边推客户端也不再干等全部数据收齐才开始处理。这背后没有魔法只有三件确定性极强的工程事实第一大模型推理本身是token级逐词生成的天然具备流式输出能力第二浏览器作为前端载体必须有能持续接收碎片化数据的通道第三前后端之间需要一种轻量、可靠、兼容性广的传输协议把“逐字”这个行为稳稳落地。而标题里提到的fetch SSE正是当前Web端实现这一目标最主流、最可控、也最容易被开发者真正吃透的技术组合。很多人误以为SSEServer-Sent Events是“高级功能”其实它本质就是HTTP协议的一个巧妙延伸服务端用一个长连接持续往响应体里写入纯文本数据块每块以data:开头以\n\n分隔浏览器用EventSource API监听并自动解析。它不像WebSocket那样双向全双工但对AI问答这种“单向推送结果”的场景恰恰够用、够稳、够简单。而fetch本身并不原生支持SSE流式读取——这是关键误区。fetch发起的是标准HTTP请求返回的是ReadableStream你需要手动读取、解码、按行分割、识别data字段才能还原出SSE语义。换句话说SSE是协议规范fetch是传输载体二者不是绑定关系而是协作关系。标题说“从fetch到SSE”指的就是这条技术链路用fetch建立连接 → 获取ReadableStream → 按SSE格式解析流 → 逐块更新UI。我第一次在生产环境上线流式响应时客户提的需求很朴素“别让用户盯着转圈圈等3秒哪怕先吐出‘正在思考…’四个字也比黑屏强。”结果上线后用户停留时长提升了27%跳出率下降了19%。这不是因为答案变准了而是因为等待感知被重构了——人类对延迟的容忍度远低于对“无反馈空转”的焦虑。所以这篇文章不讲抽象概念只拆解真实代码里每一行为什么这么写、哪个参数动不得、哪类错误必须捕获、为什么Chrome能跑通而Safari会静默失败。接下来我们就从一次真实的fetch调用开始逐帧拆解数据如何从模型输出变成你屏幕上跳动的文字。2. fetch请求的底层握手Headers、Body与Connection生命周期要让流式响应跑起来第一步不是写解析逻辑而是确保fetch请求本身就被服务端正确识别为“流式请求”。这看似简单实则藏着三个极易被忽略的细节Accept头、Content-Type声明、以及最关键的Connection管理。首先看请求头。标准fetch调用默认发送Accept: */*但服务端需要明确知道“我要给你流式数据”。因此必须显式设置const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json, Accept: text/event-stream, // 关键告诉后端我要SSE格式 Cache-Control: no-cache, // 防止CDN或浏览器缓存截断流 }, body: JSON.stringify({ message: 你好 }) });这里Accept: text/event-stream不是可选装饰而是服务端路由分流的依据。很多AI后端框架如FastAPI、Express custom middleware会根据这个Header决定是否启用流式响应中间件。如果漏掉后端可能直接走普通JSON响应流程返回一个完整对象而你的前端还在傻等data:块——永远等不到。其次Cache-Control: no-cache常被忽视。流式响应本质是长连接一旦中间代理比如公司内网网关、CDN节点对响应做了缓存它可能等数据攒满一整块再转发或者干脆超时断开。实测中某次上线后用户反馈“前10个字卡住5秒才出来”排查发现是Nginx默认启用了proxy_buffering把前几KB数据缓存后再吐出。解决方案就是在反向代理层显式关闭缓冲location /api/chat { proxy_pass http://backend; proxy_buffering off; # 关键禁用代理缓冲 proxy_cache off; proxy_http_version 1.1; proxy_set_header Connection ; }最后是Connection生命周期。fetch返回的Response对象有个body属性类型是ReadableStream。它不是一次性加载完的数据而是一个可迭代的字节流。你必须主动调用response.body.getReader()获取Reader实例然后用reader.read()循环读取chunk。这里有个致命陷阱不能用await response.json()或response.text()它们会试图消费整个流并等待关闭导致流式失效。// ❌ 错误示范会阻塞直到流结束 const data await response.json(); // 等待整个响应完成失去流式意义 // ✅ 正确做法手动读取流 const reader response.body.getReader(); while (true) { const { done, value } await reader.read(); if (done) break; // 处理valueUint8Array }更进一步reader.read()返回的value是Uint8Array即原始二进制字节。你不能直接当字符串用必须先解码。但解码时机很讲究SSE数据块可能被TCP/IP分片一个完整的data: hello\n\n可能被拆成两个chunk到达。如果每次收到chunk就立刻new TextDecoder().decode(value)你会得到乱码比如data: hel和lo\n\n分开解码。正确做法是维护一个buffer累积字节直到遇到完整的\n\n分隔符再切片解码。提示浏览器ReadableStream的read()方法在流关闭前不会resolve所以while(true)是安全的。但必须监听done true退出循环否则会无限pending。实际项目中建议加超时控制比如10秒无数据则主动abort。3. SSE数据块的逐字解码从二进制流到可渲染文本的七步转化拿到Uint8Array只是开始真正的挑战在于如何从一堆字节里精准识别出SSE协议定义的data:字段、过滤掉event:id:retry:等元信息、合并被分片的data块、并最终提取出模型生成的纯文本这个过程我称之为“SSE七步解码法”已在三个不同AI产品中稳定运行超18个月。3.1 Step 1字节累积与行边界识别SSE规范要求每个事件块以\n\n结尾。但网络传输中\n\n可能跨chunk到达。因此第一步是构建一个字节缓冲区buffer将所有value追加进去并扫描\n\n位置let buffer new Uint8Array(0); function appendChunk(chunk) { const newBuffer new Uint8Array(buffer.length chunk.length); newBuffer.set(buffer, 0); newBuffer.set(chunk, buffer.length); buffer newBuffer; } // 扫描buffer中所有\n\n位置 function findLineBreaks() { const breaks []; for (let i 0; i buffer.length - 1; i) { if (buffer[i] 10 buffer[i 1] 10) { // \n is 10 breaks.push(i); } } return breaks; }3.2 Step 2按行切片丢弃空行找到所有\n\n索引后将buffer按行切片注意SSE行以\n或\r\n结尾但\n\n是块分隔符。我们只关心非空行const lines []; let start 0; const breaks findLineBreaks(); for (const end of breaks) { const line buffer.slice(start, end); // 取\n\n前的内容 if (line.length 0) { lines.push(line); } start end 2; // 跳过\n\n } // 处理剩余未闭合的块buffer末尾可能没\n\n if (start buffer.length) { const remaining buffer.slice(start); if (remaining.length 0) lines.push(remaining); }3.3 Step 3UTF-8解码与字段解析每行都是Uint8Array需用TextDecoder解码为字符串。SSE字段格式为field: value冒号后可能有空格。我们提取data:字段值const decoder new TextDecoder(utf-8); const events []; for (const line of lines) { const str decoder.decode(line).trim(); if (!str.startsWith(data:)) continue; const value str.substring(5).trim(); // 去掉data:和空格 if (value) events.push(value); }3.4 Step 4处理多行data块SSE允许一个事件包含多行data:它们会被拼接成一个完整消息。规范要求连续的data:行其value用\n连接。所以我们需要合并let currentData ; for (const line of lines) { const str decoder.decode(line).trim(); if (str.startsWith(data:)) { const value str.substring(5).trim(); if (value) { currentData value \n; } else { // 空data:行表示结束推送当前累积内容 if (currentData) { events.push(currentData.slice(0, -1)); // 去掉末尾\n currentData ; } } } else if (str ) { // 空行结束当前事件 if (currentData) { events.push(currentData.slice(0, -1)); currentData ; } } }3.5 Step 5JSON解析与token提取AI后端通常返回data: {token: 好, finish_reason: null}这类JSON字符串。我们需要解析并提取token字段for (const event of events) { try { const parsed JSON.parse(event); if (parsed.token) { // 这就是模型生成的一个token renderToken(parsed.token); } } catch (e) { // 忽略解析失败的垃圾数据如心跳包、debug日志 } }3.6 Step 6UI渲染的防抖与连贯性直接renderToken(好)会导致文字跳跃。真实体验中我们采用“字符队列定时刷新”策略将token暂存数组每16ms约一帧批量渲染模拟打字机效果const tokenQueue []; function renderToken(token) { tokenQueue.push(token); if (!renderTimer) { renderTimer requestAnimationFrame(() { const text tokenQueue.join(); document.getElementById(output).textContent text; tokenQueue.length 0; // 清空 renderTimer null; }); } }3.7 Step 7buffer清理与内存控制buffer持续增长会OOM。我们设定阈值如1MB超过则截断旧数据if (buffer.length 1024 * 1024) { buffer buffer.slice(-512 * 1024); // 保留后512KB }这套流程看似繁琐但它是流式响应稳定性的基石。我曾在线上环境抓包发现某次模型返回的data:块里混入了调试日志data: {debug: gpu_mem: 12GB}因未做JSON parse try-catch导致整个流中断。加上这七步问题自然隔离。4. 服务端的SSE实现FastAPI实战与三大避坑点前端流式解析再完美若服务端没按规范输出一切归零。以Python FastAPI为例展示一个生产级SSE接口的写法并指出三个90%开发者踩过的坑。4.1 核心代码Generator StreamingResponsefrom fastapi import Response, Request from starlette.responses import StreamingResponse import json import asyncio async def sse_generator(request: Request): # 模拟模型流式生成 tokens [你, 好, , 世, 界, ] for token in tokens: # 构造SSE事件块 event_data { token: token, finish_reason: None } # 每个块必须以data:开头\n\n结尾 yield fdata: {json.dumps(event_data, ensure_asciiFalse)}\n\n # 模拟生成延迟真实场景是await model.generate_next_token() await asyncio.sleep(0.3) # 结束事件 yield data: {\finish_reason\: \stop\}\n\n app.post(/api/chat) async def chat_stream(request: Request): return StreamingResponse( sse_generator(request), media_typetext/event-stream, headers{ Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no, # Nginx专用禁用缓冲 } )4.2 避坑点一media_type必须是text/event-stream这是浏览器识别SSE的唯一依据。写成application/json或text/plainEventSource会报错fetch则无法触发流式读取。FastAPI中StreamingResponse的media_type参数不可省略。4.3 避坑点二X-Accel-Buffering: no是Nginx生命线如果你用Nginx反向代理FastAPINginx默认开启proxy_buffering on它会等后端返回至少4KB数据或超时才转发给前端。这意味着前几个token永远卡住。解决方案是在StreamingResponse的headers里加X-Accel-Buffering: no这是Nginx专有Header告诉它“别缓存收到啥就发啥”。4.4 避坑点三yield后必须await异步操作上面代码中await asyncio.sleep(0.3)不是为了模拟延迟而是强制让出控制权避免阻塞事件循环。如果换成time.sleep(0.3)整个FastAPI应用会卡死因为同步sleep会阻塞uvicorn的单线程事件循环。真实场景中模型推理调用如await llama_cpp_model.generate()必须是异步的否则流式响应形同虚设。注意某些LLM框架如transformers默认同步需包装成异步。例如用loop.run_in_executorloop asyncio.get_event_loop() result await loop.run_in_executor(None, model.generate, prompt)4.5 进阶处理客户端断连与重连SSE天然支持重连。客户端断开后服务端会收到client_disconnected异常。你可以捕获它清理资源async def sse_generator(request: Request): try: # ... 生成逻辑 pass except asyncio.CancelledError: # 客户端关闭连接 print(Client disconnected, cleaning up...) # 释放GPU显存、取消模型推理任务等 raise同时前端可设置重连间隔const eventSource new EventSource(/api/chat); eventSource.addEventListener(open, () console.log(Connected)); eventSource.addEventListener(error, (e) { if (e.eventPhase 0) { console.log(Reconnecting...); } }); // EventSource自动重连默认3秒可修改 eventSource.onopen () { eventSource.readyState EventSource.OPEN; };5. 真实世界中的断裂与修复stream disconnected before completion: idle timeout深度排错线上监控里stream disconnected before completion: idle timeout waiting for sse这个错误出现频率极高但它从不告诉你真正原因。我梳理了过去12个月的237次同类告警发现根源只有三类且92%属于第一类。5.1 根因一反向代理空闲超时占比76%Nginx、Cloudflare、AWS ALB等代理层为防止恶意长连接耗尽资源均设置空闲超时idle timeout。例如Nginx默认keepalive_timeout 75s意味着75秒内无数据传输连接被强制关闭。而AI生成可能卡在某个token上如模型在思考复杂逻辑导致超时。验证方法查看Nginx error.log搜索upstream timed outcurl测试curl -H Accept: text/event-stream http://your-api/chat观察是否在固定时间后断开修复方案Nginx增加proxy_read_timeout 300;5分钟并确保keepalive_timeout大于它CloudflareDashboard → Rules → Transform Rules → Add Rule → SetOrigin Response Timeoutto 300AWS ALBTarget Group → Attributes →Idle timeout改为3005.2 根因二浏览器并发连接数限制占比18%Chrome对同一域名最多维持6个HTTP/1.1连接。如果用户同时打开多个AI聊天页或页面内有其他长连接如WebSocket新SSE请求会被挂起直到有连接空闲。此时fetch会卡在pending状态最终超时。验证方法Chrome DevTools → Network → Filterwsorsse→ 查看请求状态在地址栏输入chrome://net-internals/#sockets搜索你的域名修复方案使用子域名分流chat1.yourapp.com,chat2.yourapp.com启用HTTP/2HTTP/2允许多路复用单连接承载多流彻底规避此问题需服务端和CDN支持5.3 根因三服务端生成阻塞占比6%模型推理线程被锁死或GPU显存不足导致OOM新token无法生成。典型现象前几个token正常第5个token后完全停滞。验证方法查看服务端日志搜索CUDA out of memory或deadlockPrometheus监控gpu_memory_used_percent突增修复方案为每个请求分配独立GPU上下文如使用vLLM的--tensor-parallel-size设置生成超时model.generate(..., timeout30)超时抛出异常主动关闭流5.4 终极防御前端心跳保活即使上述都配置正确网络抖动仍可能导致意外断连。我们在SSE流中加入心跳机制# 服务端每15秒发一个空事件 async def sse_generator(request: Request): last_heartbeat time.time() while True: # ... 生成token逻辑 # 发送心跳 if time.time() - last_heartbeat 15: yield : heartbeat\n\n last_heartbeat time.time() await asyncio.sleep(0.1)// 前端监听心跳重连逻辑 let lastHeartbeat Date.now(); eventSource.onmessage (e) { if (e.data ) { // 心跳事件data为空 lastHeartbeat Date.now(); } else { // 处理真实数据 } }; // 每30秒检查心跳 setInterval(() { if (Date.now() - lastHeartbeat 45000) { eventSource.close(); eventSource new EventSource(/api/chat); } }, 30000);这套组合拳下来“idle timeout”错误下降了99.2%。记住流式响应的稳定性70%靠基础设施配置30%靠代码健壮性。6. fetch vs EventSource为什么我们坚持用fetch手写解析看到这里你可能会问既然有现成的EventSourceAPI为什么还要费劲用fetchReadableStream手动解析答案很现实EventSource在AI流式场景下存在三个不可绕过的硬伤。6.1 硬伤一无法自定义请求头EventSource构造函数只接受URL不支持传入headers。这意味着你无法设置Authorization: Bearer xxx或X-User-ID: 123。所有认证信息只能塞在URL里如/api/chat?tokenxxx这违反安全最佳实践token暴露在server log、browser history中。// ❌ EventSource无法带headers const es new EventSource(/api/chat?authxxx); // 不安全 // ✅ fetch可以自由设置headers const response await fetch(/api/chat, { headers: { Authorization: Bearer token, X-User-ID: userId } });6.2 硬伤二无法捕获HTTP错误状态码EventSource在收到4xx/5xx响应时只会触发error事件但你拿不到具体的status code。而AI服务常返回401token过期、429限流、403权限不足这些信息对前端重试逻辑至关重要。// ❌ EventSource无法获取status es.onerror () { console.log(Something went wrong); // 仅此而已 }; // ✅ fetch可精确判断 const response await fetch(/api/chat); if (!response.ok) { switch (response.status) { case 401: redirectToLogin(); break; case 429: showRateLimitTip(); break; case 403: showPermissionDenied(); break; } }6.3 硬伤三Safari的兼容性黑洞Safari对EventSource的支持存在已知bug当服务端发送retry: 5000后Safari可能忽略该指令仍按默认3秒重连。更糟的是它不触发error事件导致前端无法感知断连。而fetchReadableStream在所有现代浏览器中行为一致。6.4 我们的折中方案封装成类库为避免重复造轮子我们封装了一个SSEClient类内部用fetch对外提供类似EventSource的简洁APIclass SSEClient { constructor(url, options {}) { this.url url; this.headers options.headers || {}; this.onmessage null; this.onerror null; } async connect() { const response await fetch(this.url, { method: POST, headers: { Content-Type: application/json, Accept: text/event-stream, ...this.headers }, body: JSON.stringify(this.options.body || {}) }); if (!response.ok) throw new Error(HTTP ${response.status}); 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 }); const lines buffer.split(\n); buffer lines.pop() || ; // 保留不完整行 for (const line of lines) { if (line.startsWith(data:)) { const data line.substring(5).trim(); if (data this.onmessage) { this.onmessage({ data }); } } } } } } // 使用方式 const client new SSEClient(/api/chat, { headers: { Authorization: Bearer xxx } }); client.onmessage (e) console.log(e.data); client.connect();这个类库已在我们所有AI产品中复用代码量仅200行却解决了EventSource的所有痛点。技术选型没有绝对优劣只有场景适配——当你的AI服务需要认证、错误分类、全浏览器兼容时fetch手写就是最优解。7. 从流式响应到用户体验那些文档里不会写的实战技巧最后分享几个在真实产品中打磨出来的技巧它们不改变技术原理却极大提升用户感知。7.1 技巧一首token延迟优化First Token Latency用户最敏感的是“第一字出来要多久”。模型推理的prefill阶段处理prompt往往比decode阶段生成token慢得多。我们通过预热机制降低首token延迟在用户进入聊天页时后台静默发起一个/api/prefill?prompt 请求触发模型加载和KV cache初始化当用户真正提问时prefill已完成首token延迟从1200ms降至300ms7.2 技巧二断连时的无缝续写用户网络闪断后重新连接不应从头开始。我们在服务端为每个请求生成唯一request_id前端将其存入localStorage。重连时带上request_id服务端查缓存从断点继续// 前端 const requestId localStorage.getItem(last_request_id) || uuidv4(); localStorage.setItem(last_request_id, requestId); fetch(/api/chat, { headers: { X-Request-ID: requestId } }); // 服务端 app.post(/api/chat) async def chat_stream(request: Request): req_id request.headers.get(X-Request-ID) if req_id and cache.exists(req_id): # 从cache恢复生成状态 return StreamingResponse(resume_from_cache(req_id))7.3 技巧三视觉节奏控制纯文字流式容易显得机械。我们加入动态效果每个token添加opacity: 0→opacity: 1CSS transition每5个token插入一个span classtyping-cursor|/span模拟打字光标最后一个token后光标闪烁3次再消失.typing-cursor { animation: blink 1.2s infinite; } keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }7.4 技巧四降级兜底策略当SSE因任何原因失败立即降级为轮询polling每500ms发一次GET /api/chat/status?idxxx服务端返回当前生成进度如{progress: 你好世, finished: false}用户无感知体验平滑过渡这些技巧的共同点是不增加架构复杂度只在现有流式链路上做微调却带来质的体验提升。技术的价值最终体现在用户指尖的温度上。我在做第一个AI聊天产品时曾花两周优化流式响应老板问“值得吗”我放了一段对比视频左边是传统整块返回3秒黑屏右边是流式0.8秒首字逐字动画。他看完说“就冲这个多招一个前端也值。”——有时候最深的技术恰恰藏在最浅的体验里。