不调框架、不用
EventSource,只用原生fetch+ReadableStream,把"AI 一个字一个字往外蹦"这件事从原理到代码亲手实现一遍。 全程浏览器原生 API,一个大模型 API Key 就能复现。
🎯 本文目标
读完你应该能:用自己的话讲清"为什么 AI 的回答是逐字出现的";说清楚 SSE 和 WebSocket 该怎么选、为什么 AI 流式偏偏不用
EventSource;手写一条fetch → ReadableStream → SSE 解析 → 打字机渲染的完整链路;并且知道粘包、中止、兼容这几个坑该怎么填。
写在前面:这篇想帮你解决什么
用过 ChatGPT、DeepSeek 的人都见过那个效果:回答不是"啪"地整段出现,而是像有人在打字一样,一个字一个字往外冒。
很多前端同学以为这只是个"动画效果",前端拿到完整结果后自己做了个逐字显示的定时器。这是最常见的误解。 真到面试被追问"流式是怎么实现的""为什么用 SSE 不用 WebSocket""fetch 怎么读流",就答不上来了——因为没亲手拆过。
这篇文章带你把它拆开:先搞懂字为什么是"蹦"出来的,再一行行手写前端怎么接、怎么渲染,最后填掉三个真实会踩的坑。每一章只解决一个问题。
⚡ 第 1 章:先搞懂——为什么 AI 的字是"蹦"出来的
🎯 本章目标
建立"流式不是前端的动画,而是模型生成方式的直接暴露"这个直觉。
关键前提:模型本来就是逐字生成的
大模型生成文本,是自回归的——算出第一个字,把它接回输入,再算第二个字,如此循环,直到结束。它天生就是"一个 token 一个 token"往外吐的,根本不存在"一次性想好整段话"这回事。
如果你读过我那篇《模型缓存策略详解》,这就是里面讲的 Decode 阶段:每一步只生成一个新 token,再追加进上下文继续算下一个。
既然模型本来就是逐字产生结果的,那就有两种把结果给到前端的方式:
| 方式 | 服务端行为 | 用户感受 |
|---|---|---|
| 非流式 | 等模型把整段话全部生成完,再一次性返回 | 盯着转圈等好几秒,然后整段蹦出来 |
| 流式 | 模型每生成一个片段,就立刻推给前端 | 首字很快出现,边生成边看 |
所以流式输出不是什么额外的高级技术,它只是"把模型本来就在逐字产生的结果,逐字转发给前端"而已。 前端要做的,也不是自己造打字动画,而是如实地把陆续到达的片段接住、拼起来、显示出来。
为什么值得做:TTFT 才是体验的命门
衡量流式体验有个关键指标叫 TTFT(Time To First Token,首字延迟)——从用户点下按钮,到屏幕上出现第一个字,花了多久。
- 非流式:TTFT = 整段生成时间,可能 5~10 秒,这期间用户只能干等。
- 流式:TTFT 常常只有几百毫秒,第一个字很快出来,用户立刻知道"它开始干活了"。
总耗时可能一样,但体感天差地别。 让用户"早点看到东西在动",是流式输出真正的价值。
🧭 承上启下
既然要"服务器一有片段就推给前端",那就需要一种"服务器主动往客户端推数据"的通道。这种通道有好几种,为什么 AI 场景几乎清一色选了 SSE?下一章说清楚。
📡 第 2 章:SSE 是什么,为什么不是 WebSocket
🎯 本章目标
搞懂 SSE 的定位,以及"单向推送"为什么刚好是 AI 流式的最优解。
SSE:服务器单向推送的"够用就好"方案
SSE(Server-Sent Events,服务器发送事件) 是一种服务器向客户端单向、持续推送数据的技术。它就架在普通 HTTP 之上,没有另起炉灶。
拿它和大家更熟的 WebSocket 对比一下:
| SSE | WebSocket | |
|---|---|---|
| 方向 | 单向(服务器 → 客户端) | 双向 |
| 底层 | 就是一条 HTTP 响应 | 独立协议(需要握手升级) |
| 复杂度 | 低,普通接口就能返回 | 高,要专门的服务和连接管理 |
| 适合场景 | AI 流式、消息通知、进度推送 | 聊天室、协同编辑、游戏对战 |
AI 流式输出的数据流向是什么? 用户发一次问题(一次普通请求),然后服务器源源不断把生成的字推回来。这是彻头彻尾的单向场景——前端在生成过程中并不需要再往服务器插话。
WebSocket 的双向能力在这里完全用不上,白白背上"额外协议、连接管理、心跳保活"的复杂度。SSE 用一条 HTTP 响应就把这事办了,够用、且更简单。 选型的第一原则永远是"匹配场景",不是"谁更高级"。
SSE 的数据长什么样
大模型 API(OpenAI、DeepSeek、通义等大多兼容这套格式)开启流式后,返回的响应体是这样一行一行的纯文本:
data: {"choices":[{"delta":{"content":"流"}}]}
data: {"choices":[{"delta":{"content":"式"}}]}
data: {"choices":[{"delta":{"content":"输出"}}]}
data: [DONE]
三个约定记住就行:
- 每条消息以
data:开头,后面跟一段 JSON。 - 消息之间用空行(
\n\n)分隔。 - 最后用一条
data: [DONE]表示流结束。
注意那个 JSON 里是 delta(增量)而不是 message——流式返回的每一片只包含这一次新增的那几个字,不是累计的全文。要完整内容,得前端自己把这些 delta 一路拼起来。这点后面写代码时是核心。
🧭 承上启下
数据格式清楚了。但真到前端接收,第一个反直觉的问题就冒出来了:浏览器明明内置了一个专门收 SSE 的
EventSourceAPI,为什么做 AI 流式几乎没人用它,反而要用fetch手动读流?
🔌 第 3 章:前端怎么收——为什么是 fetch 而不是 EventSource
🎯 本章目标
理解
EventSource的致命短板,以及fetch + ReadableStream手动读流的每一步在干什么。
先解决那个"为什么不用 EventSource"
浏览器确实内置了接收 SSE 的原生 API EventSource,几行就能用。但它有一个在 AI 场景里致命的限制:只能发 GET 请求,且不能自定义请求头和请求体。
而调用大模型 API 的请求长什么样?
- 得用 POST(要把 messages、模型名、
stream: true放在请求体里); - 得带
Authorization请求头(放 API Key)。
这两条 EventSource 一条都满足不了。所以社区的通用做法是:用 fetch 发起 POST 请求,再手动读取它返回的 ReadableStream,自己解析 SSE。 这不是炫技,是被 EventSource 的能力边界逼出来的唯一选择。
📎 顺便一提:EventSource 本来怎么用?
它其实比 fetch 手动读流省事得多——浏览器把"读流、解码、按行拆、认 data: 前缀"全内置了,你只管拿 event.data:
const es = new EventSource('/api/notify') // 传个地址就开始收(只能 GET)
es.onmessage = (event) => { // 每收到一条 data: 就触发一次
if (event.data === '[DONE]') { es.close(); return }
console.log('收到:', event.data)
}
es.onerror = (err) => console.error('出错(它还会自动重连)', err)
它甚至连"断线自动重连"都替你做了。可它偏偏用不了——看它的完整构造签名就明白卡在哪:
new EventSource(url, { withCredentials: true })
// ↑ 只有 URL ↑ 配置项里只有 withCredentials,没别的
没有 method 参数 → 只能 GET,塞不下 messages、stream: true 这些请求体;没有 headers 参数 → 加不了 Authorization,API Key 只能拼到 URL 上明文暴露。功能更强的它,就这样被两条硬伤锁死在 AI 场景之外。
反过来,如果是你自己的后端、GET 够用、不需要鉴权头的 SSE(站内通知、部署进度推送),EventSource 才是更省事的正解。
💡 这是个高频考点
"为什么 AI 流式不用
EventSource而用fetch" —— 能答出"EventSource只支持 GET、不能带 header 和 body,而大模型请求必须 POST + 鉴权头",比只会背"用 fetch 读流"深一个层次。
手动读流三件套:getReader + TextDecoder + 解析
fetch 的响应体 response.body 是一个 ReadableStream(可读流)。读它需要三样东西配合:
response.body.getReader()—— 拿到流的"读取器",之后反复read()就能一块块把数据取出来。TextDecoder—— 流里出来的是二进制Uint8Array,用它解码成字符串。- 手动解析 —— 把解码出的文本按 SSE 格式(
data:开头、[DONE]结束)拆出真正的内容。
先别急着封装,在浏览器 F12 控制台里跑通一遍,亲眼看到字是怎么一块块到的:
// 直接贴进浏览器控制台运行(把 API_KEY 换成你自己的)
const API_KEY = '你的 API Key'
async function testStream() {
const response = await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST', // ① 必须 POST
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}` // ② 必须带鉴权头(EventSource 做不到)
},
body: JSON.stringify({
model: 'deepseek-chat',
messages: [{ role: 'user', content: '用一句话介绍你自己' }],
stream: true // ③ 关键开关:开启流式
})
})
const reader = response.body.getReader() // 拿到读取器
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read() // 一块块读
if (done) break // 流读完了
const chunk = decoder.decode(value, { stream: true }) // 二进制 → 字符串
// 按 SSE 格式解析:只要 data: 开头的行
const lines = chunk.split('\n').filter(line => line.startsWith('data: '))
for (const line of lines) {
const data = line.replace('data: ', '')
if (data === '[DONE]') { console.log('✅ 流结束'); continue }
const json = JSON.parse(data)
const content = json.choices[0]?.delta?.content // 取增量内容
if (content) console.log('收到:', content)
}
}
}
testStream()
跑一下,控制台会一条条打印出"收到:流""收到:式""收到:输出"——这就是那个"打字"效果的数据来源。到这一步,流式的本质你已经亲眼见到了:没有魔法,就是一个 while 循环在不停地读、拼、显示。
⚠️
decoder.decode(value, { stream: true })里的{ stream: true }不是可有可无一个多字节字符(比如一个中文字)在传输时可能被拆到两个 chunk 里。加了
{ stream: true },TextDecoder会记住上一块没解码完的半个字,等下一块补齐——不加就可能解出乱码。这是第 6 章"粘包"问题的第一道防线。
🧭 承上启下
控制台里能跑通了,但把逻辑摊在业务代码里显然不行。下一步把它收拢成一个"只管吐字、不管 UI"的可复用函数。
🧩 第 4 章:封装成一个"只吐字"的函数
🎯 本章目标
用一个回调把"数据接收"和"界面渲染"彻底解耦。
好的封装只做一件事:它负责把字一个个解析出来,至于这些字怎么显示,交给外面。 做法是给它一个 onChunk 回调——每解析出一片新内容,就调一次,把"新增的这片"和"到目前为止的全文"都交出去。
// stream.js —— 只负责接收和解析 SSE,不碰任何 UI(纯 JS,用不用框架都能跑)
const API_URL = 'https://api.deepseek.com/chat/completions'
const API_KEY = 'YOUR_API_KEY' // ⚠️ 仅演示用;真实项目里 Key 要放后端,别硬编码在前端
/**
* @param {string} prompt 发给模型的问题
* @param {function} onChunk 每收到一片就回调 (新增内容, 累计全文)
* @param {AbortSignal} signal 用于中止请求(见第 6 章)
* @returns {Promise<string>} 最终完整内容
*/
async function streamChat(prompt, onChunk, signal) {
const response = await fetch(API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`
},
body: JSON.stringify({
model: 'deepseek-chat',
messages: [{ role: 'user', content: prompt }],
stream: true
}),
signal // 把中止信号透传给 fetch
})
if (!response.ok) throw new Error(`API 错误:${response.status}`)
const reader = response.body.getReader()
const decoder = new TextDecoder()
let fullContent = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
// 解码这一块,按行拆,只留 data: 开头的行(和第 3 章控制台版一样)
const chunk = decoder.decode(value, { stream: true })
const lines = chunk.split('\n').filter(line => line.startsWith('data: '))
for (const line of lines) {
const data = line.slice(6) // 去掉 'data: '
if (data === '[DONE]') continue
try {
const content = JSON.parse(data).choices[0]?.delta?.content
if (content) {
fullContent += content
onChunk(content, fullContent) // 回调:把字交出去
}
} catch {
// 解析失败先跳过(这里藏着一个坑,第 6 章专门修)
}
}
}
return fullContent
}
这个函数跟任何框架都没关系——它只认一个 onChunk 回调。纯 HTML 里 onChunk 就直接改 DOM,React 里就 setState,Vue 里就更新 ref,换任何框架、甚至不用框架,这段核心逻辑一行都不用动。把"接数据"和"画界面"拆开,是这段封装最值钱的地方。
⚠️ 这个版本"能用",但埋了个偶尔丢字的隐患
上面
chunk.split('\n')直接处理,默认了"每次拿到的 chunk 都是完整的一行行"。但真实网络不保证这点——一行data:可能被切成两半分两次到达,那半行就会JSON.parse失败被catch跳过,这个字就悄悄丢了。现在先不管它,第 6 章坑 1 会专门修这个问题,到时候你会看到那个catch为什么还不够。
🧭 承上启下
函数会吐字了,接下来就是"画"——把陆续到达的字变成带闪烁光标的打字机效果。
⌨️ 第 5 章:打字机效果——渲染 + 光标
🎯 本章目标
理解"打字机"效果其实不需要定时器,数据驱动天然就是打字机。
这里要破除另一个误解:很多人以为打字机效果要写一个 setInterval,每隔几十毫秒显示一个字。完全不用。
因为字本来就是陆续到达的——onChunk 每次带来一小片,你只要把累计的全文写进页面,屏幕上自然就逐字长出来了。数据的节奏就是打字的节奏。
用纯 HTML + JS 写一个最小页面(不依赖任何框架,存成 .html 双击就能开):
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<style>
/* 输出区:保留 AI 返回里的换行和空格 */
#output {
white-space: pre-wrap;
line-height: 1.7;
min-height: 60px;
}
/* 闪烁光标 */
.cursor {
animation: blink 0.8s infinite;
color: #3451b2;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
</style>
</head>
<body>
<button id="askBtn">🤖 提问</button>
<!-- 输出区 + 一个默认隐藏的光标 -->
<div id="output"></div>
<span id="cursor" class="cursor" style="display: none">▊</span>
<script type="module">
import { streamChat } from './stream.js' // 第 4 章那个函数
const output = document.getElementById('output')
const cursor = document.getElementById('cursor')
const askBtn = document.getElementById('askBtn')
askBtn.addEventListener('click', async () => {
output.textContent = '' // 清空上一次
cursor.style.display = 'inline' // 显示光标 = 开始输出
try {
await streamChat('用一句话介绍你自己', (_chunk, full) => {
output.textContent = full // ← 每来一片就写进 DOM,屏幕逐字生长
})
} finally {
cursor.style.display = 'none' // ← 无论成功失败都收起光标
}
})
</script>
</body>
</html>
整个"打字机"就靠回调里那一行 output.textContent = full:数据每到一片就把最新全文写进 <div>,浏览器重绘,字就一个个冒出来了。 没有 setInterval、没有框架、没有响应式——你能清楚看到"数据到达"和"界面更新"是直接对应的。
两个容易被忽略的小细节,恰恰是"精致感"的来源:
white-space: pre-wrap:模型返回的内容里有换行、缩进、列表。不加这行,所有换行会被浏览器吞掉,排版全乱。- 光标用一个独立的
<span>,靠显隐控制:开始输出时display: inline,结束时display: none——流一结束就撤掉光标,用户能清楚感知"它说完了"。
💡 换成 Vue / React 也是同一回事
框架无非把"手动
output.textContent = full"换成"更新一个状态变量、由框架重渲染":Vue 里是answer.value = full、React 里是setAnswer(full),光标的显隐则绑到一个isStreaming状态上。核心那句"每来一片就更新界面"一模一样,变的只是"谁负责把变量刷进 DOM"。所以这篇用最朴素的原生 DOM 演示,你看懂了,迁到任何框架都是顺的。
🧭 承上启下
Demo 顺风顺水,真实网络却不讲道理:数据包会被拆散、用户会中途反悔、Safari 会闹脾气。最后一章专门填这三个坑。
🕳️ 第 6 章:三个真实会踩的坑(这节最能体现工程成熟度)
🎯 本章目标
把"Demo 能跑"补齐到"线上能用",每个坑都对应一段面试能讲的细节。
坑 1:粘包 / 半包——一片 data: 可能是半截的(最重要)
现象:解析时偶尔报错,打印出来一看,data: {"choices":[{"delta":{"conte ——JSON 只有半截。
原因:网络是按数据包传输的,它根本不管你的 SSE 消息边界。一次 read() 拿到的 chunk,可能正好切在一条 data: 消息的中间;也可能一次带来两三条完整消息 + 半条。这就是"粘包/半包"。第 4 章那个简单版正是栽在这——半行 JSON.parse 失败被 catch 跳过,字就悄悄丢了。
解法:加一个跨轮次的缓冲区 buffer。核心思路是——读到的内容先塞进缓冲区,按 \n 切行,最后一行大概率不完整,先留着不处理,等下一轮 read() 把它补全再说。 相比第 4 章,改动只有加粗的三处:
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = '' // ★ 改动1:新增一个跨轮次的缓冲区
let fullContent = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true }) // ★ 改动2:累加进 buffer,不是每轮从零开始
const lines = buffer.split('\n')
buffer = lines.pop() // ★ 改动3:最后一行可能是半截的,抠出来留到下一轮再拼
for (const line of lines) {
// 现在处理的都是完整的行了
if (!line.startsWith('data: ')) continue
const data = line.slice(6)
if (data === '[DONE]') continue
try {
const content = JSON.parse(data).choices[0]?.delta?.content
if (content) { fullContent += content; onChunk(content, fullContent) }
} catch { /* 兜底,见下 */ }
}
}
📎 每次 value 到底是什么?分 3 次到达走一遍
每次 reader.read() 拿到的 value 是一段二进制字节(Uint8Array),decode 后变成字符串。但网络在哪里切这段字节是随机的,它根本不管你 SSE 一条消息的边界——可能切在消息中间,也可能一次给你两条半。
假设服务端要发这 3 条:
data: {"delta":"你"}
data: {"delta":"好"}
data: {"delta":"呀"}
网络很可能这样乱切成 3 次到达(注意断点):
第1次 value → 'data: {"delta":"你"}\ndata: {"del' ← 结尾是半条
第2次 value → 'ta":"好"}\ndata: {"delta' ← 开头是上条的后半截
第3次 value → '":"呀"}\n'
看 buffer 怎么逐轮把它们接起来:
// 第1轮:buffer = 'data: {"delta":"你"}\ndata: {"del'
// split('\n') → ['data: {"delta":"你"}', 'data: {"del']
// └─ 完整 ✅ └─ 半条 ❌
// pop() 把半条 'data: {"del' 存回 buffer,循环只处理那条完整的 → 解析出「你」
// 第2轮:buffer = 'data: {"del' + 'ta":"好"}\ndata: {"delta'
// = 'data: {"delta":"好"}\ndata: {"delta' ← 半条被补全了!
// split('\n') → ['data: {"delta":"好"}', 'data: {"delta']
// pop() 又把新半条 'data: {"delta' 存回 buffer → 解析出「好」
// 第3轮:buffer = 'data: {"delta' + '":"呀"}\n'
// = 'data: {"delta":"呀"}\n' ← 又补全了
// split('\n') → ['data: {"delta":"呀"}', '']
// pop() 把末尾空串存回 buffer(收尾干净)→ 解析出「呀」
为什么"看起来没手动动 buffer"它也在滚动? 因为每轮 buffer += value 是累加、buffer = lines.pop() 又把没处理完的尾巴存回同一个 buffer——这一加一存,buffer 自己就在跨轮次接力。核心就一句:split('\n') 后除了最后一段,前面每段后面都跟过 \n、保证是完整行;唯独最后一段悬着(还没等到 \n),所以 pop 出来留到下一轮补全。 万一某条消息特别长、跨了三四轮,它就一直躺在 buffer 里被接力,直到那个 \n 到了才被切出来处理。
再叠加 JSON.parse 外面的 try-catch 兜底,双保险:结构上用 buffer 保证"只处理完整的行",运行时用 try-catch 兜住"万一还是解析失败就跳过"。 这个坑几乎每个手写流式的人都会踩,能讲清楚"为什么会半包、buffer 怎么解决",是这篇最硬的面试素材。
📎 还有个收尾边界:循环结束后,buffer 里可能还剩最后一条(面试加分点)
buffer 的机制是"最后一段先留着,等下一轮补全"。但最后一轮之后没有"下一轮"了——如果服务端最末一条 data: 后面没有换行 \n(有些服务端会这样),那这条就会一直躺在 buffer 里,while 循环 done 退出后它再也没机会被处理,等于丢了最后一条。
严谨的写法是在循环结束后,把 buffer 里的残余再 flush 一次:
while (true) {
const { done, value } = await reader.read()
if (done) break
// …前面的 buffer 逐行处理…
}
// ★ 循环结束后:buffer 里可能还剩没处理的最后一行,补处理一次
const last = buffer.trim()
if (last.startsWith('data: ')) {
const data = last.slice(6)
if (data !== '[DONE]') {
try {
const content = JSON.parse(data).choices[0]?.delta?.content
if (content) { fullContent += content; onChunk(content, fullContent) }
} catch { /* 残缺就算了 */ }
}
}
实践中大模型的 SSE 基本都以 data: [DONE] 结尾、且带换行,所以正文那版没写这步也能跑。但你能主动说出"循环结束后应该 flush 一次残余 buffer,否则末条没换行就会丢",在面试里是个能体现严谨度的细节。
坑 2:用户中途反悔——用 AbortController 真正掐断请求
生成到一半,用户不想看了,或者要换个问题重问。这时候不能只是把界面藏起来——底层的 fetch 还在偷偷下载、还在烧 token。
正确做法是用 AbortController 从根上掐断:
let controller = null
async function ask(question) {
controller = new AbortController()
cursor.style.display = 'inline'
try {
await streamChat(question, (_c, full) => { output.textContent = full }, controller.signal)
} catch (err) {
if (err.name === 'AbortError') {
// 用户主动停止,这不是错误,静默处理即可
} else {
output.textContent = '出错了:' + err.message
}
} finally {
cursor.style.display = 'none'
}
}
function stop() {
controller?.abort() // 真正中止 fetch,连接断开,不再烧 token
}
两个要点:signal 必须一路透传给 fetch(第 4 章封装里已经留好了这个参数);中止会让 fetch 抛出一个 name 为 AbortError 的错误,要在 catch 里把它和真正的网络错误区分开——用户主动停止不是 bug,不该弹报错。
最后把 ask / stop 接回第 5 章那个页面——加一个「停止」按钮,stop 就有地方触发了:
<button id="askBtn">🤖 提问</button>
<button id="stopBtn">⏹ 停止</button> <!-- 新增:停止按钮 -->
askBtn.addEventListener('click', () => ask('用一句话介绍你自己'))
stopBtn.addEventListener('click', stop) // ← stop 就绑在这里
链路这就闭合了:点「提问」→ ask 发起流式、记下 controller → 生成途中点「停止」→ stop 调 controller.abort() → fetch 中断 → ask 的 catch 收到 AbortError,静默收尾。
坑 3:Safari 上流式卡住不动
现象:Chrome 一切正常,Safari(尤其 iOS)上流式经常卡住、憋到最后才一次性蹦出来。
原因:Safari 对 ReadableStream 的增量刷新有自己的边界行为。好在坑 1 里那套 buffer 逐行处理的写法,本身就规避了大部分问题——如果你已经按坑 1 正确处理了缓冲,Safari 的多数卡顿也就跟着解决了。 这也说明健壮的写法往往能一次性覆盖好几个边界情况。
别忘了:错误发生在"已经吐了一半"的时候
流式有个非流式没有的尴尬:报错时,屏幕上可能已经显示了半段内容。 网络断在中途、模型触发限流……这时候不能粗暴清空,建议在已有内容后面追加一句提示,让用户知道"这是中断,不是全部":
catch (err) {
if (err.name !== 'AbortError') {
output.textContent += '\n\n⚠️(回答意外中断,请重试)'
}
}
🎁 结语:流式输出没有魔法
💎 只记一句话也够
流式输出不是前端造的打字动画,而是"模型本来就逐字生成,前端如实地逐字接住并显示"——难的从来不是让字动起来,而是让它在真实网络里稳稳地动。
回头把这条链路串一遍,其实特别短:
- 为什么能流式:模型自回归、逐 token 生成,本就是一个字一个字产出的。
- 用什么传:SSE,单向推送、架在 HTTP 上,刚好匹配"一次提问、持续接收"的场景。
- 前端怎么收:
fetch发 POST(EventSource带不了鉴权头),getReader()读流,TextDecoder解码,手动解析data:。 - 怎么显示:把累计全文绑定到界面,数据到达的节奏天然就是打字节奏,末尾加个闪烁光标。
- 怎么变稳:
buffer治粘包、AbortController管中止、区分AbortError和真错误。
代码加起来也就百来行,没有一处是黑魔法。真正拉开差距的,是能讲清每一步"为什么这么做"——为什么不用 EventSource、为什么会半包、为什么错误要区分类型。这些细节,才是面试官眼里你和"用过某个流式 SDK"的人之间的区别。
🚀 下一步可以接什么
这套流式链路是很多 AI 功能的地基:接上多轮对话历史,就是完整的聊天;把
onChunk里的文本边收边喂给 Markdown 渲染器,就是 ChatGPT 那种实时格式化效果;再往上,Agent 的"思考过程可视化"也是同一套流在支撑。
💡 原创声明
本文首发于我的个人博客 rjy92.github.io。如需转载请注明出处。 如果这篇文章帮到了你,欢迎在以下平台关注、交流: