用过 ChatGPT 类产品的人都熟悉那个"打字机"效果:回答一个字一个字地蹦出来,而不是等十几秒后"啪"地整段出现。第一次见会觉得很神奇,其实底层是一门 2009 年就定型的老技术——SSE(Server-Sent Events)。
这篇文章把流式输出从下到上拆三层:HTTP 的分块传输、SSE 的文本协议、大模型 API 在其上的封装。文中服务端和客户端代码都经过实测(Node.js 原生实现,零依赖),可直接跑。
普通 HTTP 响应是"攒齐再发":服务器把完整内容准备好,带上 Content-Length,一次性交给客户端。
流式的关键是 HTTP/1.1 的 chunked transfer encoding(分块传输):不声明总长度,把响应切成一个个 chunk,生成一块发一块,直到发出一个零长度块表示结束。浏览器收到每个 chunk 都能立刻交给页面——这就是"边生成边渲染"的物理基础。
分块传输只管"分段发",不管"内容怎么切"。SSE 在其上定义了极简的文本协议,Content-Type 标记为 text/event-stream,规则三句话说完:
实测一段真实输出(本文第四节的服务端代码,curl 原样抓取):
retry: 3000
id: 1
data: {"delta":"块1"}
id: 2
data: {"delta":"块2"}
data: {"done":true}每帧一个 JSON——这就是大模型"流式 token"的传输形态:模型每生成一小段,服务端就包成一帧 SSE 发下来。
两个方向不同:WebSocket 是双向全双工,适合聊天室、协同编辑;SSE 是单向的服务端推送,搭在普通 HTTP 上——天然过代理、过网关、带标准的 HTTP 鉴权头,服务端实现成本几乎为零。
大模型对话的场景恰好是"一次请求、持续收增量"的单向流,SSE 完全够用。OpenAI、Anthropic、腾讯混元的流式接口都是 SSE。
零依赖,新建 sse-demo.mjs:
import { createServer } from 'node:http';
createServer((req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
res.write('retry: 3000\n\n');
let i = 0;
const timer = setInterval(() => {
i += 1;
res.write('id: ' + i + '\n');
res.write('data: ' + JSON.stringify({ delta: '块' + i }) + '\n\n');
if (i >= 3) {
res.write('data: ' + JSON.stringify({ done: true }) + '\n\n');
clearInterval(timer);
res.end();
}
}, 100);
req.on('close', () => clearInterval(timer));
}).listen(8787, () => console.log('sse on 8787'));三个细节值得注意:
浏览器原生有 EventSource API,但主流大模型应用几乎都用 fetch + ReadableStream 手写。原因很实际:
标准写法(实测通过):
const res = await fetch('http://127.0.0.1:8787/', {
method: 'POST',
headers: { Authorization: 'Bearer xxx' },
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let idx;
while ((idx = buf.indexOf('\n\n')) >= 0) {
const frame = buf.slice(0, idx);
buf = buf.slice(idx + 2);
const dataLine = frame.split('\n').find(l => l.startsWith('data: '));
if (dataLine) console.log('收到:', dataLine.slice(6));
}
}注意那个 buf 缓冲区:TCP 分块和 SSE 帧边界不对齐,一次 read 可能收到半帧或两帧半。先攒进 buf,再按 \n\n 切——这是流式解析最容易写错的地方,也是所有"偶发 JSON 解析失败"的根源。
各家 API 的流式响应都是"SSE 帧里装 JSON 增量",结构大同小异:
所以"打字机前端"的实现就是:解析每帧 JSON → 取出增量文本 → 追加到页面。模型侧的流式生成(逐 token 采样天然可分段)和传输侧的 SSE,刚好严丝合缝。
打字机效果没有黑科技,三层旧零件的组合:HTTP chunked 传输管"分段发",SSE 协议管"帧格式",模型 API 管帧里装 token 增量。写一次 30 行的服务端和一个带缓冲的 fetch 解析器,这条链路就彻底通了——下次流式输出抽风(粘帧、乱码、整段憋出来),你直接就知道该查哪一层。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。