一、为什么要使用异步方式处理LLM流式请求
同步方式的问题
传统的同步处理方式如下:
java
@GetMapping("/chat")
public String chat(LLMRequest request) {
// 这里会阻塞10-30秒,等待LLM完整响应
return llmService.chat(request);
}
这种方式存在两个致命问题:
- 线程阻塞:Tomcat工作线程被长时间占用,无法处理其他请求
- 无法流式输出:必须等待完整响应才能返回,用户体验差
异步方式的优势
java
@GetMapping("/chat/stream")
public SseEmitter chatStream(LLMRequest request) {
SseEmitter emitter = new SseEmitter();
// 异步执行,不阻塞当前线程
CompletableFuture.runAsync(() -> {
callLLMAndStream(request, emitter);
}, dedicatedExecutor);
return emitter; // 立即返回
}
核心要点:
- CompletableFuture.runAsync 是"提交即返回",不等待结果
- 耗时的LLM调用由专用线程池执行,与Web容器线程池隔离
- Controller线程被立即释放,可以继续处理其他请求
- LLM的流式数据通过回调接口实时推送给客户端
二、SseEmitter的核心原理
本质是什么
SseEmitter把一个标准的HTTP请求-响应模式,变成了一条服务器可以随时写入数据的长连接通道。
工作原理
当Controller返回SseEmitter对象时,Spring会:
- 自动设置响应头 Content-Type: text/event-stream
- 保持HTTP连接打开,不关闭输出流
- 后续调用emitter.send()时,按SSE协议格式化成 data: 内容\n\n 写入响应流
生命周期管理
java
SseEmitter emitter = new SseEmitter(60000L); // 超时60秒
emitter.send("数据块"); // 推送数据
emitter.complete(); // 正常结束
emitter.completeWithError(e); // 异常结束
三、分布式环境下的取消难点与解决方案
难点分析
在单机环境下,取消流式输出可以直接调用 CompletableFuture.cancel()。但在分布式环境中存在以下问题:
- 前端再次发起取消请求时,可能被负载均衡器路由到不同的节点
- CompletableFuture 对象存在于原节点内存中,其他节点无法直接访问
- 无法跨节点查找和取消正在执行的异步任务
推荐方案:基于连接状态的被动取消
核心思路:将"主动查找并取消"转变为"连接断开时被动触发"。取消的本质是客户端不想继续接收数据,那么直接断开连接即可。
实现步骤
- 客户端主动断开SSE连接
javascript
// 取消当前对话,直接关闭连接
function cancelStream() {
if (eventSource) {
eventSource.close(); // 触发后端连接断开事件
}
}
- 服务端监听连接断开事件
java
@GetMapping("/chat/stream")
public SseEmitter chatStream(@RequestParam String prompt) {
SseEmitter emitter = new SseEmitter();
String requestId = UUID.randomUUID().toString();
// 注册连接断开回调
emitter.onCompletion(() -> {
// 客户端主动断开或连接正常结束
logger.info("请求 {} 的连接已关闭,执行清理", requestId);
cancelLLMRequest(requestId);
});
emitter.onError((ex) -> {
// 连接异常(包括客户端断开)
logger.info("请求 {} 连接异常,取消LLM请求", requestId);
cancelLLMRequest(requestId);
});
// 异步处理LLM请求
CompletableFuture.runAsync(() -> {
callLLMAndStream(prompt, emitter, requestId);
}, executor);
return emitter;
}
- 在数据发送循环中捕获断开信号
java
private void callLLMAndStream(String prompt, SseEmitter emitter, String requestId) {
try {
llmClient.streamChat(prompt, new StreamCallback() {
@Override
public void onChunk(String chunk) {
try {
emitter.send(chunk);
} catch (IOException e) {
// 发送失败说明客户端已断开,停止继续获取数据
logger.info("客户端已断开,停止LLM数据流");
throw new RuntimeException("Client disconnected", e);
}
}
@Override
public void onComplete() {
emitter.complete();
}
});
} catch (Exception e) {
emitter.completeWithError(e);
}
}
方案优势
- 无需跨节点通信:取消请求就是断开连接,负载均衡器自然会将断开事件路由到正确的节点
- 无需存储句柄:利用现有的TCP连接作为天然的"句柄"
- 实时性高:连接断开时立即触发清理
- 实现简单:完全基于SseEmitter已有的回调机制
四、简要示例代码
完整实现
java
@RestController
public class LLMController {
private final ExecutorService llmExecutor = Executors.newFixedThreadPool(20);
private final Map<String, CompletableFuture<?>> activeStreams = new ConcurrentHashMap<>();
@GetMapping("/chat/stream")
public SseEmitter chatStream(@RequestParam String prompt) {
SseEmitter emitter = new SseEmitter(60000L);
String requestId = UUID.randomUUID().toString();
// 监听连接断开
emitter.onCompletion(() -> {
CompletableFuture<?> future = activeStreams.remove(requestId);
if (future != null) {
future.cancel(true);
}
});
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
try {
// 模拟调用LLM流式接口
String[] chunks = {"Hello", " ", "world", "!"};
for (String chunk : chunks) {
if (Thread.currentThread().isInterrupted()) {
emitter.complete();
return;
}
emitter.send(chunk);
Thread.sleep(100);
}
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
}, llmExecutor);
activeStreams.put(requestId, future);
return emitter;
}
}
前端调用
javascript
let eventSource = null;
function startStream() {
eventSource = new EventSource('/chat/stream?prompt=Hello');
eventSource.onmessage = (event) => {
console.log(event.data);
};
}
function cancelStream() {
if (eventSource) {
eventSource.close(); // 触发后端取消
}
}
五、总结
处理LLM流式请求的核心设计思路:
-
异步处理是必须的:LLM响应慢,必须使用专用线程池异步执行,避免阻塞Web容器线程
-
SseEmitter是关键工具:它将HTTP连接转为可多次写入的数据管道,配合异步机制实现真正的流式输出
-
分布式取消的最佳策略是"被动触发":利用SseEmitter的连接断开回调,让客户端断开连接本身成为取消信号,无需复杂的跨节点句柄管理
这种设计既保障了服务器的并发能力,实现了良好的用户体验,同时在分布式环境下保持了架构的简洁性和可靠性。