Node.js · SSE

Node.js 用 fetch 接入 Tikway 流式输出:正确解析 SSE 与中文分块

更新日期:

用 Node.js 内置 fetch 读取 Tikway OpenAI 兼容 Chat Completions 的流式响应,处理网络分块、中文 UTF-8、SSE 多行 data、错误事件和 [DONE],适合后端实时输出文字的场景。

流式响应为什么不能直接按网络 chunk 解析

流式输出可以让应用在完整回答生成前逐段展示文本。Tikway 的 OpenAI 兼容 Chat Completions 请求通过 stream: true 开启流式返回。

网络一次读取到的字节块,不一定对应一条 JSON:一个事件可能拆成几块,一块也可能含有多个事件。中文字符的 UTF-8 字节和 \r\n 换行都可能恰好被切开。因此需要先连续解码字节,再按 SSE 空行边界组装事件,最后解析事件的 data。

本例聚焦文本对话:从 choices[].delta.content 输出增量文字。角色事件、没有文字的结束事件及 usage 事件,都不能当成文本直接打印。它也不负责工具调用参数或其他媒体结果的拼接。

1. 准备 Node.js 和 API Key

安装 Node.js 22 或更新版本。本例使用内置 fetch、ReadableStream、TextDecoder 和 AbortSignal.timeout,不需要安装 npm 包。

在Tikway 模型目录确认模型支持 Chat Completions 及流式输出,并取得自己的 Tikway API Key。本例沿用官网首页的 openai/gpt-5.4-mini;更换时应复制目录中的完整标识。

Windows PowerShell:

powershell
$env:TIKWAY_API_KEY = "替换为你的 Tikway API Key"

macOS / Linux:

bash
export TIKWAY_API_KEY='替换为你的 Tikway API Key'

Key 应留在后端环境变量中。浏览器前端直接携带 Key 发请求,会把 Key 暴露给访问者;网页应用可以通过自己的后端转发。

2. 保存完整的 fetch + SSE 示例

保存为 tikway_stream.mjs,也可使用配套脚本。运行脚本会向 https://api.tikway.ai/v1/chat/completions 发起真实模型请求,费用和额度以账户及模型目录为准。

javascript
import { pathToFileURL } from "node:url";

const ENDPOINT = "https://api.tikway.ai/v1/chat/completions";
const MAX_EVENT_CHARS = 1024 * 1024;

export async function* readSSE(body) {
  const reader = body.getReader();
  const decoder = new TextDecoder("utf-8", { fatal: true });
  let pending = "";
  let dataLines = [];
  let eventName = "";
  let eventChars = 0;

  function acceptLine(line) {
    if (line === "") {
      const event = dataLines.length
        ? { event: eventName || "message", data: dataLines.join("\n") }
        : null;
      dataLines = [];
      eventName = "";
      eventChars = 0;
      return event;
    }
    eventChars += line.length;
    if (eventChars > MAX_EVENT_CHARS) throw new Error("SSE 事件过大。");
    if (line.startsWith(":")) return null; // 注释或心跳

    const colon = line.indexOf(":");
    const field = colon === -1 ? line : line.slice(0, colon);
    let value = colon === -1 ? "" : line.slice(colon + 1);
    if (value.startsWith(" ")) value = value.slice(1);
    if (field === "data") dataLines.push(value);
    if (field === "event") eventName = value;
    return null; // id/retry 等字段在本例中不使用
  }

  try {
    while (true) {
      const { value, done } = await reader.read();
      pending += done ? decoder.decode() : decoder.decode(value, { stream: true });

      while (true) {
        const match = /\r\n|\r|\n/.exec(pending);
        if (!match) break;
        // 末尾的 CR 可能与下一块开头的 LF 组成同一个换行。
        if (!done && match[0] === "\r" && match.index === pending.length - 1) break;
        const event = acceptLine(pending.slice(0, match.index));
        pending = pending.slice(match.index + match[0].length);
        if (event) yield event;
      }
      if (pending.length + eventChars > MAX_EVENT_CHARS) {
        throw new Error("SSE 事件过大。");
      }
      if (done) return; // 没有空行结尾的残缺事件不作为完整事件发出
    }
  } finally {
    try { await reader.cancel(); } catch { /* 清理连接 */ }
    reader.releaseLock();
  }
}

export async function main() {
  const apiKey = process.env.TIKWAY_API_KEY?.trim();
  if (!apiKey) throw new Error("请先设置 TIKWAY_API_KEY。");

  const response = await fetch(ENDPOINT, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      Accept: "text/event-stream",
    },
    body: JSON.stringify({
      model: process.env.TIKWAY_MODEL?.trim() || "openai/gpt-5.4-mini",
      messages: [{ role: "user", content: "请用三句话说明 SSE 的用途。" }],
      stream: true,
    }),
    signal: AbortSignal.timeout(90_000),
  });

  const requestId = response.headers.get("x-request-id") || "未提供";
  if (!response.ok) {
    let detail = "请查看错误码文档。";
    try {
      const body = await response.json();
      const apiError = body?.error;
      if (apiError && typeof apiError === "object") {
        detail = `${apiError.code || apiError.type || "unknown"}: `
          + String(apiError.message || "").slice(0, 300);
      }
    } catch { /* 错误响应也可能不是 JSON */ }
    throw new Error(`HTTP ${response.status};request_id=${requestId};${detail}`);
  }

  const contentType = response.headers.get("content-type") || "";
  if (!contentType.toLowerCase().includes("text/event-stream") || !response.body) {
    if (response.body) await response.body.cancel();
    throw new Error(`未收到 SSE 响应;request_id=${requestId}`);
  }

  let completed = false;
  for await (const event of readSSE(response.body)) {
    if (event.data.trim() === "[DONE]") {
      completed = true;
      break;
    }
    let payload;
    try { payload = JSON.parse(event.data); }
    catch { throw new Error(`SSE data 不是有效 JSON;request_id=${requestId}`); }

    if (event.event === "error" || payload?.type === "error" || payload?.error) {
      const apiError = payload?.error;
      const detail = apiError && typeof apiError === "object"
        ? `${apiError.code || apiError.type || "unknown"}: `
          + String(apiError.message || "stream interrupted").slice(0, 300)
        : "stream interrupted";
      throw new Error(`流式错误;request_id=${requestId};${detail}`);
    }

    const choices = Array.isArray(payload?.choices) ? payload.choices : [];
    const choice = choices.find((item) => item?.index === 0) || choices[0];
    const text = choice?.delta?.content;
    if (typeof text === "string") process.stdout.write(text);
    // finish_reason 或 usage 可以先到;继续读取直到 [DONE]。
  }
  if (!completed) throw new Error(`流提前结束,未收到 [DONE];request_id=${requestId}`);
  process.stdout.write("\n");
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  main().catch((err) => {
    console.error(`调用未完成:${err.message}`);
    process.exitCode = 1;
  });
}

运行:

bash
node tikway_stream.mjs

脚本会把收到的文字片段依次写到终端,完整回答的内容由模型决定。90 秒是本例的客户端总超时设置,可以按业务需求调整;它不是服务响应时间承诺。

3. 读懂几个关键处理

TextDecoder 的 stream: true 会保留尚未完整的 UTF-8 字节,因此中文不会因为网络分块而直接乱码。随后代码按行处理 LF、CRLF 和 CR,遇到空行才派发一个完整 SSE 事件;事件中的多条 data: 用换行连接后再解析。

形如下面的内容是结构说明,文字与统计数字均为示例:

text
data: {"choices":[{"index":0,"delta":{"content":"你好"},"finish_reason":null}]}

data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: [DONE]

不要在看到 finish_reason 时立即停止读取:文本结束后仍可能有 usage 等事件。本例以 [DONE] 标记正常完成;连接先关闭而没有这个标记,会报告中断,并保留已经展示的文字。

HTTP 成功也不等于整条流成功。流式连接开始以后,如果 data 中出现 error,代码会报告流式错误。因此错误处理需要同时覆盖 HTTP 层和事件层。

解析器还有一个本地的单事件大小限制,避免对异常事件无限缓存。它是示例的保护设置,不代表 Tikway 的官方响应大小限制。

4. 先用本地数据检查 SSE 解析

如果希望先排除解析代码或运行环境的问题,可以把本地演示脚本与 tikway_stream.mjs 放在同一目录,运行:

bash
node 02-sse-offline-demo.mjs

该脚本人工构造两段文本和 [DONE],并故意按单字节切开中文与 CRLF;它不读取 Key、不访问 API。预期输出如下,内容全部来自本地固定数据:

text
你好,SSE
Local SSE parsing completed (no API calls).

本地演示成功而真实请求失败时,优先检查地址、鉴权、HTTP 状态和所选模型的流式能力;真实请求成功但网页迟迟不展示文字时,再检查自己的代理缓存与前端刷新逻辑。把网络请求和事件解析分开排查,可以减少反复发送模型请求。

常见问题与接入网页时的注意点

现象 排查方法
fetch is not defined 检查 Node.js 版本,使用 Node.js 22 或更新版本。
401 检查 Key、Bearer 请求头和当前终端的环境变量。
400 / 422 核对所选模型是否支持该接口及请求参数。
429 读取具体错误字段,区分额度不足与限流;按原因处理。
5xx / 超时 保留请求 ID 和发生时间排查;客户端超时不能证明服务端未处理请求。
一次出现整段文字 检查自己的反向代理、后端转发和前端读取逻辑是否缓存了整个响应。
已输出部分文字,随后报错 把这次回答标记为未完成,查看流式错误或缺少 [DONE] 的提示。
没有 delta.content 可能是角色、usage、结束或工具调用事件;按所用能力读取对应字段。

将示例放进 Web 服务时,可以把增量文字转发给浏览器,同时在用户取消或连接关闭时取消上游读取。浏览器的 EventSource 通常不能直接满足这里的 POST、JSON 请求体和自定义 Bearer 请求头需求;本例选择 fetch 处理请求和字节流。

对于工具调用,需要另外累积 delta.tool_calls;本例只打印文本,不把工具调用视为文字回答。若要使用 Responses 或其他协议,应使用其对应事件格式,不能直接沿用本例的 choices[].delta.content 读取规则。

官方资料