Express SSE 流式输出实战:从入门 Demo 到 AI 生产级架构

Server-Sent Events (SSE) 是 AI 大模型流式输出的事实标准。但许多开发者在落地时,往往忽略了从"协议理解"到"工程实现"之间的巨大鸿沟。本文基于完整的工程实践对话,将 SSE 前后端实现拆解为三个循序渐进的阶段:先跑通最小 Demo 建立直觉,再引入心跳机制解决网络假死,最后对接 AI 流式输出完成生产级改造。

阶段一:最小可运行 Demo ------ 建立协议直觉

1. 核心认知纠偏

SSE 连接永远由前端(客户端)主动发起,后端只负责"保持响应不关闭"。

  • 建立阶段 :浏览器通过 new EventSource(url)fetch() 向服务器发送一个标准的 HTTP GET 请求。
  • 维持阶段 :服务器返回 200 OKContent-Type: text/event-stream 后,这个 HTTP 响应体保持打开状态
  • 推送阶段:服务器在这个已建立的、未关闭的 HTTP 响应通道中,持续写入数据块。

SSE 本质是一个 "永远不结束的 HTTP 响应"。后端没有能力主动"敲开"客户端的门,只是获得了在已建立的长响应中随时追加内容的权利。这也是 SSE 能穿透防火墙和代理的根本原因------它就是普通 HTTP 流量。

2. 后端实现

javascript 复制代码
const express = require('express');
const app = express();

app.get('/stream', (req, res) => {
  // ✅ 1. 设置 SSE 必需的响应头
  res.setHeader('Content-Type', 'text/event-stream'); // 声明 SSE 协议类型
  res.setHeader('Cache-Control', 'no-cache');         // 禁止任何中间层缓存
  res.setHeader('Connection', 'keep-alive');          // 显式声明长连接
  res.flushHeaders(); // 🔑 立即发送头部,否则 Express 会缓冲导致前端收不到首字节

  let count = 0;
  const timer = setInterval(() => {
    // ✅ 2. SSE 格式:data: + JSON字符串 + 双换行符(\n\n)
    // ⚠️ 注意:JSON.stringify 返回的是字符串,不是对象
    res.write(`data: ${JSON.stringify({ chunk: count++ })}\n\n`);
  }, 8000);

  // ✅ 3. 客户端断开时清理资源
  // ⚠️ 必须用 req.on('close'),而非 res.on('finish')
  req.on('close', () => {
    clearInterval(timer);
    console.log('客户端断开,定时器已清理');
  });
});

app.listen(3000);

3. 前端实现

javascript 复制代码
// ✅ 创建 SSE 连接,URL 需与后端 /stream 路由一致
const es = new EventSource('http://localhost:3000/stream');

// ✅ 接收 data: 字段的消息
es.onmessage = (event) => {
  // event.data 始终是字符串,需要手动解析
  try {
    const parsed = JSON.parse(event.data);
    console.log('收到数据块:', parsed);
  } catch (err) {
    // ⚠️ 网络截断可能导致不完整数据,必须容错
    console.error('JSON 解析失败:', err);
  }
};

es.onopen = () => console.log('SSE 连接已建立');
es.onerror = (err) => console.error('SSE 连接异常:', err);

4. 阶段一注意事项清单

1. 为什么是 data: ... 格式?(SSE 协议规范)

SSE 协议规定,服务器推送的每条消息必须由 字段名 + 冒号 + 空格 + 值 组成。data 是承载实际载荷的标准字段名。

  • data: {"chunk":0}\n\n → 客户端 EventSource.onmessage 能正确解析出 {"chunk":0}
  • {"chunk":0}\n\n → 缺少 data: 前缀,客户端会将其视为非法消息直接丢弃
  • Data: ...\n\n → 字段名区分大小写,必须是全小写 data

💡 SSE 还有其他字段如 event:(自定义事件类型)、id:(断线重连ID)、retry:(重连间隔),但 data: 是最基础且必须的。

2. 为什么是两个 \n\n?(消息边界分隔符)

这是 SSE 协议中最容易被忽视但最关键的细节:

换行符 含义
第一个\n 结束当前data:这一行
第二个\n 表示整条消息结束(空行 = 消息终止符)
text 复制代码
data: {"chunk":0}\n   ← 第一个\n:结束 data 行
\n                    ← 第二个\n:空行,触发客户端 onmessage
data: {"chunk":1}\n
\n
  • 如果只写一个 \n:客户端会认为消息还没结束,继续等待下一行,导致消息永远不会被派发。
  • 如果写了三个 \n:多出的空行会被当作一条"空消息"触发一次额外的 onmessagee.data === "")。

⚠️ 这也是为什么心跳用 : heartbeat\n\n 同样需要双换行------即使是注释,也必须以空行结尾才算一个完整的 SSE 帧。

3. 为什么要 JSON.stringify()?(数据类型约束)

res.write() 接受字符串或 Buffer,而 SSE 是纯文本协议:

  • res.write({ chunk: count++ }) → Node.js 会对对象调用 .toString(),结果是 [object Object]
  • res.write(JSON.stringify({ chunk: count++ })) → 输出合法的 JSON 字符串 {"chunk":0}

客户端收到后可以通过 JSON.parse(e.data) 还原为对象。当然,如果你的业务只需要纯文本,也可以直接写 res.write('data: hello\n\n')JSON.stringify 并非 SSE 强制要求,只是结构化数据传输的最佳实践。

4. 为什么必须调用 res.flushHeaders()?(Express 缓冲机制)

Express/Node.js 默认启用响应缓冲,即不会立即将 setHeader 的内容发送到客户端,而是等到第一次 res.write()res.end() 时才一并发出。

  • ❌ 不调用 flushHeaders():前端 EventSource 发出请求后,可能等待数秒才收到 HTTP 200 响应头和首个数据字节,表现为"连接已建立但长时间无数据"。
  • ✅ 调用 flushHeaders():强制立即发送响应头,前端瞬间收到 200 并进入监听状态。

⚠️ 原生 http 模块无此问题,此注意事项仅针对 Express/Koa 等带缓冲层的框架。

5. 为什么用 req.on('close') 而非 res.on('finish')?(断开检测可靠性)

两者触发条件完全不同:

事件 触发时机 覆盖场景
res.on('finish') 服务端主动调用res.end()且数据全部发出后 仅正常结束
req.on('close') 底层 TCP 连接关闭时 正常结束 + 客户端刷新 + 网络中断 + 标签页关闭 + 超时断开
  • ❌ 只用 res.on('finish'):用户刷新页面或网络断开时,定时器/数据库连接/LLM 调用不会被清理,造成资源泄漏。
  • ✅ 只用 req.on('close'):覆盖所有断开场景,是唯一可靠的清理钩子。
6. 为什么 event.data 必须手动 JSON.parse 且包裹 try-catch?(前端数据安全性)

SSE 是纯文本协议,EventSource.onmessage 回调中的 event.data永远是字符串​,即使后端发送的是合法 JSON。

  • ❌ 直接使用 event.data.chunkundefined,因为字符串没有 chunk 属性
  • JSON.parse(event.data) 不加 try-catch → 网络分包导致某次收到的数据是 {"chu(不完整),JSON.parse 抛出异常,后续所有消息的消费逻辑被中断
  • try { JSON.parse(event.data) } catch(e) { console.error(e) } → 单条坏消息被安全跳过,流继续消费

当 SSE 接口需要携带 Cookie(如鉴权 token)时:

  • 前端必须设置 new EventSource(url, { withCredentials: true })
  • 后端 不能 使用 Access-Control-Allow-Origin: *,必须指定具体域名
  • 后端必须添加 Access-Control-Allow-Credentials: true

* + withCredentials: true → 浏览器直接拒绝请求,报错 "The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when the request's credentials mode is 'include'."

⚠️ 本阶段局限:此 Demo 仅验证了"数据能从服务器推到浏览器",但在真实网络环境中,中间代理通常有 60s 空闲超时策略。若两次消息间隔超过阈值,连接会被静默断开,前端表现为"假死"。这就需要进入阶段二。

阶段二:心跳保活 ------ 解决网络假死

1. 为什么需要心跳?

AI 推理的首 Token 延迟可能长达数十秒,两次有效消息之间的空窗期极易触发中间代理的空闲超时。心跳的作用是在无业务数据时,持续向 TCP 通道注入微量数据,欺骗中间件"连接仍活跃"。

2. 后端实现(新增心跳)

javascript 复制代码
app.get('/stream', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.flushHeaders();

  let count = 0;
  // 业务数据定时器(模拟慢速生成)
  const timer = setInterval(() => {
    res.write(`data: ${JSON.stringify({ chunk: count++ })}\n\n`);
  }, 8000);

  // ✅ 新增:心跳保活(SSE 注释格式)
  // ⚠️ 必须以冒号开头,这是 SSE 规范的注释语法
  // ⚠️ 前端 onmessage 不会触发,但 TCP 层有数据传输
  const heartbeat = setInterval(() => {
    res.write(': heartbeat\n\n');
  }, 3000); // 间隔必须小于中间代理超时(通常60s)

  req.on('close', () => {
    clearInterval(timer);
    clearInterval(heartbeat); // 🔑 心跳定时器也必须清理!
    console.log('客户端断开,所有资源已释放');
  });
});

3. 前端实现(新增静默超时检测)

javascript 复制代码
class ResilientSSE {
  constructor(url, options = {}) {
    this.url = url;
    this.maxSilence = options.maxSilence || 10000; // 静默超时阈值
    this.reconnectDelay = options.reconnectDelay || 3000;
    this.es = null;
    this.silenceTimer = null;
    this.isManualClose = false; // 🔑 区分手动关闭与异常断开
    this.connect();
  }

  connect() {
    this.isManualClose = false;
    this.es = new EventSource(this.url);

    // ✅ 重置静默计时器的统一方法
    const resetSilence = () => {
      clearTimeout(this.silenceTimer);
      this.silenceTimer = setTimeout(() => {
        console.warn(`⚠️ ${this.maxSilence}ms 无消息,判定为假死,主动重连`);
        this.reconnect();
      }, this.maxSilence);
    };

    this.es.onopen = () => {
      console.log('✅ SSE 连接已建立');
      resetSilence(); // 连接建立时启动计时器
    };

    this.es.onmessage = (event) => {
      resetSilence(); // 🔑 核心:每收到一条 data 消息就重置超时
      if (event.data === '[DONE]') {
        this.close();
        return;
      }
      try {
        const parsed = JSON.parse(event.data);
        console.log('📦 数据块:', parsed);
      } catch (err) {
        console.error('❌ JSON 解析失败:', err);
      }
    };

    this.es.onerror = () => {
      // 🔑 只有非手动关闭时才触发重连
      if (!this.isManualClose) {
        console.error('❌ SSE 异常,准备重连...');
        this.reconnect();
      }
    };
  }

  reconnect() {
    this.cleanup();
    setTimeout(() => this.connect(), this.reconnectDelay);
  }

  close() {
    this.isManualClose = true; // 🔑 标记为手动关闭,阻止 onerror 触发重连
    this.cleanup();
    console.log('🔒 SSE 连接已手动关闭');
  }

  cleanup() {
    clearTimeout(this.silenceTimer);
    if (this.es) {
      this.es.close();
      this.es = null;
    }
  }
}

// 🚀 使用示例
const sse = new ResilientSSE('http://localhost:3000/stream', {
  maxSilence: 10000,   // 10s 无消息判定假死
  reconnectDelay: 3000 // 3s 后重连
});

// ⚠️ SPA 路由切换或标签页隐藏时必须显式关闭
window.addEventListener('beforeunload', () => sse.close());

4. 阶段二注意事项清单

1. 为什么心跳必须用 : xxx\n\n 注释格式?(SSE 注释语法)

SSE 规范定义以冒号 : 开头的行为注释,客户端 EventSource 会静默忽略,不触发 onmessage

  • : heartbeat\n\n → TCP 层有数据传输,保活生效;前端无感知
  • data: heartbeat\n\n → 前端 onmessage 被触发,JSON.parse("heartbeat") 抛异常,污染业务逻辑
  • heartbeat\n\n → 无字段名前缀,被视为非法消息丢弃,TCP 层虽有数据但语义不规范

⚠️ 注释同样必须以 \n\n 结尾,否则不构成完整 SSE 帧,中间代理可能不认为这是一次有效传输。

2. 为什么心跳间隔必须小于中间代理超时?(保活有效性)

企业 Nginx、云 LB、CDN 通常有 60s 空闲超时策略。心跳的核心目的是在超时窗口内制造流量。

  • ✅ 心跳 15-30s < 代理超时 60s → 每次超时窗口内至少有 2-4 次心跳,连接稳定
  • ❌ 心跳 65s > 代理超时 60s → 心跳到达时连接已被中间件切断,完全失效
  • ❌ 心跳 = 代理超时 → 边界竞态,网络抖动时仍可能被切断

💡 建议心跳间隔设为代理超时的 ​1/3 ~ 1/2​,留出足够的安全裕量。

3. 为什么心跳定时器必须在 req.on('close') 中清理?(资源泄漏防护)

setInterval 创建的定时器持有对 res 对象的闭包引用。若客户端断开后不清理:

  • 定时器继续执行 res.write() → 触发 Error: write after end 未捕获异常
  • res 对象无法被 GC 回收 → 每个断开连接泄漏一个定时器 + 一个 socket 引用
  • 高并发下内存持续增长 → 最终 OOM

⚠️ 阶段一中只清理了业务定时器,阶段二新增了心跳定时器,​两者都必须清理​,遗漏任何一个都是泄漏。

4. 为什么心跳和业务数据必须是两个独立的 setInterval?(节奏解耦)

业务数据的发送节奏由模型推理速度决定,完全不均匀;心跳的节奏必须恒定才能可靠保活。

  • ✅ 两个独立定时器 → 心跳不受业务消息间隔影响,即使业务暂停 30s,心跳仍每 3s 发送
  • ❌ 心跳嵌入业务定时器 → 业务消息间隔变为 30s 时,心跳也变成 30s,可能超过代理超时
5. 为什么前端不能用 onmessage 判断连接存活?(心跳不可见性)

心跳是 SSE 注释,EventSource 设计上就不派发事件。这意味着:

  • ❌ 在 onmessage 中重置超时计时器 → 心跳期间计时器不会被重置,误判为假死
  • ✅ 只在 onmessage 中重置 → 仅业务数据触发重置;心跳靠后端保证 TCP 层不断开,前端靠静默超时检测兜底

💡 这是一个​前后端分工​:后端心跳防代理超时,前端静默超时防 TCP 假死。两者互补,不可替代。

6. 为什么 maxSilence 必须大于最大消息间隔?(误判防护)

静默超时阈值 = 业务最大消息间隔 + 心跳间隔 + 安全裕量。

  • 业务间隔 8s + 心跳 3s → maxSilence 应设为 ≥ 10s
  • maxSilence = 5s → 业务消息间隔 8s 的正常空窗期内,前端在第 5s 就判定假死并断开重连,形成"连接→5s超时→重连→5s超时"的死循环
  • maxSilence = 10s → 正常空窗期内不会误判,仅在真正断连(心跳也丢失)超过 10s 时才触发重连
7. 为什么必须有 isManualClose 标记?(防止重连死循环)

原生 EventSource 调用 close() 后,浏览器仍可能异步触发 onerror 事件。

  • ❌ 无标记 → close()onerror 触发 → reconnect() → 新连接 → 再次 close() → 无限循环
  • ✅ 有标记 → close() 设置 isManualClose = trueonerror 检查标记 → 跳过重连 → 循环终止

⚠️ 这是 ResilientSSE 封装中最容易遗漏的细节,也是生产环境连接泄漏的常见根因。

8. 为什么必须在 beforeunload / 组件卸载时显式调用 close()?(SPA 生命周期管理)

EventSource 是浏览器级 API,​不感知 SPA 路由切换、React/Vue 组件卸载或标签页隐藏​。

  • ❌ 不管理 → 用户从 /chat 切换到 /settings,旧连接的 EventSource 仍在后台运行,持续消耗服务端资源和客户端内存
  • ✅ 在路由守卫 / useEffect cleanup / beforeunload 中调用 sse.close() → 连接随页面/组件生命周期正确释放

阶段三:AI 流式输出 ------ 生产级改造

1. AI 场景为何全选 SSE 而非 WebSocket?

维度 SSE WebSocket AI 选择 SSE 的原因
通信模式 单向 Server→Client 全双工 AI 推理是典型"一问一答"单向生成
协议基础 HTTP/1.1+ 独立 TCP 协议 复用现有 HTTP 基础设施,零适配成本
断线重连 浏览器内置 + Last-Event-ID 需手动实现 AI 生成耗时长,原生重连降低前端复杂度
CDN/代理 完美兼容 部分 WAF 拦截 WS AI API 需全球分发
服务端开销 普通 HTTP Handler 独立 WS 连接池 GPU 密集型服务避免额外内存开销
调试体验 curl/Network 面板可读 需专用工具 AI 开发迭代快,可观测性优先

2. 核心概念:Stream 迭代器的本质

javascript 复制代码
const stream = await openai.chat.completions.create({
  model: "gpt-4", messages, stream: true
});

这个 stream 不是数组 ,它是绑定在 HTTP Response Body 上的惰性异步拉取管道

  • 没有 .length:LLM 自回归生成,模型自己都不知道总长度
  • 不可重复消费:异步迭代器单次消费,第二次遍历为空
  • 每个 chunk 是 delta 增量:仅含新生成的 token,前端负责累积拼接
  • 永远不要 Array.from(stream):这会阻塞至流结束并撑爆内存

3. 后端生产级完整实现

javascript 复制代码
app.post('/api/chat/stream', async (req, res) => {
  // === 阶段一 & 二的 SSE 基础 ===
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.flushHeaders();

  const abortController = new AbortController();
  let tokensUsed = 0;

  // === 阶段三新增:全链路资源回收 ===
  req.on('close', () => {
    abortController.abort();                  // 🔑 中止 LLM API 调用(节省GPU/Token费用)
    contextCache.delete(req.id);              // 🔑 释放请求专属上下文缓存
    usageTracker.record(req.id, tokensUsed);  // 🔑 记录实际 token 消耗用于计费
    console.log(`[SSE] 客户端断开,已中止推理,消耗 ${tokensUsed} tokens`);
  });

  try {
    const stream = await openai.chat.completions.create({
      model: req.body.model || 'gpt-4',
      messages: req.body.messages,
      stream: true,
      signal: abortController.signal   // ✅ 传播取消信号到 SDK
    });

    // === 阶段三新增:带背压的流式转发 ===
    for await (const chunk of stream) {
      tokensUsed += estimateTokens(chunk);
      
      // 🔑 背压控制:检查写缓冲区是否已满
      const ok = res.write(`data: ${JSON.stringify(chunk)}\n\n`);
      if (!ok) {
        // TCP 内核缓冲区满,暂停上游生成,等待 drain 事件
        await new Promise(resolve => res.once('drain', resolve));
      }
    }

    // ✅ 路径A:迭代器返回 {done:true} 后循环自动退出
    // [DONE] 是上游 LLM 发出的信号经 SDK 翻译后的结果,此处仅为转发
    res.write('data: [DONE]\n\n');
  } catch (err) {
    if (err.name === 'AbortError') {
      console.log('[SSE] 推理被客户端取消');
    } else {
      // ❌ 路径B:模型报错/网络中断 → 抛异常 → 绝不发 [DONE]
      // 🔑 使用 event: error 结构化事件,前端可精确区分错误类型
      res.write(`event: error\ndata: ${JSON.stringify({
        code: err.code || 'unknown',
        message: err.message
      })}\n\n`);
    }
  } finally {
    res.end(); // 🔑 确保响应一定被关闭
  }
});

4. 前端 AI 流式消费实现

javascript 复制代码
// ✅ AI 场景推荐使用 fetch + ReadableStream(支持 POST、自定义 Header)
async function consumeAIStream(messages) {
  const response = await fetch('/api/chat/stream', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ messages })
  });

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';       // 🔑 处理跨 chunk 的不完整 SSE 消息
  let fullContent = '';  // 🔑 累积拼接 delta.content

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('\n\n'); // 按双换行分割消息
    buffer = lines.pop(); // 🔑 最后一段可能不完整,留到下次处理

    for (const line of lines) {
      if (line.startsWith('event: error')) {
        // 🔑 处理结构化错误事件
        const errorData = line.replace(/^event: error\ndata: /, '');
        console.error('AI 生成错误:', JSON.parse(errorData));
        return;
      }
      if (line.startsWith('data: ')) {
        const data = line.slice(6);
        if (data === '[DONE]') {
          console.log('✅ 生成完成,完整内容:', fullContent);
          return;
        }
        try {
          const chunk = JSON.parse(data);
          const delta = chunk.choices?.[0]?.delta?.content || '';
          fullContent += delta; // 🔑 增量拼接
          process.stdout.write(delta); // 实时输出到终端/UI
        } catch (err) {
          console.error('chunk 解析失败:', err);
        }
      }
      // : heartbeat 注释行自动忽略,无需处理
    }
  }
}

5. 阶段三注意事项清单(Demo vs 生产级差异)

1. 为什么 Stream 迭代器禁止 Array.from() 和重复消费?(惰性管道语义)

LLM 流式 API 返回的 stream 是绑定在未关闭 HTTP Response Body 上的异步迭代器,不是内存中的集合。

  • Array.from(stream) → 阻塞当前 async 函数直到整个流结束(可能数十秒),期间所有 token 堆积在内存中,高并发下直接 OOM
  • ❌ 第二次 for await (const chunk of stream) → 迭代器已耗尽,循环体永不执行
  • for await 逐条消费 → 每收到一个 token 立即转发给客户端,内存占用恒定

💡 把 Stream 想象成水龙头而非水桶:你只能接水,不能把水龙头"装进"一个容器里。

2. 为什么 [DONE] 是"等"出来的而非"算"出来的?(完成信号来源)

你的服务端代码是 ​SSE 代理/中继​,不是生成完成的判断者。

  • 真正的 data: [DONE]LLM 提供商服务器 在生成完毕后发出
  • SDK 内部读到该信号后,将迭代器的下一次 next() 返回值设为 {done: true}
  • for await 检测到 {done: true} 自动退出循环
  • 循环后的 res.write('data: [DONE]\n\n') 仅是向前端转发/重发该信号

⚠️ 如果模型中途报错,for await 会抛出异常而非正常退出。​绝不能在 catch 中发[DONE],否则前端会误认为生成成功完成。

3. 为什么必须检查 res.write() 返回值实施背压?(服务端 OOM 防护)

AI 生成速度可能远超慢速客户端的消费能力。Node.js res.write() 返回 false 表示内核 TCP 写缓冲区已满。

  • ❌ 无视返回值持续写入 → 数据堆积在 Node.js 用户态内存 → 每秒数百个并发流 → 内存指数增长 → OOM
  • ✅ 返回 falseawait new Promise(r => res.once('drain', r)) → 暂停上游 LLM 拉取,等待内核缓冲区排空后再继续 → 内存占用受控

💡 背压是生产级流式服务的​生死线​,Demo 可以忽略,生产环境必须实现。

4. 为什么异常时必须发 event: error 而非断开连接?(结构化错误传递)

前端需要区分"生成正常结束"、"模型报错"、"网络中断"、"鉴权失败"等不同情况。

  • ❌ 异常时直接 res.end() → 前端只看到连接断开,无法知道原因,用户体验差
  • ❌ 异常时发 data: [DONE]\n\n → 前端误认为生成成功,展示不完整内容
  • ✅ 异常时发 event: error\ndata: {...}\n\n → 前端通过 addEventListener('error', ...) 或解析 event: 行精确获取错误码和消息,展示友好提示

⚠️ event: error 是 SSE 协议的自定义事件机制,与 data: 平级,前端需单独监听或在 fetch 流解析中按行匹配。

5. 为什么必须在 req.on('close') 中调用 abortController.abort()?(成本控制)

这是 AI 流式输出与 Demo ​最根本的区别​。用户中途关闭页面时:

  • ❌ 不清理 → LLM API 继续生成剩余 token → GPU 算力白烧 + Token 费用白花 → 每月可能浪费数千美元
  • abortController.abort() → SDK 向上游发送取消请求 → LLM 停止生成 → 仅计费已消耗的 token

⚠️ 必须将 abortController.signal 传入 SDK 的 create() 调用,否则 abort() 无效。同时需在 catch 中识别 AbortError 并静默处理,避免将其当作模型错误上报。

6. 为什么 AI 场景前端推荐 fetch + ReadableStream 而非 EventSource?(协议限制)

EventSource 仅支持 GET 请求,无法携带自定义 Header(如 Authorization Bearer Token),无法发送 POST Body。

  • new EventSource('/api/chat/stream') → 无法传 messages、无法鉴权
  • fetch('/api/chat/stream', {method:'POST', headers:{...}, body:...}) + response.body.getReader() → 完整支持 RESTful API 设计

⚠️ fetch 流式读取需要手动按 \n\n 分割 SSE 消息并处理跨 chunk 残留(见上方前端代码中的 buffer 逻辑),这是 EventSource 自动帮你做的事。代价是需要更多代码,换来的是协议灵活性。

7. 为什么前端解析 SSE 流必须有 buffer 残留处理?(网络分包)

TCP 是流式协议,不保证消息边界。一次 reader.read() 返回的 chunk 可能在任意位置被截断:

text 复制代码
// 第一次 read() 返回:
data: {"choices":[{"delta":{"content":"你"}}]}\n\nda

// 第二次 read() 返回:
ta: {"choices":[{"delta":{"content":"好"}}]}\n\n
  • ❌ 直接对每次 read() 的结果 split('\n\n') → 第一条消息完整,第二条 da 被当作独立行解析失败
  • ✅ 维护 buffer 变量,每次 read() 追加到 buffer,split('\n\n') 后用 lines.pop() 保留最后一段不完整数据 → 下次 read() 拼接后自然还原完整消息

💡 这是 fetch 流式消费 SSE 最容易踩的坑,EventSource 内部已处理此问题,但 fetch 需要你手动实现。


全文总结

SSE 前后端实现的难度不在协议本身,而在分阶段构建正确的工程心智模型

  • 阶段一 让你理解 SSE 是"不关闭的 HTTP 响应",掌握 flushHeaders、双换行符、req.closeevent.data 字符串解析等协议级要点;
  • 阶段二 让你理解真实网络的脆弱性,掌握心跳注释格式、静默超时检测、isManualClose 防死循环、生命周期管理等保活体系;
  • 阶段三让你理解 AI 流式输出的本质是"异步管道中继",掌握迭代器语义、背压控制、AbortController 全链路取消、buffer 分包处理、结构化错误事件和 token 计量。

Demo 验证了"数据能推过去",心跳保证了"连接不会假死",而生产级改造确保了:推得稳(背压)、断得干净(AbortController)、错得明白(结构化错误事件)、钱花得清楚(token 计量)。三个阶段缺一不可,跳过任何一个都会在上线后付出代价。

相关推荐
安全指北针1 小时前
AI Agent自主入侵真实系统:OpenAI和Anthropic两大模型接连“失控“,给安全行业敲响了什么警钟?
人工智能·安全
2601_965799681 小时前
2026 在线笔试平台深度测评与选型指南:从功能、稳定性、AI能力、防作弊全面分析
人工智能·笔记·功能测试
2601_949499941 小时前
芯瑞科技 DT-1414 光模块工程实测:完美兼容博通(安华高) HFBR-1414PTZ,VCSEL 替代方案
大数据·网络·人工智能·科技·光模块
城事漫游Molly1 小时前
研究论证的四要素:主张、理由、证据、保证——用AI逐一检验
人工智能·ai for science·论文发表·博士生必读·论证argument·科研论文投稿
字节跳动视频云技术团队1 小时前
AI 视频降本的三种做法,只有一种不牺牲画质
人工智能·aigc
自动化测试行业观察1 小时前
从“自动化”到“智能化”:TestMan AI测试平台引领软件测试范式转移
运维·自动化测试·人工智能·测试工具·自动化·app测试·移动应用测试
HyperAI超神经1 小时前
在线教程|ProteinGym 第一名!VenusREM 用「检索增强」预测蛋白突变影响,加速蛋白质设计
人工智能·深度学习·生物信息学·大模型推理·生物医学
合调于形1 小时前
Bianfchheng (Baf) 《边城(八)》全文汉语拼音字母标调实测案例
人工智能·自然语言处理·人机交互·语音识别·学习方法
同创永益2 小时前
锚定AI数字韧性赛道,同创永益完成新一轮股权融资
人工智能·it·同创永益·数字韧性