SSE (Server-Sent Events) 流式长链接代理:Nginx 缓冲禁用与 TCP Keepalive 调优

封面信息图

在基于大语言模型(LLM)的应用架构中,流式响应(Streaming Output)已经成为标配体验。相比于 WebSocket 双向全双工通道的复杂维护成本,基于 HTTP 协议的单向长链接技术——Server-Sent Events (SSE) 成为了 AI 对话场景的首选。

然而,在生产环境中把 SSE 应用从本地开发机搬到 Kubernetes 容器集群或 Nginx 反向代理层后,前端开发者经常会遇到一连串诡异的故障:

  1. 字符顿挫与积压:前端并没有收到如丝般顺滑的“打字机”效果,而是卡顿 5~10 秒后突然“喷”出一大坨文字;
  2. 长连接莫名中断:每当模型思考或生成时间超过 60 秒,请求直接报错 504 Gateway Timeout 或 net::ERR_INCOMPLETE_CHUNKED_ENCODING;
  3. 同域鉴权与跨域 Preflight 损耗:跨域 SSE 无法平滑携带 Cookie,且每次都要发起一次 OPTIONS 预检请求。

要彻底解决这些顽疾,必须从前端 Ingress Nginx 路由规约、BFF 网关转发到底层 TCP 连接保活进行全链路重构。


生产级架构拓扑:同域规约与 BFF 流式代理

为了杜绝 CORS 预检请求开销并保障同域 HttpOnly Cookie 自动携带,推荐采用微前端/单页应用与 API 网关同域聚合架构:

Client (Browser)
      |  (HTTPS / 同域: https://app.example.com)
      v
Ingress-Nginx Controller (SSL Termination + 路由分发)
      |
      +---> /               --> Static Assets (Nginx Pod / CDN)
      |
      +---> /api/v1/stream  --> BFF Gateway (Node.js / Go)
                                  |
                                  | (gRPC / HTTP Stream)
                                  v
                             LLM Inference Service (vLLM / Triton)

Nginx 与 Ingress 配置的核心坑点剖析

造成 SSE“顿挫喷涌”的核心元凶是 Nginx 默认启用的 反向代理缓冲区(Proxy Buffering) 与 Gzip 压缩缓冲。

Nginx 默认会把上游微服务返回的数据存入 proxy_buffer,直到凑满一定字节(如 4k/8k)或上游响应结束才一次性下发给客户端。对于 SSE 这种以微小 Chunk 持续下发的场景,缓冲机制必须被强制关闭。

1. Ingress-Nginx CRD 生产配置

在 Kubernetes 环境下,必须在 Ingress 资源的 metadata.annotations 中明确配置流式代理参数:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app-gateway-ingress
  namespace: production
  annotations:
    kubernetes.io/ingress.class: "nginx"
    # 彻底关闭反向代理缓冲,确保每个 SSE event 立即推送到客户端
    nginx.ingress.kubernetes.io/proxy-buffering: "off"
    # 关闭对流式响应的 gzip 压缩,防止 gzip 收集 buffer
    nginx.ingress.kubernetes.io/proxy-request-buffering: "off"
    # 延长读写超时时间,防止 LLM 长思考(Deep Think)触发 504
    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
    # 开启上游 HTTP/1.1 与 Keepalive 支持
    nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
    nginx.ingress.kubernetes.io/configuration-snippet: |
      # 强制清除 Transfer-Encoding 缓存干扰
      chunked_transfer_encoding on;
      proxy_set_header Connection "";
      # 禁用 Nginx fastcgi/proxy 的 gzip 缓冲
      gzip off;
spec:
  rules:
  - host: app.example.com
    http:
      paths:
      - path: /api/v1/stream
        pathType: Prefix
        backend:
          service:
            name: bff-gateway-svc
            port:
              number: 8080
      - path: /
        pathType: Prefix
        backend:
          service:
            name: frontend-static-svc
            port:
              number: 80

BFF 网关实现:严谨的 SSE 管道与保活心跳

在 BFF 网关层(以 Node.js / TypeScript 为例),必须正确设置标准 SSE 协议头,并在模型推理间歇期插入心跳注释包(Comment Ping),防止链路中途的 NAT 网关或负载均衡器因空闲超时(Idle Timeout)强行掐断 TCP 链接。

import { IncomingMessage, ServerResponse } from 'http';

export function handleSSEStream(req: IncomingMessage, res: ServerResponse, upstreamStream: AsyncIterable<string>) {
  // 1. 设置标准 SSE 响应头
  res.writeHead(200, {
    'Content-Type': 'text/event-stream; charset=utf-8',
    'Cache-Control': 'no-cache, no-transform',
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no', // 告诉 Nginx/OpenResty 禁用本响应的缓冲
  });

  res.flushHeaders?.();

  // 2. 发送初始建立帧
  res.write(`event: ready\ndata: {"status":"connected"}\n\n`);

  // 3. 建立 15 秒保活心跳(Ping Comment),防止防火墙掐断
  const heartbeatTimer = setInterval(() => {
    if (!res.writableEnded) {
      // SSE 规范:以冒号开头的行会被浏览器解析器忽略,作为心跳包
      res.write(`: ping - ${Date.now()}\n\n`);
    }
  }, 15000);

  // 4. 消费上游推流并写入客户端
  (async () => {
    try {
      let eventId = 1;
      for await (const chunk of upstreamStream) {
        if (res.writableEnded) break;

        const payload = JSON.stringify({ text: chunk });
        res.write(`id: ${eventId++}\nevent: message\ndata: ${payload}\n\n`);
      }

      // 发送结束帧
      res.write(`event: done\ndata: [DONE]\n\n`);
    } catch (err: any) {
      const errorMsg = JSON.stringify({ error: err.message || 'Stream processing failed' });
      res.write(`event: error\ndata: ${errorMsg}\n\n`);
    } finally {
      clearInterval(heartbeatTimer);
      res.end();
    }
  })();

  // 5. 监听客户端异常断开,及时清理上游资源
  req.on('close', () => {
    clearInterval(heartbeatTimer);
  });
}

前端 Fetch 流式解析与自动重连健壮性

浏览器原生 EventSource 只支持 GET 请求且无法自定义 Header(如无法传递特殊的 Authorization 或复杂 JSON 请求体)。在生产环境中,推荐使用基于 fetch + ReadableStreamDefaultReader 自研流式解析器:

export interface SSEOptions {
  url: string;
  body: Record<string, any>;
  onMessage: (data: any) => void;
  onError?: (err: Error) => void;
  onFinish?: () => void;
}

export async function consumeSSEStream(options: SSEOptions) {
  const controller = new AbortController();

  try {
    const response = await fetch(options.url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'text/event-stream',
      },
      body: JSON.stringify(options.body),
      signal: controller.signal,
      credentials: 'same-origin', // 携带同域 Cookie
    });

    if (!response.ok || !response.body) {
      throw new Error(`SSE Request failed with HTTP status: ${response.status}`);
    }

    const reader = response.body.getReader();
    const decoder = new TextDecoder('utf-8');
    let buffer = '';

    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 block of lines) {
        if (!block.trim() || block.startsWith(':')) continue; // 过滤空行与心跳

        const dataLine = block.split('\n').find(line => line.startsWith('data: '));
        if (dataLine) {
          const rawData = dataLine.slice(6).trim();
          if (rawData === '[DONE]') {
            options.onFinish?.();
            return;
          }
          try {
            options.onMessage(JSON.parse(rawData));
          } catch {
            options.onMessage({ text: rawData });
          }
        }
      }
    }
    options.onFinish?.();
  } catch (error: any) {
    if (error.name !== 'AbortError') {
      options.onError?.(error);
    }
  }
}

TCP Keepalive 内核参数关键调优

除了应用层配置,底层 Linux TCP 栈也是长连接存活的关键。若云厂商负载均衡器(如 ALB/SLB)默认 90 秒清除静默会话,宿主机内核需做相应调优:

# 探测前空闲等待时间(秒)
sysctl -w net.ipv4.tcp_keepalive_time=60

# 探测报文发送间隔(秒)
sysctl -w net.ipv4.tcp_keepalive_intvl=10

# 探测失败重试次数
sysctl -w net.ipv4.tcp_keepalive_probes=3

长链接治理是云原生基础设施与前端体验深度咬合的典型场景。只有确保从内核 TCP 保活、Nginx 无缓冲直通、BFF 心跳注入到前端健壮解析器四位一体,才能支撑起稳定丝滑的大模型流式对话服务。

更多推荐