[Node.js] 流式返回信息

SSE

SSE(Server-Sent Events) 是一种浏览器原生支持的、单向的流式通信协议

服务器 → 客户端
持续不断推送数据(比如:消息、token、进度)

它基于 HTTP 协议,使用 MIME 类型 text/event-stream

核心特性

  1. 单向通信:只能从服务端推送到客户端(不能反向)
  2. 基于 HTTP/1.x:无需额外协议,浏览器天然支持
  3. 保持连接、实时推送:服务端主动发送数据,客户端自动接收

使用场景

  • 流式大模型输出(如 ChatGPT/Ollama)
  • 实时日志、监控信息推送
  • 股票/天气/订单状态实时更新
  • 打字机式的问答机器人

流式处理

在流式请求中(如 Ollama、OpenAI、Claude 的 stream: true 模式),响应体不是一次性完整返回,而是按块分批返回

比如你请求了大模型回答问题,它不会一下子返回整段文字,而是:

"你"
"好"
","
"请问"
"需要"
"帮助吗?"

这些字符被拆成若干个“chunk”,逐个返回。

假设我们开启 stream 流式模式:

const response = await fetch("/api/generate", {
  method: "POST",
  body: JSON.stringify({ prompt: "你是谁?", stream: true }),
});

response.body 就是 ReadableStream 可读流,可读流有一个 getReader 的方法,可以拿到一个 Reader 对象。

const reader = response.body.getReader();

该 Reader 对象上面有一个 read 方法,可以读取每一个 chunk。

while (true) {
  // 不断的去读每一个块,取出当前块所对应的数据
  const { done, value } = await reader.read();
  if (done) break; // 进入该 if,说明没有数据了
}
  • value: 一个 Uint8Array,代表当前的字节数据块
  • done: 是否已经读完(true 表示所有数据已读取完毕)。

但现在读取到的是二进制数据,我们需要去解码,因此我们创建一个 UTF-8 解码器,将从 reader.read() 拿到的 二进制 Uint8Array 解码为字符串。

const decoder = new TextDecoder("utf-8");

TextDecoder 会自动处理中文、emoji 这类多字节字符。

解码代码如下:

const chunk = decoder.decode(value, { stream: true });

例如其中一个 value 解码后结果是:

chunk = '{"response":"你好"}\n{"response":","}\n'

这里设置 stream: true 的作用是:保留未完整字符到下一次解码继续拼接。

比如如果只读到 {"response":"你,它不会报错,而是等待下次补上 好"} 再组合起来。

假设将 stream 设置为 false,遇到以下情况会出 bug:

Uint8Array.from([123, 34, 114, 101, 115, 112, 111, 110, 115, 101, 34, 58, 34, 228])

这是部分字符 "你" 的 utf-8,但还没完整;stream: false 会报错(因为字符不完整);stream: true 会“记住”这个未完整字符,等下一次拼接回来。


Code:

router.post("/ask", async function (req, res) {
  // 获取用户输入的问题
  const question = req.body.question || "";

  // 需要加载向量数据库里面的内容
  // 拿到外挂知识的所有向量(正常开发中,是放在向量数据库里面的)
  const embeddedDocs = await loadCachedEmbeddings();

  // 接下来就需要用用户的问题和向量数据库里面的数据做一个比对
  // 然后拿到和用户问题相关的向量
  const relevantDocs = await searchByEmbedding(question, embeddedDocs);

  // 将relevantDocs里面的文本提取出来,作为上下文
  const context = relevantDocs.map((d) => `- ${d.content}`).join("\n");

  // 这是一个提示词模板
  const prompt = `
你是一个中文智能助手,请使用中文回答用户的问题。以下是背景知识和问题内容:

背景知识:
${context}

问题:${question}
`;
  // 请求ollama服务器
  const response = await fetch("http://localhost:11434/api/generate", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      model: "llama3",
      prompt,
      stream: true,
    }),
  });

  // 设置响应头
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");

  // 获取响应的流
  const reader = response.body.getReader();

  while (true) {
    const { done, value } = await reader.read();
    if (done) {
      break;
    }
    const chunk = new TextDecoder("utf-8").decode(value, { stream: true });
    const lines = chunk.split("\n").filter((line) => line.trim());
    for (const line of lines) {
      try {
        const data = JSON.parse(line);
        if (data.response) {
          res.write(`${JSON.stringify({ response: data.response })}\n`);
        }
      } catch (err) {
        console.error("Failed to parse chunk:", err);
      }
    }
  }

  res.end();
});
posted @ 2025-09-06 19:52  Zhentiw  阅读(33)  评论(0)    收藏  举报