Java 程序员第 44 阶段13:大模型微服务拆分,独立服务解耦便于扩容维护,流式输出服务:SSE/WebSocket独立化改造

  1. 流式输出的业务价值与技术挑战
  2. SSE与WebSocket协议对比

   2.1 服务端推送事件

   2.2 WebSocket双向通信

  1. 流式输出服务架构设计

   3.1 从单体到独立流式服务

   3.2 流式服务的核心组件

  1. SSE实现方案

   4.1 Spring WebFlux响应式实现

   4.2 连接管理与心跳保持

  1. WebSocket实现方案

   5.1 STOMP协议集成

   5.2 会话状态管理

  1. 流式服务的可靠性保障

   6.1 断线重连与恢复机制

   6.2 流量控制与背压处理

  1. 性能优化策略
  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测试和灰度发布在大模型服务中的实践。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

洛水石

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值