ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

AI流式响应实战:从fetch到SSE的全链路解析

AI流式响应实战:从fetch到SSE的全链路解析 1. 流式响应不是“快”而是“边生成边吐”——从用户按下回车那一刻说起你有没有注意过当在 ChatGPT 或国内主流大模型网页端输入问题、点击发送后答案并不是等几秒突然整段弹出来而是一字一字、像打字员在你眼前实时敲出——光标在跳文字在生长甚至能看清标点符号一个一个浮现。这不是前端加了什么炫酷动画也不是网络变快了而是一种被刻意设计出来的响应节奏流式响应Streaming Response。它解决的从来不是“快不快”的问题而是“感知是否在工作”“等待是否可忍受”“中断是否可接受”的人机交互本质问题。我第一次在项目里落地流式响应时客户提的需求特别朴素“别让用户盯着转圈圈干等”。但真正动手才发现这背后牵扯的是整个请求链路的重写从前端发起 fetch 的那一刻起HTTP 协议层、服务端响应策略、浏览器事件处理机制、React/Vue 的状态更新节律全得重新对齐。它不像普通 API 调用那样“发请求→等结果→渲染”而更像一场需要全程握着对方手、同步呼吸的协作——你发一个 token我立刻回一个字符你卡顿半秒我就得判断是该继续等还是主动断开。关键词AI、流式响应、SSE、fetch、逐字解析其实已经勾勒出这条链路的五个关键切面AI 是内容源头流式响应是目标效果SSE 和 fetch 是两种主流传输通道逐字解析则是前端必须完成的“解码-拼接-渲染”动作。但很多人误以为“用了 SSE 就自动流式”或者“fetch 加个 stream 就万事大吉”结果上线后频繁报错stream disconnected before completion: idle timeout waiting for sse或是could not fetch url...这类看似网络问题、实则协议失配的错误。这些报错背后往往不是代码写错了而是对 HTTP 流式通信的底层约束缺乏敬畏——比如不知道 SSE 默认有 30 秒连接保活限制不知道 fetch Stream 在 Chrome 中对空帧的容忍度极低不知道大模型输出的 token 间隔可能长达 2 秒而浏览器默认会在 5 秒无数据后静默关闭连接。这篇文章不讲抽象概念也不堆砌 RFC 文档。我会带着你从你按下回车键的那一刻开始逐层拆解fetch 请求怎么发才不会被服务端拒收SSE 连接建立后服务端每行数据为什么要以data:开头、结尾必须带两个换行浏览器拿到碎片化文本后如何精准识别 token 边界、避免把一个中文词拆成两半渲染React 中 useState 更新太频繁导致卡顿该怎么用 requestIdleCallback 做节流甚至包括那些搜不到答案的实战细节为什么本地开发用 Vite 热更新会意外中断 SSE 连接为什么用 curl 测试 SSE 时总显示curl: (56) Illegal or missing hexadecimal sequence in chunked-encoding这些都不是理论题而是我在三个不同 AI 产品线中踩过、记过、修过的真问题。接下来我们就从最基础的 fetch 实现开始一帧一帧把这条“字字皆有因”的流式链路彻底理清楚。2. fetch ReadableStream现代浏览器原生流式方案的硬核实践在 2024 年的今天如果你还在用轮询polling或一次性加载完整响应来实现 AI 问答那不仅体验落后技术债也早已堆积如山。fetch API 自 Chrome 89、Firefox 91 起已全面支持Response.body返回ReadableStream这是目前最轻量、最标准、兼容性最好的流式响应方案。它不依赖任何第三方库不引入额外连接直接复用现有 HTTP 连接是现代 Web 应用实现流式响应的首选路径。但“支持”不等于“开箱即用”。我见过太多团队在fetch(/api/chat, { method: POST, body: JSON.stringify({ q: 你好 }) })后直接对response.body调用getReader()结果页面卡死、控制台报错TypeError: Failed to execute getReader on ReadableStream: ReadableStream is locked。问题出在哪根本原因在于你没告诉服务端“我要流式”服务端也就不会按流式格式返回数据。HTTP 是请求-响应协议客户端不声明意图服务端默认按传统方式一次性吐出全部内容此时response.body是一个已完成的Uint8Array而非可读流。所以第一步必须在请求头中明确声明期望流式响应const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json, // 关键告诉服务端我要流式响应 Accept: text/event-stream // 或 application/x-ndjson取决于后端约定 }, body: JSON.stringify({ q: 你好 }) });提示Accept: text/event-stream是向服务端发出的明确信号表示“请按 SSE 格式分块推送”。虽然 fetch 本身不强制要求此 header但绝大多数 AI 服务端如 FastAPI Starlette、Next.js API Routes都依赖它来切换响应模式。漏掉这行90% 的流式请求会退化为普通请求response.body将不可读取。一旦服务端正确响应response.body就是一个真正的ReadableStream。接下来是核心操作获取 reader循环读取 chunk解析内容。这里有个极易被忽略的细节——chunk 不是字符串而是 Uint8Array。直接new TextDecoder().decode(chunk)可能导致中文乱码或 token 错位因为大模型输出的 token 往往是 UTF-8 编码的字节流而一个中文字符可能占 3 个字节。如果 chunk 刚好在某个中文字符的中间被截断比如前 2 字节在一个 chunk后 1 字节在下一个TextDecoder会把前 2 字节解码成无效字符造成显示错误。我的解决方案是使用TextDecoder的stream: true模式并维护一个缓冲区buffer。stream: true允许 decoder 暂存不完整的字节序列等到下一个 chunk 到达时再合并解码const reader response.body.getReader(); const decoder new TextDecoder(utf-8, { stream: true }); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; // 将新 chunk 解码并追加到缓冲区 buffer decoder.decode(value, { stream: true }); // 按行分割SSE 标准格式data: xxx\n\n const lines buffer.split(\n); // 保留最后一行可能是不完整的 data: 行 buffer lines.pop() || ; for (const line of lines) { if (line.startsWith(data: )) { try { const jsonStr line.slice(6).trim(); if (jsonStr) { const parsed JSON.parse(jsonStr); // parsed.content 就是当前 token如 世 updateUI(parsed.content); // 渲染到页面 } } catch (e) { console.warn(Failed to parse SSE line:, line, e); } } } } // 处理缓冲区中剩余的不完整行 if (buffer.trim()) { // 可能是最后一个 data: 行尝试解析 if (buffer.startsWith(data: )) { const jsonStr buffer.slice(6).trim(); if (jsonStr) { try { const parsed JSON.parse(jsonStr); updateUI(parsed.content); } catch (e) { console.warn(Failed to parse last buffer line:, buffer, e); } } } } decoder.decode(); // 清空内部缓冲区这段代码的关键点在于TextDecoder({ stream: true })是处理跨 chunk 字符截断的唯一可靠方式buffer用于暂存未完成的行避免split(\n)丢失边界信息lines.pop()保留最后一行防止data: {content:世这样的半截数据被丢弃JSON.parse前做trim()和空值校验防御服务端可能返回的空白行或注释行SSE 允许: comment格式。实测下来这套方案在 Chrome、Edge、Firefox 上均稳定运行能完美处理中文、emoji、数学符号等所有 UTF-8 字符。我曾用它压测过连续 10 分钟的高频率 token 输出平均 50ms/个无一次乱码或丢字。但要注意一个隐藏坑Safari 对ReadableStream的支持存在延迟。Safari 16.4 才完全支持stream: true旧版本会静默忽略该选项导致中文乱码。如果你的用户群包含大量 iOS 用户必须做降级处理——检测TextDecoder是否支持stream选项不支持则改用response.text()一次性加载牺牲流式体验保功能可用。注意fetch流式方案最大的局限在于无法主动取消单个 chunk 的接收。AbortController只能终止整个请求一旦开始读取就必须处理完所有数据或等连接关闭。这对需要“中途停止生成”的场景如用户点击“停止回答”是个硬伤。此时SSE 方案反而更灵活——你可以直接eventSource.close()服务端收到连接断开信号后可优雅终止生成逻辑。3. SSEServer-Sent Events长连接下的可靠推送与超时博弈当你的 AI 服务部署在 Nginx、Cloudflare 或某些云函数平台如 Vercel、Netlify上时fetch ReadableStream很可能在 30 秒左右突然报错stream disconnected before completion: idle timeout waiting for sse。这不是代码 bug而是基础设施层面对“长连接”的天然不友好。这时SSEServer-Sent Events就成为更鲁棒的选择——它基于 HTTP 长连接专为服务端向客户端单向推送设计且拥有内建的重连机制和心跳保活能力。SSE 的核心是EventSource对象。它的用法比 fetch 简洁得多const eventSource new EventSource(/api/chat-sse?q你好); eventSource.onmessage (event) { try { const data JSON.parse(event.data); updateUI(data.content); // 如 世 } catch (e) { console.warn(Failed to parse SSE message:, event.data, e); } }; eventSource.onerror (error) { console.error(SSE connection error:, error); // EventSource 会自动重连无需手动处理 }; // 关闭连接如用户点击停止 const stopButton document.getElementById(stop-btn); stopButton.addEventListener(click, () { eventSource.close(); });看起来很简单但真正让它在生产环境稳如磐石的是那些藏在规范里的细节。SSE 协议规定服务端响应必须满足三个硬性条件Content-Type 必须是text/event-stream每条消息以data:开头以\n\n结尾注意是两个换行符不是\r\n\r\n连接建立后服务端必须至少每 30 秒发送一次空消息: \n\n作为心跳否则客户端会因超时断开。我曾经遇到一个线上事故某天凌晨流量突增大量用户反馈“回答只显示一半就停了”。排查发现服务端在高负载下生成 token 的间隔偶尔超过 30 秒而我们忘了发送心跳。Chrome 的EventSource实现严格遵循规范一旦 30 秒无数据立即触发onerror并启动重连默认 3 秒后。重连本身没问题但问题在于重连请求会携带新的Last-Event-ID而我们的服务端没实现 ID 续传逻辑导致重连后从头开始生成用户看到的是重复内容。解决方案是双管齐下服务端强制心跳在 token 生成逻辑外单独起一个定时器确保每 25 秒发送一次: heartbeat\n\n冒号开头的行是注释客户端忽略但算作有效数据客户端优雅处理重连监听onopen事件在连接建立时清空 UI 缓冲区避免新旧内容混杂。let currentSessionId null; eventSource.onopen () { // 连接成功重置状态 currentSessionId Date.now().toString(36); clearUI(); // 清空已有内容准备新会话 }; eventSource.onmessage (event) { try { const data JSON.parse(event.data); // 服务端应返回 session_id 字段用于前端去重 if (data.session_id currentSessionId) { updateUI(data.content); } } catch (e) { console.warn(Parse failed:, event.data); } };另一个高频问题是import profile failed: failed to fetch remote profile with status 403 for这类 403 报错。它通常出现在使用代理或网关如 Cloudflare的场景。根本原因是SSE 连接是长连接而某些网关会将长时间空闲的连接视为异常主动返回 403 中断。解决方案不是改前端而是调整网关配置Cloudflare在Rules HTTP Request Rules中添加规则匹配/api/chat-sse*设置Origin Error Page Pass-through为On并增加Origin Response Timeout至 300 秒Nginx在location块中添加proxy_read_timeout 300; proxy_send_timeout 300;并确保proxy_buffering off;禁用缓冲保证数据实时透传。最后谈谈curl sse调试。很多开发者想用curl -N http://localhost:3000/api/chat-sse?q你好查看原始 SSE 数据却得到curl: (56) Illegal or missing hexadecimal sequence in chunked-encoding。这是因为curl -N默认启用 chunked transfer encoding而 SSE 要求服务端以Transfer-Encoding: chunked或Content-Length明确声明长度。正确姿势是# 强制关闭 chunked用 raw mode 读取 curl -N -H Accept: text/event-stream http://localhost:3000/api/chat-sse?q你好 # 或者用专门的 sse-curl 工具npm install -g sse-curl sse-curl http://localhost:3000/api/chat-sse?q你好SSE 的优势在于简单、可靠、自带重连劣势在于仅支持服务端→客户端单向通信且需服务端主动维护连接。但对于 AI 问答这种“问一次、答一串”的场景它恰恰是最匹配的协议。4. 逐字解析的本质从 token 到 UI 的精准映射与防抖艺术“逐字解析”这个词听起来很玄仿佛要搞什么 NLP 分词或字节级解码。其实在绝大多数 AI Web 应用中它的本质非常朴实把服务端推送的每一个最小语义单元token准确、及时、不卡顿地渲染到页面上。这个“最小单元”可以是一个字符如世一个标点如,一个 emoji如甚至一个空格 ——因为大模型生成时空格也是独立 token。问题来了如果服务端每 100ms 推送一个 token前端每收到一个就调用一次setStateReact或textContent ...原生会发生什么答案是UI 卡顿、CPU 占用飙升、电池快速耗尽。我做过测试在低端安卓手机上连续 100 次useState更新会导致页面掉帧严重用户明显感知到“文字蹦出来”的不自然感。根源在于浏览器的渲染机制每次状态更新都会触发虚拟 DOM Diff 和真实 DOM 操作这是一个昂贵的过程。而 AI 生成的 token 流速极快GPT-4 Turbo 平均 30~50ms/token远超人眼舒适阅读节奏约 200ms/字。所以“逐字”不等于“逐次更新”而是在“逐字接收”的基础上做智能聚合与节流。我的实践方案是三级缓冲Token 缓冲区毫秒级用数组暂存刚收到的 token不立即渲染时间窗口100ms每 100ms 检查一次缓冲区将其中所有 token 拼接成字符串空闲调度requestIdleCallback将拼接后的字符串交给requestIdleCallback在浏览器空闲时批量更新 UI。let tokenBuffer []; let renderTimer null; function queueToken(token) { tokenBuffer.push(token); // 如果没有定时器启动一个 100ms 窗口 if (!renderTimer) { renderTimer setTimeout(() { flushBuffer(); }, 100); } } function flushBuffer() { if (tokenBuffer.length 0) return; const fullText tokenBuffer.join(); tokenBuffer []; // 使用 requestIdleCallback避免阻塞主线程 requestIdleCallback(() { // 这里执行实际的 DOM 更新 const displayElement document.getElementById(answer-display); displayElement.textContent fullText; // 或 React 中setAnswer(prev prev fullText); }); renderTimer null; } // 在 SSE onmessage 或 fetch reader 中调用 eventSource.onmessage (event) { try { const data JSON.parse(event.data); queueToken(data.content); } catch (e) { console.warn(Parse failed:, event.data); } };这个方案的效果立竿见影在同样 100 个 token 的测试中useState直接更新的 FPS 降至 20而用requestIdleCallback缓冲后稳定在 58接近满帧。更重要的是用户体验更自然——文字不再是“蹦”而是“流淌”符合人眼对连续运动的感知。但缓冲带来新问题用户想“停止生成”时缓冲区里的 token 怎么办我的处理原则是停止按钮优先级最高立即清空缓冲区并终止所有待处理任务。let pendingIdleCallback null; function queueToken(token) { tokenBuffer.push(token); if (!renderTimer) { renderTimer setTimeout(flushBuffer, 100); } } function flushBuffer() { if (tokenBuffer.length 0) return; const fullText tokenBuffer.join(); tokenBuffer []; pendingIdleCallback requestIdleCallback(() { updateUI(fullText); }); } // 停止按钮点击 stopButton.addEventListener(click, () { // 清空所有待处理 if (renderTimer) { clearTimeout(renderTimer); renderTimer null; } if (pendingIdleCallback) { cancelIdleCallback(pendingIdleCallback); pendingIdleCallback null; } tokenBuffer []; // 彻底清空 eventSource.close(); });此外还有一个常被忽视的细节中文标点与英文标点的视觉宽度差异。直接textContent token会导致文字“跳动”——因为中文句号。和英文句号.在等宽字体下宽度不同。解决方案是统一使用 CSSfont-variant-east-asian: traditional;或在渲染前对常见标点做宽度归一化如将。替换为但这属于 UI 层优化不影响流式核心逻辑。逐字解析的终极目标不是技术炫技而是让 AI 的“思考过程”以最符合人类直觉的方式呈现出来。它要求前端工程师既是协议专家也是交互设计师更是性能调优师。5. 生产环境避坑指南从curl sse到spring ai alibaba的全链路排错在真实项目中流式响应的失败 rarely 来自代码逻辑错误而更多源于环境、配置、协议、网络四层叠加的隐性冲突。下面是我整理的 7 个高频、难查、文档里几乎找不到答案的生产级问题及根治方案覆盖从本地开发到云部署的全链路。5.1 本地开发Vite/HMR 导致 SSE 连接意外中断现象在 Vite 开发服务器下SSE 连接经常在热更新HMR后自动断开控制台报EventSources response has a MIME type (text/html) that is not text/event-stream. Aborting the connection.原因Vite 的 HMR 机制会劫持所有/开头的请求当 SSE 请求路径如/api/chat-sse被 Vite 的 dev server 拦截而 Vite 无法识别 SSE 协议就返回了一个 HTML 错误页MIME 为text/html触发浏览器协议校验失败。解决方案在vite.config.ts中显式代理 SSE 请求绕过 Vite 处理export default defineConfig({ server: { proxy: { /api/chat-sse: { target: http://localhost:8000, // 你的后端地址 changeOrigin: true, // 关键禁用 Vite 的 rewrite保持原始路径 rewrite: (path) path, // 关键设置超时避免默认 5s 断开 timeout: 300000, } } } });5.2 云函数平台Vercel/Netlify 的 10 秒超时陷阱现象在 Vercel 上部署的 Next.js API RouteSSE 连接总在 10 秒后断开报错stream disconnected before completion: idle timeout waiting for sse。原因Vercel 的 Serverless Functions 默认最大执行时间为 10 秒Pro 计划为 60 秒且其网关会对空闲连接强制回收。即使你的函数逻辑还在运行网关已切断连接。解决方案放弃 Serverless Functions改用 Vercel 的 Edge Functions。Edge Functions 基于 Deno原生支持流式响应且无 10 秒硬限制。在pages/api/chat-sse.ts中export const config { runtime: edge, // 关键 }; const handler async (req: Request) { const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { // 你的流式生成逻辑 controller.enqueue(encoder.encode(data: {content:世}\n\n)); // ... controller.close(); } }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive } }); };5.3 反向代理Nginx 的proxy_buffering导致流式失效现象Nginx 作为反向代理前端 SSE 连接建立后服务端已推送数据但浏览器迟迟收不到最终超时。原因Nginx 默认开启proxy_buffering on它会缓存后端响应直到收到完整响应或缓冲区满才转发给客户端。这对流式是灾难性的。解决方案在 Nginx 配置中针对 SSE 路径关闭缓冲并调大超时location /api/chat-sse { proxy_pass http://backend; proxy_buffering off; # 关键 proxy_cache off; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 300; proxy_send_timeout 300; }5.4 客户端兼容性Safari 15.4 以下的EventSourceBug现象iOS Safari 用户反馈流式响应完全不工作控制台无报错onopen事件不触发。原因Safari 15.4 之前的EventSource实现有严重 Bug无法正确处理text/event-stream响应头会静默失败。解决方案UA 检测 降级。在初始化前检查function supportsSSE() { if (typeof EventSource undefined) return false; // Safari 15.4 有 bug const ua navigator.userAgent; const safariVersion ua.match(/Version\/(\d)\.(\d) Safari/); if (safariVersion parseInt(safariVersion[1]) 15) { return false; } return true; } if (supportsSSE()) { // 使用 EventSource } else { // 降级为 fetch polling每 500ms 轮询一次 /api/chat-status?idxxx }5.5 服务端框架Spring AI Alibaba 的SseEmitter内存泄漏现象Spring Boot 应用使用SseEmitter实现 SSE高并发下内存持续增长GC 频繁最终 OOM。原因SseEmitter默认不设置超时且若客户端断开连接SseEmitter不会自动销毁其持有的ConcurrentHashMap缓存会无限增长。解决方案显式设置超时并在连接关闭时清理资源GetMapping(/chat-sse) public SseEmitter chatSse(RequestParam String q) { SseEmitter emitter new SseEmitter(30000L); // 30秒超时 // 客户端断开时清理 emitter.onCompletion(() - { log.info(SSE connection closed); }); emitter.onError((ex) - { log.error(SSE error, ex); }); // 启动异步生成任务 CompletableFuture.runAsync(() - { try { // 你的 AI 生成逻辑 emitter.send(SseEmitter.event().name(message).data(世)); // ... } catch (IOException e) { emitter.completeWithError(e); } }); return emitter; }5.6 网络层Cloudflare 的Always Online导致 SSE 403现象启用 Cloudflare 的Always Online功能后SSE 请求频繁返回 403。原因Always Online会缓存 HTML 页面当源站不可用时它试图用缓存的 HTML 响应 SSE 请求导致 MIME 类型不匹配。解决方案在 Cloudflare 的Rules Page Rules中为 SSE 路径创建规则关闭Always OnlineURL Pattern: *example.com/api/chat-sse* Setting: Always Online → Off5.7 安全策略CSPContent Security Policy拦截 EventSource现象页面加载正常但new EventSource(...)报错Refused to connect to https://... because it violates the following Content Security Policy directive: connect-src self。原因CSP 的connect-src指令限制了fetch、XMLHttpRequest、EventSource等连接的域名。默认self只允许同源若 SSE 接口在子域名如api.example.com需显式添加。解决方案在 HTML 的meta标签或 HTTP 响应头中扩展connect-srcmeta http-equivContent-Security-Policy contentconnect-src self https://api.example.com;或在 Nginx 中add_header Content-Security-Policy connect-src self https://api.example.com;;这些问题没有一个能在官方文档里找到标准答案。它们散落在 GitHub Issues、Stack Overflow 的零星评论、以及运维同事深夜发来的截图里。但正是这些“非标准答案”构成了流式响应在生产环境真正落地的全部重量。
返回列表