用 TypeScript 写一个健壮的 SSE 流式响应解析器

很多 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();
  }
}

这个实现解决了四个关键问题:

  1. 使用流式 TextDecoder,避免跨 chunk 的 UTF-8 字符损坏。
  2. 使用 textBuffer 保存最后一段不完整行,不对半行执行解析。
  3. 等空行出现后再产出事件,正确支持多行 data:
  4. 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 错误、最后一条消息丢失和取消失效都会变成可复现、可测试的问题,而不是只能在线上碰运气。

相关推荐
用户0934077735145 小时前
HarmonyOS WPS Open SDK 实践:OpenFileRequest 打开链路与沙箱拷贝
android·typescript·harmonyos
YHHLAI7 小时前
TypeScript 面试题:type 与 interface 的区别与相同点
java·ubuntu·typescript
ModyQyW17 小时前
vite-plugin-uni-manifest 更新了什么
前端·typescript·uni-app
后除1 天前
从零到一: 创建一个 TypeScript 7 项目
前端·webpack·typescript
小磊哥er2 天前
深入解构Claude Code - 第 10 篇 · 高级能力
typescript·ai编程
小磊哥er2 天前
深入解构Claude Code - 第 9 篇 · 怎么给它加功能
typescript·ai编程
FungLeo2 天前
成为全栈·Node 后端篇·后端工程从零搭建:TypeScript、目录与热更新
javascript·typescript·node 后端
梅雅达编程笔记2 天前
实战:用Harness做一个自动化日报Agent
typescript·实战教程·deepseek·harness·自动化agent