在浏览器里“养”一只会写字的仓鼠——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),你就能看到字节像毛毛雨一样落下。

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

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

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

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

相关推荐
陈随易3 小时前
Postman 被曝自动往 Claude Code 和 Codex 装插件
前端·后端·程序员
怕浪猫4 小时前
小程序成长计划上新:10 亿 Token 现在能调 DeepSeek-v4.1-flash了
人工智能·aigc·ai编程
Dream-Y.ocean5 小时前
鸿蒙PC平台宅匠 3D 室内设计适配实战:基于 Electron 壳方案的 Three.js 双渲染引擎与参数化户型编辑器开发
javascript·3d·harmonyos
Csvn6 小时前
性能监控
前端
Highcharts.js6 小时前
Highcharts Gantt使用符号的自定义数据标签开发甘特图示例
javascript·甘特图·highcharts·图表开发·实战代码·响应式图表
Csvn6 小时前
构建工具
前端
FRing037 小时前
vue2干货总结3(computed计算属性、watch监听器)
开发语言·前端·vue.js·vue2
stormzhangV9 小时前
未来十年的建议
人工智能·aigc·agent
kyriewen9 小时前
Skills 榜前 10 我全装了:只有 3 个能直接用
前端·javascript·程序员
IT_陈寒9 小时前
当Java的hashCode遇上Lombok,我的缓存被全清了
前端·人工智能·后端