很多 AI 接口都采用 Server-Sent Events(SSE)持续返回增量内容。最初接入时,我们往往会写出这样的代码:读取一个 chunk,按换行切开,然后解析每一行的 data:。它在本地演示里通常能工作,但一到真实网络就会出现内容截断、中文乱码、JSON 偶发解析失败,甚至最后一条消息永远收不到。
根因不是 JSON,而是把"网络分块"误当成了"SSE 事件边界"。TCP、HTTP 和浏览器的 ReadableStream 都不保证一次读取正好得到一个完整事件。一个 UTF-8 字符、一个 data: 字段,甚至分隔事件的两个换行,都可能被拆到不同 chunk 中。
本文从协议边界出发,实现一个可复用的 TypeScript SSE 解析器,并补齐流式 AI 调用里最容易遗漏的取消、异常和结束处理。
先明确三个边界
1. chunk 不是字符串边界
ReadableStreamDefaultReader.read() 返回的是任意长度的字节块。中文字符由多个 UTF-8 字节组成,字符可能跨 chunk。必须使用同一个 TextDecoder,并在每次解码时传入 { stream: true },让解码器保留未完成字节。
2. 一行不是一个事件
SSE 使用空行结束一个事件。一个事件可以包含多行 data:,最终数据需要使用换行拼接。event:、id: 和 retry: 也属于同一个事件,不能逐行立即抛出。
3. 流结束不等于协议正常结束
有些服务使用 data: [DONE] 表示完成;有些服务只关闭连接;网络异常也可能导致连接突然结束。调用层应该区分"收到业务结束标记""正常 EOF"和"异常中断"。
定义解析结果
先定义一个不绑定具体 AI 厂商的事件结构:
ts
export interface SseEvent {
event?: string;
data: string;
id?: string;
retry?: number;
}
解析器只负责协议,不负责假设 data 一定是 JSON。这样既能处理标准 SSE,也能处理 [DONE]、心跳文本和供应商自定义事件。
完整实现
ts
export interface SseEvent {
event?: string;
data: string;
id?: string;
retry?: number;
}
type PendingEvent = {
event?: string;
dataLines: string[];
id?: string;
retry?: number;
};
function createPendingEvent(): PendingEvent {
return { dataLines: [] };
}
export async function* parseSse(
stream: ReadableStream<Uint8Array>,
signal?: AbortSignal,
): AsyncGenerator<SseEvent> {
const reader = stream.getReader();
const decoder = new TextDecoder("utf-8");
let textBuffer = "";
let pending = createPendingEvent();
const flushEvent = (): SseEvent | null => {
if (pending.dataLines.length === 0) {
pending = createPendingEvent();
return null;
}
const result: SseEvent = {
data: pending.dataLines.join("\n"),
};
if (pending.event !== undefined) result.event = pending.event;
if (pending.id !== undefined) result.id = pending.id;
if (pending.retry !== undefined) result.retry = pending.retry;
pending = createPendingEvent();
return result;
};
const consumeLine = (line: string): SseEvent | null => {
if (line === "") return flushEvent();
if (line.startsWith(":")) return null;
const separator = line.indexOf(":");
const field = separator === -1 ? line : line.slice(0, separator);
let value = separator === -1 ? "" : line.slice(separator + 1);
if (value.startsWith(" ")) value = value.slice(1);
switch (field) {
case "data":
pending.dataLines.push(value);
break;
case "event":
pending.event = value;
break;
case "id":
if (!value.includes("\0")) pending.id = value;
break;
case "retry": {
const retry = Number(value);
if (Number.isInteger(retry) && retry >= 0) pending.retry = retry;
break;
}
}
return null;
};
try {
while (true) {
if (signal?.aborted) throw signal.reason ?? new DOMException("Aborted", "AbortError");
const { value, done } = await reader.read();
if (done) break;
textBuffer += decoder.decode(value, { stream: true });
const lines = textBuffer.split(/\r\n|\r|\n/);
textBuffer = lines.pop() ?? "";
for (const line of lines) {
const event = consumeLine(line);
if (event) yield event;
}
}
textBuffer += decoder.decode();
if (textBuffer.length > 0) {
const event = consumeLine(textBuffer);
if (event) yield event;
}
const finalEvent = flushEvent();
if (finalEvent) yield finalEvent;
} finally {
reader.releaseLock();
}
}
这个实现解决了四个关键问题:
- 使用流式
TextDecoder,避免跨 chunk 的 UTF-8 字符损坏。 - 使用
textBuffer保存最后一段不完整行,不对半行执行解析。 - 等空行出现后再产出事件,正确支持多行
data:。 - EOF 时刷新解码器、残留行和未结束事件,避免丢失最后一条数据。
在 AI 流式调用中消费
协议层完成后,业务层再处理 JSON 和结束标记:
ts
type ChatDelta = {
choices?: Array<{
delta?: { content?: string };
finish_reason?: string | null;
}>;
};
export async function streamChat(
response: Response,
onText: (text: string) => void,
signal?: AbortSignal,
): Promise<"done-marker" | "eof"> {
if (!response.ok) {
const detail = await response.text();
throw new Error(`HTTP ${response.status}: ${detail.slice(0, 300)}`);
}
if (!response.body) throw new Error("Response body is empty");
for await (const event of parseSse(response.body, signal)) {
if (event.data === "[DONE]") return "done-marker";
let payload: ChatDelta;
try {
payload = JSON.parse(event.data) as ChatDelta;
} catch {
throw new Error(`Invalid SSE JSON: ${event.data.slice(0, 200)}`);
}
const content = payload.choices?.[0]?.delta?.content;
if (content) onText(content);
}
return "eof";
}
这里刻意把 HTTP 错误、空响应体、协议解析失败和业务结束方式分开。线上排障时,"状态码为 200"并不能证明流可用;至少还要确认响应体存在、SSE 可以完成分帧、每个业务事件可解析,以及结束方式符合预期。
用碎片化输入做回归测试
健壮性测试不能只喂完整字符串,必须主动把字节拆开:
ts
function streamFromChunks(chunks: Uint8Array[]) {
return new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) controller.enqueue(chunk);
controller.close();
},
});
}
const encoder = new TextEncoder();
const bytes = encoder.encode(
"event: message\n" +
"data: {\"text\":\"你\n" +
"data: 好\"}\n\n" +
"data: [DONE]\n\n",
);
const chunks = [
bytes.slice(0, 7),
bytes.slice(7, 21),
bytes.slice(21, 28),
bytes.slice(28),
];
for await (const event of parseSse(streamFromChunks(chunks))) {
console.log(event);
}
实际项目里还应覆盖:CRLF 与 LF 混用、空 data:、注释心跳、连续空行、无结束空行的 EOF、非法 retry:、id 中包含空字符、用户取消,以及中文字符正好被切在两个 chunk 中间。
生产环境还要补什么
解析器只是流式链路的一层。上线前建议再补四项:
- 超时分层:连接超时、首包超时、相邻事件超时分别记录,避免只设置一个总超时。
- 取消传播 :页面取消后使用同一个
AbortSignal中止fetch和消费循环。 - 可观测性:记录状态码、内容类型、首包耗时、事件数和结束类型,但不要记录访问凭据或完整用户内容。
- 协议校验 :检查
content-type,对返回 HTML、JSON 错误页或空 body 给出明确错误。
如果需要在不同模型之间复用同一套流式消费逻辑,可以把协议解析、供应商事件适配和 UI 渲染拆成三层。以 FishAI API 中转站为例,公开接入地址是 https://yufish.cc;无论接入哪个兼容模型,上面的解析器都只依赖标准 Response.body,不会把业务代码绑定到某个模型名称。
总结
一个可靠的 SSE 客户端不应该按网络 chunk 解析 JSON,而应该依次处理字节解码、行缓冲、事件分帧和业务事件四层边界。真正决定稳定性的往往不是代码量,而是是否明确了每层的输入、结束条件和失败方式。
把这些边界拆开后,中文乱码、偶发 JSON 错误、最后一条消息丢失和取消失效都会变成可复现、可测试的问题,而不是只能在线上碰运气。