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:
$env:TIKWAY_API_KEY = "替换为你的 Tikway API Key"
macOS / Linux:
export TIKWAY_API_KEY='替换为你的 Tikway API Key'
Key 应留在后端环境变量中。浏览器前端直接携带 Key 发请求,会把 Key 暴露给访问者;网页应用可以通过自己的后端转发。
2. 保存完整的 fetch + SSE 示例
保存为 tikway_stream.mjs,也可使用配套脚本。运行脚本会向 https://api.tikway.ai/v1/chat/completions 发起真实模型请求,费用和额度以账户及模型目录为准。
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;
});
}
运行:
node tikway_stream.mjs
脚本会把收到的文字片段依次写到终端,完整回答的内容由模型决定。90 秒是本例的客户端总超时设置,可以按业务需求调整;它不是服务响应时间承诺。
3. 读懂几个关键处理
TextDecoder 的 stream: true 会保留尚未完整的 UTF-8 字节,因此中文不会因为网络分块而直接乱码。随后代码按行处理 LF、CRLF 和 CR,遇到空行才派发一个完整 SSE 事件;事件中的多条 data: 用换行连接后再解析。
形如下面的内容是结构说明,文字与统计数字均为示例:
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 放在同一目录,运行:
node 02-sse-offline-demo.mjs
该脚本人工构造两段文本和 [DONE],并故意按单字节切开中文与 CRLF;它不读取 Key、不访问 API。预期输出如下,内容全部来自本地固定数据:
你好,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 读取规则。
官方资料
- Tikway 模型目录:确认模型标识、接口及能力。
- 快速开始:准备账户和首次调用。
- OpenAI 兼容请求协议:核对地址与请求格式。
- 网关鉴权:确认 API Key 请求头。
- 错误码说明:解释 HTTP 和错误字段。