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; } // 每行一个 JSON,NDJSON 格式 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 id="output"></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 那套只关心「怎么分块发」,格式随便你定(这里用了 NDJSON);SSE 那套则被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":"响应"}如果你去掉-N,curl 会等整个响应结束才一次性打印,这就是缓冲带来的假象。验证 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观察,能明显感觉到文字是「一段一段」冒出来的,而不是憋到最后。
如果你想看响应头确认底层机制,可以加-i:
curl -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: chunked(HTTP/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\n(SSE 里以冒号开头的是注释,客户端会忽略),保持连接活跃。同时检查你的 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/api,Key 填你的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 能看到逐块到达,剩下的就都是解析和渲染的活儿了。