在浏览器里“养”一只会写字的仓鼠——AI SSE 流式文本生成全攻略

开场白:为什么你的 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

  1. 代理层压缩
    Nginx 默认 gzip 会缓冲 SSE,关掉:

    nginx 复制代码
    proxy_buffering off;
  2. 浏览器缓存
    给 URL 加时间戳或 Cache-Control: no-store

  3. 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),你就能看到字节像毛毛雨一样落下。

下次面试官问你:"如何实现实时文本生成?"

你可以淡定地回答------

"我开了个农场,养了一只会打字的仓鼠。"

祝编码愉快,愿你的仓鼠永不掉线!

相关推荐
界面开发小八哥32 分钟前
DevExtreme Angular UI控件更新:引入全新严格类型配置组件
前端·ui·界面控件·angular.js·devexpress
bitbitDown40 分钟前
重构缓存时踩的坑:注释了三行没用的代码却导致白屏
前端·javascript·vue.js
xiaopengbc44 分钟前
火狐(Mozilla Firefox)浏览器离线安装包下载
前端·javascript·firefox
用户016523844411 小时前
Webpack5 入门与实战,前端开发必备技能无密
前端
小高0071 小时前
🔥🔥🔥前端性能优化实战手册:从网络到运行时,一套可复制落地的清单
前端·javascript·面试
古夕1 小时前
my-first-ai-web_问题记录01:Next.js的App Router架构下的布局(Layout)使用
前端·javascript·react.js
Solon阿杰1 小时前
solon-flow基于bpmnJs的流程设计器
javascript·bpmn-js
Solon阿杰1 小时前
前端(react/vue)实现全景图片(360°)查看器
javascript·vue.js
杨超越luckly1 小时前
HTML应用指南:利用POST请求获取上海黄金交易所金价数据
前端·信息可视化·金融·html·黄金价格
郝学胜-神的一滴1 小时前
Three.js 材质系统深度解析
javascript·3d·游戏引擎·webgl·材质