← 算法与AI总览 首页 / 算法与AI / AI 基础入门 / 第22课 · 流式输出
AI 基础入门 · 第22课

流式输出:打字机是怎么来的——SSE、首字延迟与前后端实现

你在 ChatGPT 里看到的回答是一个字一个字蹦出来的(打字机效果),而不是等模型全想完再啪一下吐一整屏。这背后不是什么魔法,而是一套叫 SSE(Server-Sent Events,服务器推送事件)的老技术:请求时加一个 stream: true,服务器就把回答切成一小段一小段,用 data: 一行一行推给你。这一课讲清 SSE 原理、/completion 的 data: 行格式、首字延迟 TTFT、前端用 fetch ReadableStream / EventSource 怎么接,以及它怎么和 TTS 语音联动。这是整个 22 课系列的收尾。

① 小白第一课怎么学(4 步走,约 65 分钟)

这一课是工程落地课,重点看懂"字是怎么一个一个到屏幕上的"。

1先对比两种模式(10 分钟)
读②③:一次性返回 vs 流式返回。
2看懂 SSE 数据格式(15 分钟)
读④:data: 行、[DONE] 结束符。
3前端怎么接 + TTFT(20 分钟)
读⑤:fetch ReadableStream / EventSource,首字延迟。
4刷题自测(20 分钟)
做⑦⑩,错题回⑥看易错点。
本课小目标学完你要能:① 说清流式为什么比"等全部返回"体验好;② 认出 SSE 的 data: 行格式和 [DONE];③ 解释 TTFT(首字延迟)是什么、为什么它最重要;④ 知道前端用 fetch 流还是 EventSource。

② 一图看懂:一次性返回 vs 流式返回

传统:等模型全写完再一次性返回 用户干等 5 秒(屏幕空白,以为卡死) 5 秒后一整屏文字突然全部出现 流式 stream:true:边生成边推,打字机效果 第1个字 第2个字 第3个字 …→ 逐字累加,越出越多 data: {"choices":[{"delta":{"content":"你"}}]} data: {"choices":[{"delta":{"content":"好"}}]} data: [DONE] ← 结束符
读法:上面是"傻等"——用户盯着空白屏 5 秒,体验很差;下面是流式——模型每算出几个 token 就立刻通过 data: 行推给前端,前端把 content 一段段追加到屏幕上,就是打字机效果。最后收到 data: [DONE] 表示结束。

③ 本质直觉:把"挤牙膏"变成"水龙头"

先想清楚:模型本来就是一个 token 一个 token 吐的(第12课)。既然它是逐字生成的,为什么要等全部生成完才返回?完全可以生成一个字就推一个字。流式输出不是让模型变快,而是让它"边想边说",用户的等待感受大幅提前。

两个关键时间指标 TTFT(Time To First Token,首字延迟)= 从你发送请求到看到第一个字的时间
TPOT(每 token 耗时)= 第一个字之后,字与字之间平均间隔

为什么 TTFT 最重要?用户体感里"卡不卡"主要看第一个字多快出来。如果首字只要 0.3 秒,后面就算慢慢蹦,用户也觉得"它在认真回答";如果首字要等 5 秒,哪怕后面飞速,用户早以为网页崩了。所以流式的核心价值就是把 TTFT 变得很短。

用什么技术推送?答案是 SSE(Server-Sent Events)——一个基于 HTTP、服务器单向持续推送的老协议。注意它和 WebSocket 不一样:SSE 是服务器→浏览器单向,文本流,正好契合"AI 不停往外吐字"的场景,比 WebSocket 简单。

一句话本质流式 = 请求里说"别等我,吐一点发一点",服务器用 SSE 把每个 token 塞进 data: 行推过来,前端追加到屏幕,最后用 [DONE] 收尾。它优化的是"体感等待",不是总生成速度。

④ 完整体系:SSE 协议与请求格式

开启流式的请求

在 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(禁用缓冲),否则你会看到攒一批才刷出来。

服务器返回的 SSE 长这样

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,屏幕什么都不显示。

⑤ 前后端实现:fetch ReadableStream / EventSource / TTS 联动

方式一:fetch + ReadableStream(推荐,POST 请求)

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: 行。

方式二:EventSource(GET 的简单场景)

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(文字转语音)联动

这是流式最爽的组合:模型一边吐字,一边把吐出来的字丢给 TTS 去念,不用等整篇写完。用户几乎立刻听到声音,文字和语音同步往外冒。实现上就是在上面的 append() 里,除了把字加到屏幕,还把这一小段文本喂给 TTS 播放队列。

典型数据通路模型逐 token 生成 → 塞进 SSE data: 行 → 网络推给浏览器 → fetch 流读出 delta.content → ①追加到页面(打字机)②丢给 TTS 队列(边说边念)→ 收到 [DONE] 收尾。

⑥ 高频错误诊断(4 条)

错误 1:流式了却看到"攒一批才出字"多半是中间有缓冲:curl 没加 -N、反向代理(nginx)开了缓冲、或后端没及时 flush。SSE 要求一路不缓冲,字才算"实时"。
错误 2:前端读 message.content 导致空白流式增量在 delta.content,不是 message.content。字段名取错,页面一个字都不显示。
错误 3:忘了处理 [DONE] 和粘包网络流可能一次 read 拿到半行或好几行,要按 \n 切分、缓冲不完整的行;收到 [DONE] 要记得关闭 reader,否则一直挂着。
错误 4:以为流式让模型总速度变快了流式优化的是 TTFT(首字快、体感好),整篇生成完的总时间并不会变短,甚至因为频繁 flush 略增。别用它来"提速",它是"提体验"。

⑦ 考点真题演练(4 题)

考点分布

考法出题形式应对
开启方式问请求加什么stream: true
数据格式问行前缀/结束符data: / [DONE]
TTFT问哪个指标决定体感首字延迟
前端 API问 POST 流用哪个fetch ReadableStream

真题基础1. 要让接口流式返回,请求体里要加?

真题中档2. SSE 流式响应里,每一行数据的前缀和结束标记分别是?

真题中档3. 用户感觉"AI 反应快不快",最主要取决于哪个指标?

真题拔高4. 聊天接口要用 POST 发 messages,前端读取流式响应用哪个最合适?

⑧ 必背知识点卡

流式是什么:边生成边推送,打字机效果 不是等全部写完
开启方式:请求体 "stream": true,curl 加 -N 别缓冲
SSE 行格式:每行 data: {json},结束 data: [DONE] text/event-stream
增量字段:流式读 delta.content,非流式读 message.content 别取错
TTFT:首字延迟,决定用户体感"快不快" 最重要的指标
前端:POST 用 fetch ReadableStream;简单 GET 用 EventSource EventSource 不支持 POST
TTS 联动:吐一段字就丢给语音念一段,文字语音同步 边说边念
本质:流式优化体感等待,不缩短总生成时间 提体验,不是提速

⑨ 应用输出:手写一个最小打字机前端

实战场景:给你自己跑的 llama.cpp server 接一个打字机对话框
① 后端:llama-server -m model.gguf --port 8080,它自带兼容的 /v1/chat/completions。
② 前端请求:POST 时 body 里放 stream: true。
③ 读流:fetch → response.body.getReader() → TextDecoder → 按行切 data: → 遇到 [DONE] 就停。
④ 追加:把每个 delta.content 追加到答案 div;可选同时喂给 TTS 播放队列,实现边出字边出声。
口述全链路"模型本来就逐 token 生成,流式只是不再等它写完:请求加 stream:true,服务器用 SSE 把每个 token 包成 data: 行推过来,最后用 [DONE] 收尾。前端用 fetch 的 ReadableStream 逐段读出 delta.content,追加到屏幕就是打字机;再把同一段字喂给 TTS,就能边出字边念。流式优化的是首字延迟 TTFT,让你觉得它反应快,而不是让整篇生成得更快。"

⑩ 分层练习 16 题(基础 6 + 中档 5 + 拔高 5)

▍基础 6 题

基础1流式输出的"打字机效果"指什么?
模型边生成、前端边逐字把内容追加到屏幕,而不是等整篇写完一次性显示。
基础2请求里加哪个字段开启流式?
"stream": true。
基础3SSE 数据行以什么开头?结束标记是什么?
每行以 data: 开头;结束标记是 data: [DONE]。
基础4流式时,每个增量文本在哪个字段里?
choices[0].delta.content(不是非流式的 message.content)。
基础5TTFT 的中文意思是什么?
首字延迟(Time To First Token),从发请求到看到第一个字的时间。
基础6SSE 是单向还是双向通信?
服务器→浏览器单向推送,正好匹配 AI 往外吐字的场景,比 WebSocket 简单。

▍中档 5 题

中档7为什么流式能改善用户体验?它让模型生成变快了吗?
它缩短了 TTFT,让用户很快看到第一个字、不再盯空白屏,体感"反应快";但整篇生成完的总时间基本不变,甚至因频繁 flush 略增。它优化体感,不优化总速度。
中档8curl 测试流式接口为什么要加 -N?
-N 禁用输出缓冲,否则 curl 会攒一批再打印,你看不到逐行实时效果,误以为没流式。
中档9为什么聊天接口推荐用 fetch 读流而不是 EventSource?
聊天接口是 POST,要在 body 里带 messages;而原生 EventSource 只支持 GET、不能发 POST body。fetch 能 POST 并读取 response.body 的 ReadableStream,所以更合适。
中档10流式前端为什么要处理"粘包/半行"?
网络 read 一次可能拿到多行或半行 JSON,不能直接 parse。要按 \n 切分,把不完整的行缓存到下次拼接,遇到 [DONE] 再收尾。
中档11流式和 TTS 怎么联动?
模型每吐出一小段字,前端除了显示,就把这段文本丢进 TTS 播放队列去念。不用等整篇写完,实现"边出字边出声",语音几乎无延迟跟上。

▍拔高 5 题

拔高12开了流式但字还是攒一批才蹦,可能是哪里缓冲了?
常见原因:① curl 没加 -N;② nginx 等反向代理开了 proxy_buffering;③ 后端没及时 flush 响应;④ Content-Encoding 用了 gzip。需要一路关缓冲。
拔高13SSE 和 WebSocket 怎么选?
只需服务器单向持续推送(AI 吐字、行情、通知)用 SSE,简单、基于 HTTP、自动重连;需要双向实时互动(协作编辑、游戏)才上 WebSocket。AI 聊天天然是"服务器单向往外流",所以用 SSE。
拔高14TTFT 和 TPOT 分别受什么影响?
TTFT 主要受"读 prompt + 首步前向计算"影响(上下文越长、prefill 越慢,首字越晚);TPOT 是逐 token 解码速度,受模型大小、量化、GPU 算力影响。优化首字要砍 prompt / 加速 prefill,优化后续速度要换更快的引擎。
拔高15结合第6课 KV Cache,解释为什么流式下"第二个字"比"第一个字"快?
第一个字要先把整段 prompt 做一次 prefill、建立 KV Cache(贵);之后每个新 token 只需基于已有 KV Cache 做一步增量解码(便宜)。所以 TTFT 偏大、之后 TPOT 平滑,这也是流式体验流畅的底层原因。
拔高16在第21课结构化输出的基础上,流式 JSON 有什么坑?
流式时 JSON 是一段段拼出来的,中途任何一刻都不是完整 JSON,不能每收到一个 chunk 就 JSON.parse。要先把所有 delta 拼接成完整字符串,收到 [DONE] 后再一次性 parse;配合 grammar 约束可保证拼出来必然合法。

⑪ 记忆口诀 + 7 天复习计划

三句口诀 ① 流式不是让模型快,是让首字早出来。
② 请求 stream:true,推送 data: 行,结束 [DONE]。
③ POST 用 fetch 流,delta.content 追加屏;边吐边念接 TTS,首字延迟是命门。
天任务自检
第 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 才能一边流式思考、一边调工具、一边把过程讲给用户看。

← 第21课 · 结构化输出 算法与AI总览