Spring Boot中LLM流式交互的核心原理

一、为什么要使用异步方式处理LLM流式请求

同步方式的问题

传统的同步处理方式如下:

java 复制代码
@GetMapping("/chat")
public String chat(LLMRequest request) {
    // 这里会阻塞10-30秒,等待LLM完整响应
    return llmService.chat(request);
}

这种方式存在两个致命问题:

  1. 线程阻塞:Tomcat工作线程被长时间占用,无法处理其他请求
  2. 无法流式输出:必须等待完整响应才能返回,用户体验差

异步方式的优势

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会:

  1. 自动设置响应头 Content-Type: text/event-stream
  2. 保持HTTP连接打开,不关闭输出流
  3. 后续调用emitter.send()时,按SSE协议格式化成 data: 内容\n\n 写入响应流

生命周期管理

java 复制代码
SseEmitter emitter = new SseEmitter(60000L); // 超时60秒

emitter.send("数据块");      // 推送数据
emitter.complete();           // 正常结束
emitter.completeWithError(e); // 异常结束

三、分布式环境下的取消难点与解决方案

难点分析

在单机环境下,取消流式输出可以直接调用 CompletableFuture.cancel()。但在分布式环境中存在以下问题:

  • 前端再次发起取消请求时,可能被负载均衡器路由到不同的节点
  • CompletableFuture 对象存在于原节点内存中,其他节点无法直接访问
  • 无法跨节点查找和取消正在执行的异步任务

推荐方案:基于连接状态的被动取消

核心思路:将"主动查找并取消"转变为"连接断开时被动触发"。取消的本质是客户端不想继续接收数据,那么直接断开连接即可。

实现步骤

  1. 客户端主动断开SSE连接
javascript 复制代码
// 取消当前对话,直接关闭连接
function cancelStream() {
    if (eventSource) {
        eventSource.close(); // 触发后端连接断开事件
    }
}
  1. 服务端监听连接断开事件
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;
}
  1. 在数据发送循环中捕获断开信号
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流式请求的核心设计思路:

  1. 异步处理是必须的:LLM响应慢,必须使用专用线程池异步执行,避免阻塞Web容器线程

  2. SseEmitter是关键工具:它将HTTP连接转为可多次写入的数据管道,配合异步机制实现真正的流式输出

  3. 分布式取消的最佳策略是"被动触发":利用SseEmitter的连接断开回调,让客户端断开连接本身成为取消信号,无需复杂的跨节点句柄管理

这种设计既保障了服务器的并发能力,实现了良好的用户体验,同时在分布式环境下保持了架构的简洁性和可靠性。

相关推荐
叶落阁主1 小时前
Spring Boot 4 实战:Jackson 2.x 升级到 3.x 踩坑全记录
java·后端·架构
曹牧2 小时前
Spring WebService 的两种主流实现方式‌
java·后端·spring
用户327980567072 小时前
JDK9+日志Xlog的使用方式
后端
直奔標竿2 小时前
Java开发者AI转型第二十三课!Spring AI个人知识库实战(二):异步ETL流水线搭建与避坑指南
java·人工智能·spring boot·后端·spring
会编程的土豆2 小时前
洛谷题单 入门1 顺序结构(go语言)
开发语言·后端·golang·洛谷
青木9602 小时前
前后端开发调试运行技巧
linux·服务器·前端·后端·npm·uv
浮尘笔记3 小时前
在Snowy后台无需编码实现自动化生成CRUD操作流程
java·开发语言·经验分享·spring boot·后端·程序人生·mybatis
希望永不加班3 小时前
SpringBoot 接口签名验证(AppKey/Secret)
java·spring boot·后端·spring