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=帮我写一份报告');
- 仅支持 GET 请求:无法发送包含长文本上下文、工具定义及复杂业务状态的 JSON Payload(极易触发 URL 长度限制
414 URI Too Long)。 - 规范层面严禁自定义 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 买单”。
- 先发一次 POST 接口拿一个 30 秒有效期的临时 Ticket,再用
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. 总结
在建设大模型生产级系统的过程中,网络传输与鉴权架构的落地可以总结为三条工程准则:
- 协议轻量优先:AI 问答具有“单向推流”的典型特征,选型应首选基于标准 HTTP 协议的 SSE,避免盲目引入全双工的 WebSocket 导致运维复杂度上升。
- 拒绝为落后 API 打补丁:不要在 URL Query 中拼接包含权限的敏感 Token。果断将前端升级为基于 Fetch API 的现代流式客户端,利用标准的
POST与Authorization Header完成通信。 - 架构无缝复用:让大模型流式接口在网络拓扑与安全链条中“回归普通 HTTP 请求”的本质,既能实现完整的流式打字机交互,又能 100% 沿用已有的微服务网关、权限过滤链与安全审计机制。