
- 流式输出的业务价值与技术挑战
- SSE与WebSocket协议对比
2.1 服务端推送事件
2.2 WebSocket双向通信
- 流式输出服务架构设计
3.1 从单体到独立流式服务
3.2 流式服务的核心组件
- SSE实现方案
4.1 Spring WebFlux响应式实现
4.2 连接管理与心跳保持
- WebSocket实现方案
5.1 STOMP协议集成
5.2 会话状态管理
- 流式服务的可靠性保障
6.1 断线重连与恢复机制
6.2 流量控制与背压处理
- 性能优化策略
- 总结
大模型区别于传统API的最显著特征之一是流式输出能力。模型在生成过程中逐token产出结果,而非等待全部内容完成后一次性返回。这种"边生成边展示"的用户体验已经成为AI应用的标配。当用户看到文字逐字出现时,感知到的等待时间大幅缩短,交互的自然度显著提升。
在单体应用中,流式输出相对简单。Controller方法返回Stream或Flux,框架层处理SSE协议封装,直接推送给前端。但当我们将系统拆分为微服务后,原本贯穿请求处理链的流式管道被切断。API网关接收外部请求,通过内部RPC调用推理服务,推理服务返回流式结果,网关再将结果逐块转推给客户端。这个过程中涉及多段连接的适配和转换。
核心挑战包括:第一,内部服务间的流式传输协议选择与实现。第二,多段传输链路中的缓冲问题导致的首token延迟增加。第三,客户端断开后如何优雅地取消后端推理任务。第四,在大规模并发连接下如何管理有限的连接资源和GPU资源。第五,不同客户端(Web、移动端、第三方API集成)对流式传输的协议偏好不同。
独立流式输出服务的价值在于将这些复杂性封装起来,对外提供统一的流式接口,对内通过高效的流式传输协议连接各个模型服务。独立的流式服务可以根据连接数和流量的变化独立扩缩容,不像单体那样受限于整体资源池。
2.1 服务端推送事件
Server-Sent Events是基于HTTP协议的服务器向客户端推送数据的技术。客户端通过标准的HTTP GET请求建立连接后,服务器通过持久的HTTP响应持续发送事件流。每个事件由event、data、id和retry字段组成,以双换行符分隔。
SSE的优势在于实现简单、与HTTP基础设施完全兼容。它经过代理、负载均衡、防火墙时不需要特殊配置。客户端使用浏览器原生的EventSource API即可接收,无需额外库。对于大模型流式输出这种服务器到客户端的单向数据流场景,SSE天然契合。
SSE的局限在于它是单向的。客户端不能通过同一连接向服务器发送数据,需要另外的HTTP请求来发送用户输入或取消指令。此外,SSE基于HTTP/1.1,存在浏览器对同一域名的并发连接数限制(6个),不过在现代应用中使用HTTP/2可以规避此限制。
2.2 WebSocket双向通信
WebSocket提供了全双工的通信通道。通过HTTP升级协商后,连接切换为WebSocket协议,双方可以随时发送数据。WebSocket的消息帧开销极小(2-14字节),延迟极低。
对于大模型流式输出场景,WebSocket的双向能力带来了显著优势。客户端可以在推理过程中通过同一连接发送取消指令,无需额外的HTTP请求。当用户点击"停止生成"按钮时,取消消息可以立即到达服务端,服务端随即终止推理任务,避免GPU资源浪费。此外,WebSocket支持二进制消息帧,对于需要传输图片、音频等多模态内容的场景更为灵活。
WebSocket的部署复杂度稍高。需要确保反向代理和负载均衡器支持WebSocket协议升级和长连接保持。Nginx配置需要添加Upgrade和Connection头部的代理转发。在Kubernetes环境中,Ingress Controller需要配置合适的超时时间。
对于纯文本流式输出场景,SSE通常足够且更简单。对于需要双向交互或传输二进制数据的场景,WebSocket是更好的选择。在实践中,可以在同一流式服务中同时支持两种协议。
3.1 从单体到独立流式服务

在将流式输出独立为微服务时,需要进行架构的重构。原有的单体结构中,请求处理流程是连续的:HTTP请求进入Controller,Controller调用Service层,Service调用模型推理接口,推理接口返回Iterator或Stream,框架层层层透传直到响应写入Socket。
拆分后的架构变为分段式。核心变化是拆分为多个独立的Pipeline环节:API网关负责接收外部HTTP请求;流式输出服务负责管理客户端连接和协议适配;推理服务负责实际的模型调用。
拆分后需要解决两个核心问题。第一个是内部流式传输:推理服务如何将流式结果高效地传输给流式输出服务。第二个是连接映射:每个客户端的连接如何与后端的推理流对应起来。
对于内部流式传输,推荐使用gRPC的服务端流模式。通过gRPC的StreamObserver机制,推理服务将生成的token逐个推送,流式输出服务逐块接收并转发给客户端。gRPC基于HTTP/2,天然支持流式传输和流量控制,性能优于反复的HTTP请求轮询。
3.2 流式服务的核心组件

流式输出服务由以下核心组件构成。
连接管理器负责维护所有客户端连接的生命周期,包括连接的建立、心跳保持、断线检测和资源回收。使用ConcurrentHashMap存储sessionId到连接的映射,配合ScheduledExecutorService定期清理过期连接。
协议适配器负责协议的转换和封装。对于不同的客户端请求类型,适配器将内部统一的流式数据格式(如StreamChunk模型)转换为对应的协议格式(SSE的data:前缀、WebSocket的JSON帧等)。
推理流代理是连接客户端和推理服务的关键桥梁。它通过gRPC StreamObserver机制,将客户端的连接请求映射为对推理服务的流式调用,并将推理服务的输出逐块转发给客户端连接。同时负责处理取消信号——当客户端连接断开时,取消对应的gRPC流。
@Component
public class InferenceStreamProxy {
private final Map<String, InferenceServiceGrpc.InferenceServiceStub> stubs;
private final Map<String, StreamObserver<ChatStreamChunk>> activeStreams;
public InferenceStreamProxy() {
this.activeStreams = new ConcurrentHashMap<>();
}
public void startStream(String sessionId, String modelId,
List<Message> messages, SseEmitter emitter) {
InferenceServiceStub stub = stubs.get(modelId);
ChatRequest request = buildGrpcRequest(modelId, messages);
stub.streamChatCompletion(request,
new StreamObserver<ChatStreamChunk>() {
@Override
public void onNext(ChatStreamChunk chunk) {
try {
emitter.send(SseEmitter.event()
.name("token")
.data(chunk));
} catch (IOException e) {
// 客户端断开
cancelStream(sessionId);
}
}
@Override
public void onError(Throwable t) {
emitter.completeWithError(t);
activeStreams.remove(sessionId);
}
@Override
public void onCompleted() {
emitter.complete();
activeStreams.remove(sessionId);
}
});
activeStreams.put(sessionId, observer);
}
public void cancelStream(String sessionId) {
StreamObserver<ChatStreamChunk> observer = activeStreams.remove(sessionId);
if (observer != null) {
observer.onCompleted(); // 通知推理服务取消
}
}
}
4.1 Spring WebFlux响应式实现
Spring WebFlux提供了对SSE的原生支持。通过返回Flux类型,框架自动处理SSE协议的封装。以下是流式输出服务的SSE端点实现。
@RestController
@RequestMapping("/api/v1/stream")
public class StreamController {
private final InferenceStreamProxy streamProxy;
private final ConnectionManager connectionManager;
@PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamChat(
@RequestBody ChatStreamRequest request) {
String sessionId = UUID.randomUUID().toString();
return Flux.create(sink -> {
connectionManager.registerConnection(sessionId, sink);
CallbackStreamObserver observer = new CallbackStreamObserver() {
@Override
public void onToken(String token, int index) {
ChatStreamChunk chunk = ChatStreamChunk.builder()
.id(sessionId)
.choices(List.of(StreamChoice.builder()
.delta(DeltaMessage.builder()
.content(token)
.build())
.index(index)
.build()))
.build();
sink.next(ServerSentEvent.builder(chunk)
.id(sessionId)
.event("token")
.build());
}
@Override
public void onComplete(String finishReason) {
ChatStreamChunk chunk = ChatStreamChunk.builder()
.id(sessionId)
.choices(List.of(StreamChoice.builder()
.finishReason(finishReason)
.build()))
.build();
sink.next(ServerSentEvent.builder(chunk)
.id(sessionId)
.event("done")
.build());
sink.complete();
}
@Override
public void onError(Throwable t) {
sink.error(t);
}
};
streamProxy.startStream(sessionId, request.getModel(),
request.getMessages(), observer);
sink.onDispose(() -> {
streamProxy.cancelStream(sessionId);
connectionManager.removeConnection(sessionId);
});
});
}
}
在某些非响应式场景中,也可以使用Spring MVC的SseEmitter实现流式推送。SseEmitter使用异步线程管理,适合与阻塞式服务集成。以下是使用SseEmitter的实现。
首先,在Controller中注入SseEmitter并设置超时时间。在发送数据时,通过emitter.send方法逐步推送内容,监听onCompletion和onTimeout事件处理连接终止。当推理服务产生token时,通过回调机制将token写入SseEmitter的发送缓冲区。
关键配置包括超时时间设置。大模型生成可能需要较长时间,建议将SseEmitter超时设置为300秒或更长,同时定期发送心跳事件(空白SSE注释行)以防止代理超时。
4.2 连接管理与心跳保持
长连接场景下,连接状态管理至关重要。需要处理的情况包括客户端主动断开、网络中断导致的半开连接、代理服务器的空闲超时等。
@Component
public class ConnectionManager {
private final Map<String, SseEmitter> connections = new ConcurrentHashMap<>();
private final ScheduledExecutorService heartbeatExecutor;
public ConnectionManager() {
this.heartbeatExecutor = Executors.newSingleThreadScheduledExecutor();
startHeartbeat();
}
public void registerConnection(String sessionId, SseEmitter emitter) {
connections.put(sessionId, emitter);
emitter.onCompletion(() -> connections.remove(sessionId));
emitter.onTimeout(() -> {
connections.remove(sessionId);
log.warn("SSE connection timeout: sessionId={}", sessionId);
});
emitter.onError(e -> {
connections.remove(sessionId);
log.warn("SSE connection error: sessionId={}", sessionId, e);
});
}
public void removeConnection(String sessionId) {
SseEmitter emitter = connections.remove(sessionId);
if (emitter != null) {
try { emitter.complete(); } catch (Exception ignored) {}
}
}
private void startHeartbeat() {
heartbeatExecutor.scheduleAtFixedRate(() -> {
connections.forEach((sessionId, emitter) -> {
try {
emitter.send(SseEmitter.event()
.name("ping")
.comment("heartbeat"));
} catch (IOException e) {
log.warn("Heartbeat failed, removing: {}", sessionId);
connections.remove(sessionId);
streamProxy.cancelStream(sessionId);
}
});
}, 15, 15, TimeUnit.SECONDS);
}
}
心跳机制:每15秒通过SSE通道发送一条注释,这是一个零负载的心跳包。Nginx等反向代理的默认proxy_read_timeout通常为60秒,15秒的心跳间隔可以确保连接在被代理关闭之前被刷新。如果心跳发送失败,说明客户端已断开,及时清理资源。
5.1 STOMP协议集成
Spring框架对WebSocket的支持基于STOMP(Simple Text Oriented Messaging Protocol)协议。STOMP在WebSocket之上提供了发布-订阅模型,使消息路由更加结构化。
WebSocket配置需要实现WebSocketMessageBrokerConfigurer接口:
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// 客户端订阅的前缀
config.enableSimpleBroker("/topic", "/queue");
// 客户端发送消息的前缀
config.setApplicationDestinationPrefixes("/app");
// 点对点消息前缀
config.setUserDestinationPrefix("/user");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws/chat")
.setAllowedOriginPatterns("*")
.withSockJS(); // 降级支持
}
}
流式消息的推送通过SimpMessagingTemplate发送到指定目标地址:
@Controller
public class WebSocketStreamController {
private final SimpMessagingTemplate messagingTemplate;
private final InferenceStreamProxy streamProxy;
private final Map<String, StreamSession> sessions;
@MessageMapping("/chat/stream")
public void handleStreamRequest(
@Payload ChatStreamRequest request,
Principal principal) {
String sessionId = UUID.randomUUID().toString();
String userId = principal.getName();
// 注册会话
sessions.put(sessionId, new StreamSession(sessionId, userId,
request.getModel()));
// 启动推理流
streamProxy.startStream(sessionId, request.getModel(),
request.getMessages(), new WebSocketCallback(sessionId, userId));
}
@MessageMapping("/chat/cancel")
public void handleCancel(@Payload CancelRequest request) {
streamProxy.cancelStream(request.getStreamId());
sessions.remove(request.getStreamId());
}
private class WebSocketCallback implements StreamCallback {
private final String sessionId;
private final String userId;
@Override
public void onToken(String token, int index) {
ChatStreamChunk chunk = ChatStreamChunk.builder()
.content(token)
.streamId(sessionId)
.build();
messagingTemplate.convertAndSendToUser(
userId, "/queue/chat/token", chunk);
}
@Override
public void onComplete(String finishReason) {
messagingTemplate.convertAndSendToUser(
userId, "/queue/chat/done",
Map.of("streamId", sessionId, "finishReason", finishReason));
sessions.remove(sessionId);
}
@Override
public void onError(Throwable t) {
messagingTemplate.convertAndSendToUser(
userId, "/queue/chat/error",
Map.of("streamId", sessionId, "error", t.getMessage()));
sessions.remove(sessionId);
}
}
}
5.2 会话状态管理
WebSocket连接是长连接,会话中可能同时存在多个并发推理流。会话管理需要追踪用户的连接状态和活跃流。
@Component
public class WebSocketSessionManager {
private final Map<String, Set<String>> userSessions = new ConcurrentHashMap<>();
private final Map<String, StreamSession> streamSessions = new ConcurrentHashMap<>();
private final Map<String, String> userConnections = new ConcurrentHashMap<>();
public void onUserConnected(String userId, String sessionId) {
userConnections.put(userId, sessionId);
userSessions.computeIfAbsent(userId, k -> ConcurrentHashMap.newKeySet());
}
public void onUserDisconnected(String userId) {
// 取消该用户所有活跃的推理流
Set<String> streams = userSessions.remove(userId);
if (streams != null) {
for (String streamId : streams) {
streamProxy.cancelStream(streamId);
streamSessions.remove(streamId);
}
}
userConnections.remove(userId);
}
public boolean isUserConnected(String userId) {
return userConnections.containsKey(userId);
}
@EventListener
public void handleSessionDisconnect(SessionDisconnectEvent event) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
String userId = accessor.getUser().getName();
if (userId != null) {
onUserDisconnected(userId);
}
}
}
用户断连时取消所有推理流的机制十分重要。大模型推理消耗大量GPU资源,如果不及时取消,断连用户的推理任务将继续占用资源直到完成。通过在SessionDisconnectEvent事件中触发取消逻辑,确保资源得到及时释放。
6.1 断线重连与恢复机制
流式输出过程中,客户端网络可能中断。对于WebSocket,SockJS提供了自动重连机制。对于SSE,需要客户端实现重连逻辑。浏览器的EventSource API在连接中断后会自动重连,利用Last-Event-Id头部告知服务器从上一次接收的事件之后继续。
但在大模型流式场景中,简单的重连并不足够。因为模型推理是持续进行的,断线期间产生的token已经丢失,无法从断点续传。因此需要设计适合不同场景的恢复策略。
对于需要完整上下文的应用(如代码生成、文档撰写),可以实施检查点机制。流式输出服务定期将推理状态和已生成的完整内容保存到Redis中。重连后,客户端携带streamId请求恢复,服务端从最近的检查点返回已生成内容,并重新启动推理流(可能需要重新发送完整的上下文给模型)。
@Service
public class StreamRecoveryService {
private final RedisTemplate<String, String> redis;
public void saveCheckpoint(String streamId, int tokenCount,
String accumulatedText) {
Map<String, String> checkpoint = Map.of(
"tokenCount", String.valueOf(tokenCount),
"accumulatedText", accumulatedText,
"timestamp", String.valueOf(System.currentTimeMillis())
);
redis.opsForHash().putAll("stream:checkpoint:" + streamId, checkpoint);
redis.expire("stream:checkpoint:" + streamId, Duration.ofMinutes(10));
}
public Optional<StreamCheckpoint> getCheckpoint(String streamId) {
Map<Object, Object> data = redis.opsForHash()
.entries("stream:checkpoint:" + streamId);
if (data.isEmpty()) return Optional.empty();
return Optional.of(StreamCheckpoint.builder()
.tokenCount(Integer.parseInt((String) data.get("tokenCount")))
.accumulatedText((String) data.get("accumulatedText"))
.timestamp(Long.parseLong((String) data.get("timestamp")))
.build());
}
public ChatStreamRecoveryResponse recoverStream(
String streamId, ChatStreamRequest originalRequest) {
Optional<StreamCheckpoint> checkpoint = getCheckpoint(streamId);
if (checkpoint.isPresent()) {
// 将累积文本作为assistant消息追加到对话中
// 然后重新请求模型继续生成
List<Message> recoveryMessages = new ArrayList<>(
originalRequest.getMessages());
recoveryMessages.add(Message.assistant(
checkpoint.get().getAccumulatedText()));
// 更新max_tokens减去已生成的数量
return buildRecoveryResponse(recoveryMessages, checkpoint.get());
}
return buildNewStreamResponse(originalRequest);
}
}
6.2 流量控制与背压处理
当流式输出服务的消费速度跟不上推理服务的产出速度时,需要实施流量控制。模型每秒可以产生20-50个token,而如果中间代理(流式输出服务)的转发速度慢于此,会导致内部缓冲区不断增长。
gRPC天然支持流量控制。服务端在推送消息前会检查客户端的可用窗口大小,如果窗口满了则阻塞等待。这形成了自然的背压机制。
在SSE层面,Spring WebFlux的响应式编程模型提供了内置的背压支持。通过request(unbounded)或request(n)来控制下游的请求速率。如果客户端处理速度慢,Flux会自然地降低上游数据的请求速度,最终传导到推理服务的token产出速率。
增强的背压策略实现:
public Flux<ServerSentEvent<ChatStreamChunk>> streamWithBackpressure(
String modelId, List<Message> messages) {
return Flux.create(sink -> {
// 设置初始请求量为10个chunk
sink.onRequest(n -> log.debug("Client requested {} chunks", n));
streamProxy.startStream(sessionId, modelId, messages,
new BackpressureCallback(sink));
}, FluxSink.OverflowStrategy.BUFFER); // 缓冲区溢出的处理策略
}
private class BackpressureCallback implements StreamCallback {
private final FluxSink<ServerSentEvent<ChatStreamChunk>> sink;
private final AtomicInteger pending = new AtomicInteger(0);
private static final int MAX_PENDING = 100;
@Override
public void onToken(String token, int index) {
if (pending.incrementAndGet() > MAX_PENDING) {
// 下游处理太慢,取消推理以节省GPU资源
sink.error(new BackpressureException("Client too slow"));
return;
}
ChatStreamChunk chunk = buildChunk(token, index);
sink.next(ServerSentEvent.builder(chunk).build());
pending.decrementAndGet();
}
}
流式输出服务的性能优化主要聚焦在三个方面:降低首token延迟、提高并发连接处理能力和减少网络传输开销。
降低首token延迟的策略包括:在内部推理服务连接上使用连接池预热,避免首次gRPC调用时的连接建立开销;使用HTTP/2协议减少连接建立和头部传输耗时。预连接池实现如下:
@Configuration
public class GrpcConnectionPool {
private final Map<String, List<ManagedChannel>> channelPools =
new ConcurrentHashMap<>();
@PostConstruct
public void warmup() {
List<String> modelServices = List.of("gpt-service", "embedding-service");
for (String service : modelServices) {
List<ManagedChannel> pool = new ArrayList<>();
for (int i = 0; i < 10; i++) {
ManagedChannel channel = ManagedChannelBuilder
.forAddress(service, 9090)
.usePlaintext()
.build();
pool.add(channel);
}
channelPools.put(service, pool);
}
}
public ManagedChannel borrowChannel(String service) {
List<ManagedChannel> pool = channelPools.get(service);
return pool.get(ThreadLocalRandom.current().nextInt(pool.size()));
}
}
并发连接支持:使用Netty作为底层网络框架,利用其事件驱动模型处理大量并发连接。在Spring中,WebFlux默认使用Netty,而WebSocket配置可以显式使用Netty的SockJS实现。
响应压缩:对于SSE中的data字段,可以使用gzip压缩。但需要注意,压缩会引入额外的CPU开销和延迟,对于小文本token可能得不偿失。建议对大于1KB的消息启用压缩,token级别的消息则不压缩。
流式输出服务是大模型微服务架构中承载实时交互体验的关键组件。通过将流式推送能力独立为微服务,我们实现了连接管理、协议适配和推理调用的解耦,为横向扩容和协议演进奠定了基础。
核心要点归纳如下。第一,SSE适合纯文本单向流式场景,实现简单、兼容性好。WebSocket适合需要双向交互或二进制传输的场景。第二,独立流式服务通过gRPC服务端流与内部推理服务通信,实现了高性能的流式数据管道。第三,连接管理与心跳机制是长连接服务的运维基础。第四,断线恢复策略需要根据应用场景选择合适方案。第五,背压机制保障了系统的稳定性,防止消费者过慢导致的资源浪费。
在下一章中,我们将讨论模型版本管理服务的设计,探讨A/B测试和灰度发布在大模型服务中的实践。
202

被折叠的 条评论
为什么被折叠?



