开场白:为什么你的 AI 像便秘?
想象一下你在咖啡馆里,隔壁桌的程序员小哥对着屏幕怒吼:"快出来啊!"
你以为他在催稿,结果他只是调 OpenAI 的接口------等待 8 秒一次性返回 800 个 token,像极了 90 年代的拨号上网。
而隔壁的隔壁桌,小姐姐的屏幕像打字机一样哒哒哒蹦字,用户眉开眼笑。
秘诀?SSE(Server-Sent Events)------把 AI 当成一只打字仓鼠,让它在笼子里边跑边喷墨,字就一行行蹦出来。
一、SSE 是什么?能吃吗?
特征 | 描述 |
---|---|
全称 | Server-Sent Events |
协议 | 基于 HTTP/1.1 的单向流(服务器 → 浏览器) |
内容类型 | text/event-stream |
重连 | 浏览器原生支持自动重连,比 WebSocket 省心 |
兼容性 | 除了 IE 和某些老安卓,全员 OK(2025 年了,IE 终于死了) |
底层原理一句话:HTTP 长连接 + 纯文本帧格式 。
每个帧长这样(别眨眼):
kotlin
data: 你好,我是仓鼠\n\n
两个换行代表一条消息结束,浏览器立刻触发 MessageEvent
。
如果你把 data:
后面塞进 JSON,就能带结构化数据,比如:
kotlin
data: {"token":"机","done":false}\n\n
二、在 Node.js 端喂饱仓鼠
2.1 最小可运行示例(Express)
js
// server.js
import express from 'express';
import { OpenAI } from 'openai';
const app = express();
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
app.get('/stream', async (req, res) => {
// 1. 告诉浏览器这是一只活仓鼠
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders(); // 立刻把 header 冲出去,不然浏览器会等
const prompt = req.query.q || '写一首关于猫的诗';
try {
const stream = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: prompt }],
stream: true,
});
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content || '';
if (delta) {
res.write(`data: ${JSON.stringify({ token: delta, done: false })}\n\n`);
}
}
// 仓鼠跑完了,给个终止符
res.write(`data: ${JSON.stringify({ token: '', done: true })}\n\n`);
res.end();
} catch (e) {
res.write(`data: ${JSON.stringify({ error: e.message })}\n\n`);
res.end();
}
});
app.listen(3000, () => console.log('仓鼠农场已开业:http://localhost:3000'));
2.2 防踩坑 Tips
-
代理层压缩
Nginx 默认 gzip 会缓冲 SSE,关掉:nginxproxy_buffering off;
-
浏览器缓存
给 URL 加时间戳或Cache-Control: no-store
。 -
token 计费
别忘了在done:true
时把总用量回传前端,免得用户以为你偷字数。
三、在浏览器端看仓鼠表演
3.1 最小可运行示例(原生 JS)
html
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>仓鼠打字机</title>
<style>
#output { white-space: pre-wrap; font-family: 'Courier New', monospace; }
</style>
</head>
<body>
<input id="prompt" placeholder="说点什么..." />
<button onclick="startStream()">喂仓鼠</button>
<div id="output"></div>
<script>
const output = document.getElementById('output');
function startStream() {
output.textContent = '';
const q = document.getElementById('prompt').value;
const es = new EventSource(`/stream?q=${encodeURIComponent(q)}`);
es.onmessage = e => {
const { token, done, error } = JSON.parse(e.data);
if (error) {
output.textContent += '\n[系统提示] 仓鼠中暑:' + error;
es.close();
return;
}
output.textContent += token;
if (done) es.close();
};
es.onerror = () => {
output.textContent += '\n[系统提示] 仓鼠掉线,正在重连...';
};
}
</script>
</body>
</html>
3.2 React 版(hooks 党福利)
jsx
// useSSE.js
import { useEffect, useState, useRef } from 'react';
export default function useSSE(url) {
const [text, setText] = useState('');
const esRef = useRef(null);
useEffect(() => {
if (!url) return;
esRef.current = new EventSource(url);
esRef.current.onmessage = e => {
const { token, done, error } = JSON.parse(e.data);
if (error) {
setText(prev => prev + `\n[错误] ${error}`);
esRef.current.close();
return;
}
setText(prev => prev + token);
if (done) esRef.current.close();
};
return () => esRef.current?.close();
}, [url]);
return text;
}
四、高级花样:给仓鼠加特效
| 特效 | 实现思路 |
|---------------|------------------------------------------------------------------------------------------|--------------------------------------|
| 打字机光标 | 用 CSS 动画 `::after { content: ' | '; animation: blink 1s infinite; }` |
| Markdown 实时渲染 | 收到 token 后喂给 marked.parseInline
,但记得节流 16ms |
| 语音同步 | 把 token 同时送进 Web Speech API,speechSynthesis.speak(new SpeechSynthesisUtterance(delta))
|
| 中途打断 | 前端调用 es.close()
,后端用 AbortController
取消 OpenAI 请求 |
五、一张图看懂数据流向
vbnet
┌─────────────┐ HTTP GET /stream?q=猫 ┌─────────────┐
│ 浏览器页面 │◀─────────────────────────────│ Node 服务 │
│ │ text/event-stream │ │
│ #output │◁───data:{"token":"喵"}──────│ OpenAI API │
└─────────────┘ └─────────────┘
(ASCII 艺术也是艺术,对吧?)
六、彩蛋:仓鼠的健康检查清单
- 服务器 HTTP/2 会让 SSE 复用连接,延迟更低
- 移动端杀掉 App 后重连,iOS Safari 会帮你自动续命
- 如果 token 里本身带换行,记得
delta.replace(/\n/g, '\\n')
再 JSON 化 - 不要把
res.write
放在try
外面,否则异常时浏览器会看到半截 JSON
结语:把 AI 当宠物,而不是黑箱
AI 不再是神秘的水晶球,而是一只可观察、可打断、可撸毛的仓鼠。
给它一条跑道(SSE),你就能看到字节像毛毛雨一样落下。
下次面试官问你:"如何实现实时文本生成?"
你可以淡定地回答------
"我开了个农场,养了一只会打字的仓鼠。"
祝编码愉快,愿你的仓鼠永不掉线!