从 EventSource 到 Fetch:大模型流式对话协议选型与鉴权工程实践

0 阅读1分钟

1. 为什么大模型对话必须流式(Streaming)?

在将生成式 AI 接入传统业务系统时,许多团队最初习惯使用常规的同步阻塞接口:客户端发起 HTTP POST 请求,服务端调用大模型并等待完整文本生成,最后一次性返回一个大 JSON。然而在真实业务场景中,这种模式通常会暴露出严重的性能与体验瓶颈。

1.1 TTFT(Time-to-First-Token)指标与体感优化

大模型生成长文本受自回归机制限制,往往需要耗时十几秒甚至数分钟。

  • 传统非流式:前端处于长达数十秒的无响应等待状态。用户极易误判为“系统死机”或“网络超时”,从而频繁刷新页面或重复点击提交,导致请求雪崩。
  • 流式(Streaming)输出:将系统的体验指标从“总生成耗时”降维到 首字时延(TTFT,Time-to-First-Token)。通常在 1 秒左右,第一个 Token 即可推送到前端并开始以“打字机”形式呈现。人的阅读速度通常在每秒 5~10 字,刚好能够掩盖模型后续计算的延迟,将等待焦虑降到最低。

1.2 服务端堆内存与网关反向代理保护

  • 堆内存占用与 GC:若并发场景下有数百个会话同时等待 3,000+ 字的响应,后端必须在 JVM 内存中为每个连接开辟大缓冲区维持全量文本。这会导致堆内存水位急速飙升,频繁引发 Full GC 甚至 OOM。流式传输以极小的数据切片分段写出,数据随发随放,大幅减轻单机内存常驻负担。
  • 连接保活与超时防护:Nginx、Spring Cloud Gateway 等反向代理通常配置了连接超时阈值(如 proxy_read_timeout 60s)。若模型生成耗时超出限制,长期的静默状态会触发网关强制掐断 TCP 连接(504 Gateway Timeout)。流式输出通过持续推送文本帧,维持了 TCP 链路活性。

2. 协议选型博弈:SSE vs WebSocket

做长文本流式推送时,技术团队常在 SSE(Server-Sent Events) 和 WebSocket 之间权衡。

评估维度SSE (Server-Sent Events)WebSocket
通信模式单向传输(服务端 ➔ 客户端)全双工(双向均可随时收发)
底层协议标准 HTTP/1.1 或 HTTP/2 文本流独立 TCP 协议(依赖 HTTP 101 Upgrade 握手)
连接复用HTTP/2 模式下支持同域名多路复用每个连接独立占用 TCP Socket 连接
断线重连浏览器原生支持,配合 Last-Event-ID 机制需前端应用层手写心跳探活与重连逻辑
网关/代理穿透天然兼容 Nginx 缓存配置与企业防火墙需中间件专门放行升级头与双向长连接路由
AI 对话场景匹配度最佳实践(提问一次,持续输出)过度设计(维持双向心跳与状态开销过重)

对于常规的 AI 对话系统,交互模式高度固定:客户端单次提交 Prompt,服务端持续向客户端吐字。使用全双工的 WebSocket 相当于为单向推送维持复杂的连接心跳与状态管理,属于典型的架构过度设计。因此,SSE 是大模型流式对话在网络传输层的第一选择。

3. 核心痛点剖析:为什么“旧前端”传不了 Token?

选定 SSE 作为传输方案后,很多团队会遭遇鉴权落地的“拦路虎”:原系统的安全拦截机制突然失效。

3.1 还原“原生 API 的历史包袱”

浏览器原生内置了 window.EventSource API,但该 API 是十多年前由 HTML5 规范制定的,存在两大缺陷:

// 浏览器自带的原生 EventSource
const sse = new EventSource('/api/chat/stream?prompt=帮我写一份报告');
  1. 仅支持 GET 请求:无法发送包含长文本上下文、工具定义及复杂业务状态的 JSON Payload(极易触发 URL 长度限制 414 URI Too Long)。
  2. 规范层面严禁自定义 Header:没有任何设置请求头的参数入口,物理上无法通过常规方式传入 Authorization: Bearer <JWT_TOKEN>。

3.2 两种错误应对路径及其风险

  • 错误路径 1:把 Token 明文挂在 URL 上(/stream?token=eyJh...)

    • 风险:Token 会直接记录在 Nginx 访问日志、浏览器历史记录及监控链路中,带来极大的凭据泄露风险,安全合规审查无法通过;同时后端也需要打破现有的 Header 拦截机制,写补丁代码去解析 Query 参数。
  • 错误路径 2:引入多阶段换票机制(Ticket 模式)

    • 先发一次 POST 接口拿一个 30 秒有效期的临时 Ticket,再用 EventSource 传 Ticket。这种做法凭空增加了网络 RTT、Redis 存储与状态维护开销,本质上是“用复杂的系统设计为落后的前端 API 买单”。

3.3 现代解法:重构前端网络层

解决该痛点的最优策略并非改动后端,而是升级前端的网络请求库。

现代浏览器早已全面普及基于 fetch 的 ReadableStream。微软开源的 @microsoft/fetch-event-source 库,正是基于原生 fetch 对 SSE 规范进行了重新实现:

[现代浏览器网络层] 
  └─ fetch API (POST + 携带 Authorization Header)
       └─ ReadableStream (分块流式解析)
            └─ 触发 onmessage 回调 (打字机渲染)

这样既保留了 SSE 的打字机消费体验,又使得该请求在传输形态上完全退回为标准的 HTTP POST 请求,原系统的鉴权链条得以 100% 原样复用。

4. 端到端工程架构图解

整个交互链路保持了极高的内聚度,请求自客户端穿透网关与鉴权拦截器后直达 AI Controller,再通过 Reactor 响应式管道流向模型并回推。

+-----------------------------------------------------------------------------------+
| 客户端 (浏览器 / 移动端)                                                            |
|  - @microsoft/fetch-event-source                                                 |
|  - 发起 POST /api/chat/stream (Body: JSON, Header: Authorization: Bearer xxx)      |
+----------------------------------------+------------------------------------------+
                                         |
                                         | 标准 HTTP 长连接 (带 Token)
                                         v
+-----------------------------------------------------------------------------------+
| 基础设施层 (Nginx / API Gateway)                                                   |
|  - proxy_buffering off;   (关闭响应缓冲,避免数据滞留)                               |
|  - proxy_cache off;       (关闭缓存)                                               |
|  - 路由转发至后端集群                                                                |
+----------------------------------------+------------------------------------------+
                                         |
                                         v
+-----------------------------------------------------------------------------------+
| 应用层 (Spring Boot + Spring Security)                                             |
|                                                                                   |
|  1. JwtAuthenticationFilter: 校验 Authorization Header,构建 SecurityContext      |
|  2. ChatStreamController: 接收 POST 请求,返回 MediaType.TEXT_EVENT_STREAM_VALUE   |
|  3. Reactor 管道: Flux<ServerSentEvent<String>>                                   |
|       └─ doOnCancel: 捕获客户端断开连接信号,向底层发射 cancel 指令                    |
+----------------------------------------+------------------------------------------+
                                         |
                                         | HTTP/2 流式调用 (Streaming)
                                         v
+-----------------------------------------------------------------------------------+
| 大模型服务提供商 (OpenAI / DeepSeek / 本地私有化模型)                                |
+-----------------------------------------------------------------------------------+

5. 生产级前后端完整代码实现

5.1 前端:基于 Fetch 的健壮流式客户端

前端基于 @microsoft/fetch-event-source 封装,支持传递复杂的 JSON Body、携带标准 Token,并具备主动断开与异常兜底能力。

npm install @microsoft/fetch-event-source

TypeScript

import { fetchEventSource } from '@microsoft/fetch-event-source';

export interface ChatMessagePayload {
  prompt: string;
  conversationId?: string;
}

export function streamChatDialogue(
  payload: ChatMessagePayload,
  token: string,
  callbacks: {
    onMessage: (chunk: string) => void;
    onEnd: () => void;
    onError: (err: Error) => void;
  }
) {
  const abortController = new AbortController();

  fetchEventSource('/api/chat/stream', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      // 标准请求头传递凭证,复用已有鉴权体系
      'Authorization': `Bearer ${token}`,
    },
    body: JSON.stringify(payload),
    signal: abortController.signal,

    // 禁用默认的错误自动重试(非幂等场景重试易导致模型重复推理)
    openWhenHidden: true,

    async onopen(response) {
      if (response.ok && response.headers.get('content-type')?.includes('text/event-stream')) {
        return; // 连接建立成功
      } else if (response.status === 401 || response.status === 403) {
        throw new Error('鉴权未通过,请重新登录');
      } else {
        throw new Error(`连接失败,状态码: ${response.status}`);
      }
    },

    onmessage(msg) {
      // 约定服务端推送 [DONE] 标记为结束
      if (msg.data === '[DONE]') {
        callbacks.onEnd();
        abortController.abort(); // 正常关闭底层流
        return;
      }
      callbacks.onMessage(msg.data);
    },

    onclose() {
      callbacks.onEnd();
    },

    onerror(err) {
      callbacks.onError(err instanceof Error ? err : new Error(String(err)));
      throw err; // 抛出错误以停止自动轮询重试
    },
  });

  // 返回控制器供外部在需要时主动掐断(如用户点击“停止生成”)
  return abortController;
}

5.2 后端:Spring Boot 3 + Spring WebFlux 响应式控制器

后端基于响应式编程栈实现,支持解析 Header 中的用户凭证、以 TEXT_EVENT_STREAM_VALUE 下发事件,并在客户端主动断连时释放资源。

依赖配置 (pom.xml)

XML

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webflux</artifactId>
    </dependency>
    <!-- 安全组件按需引入,此处复用原生 Security 拦截链 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
</dependencies>

控制器实现 (ChatStreamController.java)

package com.example.ai.controller;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;

import java.time.Duration;

@RestController
@RequestMapping("/api/chat")
public class ChatStreamController {

    private static final Logger log = LoggerFactory.getLogger(ChatStreamController.class);

    public record ChatRequest(String prompt, String conversationId) {}

    /**
     * 流式对话接口:使用 POST 规范,返回 text/event-stream
     */
    @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<String>> streamChat(
            @RequestBody ChatRequest request,
            @AuthenticationPrincipal UserDetails userDetails) {

        String username = (userDetails != null) ? userDetails.getUsername() : "Anonymous";
        log.info("用户 [{}] 发起会话 [{}], prompt: {}", username, request.conversationId(), request.prompt());

        // 模拟大模型流式调用管道(生产环境下此处调用 WebClient 接入底层模型推理流)
        Flux<String> modelTokenFlux = mockLlmInferenceStream(request.prompt());

        return modelTokenFlux
                // 将模型吐出的每一个 Token 封装为标准的 SSE 事件帧
                .map(token -> ServerSentEvent.<String>builder()
                        .data(token)
                        .build())
                // 追加结束通知标识
                .concatWith(Flux.just(ServerSentEvent.<String>builder().data("[DONE]").build()))
                // 监听连接取消:实现“断连即止损”
                .doOnCancel(() -> {
                    log.warn("检测到客户端断开连接,正在掐断与大模型的推理链路以节省 Token 消耗...");
                    // 业务逻辑:在此处可取消异步 Future 或关闭底层 RPC Client
                })
                .doOnError(e -> log.error("流式推送过程中发生异常", e));
    }

    /**
     * 模拟大模型打字机分词逐字输出
     */
    private Flux<String> mockLlmInferenceStream(String prompt) {
        String mockResponse = "这是一段模拟大模型推流响应的分析文本。基于您的提问,系统正在进行多维度分析并按字元流式回传...";
        return Flux.fromArray(mockResponse.split(""))
                .delayElements(Duration.ofMillis(80)); // 模拟模型生成抖动与延迟
    }
}

5.3 网关层关键配置(Nginx)

SSE 是长期存活的 HTTP 流。若使用 Nginx 作为反向代理,必须关闭代理缓冲(Proxy Buffering),否则 Nginx 会在内存中攒满一块数据后才向前端发送,导致打字机效果直接退化为卡顿的大段跳字。

server {
    listen 80;
    server_name api.example.com;

    location /api/chat/stream {
        proxy_pass http://backend-upstream;

        # 核心设置:禁用反向代理缓存与缓冲机制
        proxy_buffering off;
        proxy_cache off;

        # 支持 HTTP/1.1 长连接穿透
        proxy_http_version 1.1;
        proxy_set_header Connection '';

        # 透传客户端凭据请求头与真实 IP
        proxy_set_header Host $host;
        proxy_set_header Authorization $http_authorization;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        # 延长读取超时时间(防止模型思考阶段较长导致连接被中间件掐断)
        proxy_read_timeout 300s;
        proxy_send_timeout 300s;
    }
}

6. 总结

在建设大模型生产级系统的过程中,网络传输与鉴权架构的落地可以总结为三条工程准则:

  1. 协议轻量优先:AI 问答具有“单向推流”的典型特征,选型应首选基于标准 HTTP 协议的 SSE,避免盲目引入全双工的 WebSocket 导致运维复杂度上升。
  2. 拒绝为落后 API 打补丁:不要在 URL Query 中拼接包含权限的敏感 Token。果断将前端升级为基于 Fetch API 的现代流式客户端,利用标准的 POST 与 Authorization Header 完成通信。
  3. 架构无缝复用:让大模型流式接口在网络拓扑与安全链条中“回归普通 HTTP 请求”的本质,既能实现完整的流式打字机交互,又能 100% 沿用已有的微服务网关、权限过滤链与安全审计机制。