你在 ChatGPT 里看到的回答是一个字一个字蹦出来的(打字机效果),而不是等模型全想完再啪一下吐一整屏。这背后不是什么魔法,而是一套叫 SSE(Server-Sent Events,服务器推送事件)的老技术:请求时加一个 stream: true,服务器就把回答切成一小段一小段,用 data: 一行一行推给你。这一课讲清 SSE 原理、/completion 的 data: 行格式、首字延迟 TTFT、前端用 fetch ReadableStream / EventSource 怎么接,以及它怎么和 TTS 语音联动。这是整个 22 课系列的收尾。
这一课是工程落地课,重点看懂"字是怎么一个一个到屏幕上的"。
data: 行、[DONE] 结束符。data: 行格式和 [DONE];③ 解释 TTFT(首字延迟)是什么、为什么它最重要;④ 知道前端用 fetch 流还是 EventSource。data: 行推给前端,前端把 content 一段段追加到屏幕上,就是打字机效果。最后收到 data: [DONE] 表示结束。先想清楚:模型本来就是一个 token 一个 token 吐的(第12课)。既然它是逐字生成的,为什么要等全部生成完才返回?完全可以生成一个字就推一个字。流式输出不是让模型变快,而是让它"边想边说",用户的等待感受大幅提前。
为什么 TTFT 最重要?用户体感里"卡不卡"主要看第一个字多快出来。如果首字只要 0.3 秒,后面就算慢慢蹦,用户也觉得"它在认真回答";如果首字要等 5 秒,哪怕后面飞速,用户早以为网页崩了。所以流式的核心价值就是把 TTFT 变得很短。
用什么技术推送?答案是 SSE(Server-Sent Events)——一个基于 HTTP、服务器单向持续推送的老协议。注意它和 WebSocket 不一样:SSE 是服务器→浏览器单向,文本流,正好契合"AI 不停往外吐字"的场景,比 WebSocket 简单。
data: 行推过来,前端追加到屏幕,最后用 [DONE] 收尾。它优化的是"体感等待",不是总生成速度。在 OpenAI 兼容接口里,请求体加一个 "stream": true:
curl -N http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "qwen2.5",
"messages": [{"role":"user","content":"讲个笑话"}],
"stream": true
}'
注意 curl 要加 -N(禁用缓冲),否则你会看到攒一批才刷出来。
data: {"choices":[{"delta":{"content":"为"}}]}
data: {"choices":[{"delta":{"content":"什"}}]}
data: {"choices":[{"delta":{"content":"么"}}]}
data: [DONE]
| 约定 | 说明 |
|---|---|
每行以 data: 开头 | SSE 协议规定的数据前缀,后面跟一行 JSON |
每个 chunk 用 delta.content | 流式时不是 message.content,而是增量字段 delta |
| 两个空行分隔事件 | SSE 规范里事件之间用空行隔开 |
data: [DONE] | 约定的结束标记,收到它就关闭流、停止拼接 |
| 响应头 | Content-Type: text/event-stream,且不能 gzip 缓冲 |
stream:false)的完整回答在 choices[0].message.content;流式时每个增量在 choices[0].delta.content。前端如果按 message.content 去取,会拿到 undefined,屏幕什么都不显示。const resp = await fetch('/v1/chat/completions', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({model:'qwen2.5', messages:[...], stream:true})
});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
while (true) {
const {done, value} = await reader.read();
if (done) break;
// value 是字节流,按 data: 行解析,把 delta.content 追加到页面
processChunk(decoder.decode(value, {stream:true}));
}
聊天接口是 POST(要带 messages 请求体),而浏览器原生 EventSource 只支持 GET,所以实战中多用 fetch 读 ReadableStream,自己切分 data: 行。
const es = new EventSource('/sse-feed');
es.onmessage = (e) => {
if (e.data === '[DONE]') { es.close(); return; }
const chunk = JSON.parse(e.data);
append(chunk.choices[0].delta.content);
};
EventSource 自动帮你处理 data: 行和重连,但只支持 GET、不能发 POST body,所以一般只用于简单推送,聊天接口仍首选 fetch 流。
这是流式最爽的组合:模型一边吐字,一边把吐出来的字丢给 TTS 去念,不用等整篇写完。用户几乎立刻听到声音,文字和语音同步往外冒。实现上就是在上面的 append() 里,除了把字加到屏幕,还把这一小段文本喂给 TTS 播放队列。
data: 行 → 网络推给浏览器 → fetch 流读出 delta.content → ①追加到页面(打字机)②丢给 TTS 队列(边说边念)→ 收到 [DONE] 收尾。-N、反向代理(nginx)开了缓冲、或后端没及时 flush。SSE 要求一路不缓冲,字才算"实时"。message.content 导致空白流式增量在 delta.content,不是 message.content。字段名取错,页面一个字都不显示。[DONE] 和粘包网络流可能一次 read 拿到半行或好几行,要按 \n 切分、缓冲不完整的行;收到 [DONE] 要记得关闭 reader,否则一直挂着。| 考法 | 出题形式 | 应对 |
|---|---|---|
| 开启方式 | 问请求加什么 | stream: true |
| 数据格式 | 问行前缀/结束符 | data: / [DONE] |
| TTFT | 问哪个指标决定体感 | 首字延迟 |
| 前端 API | 问 POST 流用哪个 | fetch ReadableStream |
真题基础1. 要让接口流式返回,请求体里要加?
真题中档2. SSE 流式响应里,每一行数据的前缀和结束标记分别是?
真题中档3. 用户感觉"AI 反应快不快",最主要取决于哪个指标?
真题拔高4. 聊天接口要用 POST 发 messages,前端读取流式响应用哪个最合适?
"stream": true,curl 加 -N 别缓冲data: {json},结束 data: [DONE] text/event-streamdelta.content,非流式读 message.content 别取错llama-server -m model.gguf --port 8080,它自带兼容的 /v1/chat/completions。stream: true。fetch → response.body.getReader() → TextDecoder → 按行切 data: → 遇到 [DONE] 就停。delta.content 追加到答案 div;可选同时喂给 TTS 播放队列,实现边出字边出声。"stream": true。data: 开头;结束标记是 data: [DONE]。choices[0].delta.content(不是非流式的 message.content)。-N?-N 禁用输出缓冲,否则 curl 会攒一批再打印,你看不到逐行实时效果,误以为没流式。\n 切分,把不完整的行缓存到下次拼接,遇到 [DONE] 再收尾。-N;② nginx 等反向代理开了 proxy_buffering;③ 后端没及时 flush 响应;④ Content-Encoding 用了 gzip。需要一路关缓冲。JSON.parse。要先把所有 delta 拼接成完整字符串,收到 [DONE] 后再一次性 parse;配合 grammar 约束可保证拼出来必然合法。| 天 | 任务 | 自检 |
|---|---|---|
| 第 1 天 | 读②③,对比一次性 vs 流式 | 说清 TTFT 意义 |
| 第 2 天 | 背知识点卡 + 基础 1-6 | 基础全对 |
| 第 3 天 | 读④⑤ SSE 格式与前端代码,做中档 7-11 | 认出 data:/[DONE] |
| 第 4 天 | 做拔高 12-16 | 讲清 SSE vs WebSocket |
| 第 5 天 | 做⑦真题 4 题 | 限时每题 2 分钟 |
| 第 6-7 天 | 合上书口述三句口诀和数据通路 | 不看资料全说对 |
前置知识(先学):第12课 采样与生成参数——流式就是把逐 token 采样的结果一点点推出来,不先懂逐 token 生成就看不懂流;第20课 llama.cpp 配置实战——llama-server 自带兼容接口,stream:true 正是打在它的 /v1/chat/completions 上。
本节位置:这是 22 课系列的收尾——模型会跑了(19-20)、输出能约束成 JSON 了(21),这一课解决"怎么把结果逐字、低延迟地送到用户眼前,并接上语音"。
下一步(学完去):第10课 Agent——流式 + 结构化输出 + function calling 三件套齐备后,Agent 才能一边流式思考、一边调工具、一边把过程讲给用户看。