《从“以为正常”到“前端暴怒”:一场 SSE 流式输出的幻觉破灭记》-使用SseEmitter的BUG排查报告
需求背景
我接手了一个AI对话助手的需求,要实现的功能是让AI去生成Java代码,能在我们低代码平台上直接和AI聊天就能完成项目开发,在拖拉拽的基础上进一步简化开发。AI的实现的python后端做的,Java是调py提供的接口。Java就只是解析其中的json数据,做一些业务处理返回给前端。
问题描述
作为新接手的这个项目的我,肯定是先参考之前写的代码。 在完成需求后,交付给前端测试,发现了个大问题。 原本期望的流式输出,在前端页面展示的时候,没有正常的流式输出一个个代码块,而是将一个个代码块一次性返回!! 这不成了脱裤子放p吗,这还是跟一次性返回一样(╯▔皿▔)╯ 这时候你可能会问,难道我交付代码前不测测吗?没有返回流式输出看不出来? 我确实测过,也远远不只一遍,只不过我用的是apifox。 这个接口文档响应后确实像是流式的那种一块块返回,搞得我一开始认为我的代码没错!! 只不过十分有九分不对劲,它是等待很久之后才流式返回,就像是等所有数据返回之后才响应。 正常的流式输出是先建立一个初始连接,然后等AI那边的数据传过来后才一块块返回。 如图所示,一开始发送请求的时候就会有初始连接,有两个初始块
不过一开始的时候我也没在意,反正以为是正常流式输出了。
排查阶段
前端反馈问题后,我很疑惑,又拿apifox试试,像是正常的流式输出。 然后我又试了其它接口文档,knife4j是一次性返回所有块,不正常。 powershell发送curl请求,也是一次性返回所有块,不正常。 网上随便找的一个sse测试工具,也是一次性返回所有块,不正常。 而对照组,发送请求给我参考的AI对话原型接口,是先建立sse连接,正常。
至此,我确定是我接口的问题。
然后就是看代码逻辑。 为了甩锅,当然是检查一下python传过来的接口有没有问题。 1.打断点,看看代码调用python的接口有没有问题,主要是看执行到这一步的时候,是阻塞还是继续往下走,如果是阻塞,那就是python没有传流式输出的代码。 2.日志打印python请求的响应头有没有携带sse的响应头 , 像event stream,结果是有,看来python的接口没有问题 3.打断点看看代码逻辑,是不是在读取每一行的循环里都发送 sseEmitter.send(SseEmitter.event().data(vo)); 结果也没问题。
看来service层没有问题,只能看看controller层。
发现问题
对比参考原型的controller层,我好像没有在调用service的时候套用线程池。结果一侧,流式输出的问题确实解决了O(∩_∩)O。 至于为什么当时不一起复制过来,我以为线程池的作用是起到一个什么优化效果,当时刚接手考虑精简代码,就先去掉了。其实最抽象的还是我参考的代码,每调用一次请求就new一个线程池,这不有纯纯有病吗,为什么不用自定义全局线程池?当时我以为这是哪个天才的杰作,就自以为是的删除了,没想到坑在这里。但是这里没用全局线程池,其它AI chat的代码也复用的这个写法,后面开发的小伙伴可能和我一样感觉很奇怪,但是又觉得这么用一定有他的道理,狠狠保留O(∩_∩)O。
▼text复制代码ThreadPoolExecutor executor = new ThreadPoolExecutor( 1, 5, 1000, TimeUnit.SECONDS, new ArrayBlockingQueue<>(5), Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy()); SseEmitter sseEmitter = new SseEmitter((long)Integer.MAX_VALUE); executor.execute(() -> { aiAssistantService.aiAssistantChat(sseEmitter, query, currentUser); }); return sseEmitter; }
原理解析
我觉得不少人应该和我一样,SseEmitter 和 webFlux 傻傻分不清, 以为是一个东西。 贴一张AI的对比图

SseEmitter是 spring mvc提供的,它是阻塞模型,虽然能实现一块块输出,但是由于是阻塞的,它默认会将所有的数据一并返回,而不是边发边返回。
整个请求处理被阻塞在 Controller 方法内;
所有 sseEmitter.send(...) 调用的数据被 暂存于内存缓冲区
▼text复制代码Client ──┐ │ ▼ Tomcat (等待 Controller return...) │ ├─ callPython() → 阻塞 5s ├─ emitter.send("...") └─ return emitter → 此时才发 headers + all data! ▲ └── Client: "终于收到了...但是一大坨"
而flux是 Reactor 模型,天生支持流式响应,并且是异步非阻塞的。 平时也更推荐用这个,只不过我们公司用的是SseEmitter,我也只能照搬。 平时写🐟皮项目的时候用的也是这个flux,所以我以前在练习的时候没有感知到有什么问题。
▼text复制代码Client ←[headers]→ Netty → Flux → [chunk1] → Client ↓ [chunk2] → Client ↓ [complete]
而使用SseEmitter时需要搭配线程池使用,
调用service方法时,将其交给子线程去异步执行,而主线程直接返回sseEmitter。
▼text复制代码Client ←[headers immediately]← Tomcat ← return emitter (fast!) │ └─ background thread: callPython() emitter.send(...) → 实时推送到 Client
这样就能实现用sseEmitter实现流式输出了o( ̄▽ ̄)ブ。
知识联想
看到这个问题,如果是做过云图库项目的小伙伴,会想到什么? 没错,就是websocket

这里也是单开一个线程专门来异步处理消息,只不过AI的流式响应对实时性要求更高,出现的问题更加明显。同样是解耦提交任务的线程和执行任务的线程。思路一致,可以作为面试时候的理解!!!
加了个线程池,问题就解决了吗?
显然,到这里,问题还是没有彻底解决。不过我不想讨论每发起一个对话就new一个线程池这种迷惑行为,而是另一个致命的问题亟待解决。
前端同学发现,流式输出是解决了,但是十分又有十分不对劲(T_T) 好像经常AI流式输出消息的时候,压根没后走最后一步,程序就中断了 正常我Java后端会在执行AI输出的最后一块后,发送一个##chat_completion##标识符,告诉前端AI执行完了,并且再拼接一个完整内容。但是此时前端好几次发现,流式输出后,程序很多时候压根走不到最后一步,而最后一步又是关键,需要用户确认是否要进行工具调用,生成接口报告和接口文档。这不一根筋变成两头堵了(╯▔皿▔)╯。
初步猜测
估计是在流式输出时,由于AI输出的内容过长,导致主线程先一步执行完任务,而子线程有延迟,最后几步差一点传完,主线程就关闭了(T_T)。相当于后20%的内容被截断了。 初步解决方案:用countDownLatch试试等待子线程执行完后才进行发送。 为什么是初步方案呢,因为这个问题还没来得及解决周五就下班了,得下周解决了O(∩_∩)O。 要是解决了也算是积累一个并发工具类的使用场景。
至于为什么我参考的原型没有出现这个问题,我猜测,从我实际测试的感觉上看,原型输出的文本量不多,每次最多不超过10行。而我们做的AI接口生成助手,是要输出很长的接口询问细节,接口报告文本,这就可能会导致上述我提到的问题。
