多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

深入解析 Streamable HTTP 与 SSE 的本质区别、联系及技术实现:从 Chunked Transfer Encoding 到 EventSource 的流式传输实践

深入解析 Streamable HTTP 与 SSE 的本质区别、联系及技术实现:从 Chunked Transfer Encoding 到 EventSource 的流式传输实践 1. 从一次 AI 流式输出卡顿说起Streamable HTTP 与 SSE 到底差在哪如果你正在做 AI 对话类产品大概率遇到过这种场景模型明明已经在吐字了前端却要等两三秒才一次性刷出一大段或者反过来用 EventSource 接得好好的一放到负载均衡后面就开始断流重连。这类问题的根子往往不在模型侧而在你对 Streamable HTTP 和 SSE 这两套流式传输机制的理解是否到位。先把概念说清楚。SSE全称 Server-Sent Events是 HTML5 定义的一个应用层协议专门干一件事服务器单向、持续地往浏览器推文本事件。它规定了Content-Type: text/event-stream、data:前缀、双换行分隔、id:断点续传这些格式约束浏览器用EventSource这个原生 API 就能直接消费自动重连都帮你做好了。而 Streamable HTTP 不是一个独立协议它是一种传输设计模式——依托 HTTP/1.1 的 Chunked Transfer Encoding 或 HTTP/2 的 DATA 帧让服务器不必凑齐完整响应体就能一块一块地把数据发出去。一句话概括两者的关系SSE 是「标准化的上层应用协议」Streamable HTTP 是「无格式约束的底层流式传输能力」。SSE 本质上就是 Streamable HTTP 的一种特定实现——它借用了分块传输的底层能力再叠加一套事件编码规范。理解了这层包含关系很多选型纠结就迎刃而解了。这篇文章面向正在做 AI 流式响应接入的开发者不管你是刚接触流式传输的小白还是被代理层断流折磨过的老手我都会给出可直接复制的 Node.js 服务端分块配置、浏览器端 EventSource 接入代码以及用 curl 验证分块到达顺序的具体动作。适合谁做 AI 应用后端、写前端流式渲染、或者要对接 MCP 这类现代协议的同学都能直接拿去用。2. TaoToken 前置准备拿到流式接口的 Base URL 与 Key在动手写流式代码之前得先有一个能真正吐出流式响应的模型接口。我这里用 TaoToken 作为演示后端因为它同时支持标准 HTTP 流式返回和 SSE 格式正好能把两种机制放在一起对比。你需要准备三样东西Base URL、API Key、Model ID。这三件套是任何流式接入的起点缺一不可。Base URL 统一用https://taotoken.net/api注意这个地址不带任何查询参数是纯 API 入口。API Key 需要到控制台里创建路径是 API Keys 管理页创建后复制那串以sk-开头的密钥只显示一次记得存好。Model ID 则根据你要调的模型填比如对话类模型填对应的模型标识即可。如果你更习惯用图形界面先验证一下模型能不能正常流式输出可以直接打开模型对话页面发一句话看看是不是逐字返回。这一步能帮你排除掉「Key 没生效」或「模型不支持流式」这类低级问题省得后面写代码时怀疑人生。对于长期要做编码 Agent 或者需要稳定流式通道的场景可以考虑 Coding Plan它更适合高频、长时间的流式调用。而如果你只是想快速验证一个流式请求用 API Keys 配合下面的 curl 就够了。这里要提醒一句TaoToken 是合规的 API 服务入口你拿到的就是一个标准的 HTTP 接口所有流式能力都建立在标准 HTTP 语义之上不存在任何特殊通道。这一点很重要因为它意味着你下面学到的 Chunked Encoding、EventSource 知识换到任何标准 HTTP 服务上都通用。3. 可复制配置Node.js 分块传输与 EventSource 接入这一节是全文的核心我会给出两套可运行的代码一套是 Node.js 服务端演示如何用 Chunked Transfer Encoding 做 Streamable HTTP 流式输出另一套是浏览器端用 EventSource 消费 SSE。两套代码放在一起你就能直观看到底层传输和上层协议的区别。3.1 Node.js 服务端手写 Chunked 流式响应先看 Streamable HTTP 的底层写法。核心是不设置Content-Length让 Node.js 自动切换到分块传输模式然后多次res.write()逐步推送。// server-stream.js const http require(http); const server http.createServer((req, res) { if (req.url /stream req.method POST) { // 关键不设置 Content-Length声明 chunked res.writeHead(200, { Content-Type: application/x-ndjson, Transfer-Encoding: chunked, Cache-Control: no-cache, Connection: keep-alive, }); const chunks [ { delta: 你好 }, { delta: 我是 }, { delta: 流式 }, { delta: 响应 }, ]; let i 0; const timer setInterval(() { if (i chunks.length) { clearInterval(timer); res.end(); // 结束流Node 自动补 0\r\n\r\n return; } // 每行一个 JSONNDJSON 格式 res.write(JSON.stringify(chunks[i]) \n); i; }, 300); req.on(close, () clearInterval(timer)); } else { res.writeHead(404); res.end(not found); } }); server.listen(3000, () console.log(stream server on :3000));这段代码里最关键的一行是Transfer-Encoding: chunked。当你手动声明它、并且不写Content-Length时Node.js 就会把每次res.write()的内容作为一个独立数据块发送块与块之间由 HTTP 层自动加上十六进制长度前缀和\r\n。客户端收到的是「一块一块」的数据而不是等全部生成完再一次性到达。3.2 浏览器端EventSource 消费 SSE再看 SSE 的写法。服务端需要返回text/event-stream并按data:格式推送浏览器端直接用EventSource接收。// server-sse.js const http require(http); const server http.createServer((req, res) { if (req.url /sse) { res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }); let count 0; const timer setInterval(() { count; // SSE 强制格式data: 前缀 双换行 res.write(id: ${count}\n); res.write(event: message\n); res.write(data: ${JSON.stringify({ delta: 第${count}块 })}\n\n); if (count 5) { clearInterval(timer); res.end(); } }, 300); req.on(close, () clearInterval(timer)); } else { res.writeHead(404); res.end(not found); } }); server.listen(3001, () console.log(sse server on :3001));浏览器端接入!DOCTYPE html html body div idoutput/div script const es new EventSource(http://localhost:3001/sse); const out document.getElementById(output); es.addEventListener(message, (e) { const data JSON.parse(e.data); out.textContent data.delta; }); es.onerror () { console.warn(连接异常EventSource 会自动重连); }; /script /body /html对比两段代码你能清楚看到Streamable HTTP 那套只关心「怎么分块发」格式随便你定这里用了 NDJSONSSE 那套则被data:、event:、双换行这些格式绑死但换来的是浏览器原生EventSource的自动重连和事件解析。3.3 用 curl 验证分块到达顺序光看代码不够得亲眼看到分块是怎么一块块到的。用 curl 加--no-buffer参数就能实时打印每一块curl -N -X POST http://localhost:3000/stream \ -H Content-Type: application/json \ -d {prompt:hi}-N等价于--no-buffer它会禁用 curl 的输出缓冲让每个数据块一到就打印。你会看到类似这样的输出每 300ms 冒出一行{delta:你好} {delta:我是} {delta:流式} {delta:响应}如果你去掉-Ncurl 会等整个响应结束才一次性打印这就是缓冲带来的假象。验证 SSE 同理curl -N http://localhost:3001/sse输出会是带id:、event:、data:前缀的完整事件流。这一步是排查流式问题最有效的手段——只要 curl 能看到逐块到达就说明服务端分块没问题剩下的锅在前端或代理层。4. 验证请求从 curl 到真实模型流式响应本地服务跑通后把目标换成真实的模型接口验证整条链路。这里用 TaoToken 的 API 做一次流式请求重点观察响应头里的Transfer-Encoding和实际到达节奏。curl -N -X POST https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer sk-你的Key \ -H Content-Type: application/json \ -d { model: 你的ModelID, stream: true, messages: [{role:user,content:用一句话介绍流式传输}] }关键在stream: true。开启后服务端会以 SSE 格式逐块返回你会看到一连串data: {...}行最后以data: [DONE]收尾。用-N观察能明显感觉到文字是「一段一段」冒出来的而不是憋到最后。如果你想看响应头确认底层机制可以加-icurl -i -N -X POST https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer sk-你的Key \ -H Content-Type: application/json \ -d {model:你的ModelID,stream:true,messages:[{role:user,content:hi}]}在响应头里你会看到Transfer-Encoding: chunkedHTTP/1.1 下或 HTTP/2 的帧传输。这就直接印证了即便是 SSE 格式的响应底层依然是 Streamable HTTP 的分块能力在支撑。SSE 只是在这层分块之上套了一层事件编码规范而已。实测下来从发出请求到收到第一个data:块的延迟通常就是首字节延迟TTFB这个值直接决定用户感知的「响应快不快」。而后续块的到达间隔则反映模型的生成速度。把这两个指标分开看你就能判断卡顿到底出在网络还是模型。5. 本篇常见错排查401、proxy failed 与 choices 解析流式接入踩坑是常态下面这几个报错几乎人人都会遇到逐个拆解。401 Unauthorized最常见八成是 Key 没带对。检查Authorization: Bearer sk-xxx里的Bearer和空格有没有漏Key 有没有复制完整。注意 API Key 只在创建时显示一次如果你复制时截断了只能重新创建一个。另外确认请求打的是https://taotoken.net/api这个 Base URL路径拼错也会 401。local proxy failed / connection reset这个报错通常出现在你本地起了代理或者公司网络有中间层。流式连接是长连接中间层如果对空闲连接有超时限制就会在模型思考的间隙把连接掐断。解决办法是给服务端加心跳比如每 15 秒发一个注释行: ping\n\nSSE 里以冒号开头的是注释客户端会忽略保持连接活跃。同时检查你的 HTTP 客户端有没有设置过短的 timeout。reading choices of undefined这是解析流式响应时的经典错误。流式返回的每个data:块是一个增量 delta结构里choices[0].delta才是内容而不是choices[0].message。如果你按非流式的结构去取choices[0].message.content就会 undefined。正确写法是判断delta.content是否存在再拼接。另外最后一个块可能是data: [DONE]解析前要先过滤掉否则 JSON.parse 会直接抛错。OAuth / 鉴权相关报错如果你用的是 Claude Code 这类工具接入注意它走的是 Anthropic 兼容格式Base URL、Key、Model ID 三件套要填全。Base URL 填https://taotoken.net/apiKey 填你的sk-密钥Model ID 填对应模型标识。三者任一缺失或格式不对都会在鉴权阶段直接失败。遇到 OAuth 报错时先确认你用的是 API Key 模式而不是交互式登录模式。排查顺序建议先用 curl 直连 API 确认 Key 和网络没问题再套本地服务最后接前端。一层层往上排比一上来就怀疑前端要高效得多。6. 选型与接入把流式能力落到你的 AI 应用里回到选型本身。什么时候用 SSE什么时候用裸的 Streamable HTTP我的经验是如果你的消费端是浏览器且只需要服务器单向推文本SSE 是最省事的选择EventSource帮你把重连、断点续传都包了。但如果你要双向流、要传二进制、要部署在复杂的负载均衡和代理层后面那就该用裸的 Streamable HTTP自己控制分块格式避开 SSE 对长连接和特定路径的依赖。现代协议的趋势也印证了这点。MCP 规范已经把 SSE 降格为可选的流式格式之一而不是强制架构核心传输转向了 Streamable HTTP。原因很实际无状态、单端点、兼容标准 HTTP 生态这些特性在云原生和 Serverless 环境下优势明显。要动手接入的话先去 API Keys 页面创建密钥然后对照接入文档把 Base URL、Key、Model ID 三件套配好。想先肉眼验证流式效果打开模型对话发一句话看逐字返回要长期跑编码 Agent就上 Coding Plan。文档里对每种接入方式都有完整示例照着改就能用。最后留一个实用技巧不管用哪种机制永远先用curl -N验证服务端分块是否正常。这一步能帮你把「服务端没流式」和「前端没渲染」两类问题彻底分开省下大量瞎猜的时间。流式传输的本质就是「边生成边发送」只要 curl 能看到逐块到达剩下的就都是解析和渲染的活儿了。
返回列表