后端大模型流式输出被springcloud gateway"阻塞"的解决办法

摘要

本文记录了在使用 springcloud gateway 的场景下,后端流式输出异常的情况,具体表现为大模型返回并没有流式输出,而是一次性全部返回;并且经过测试,直接对业务服务进行请求能够成功进行流式输出。

最终发现,是业务服务返回的 Content-Type 错误导致 gateway 没有按照流式输出返回给前端,只需要修改接口的 Content-Type 即可解决问题。

修改接口 Content-Type 之后,spring web 框架会按照 SSE 标准返回,此时需要前端使用 eventsource-parser 这个开源库对返回的数据进行解析。

业务场景描述

最近笔者在做一个和大模型下游应用有关的业务,具体形态可以理解为是一个套壳大模型的应用,其中的核心交互是用户输入问题,然后大模型给出回答。为了提升使用体验,笔者决定在后端使用流式输出的方式来返回,这种返回方式能够实现市面上大模型应用的那种打字机一样的效果,可以在大模型返回输出内容的同时把内容展示给用户而不必等待内容生成完之后再展示。

问题描述

系统后端是一个使用了 springboot 框架的微服务架构后端,包含业务服务和 gateway,流量都是经过 gateway 到达业务服务的。

业务服务使用了最新的 spring ai 框架,使用该框架后,流式输出可以使用以下的代码轻松实现(文末附上了一个 AI 生成的前端 html 页面,可以用于测试流式输出的效果)

kotlin 复制代码
@RestController
class DebugController(
    private val streamingChatModel: StreamingChatModel
) {
    @GetMapping("/debug/stream_output")
    fun streamOutputDemo(
        @RequestParam("content") content: String
    ): Flux<String> {
        return streamingChatModel.stream(content)
    }
}

但是在实际测试时发现:如果直接对业务服务进行请求,能够正常实现流式输出;但是如果对 gateway 进行请求,大模型的回复就只会一次性输出所有,而不会流式输出。

具体的表现可以通过下面这两张图的对比看出

这里为了更直观的看出效果,我把接口的返回内容改成了每 500ms 返回一个数字,具体的 Controller 实现如下

kotlin 复制代码
@GetMapping("/debug/stream_output")
fun streamOutputDemo(
    @RequestParam("content") content: String
): Flux<String> {
    val contentList = mutableListOf<String>()
    for (i in 0 until 6) {
        contentList.add("$i")
    }

    return Flux.fromArray(contentList.toTypedArray()).delayElements(Duration.ofMillis(500))
}

解决方案

在参考了 https://github.com/spring-cloud/spring-cloud-gateway/issues/3039 这个 issue 之后,我发现了问题:Controller 默认返回的 Content-Typetext/plain,gateway 在遇到这种返回类型时,就会按照常规的文本类型处理,即全部读取之后再返回。如果要解决这一问题,只需要把返回的 Content-Type 改成 text/event-stream,如此 gateway 便会将返回值视为流式输出处理。

具体实现方式是,在 GetMapping 注解上加上 produces = [MediaType.TEXT_EVENT_STREAM_VALUE],最终接口实现为

kotlin 复制代码
@GetMapping("/debug/stream_output", produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun streamOutputDemo(
    @RequestParam("content") content: String
): Flux<String> {
    val contentList = mutableListOf<String>()
    for (i in 0 until 6) {
        contentList.add("$i")
    }

    return Flux.fromArray(contentList.toTypedArray()).delayElements(Duration.ofMillis(500))
}

这样修改之后,又有了一个新的问题,如下图所示

输出的内容里额外添加了 data:。这其实是 SSE 规范里规定的格式,SSE(Server-Sent Event)是流式返回的底层实现方式,SSE 允许 http 连接里,服务器主动向客户端推送消息。在 SSE 规范里,数据类型的消息需要以 data: 开头,在我们添加了 produces = [MediaType.TEXT_EVENT_STREAM_VALUE] 之后,spring web 框架便认为这个返回是需要遵从 SSE 标准的数据,所以就自动帮我们进行了封装。

因此,最终我们还需要在前端按照 SSE 规范对消息进行解析。这里不推荐手动写解析代码,因为这里的边界条件还是比较多的,比如当要传输的数据里存在换行符时,此时就不能简单的按照换行符进行分割后处理。

这里推荐使用 github 上的一个开源的 SSE 事件解析库,链接为 https://github.com/rexxars/eventsource-parser

前端处理 SSE 事件并完成流式输出的示例代码如下,这里的代码可以很轻松地使用 AI 生成,这里就不再过多赘述了

javascript 复制代码
import {createParser} from 'eventsource-parser'

// 处理流式返回的数据
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')

const assistantMessageIndex = currentSession.value.messages.findIndex(msg => msg.id === assistantMessage.id)

const parser = createParser({
  onEvent: (event) => {
    console.log(event)
    currentSession.value.messages[assistantMessageIndex].content += event.data
    currentSession.value.messages = [...currentSession.value.messages]
  }
})

while (true) {
  const { done, value } = await reader.read()
  if (done) break

  let chunk = decoder.decode(value, { stream: true })
  parser.feed(chunk)
}

最终的成品如图所示

总结与心得体会

springcloud gateway 默认是支持流式返回的,无需额外进行任何配置。我们要做的只是把返回的 Content-Type 正确设置即可。

这次 debug 过程对我个人而言也是收获颇丰的,微服务架构的系统往往会面临 debug 困难的问题,因为这其中涉及到的中间环节太多了。就以这次流式输出异常为例,可能是业务服务的问题,可能是 gateway 的问题,也有可能是前端代码的问题。这种场景下的首要任务是从这个链条的两端出发,逐步缩小问题范围,比如,可以通过一个非常简单的 html 页面替换掉原本的前端进行测试,由此排除前端代码的问题,然后用这个 html 页面直接对业务服务进行测试,进而排除掉业务服务的问题,最后就能把问题锁定到 gateway 上。

在查找资料时,还注意到有些 case 是因为 nginx 缓存导致的问题(具体可以看之前的哪个 issue 链接),如果有遇到同样的问题,但是系统部署了 nginx 的朋友可以尝试从这个角度来找一下问题。

在对业务服务进行测试时,我还遇到了 chrome 浏览器跨域拦截的问题,但是我又不想因为这一次临时测试去该跨域配置,所以就参考 https://blog.csdn.net/qq_44472722/article/details/117475364 这篇文章启动了一个关闭安全检查的 chrome,希望这个小技巧也能对大家有所帮助。

附:测试用 html 页面代码

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Spring AI 流式输出示例</title>
    <style>
        * {
            box-sizing: border-box;
            margin: 0;
            padding: 0;
        }

        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            line-height: 1.6;
            color: #333;
            background-color: #f5f7fa;
            padding: 20px;
        }

        .container {
            max-width: 800px;
            margin: 0 auto;
            background: white;
            border-radius: 12px;
            box-shadow: 0 4px 极客 20px rgba(0, 0, 0, 0.1);
            overflow: hidden;
        }

        header {
            background: linear-gradient(135deg, #6e8efb, #a777e3);
            color: white;
            padding: 25px;
            text-align: center;
        }

        h1 {
           极客 font-size: 28px;
            margin-bottom: 10px;
        }

        .description {
            font-size: 16px;
            opacity: 0.9;
        }

        .chat-container {
            padding: 20px;
        }

        .极客 input-area {
            display: flex;
            gap: 10px;
            margin-bottom: 20px;
        }

        #messageInput {
            flex: 1;
            padding: 15px;
            border: 1px solid #ddd;
            border-radius: 8px;
            font-size: 16px;
            outline: none;
            transition: border-color 0.3s;
        }

        #messageInput:focus {
            border-color: #6e8efb;
        }

        #sendButton {
            padding: 15px 25px;
            background: #6极客 e8efb;
            color: white;
            border: none;
            border-radius: 8px;
            cursor: pointer;
            font-size: 16px;
            font-weight: 600;
            transition: background 0.3s;
        }

        #sendButton:hover {
            background: #5a7dfa;
        }

        #sendButton:disabled {
            background: #ccc;
            cursor: not-allowed;
        }

        .response-area {
            background: #f9fafc;
            border-radius: 8px;
            padding: 20px;
            min-height: 300px;
            max-height: 500px;
            overflow-y: auto;
            border: 1px solid #eee;
        }

        .response-content {
            white-space: pre-wrap;
            line-height: 1.8;
            font-size: 16px;
        }

        .typing-indicator {
            display: inline-block;
            background: #e6e9ff;
            padding: 8px 15px;
            border-radius: 18px;
            margin-top: 10px;
            animation: pulse 1.5s infinite;
        }

        @keyframes pulse {
            0% { opacity: 0.6; }
            50% { opacity: 1; }
            100% { opacity: 0.6; }
        }

        .message-bubble {
            background: #e6e9ff;
            padding: 12px 18px;
            border-radius: 18px;
            margin-bottom: 15px;
            max-width: 80%;
            align-self: flex-start;
        }

        .user-message {
            background: #6e8efb;
            color: white;
            align-self: flex-end;
        }

        .messages-container {
            display: flex;
            flex-direction: column;
            gap: 10极客 px;
        }
    </style>
</head>
<body>
<div class="container">
    <header>
        <h1>Spring AI 流式输出演示</h1>
        <p class="description">体验AI模型的实时流式响应效果</p>
    </header>

    <div class="chat-container">
        <div class="input-area">
            <input type="text" id="messageInput" placeholder="输入您的问题..." autocomplete="off">
            <button id="sendButton">发送</button>
        </div>

        <div class="response-area">
            <div class="messages-container" id="messagesContainer">
                <div class="message-bubble">您好!我是AI助手,请输入您的问题,我将以流式方式回复您。</div>
            </div>
        </div>
    </div>
</div>

<script>
    document.addEventListener('DOMContentLoaded', function() {
        const messageInput = document.getElementById('messageInput');
        const sendButton = document.getElementById('sendButton');
        const messagesContainer = document.getElementById('messagesContainer');

        sendButton.addEventListener('click', sendMessage);
        messageInput.addEventListener('keypress', function(e) {
            if (e.key === 'Enter') {
                sendMessage();
            }
        });

        function sendMessage() {
            const message = messageInput.value.trim();
            if (!message) return;

            // 添加用户消息
            addMessage(message, 'user-message');
            messageInput.value = '';
            sendButton.disabled = true;

            // 创建AI消息占位符
            const aiMessageElement = addMessage('', '');
            const typingIndicator = document.createElement('div');
            typingIndicator.className = 'typing-indicator';
            typingIndicator.textContent = '思考中...';
            aiMessageElement.appendChild(typingIndicator);

            // 调用流式API
            fetch(`http://localhost:10090/debug/stream_output?content=${encodeURIComponent(message)}`)
                .then(response => {
                    const reader = response.body.getReader();
                    const decoder = new TextDecoder();
                    let accumulatedText = '';

                    function read() {
                        console.log("Call read")
                        reader.read().then(({ done, value }) => {
                            console.log("Read chunk " + decoder.decode(value))
                            if (done) {
                                sendButton.disabled = false;
                                typingIndicator.remove();
                                return;
                            }

                            const text = decoder.decode(value);
                            accumulatedText += text;

                            // 移除打字指示器
                            if (typingIndicator.parentNode) {
                                typingIndicator.remove();
                            }

                            // 更新消息极客 内容
                            aiMessageElement.textContent = accumulatedText;

                            // 继续读取
                            read();
                        }).catch(error => {
                            console.error('读取流数据时出错:', error);
                            sendButton.disabled = false;
                            typingIndicator.textContent = '出错啦,请重试!';
                        });
                    }

                    read();
                })
                .catch(error => {
                    console.error('请求失败:', error);
                    sendButton.disabled = false;
                    typingIndicator.textContent = '请求失败,请重试!';
                });
        }

        function addMessage(text, className) {
            const messageElement = document.createElement('div');
            messageElement.className = `message-bubble ${className}`;
            messageElement.text极客 Content = text;
            messagesContainer.appendChild(messageElement);

            // 滚动到底部
            messages极客 Container.scrollTop = messagesContainer.scrollHeight;

            return messageElement;
        }
    });
</script>
</body>
</html>