为什么需要流式输出

大模型生成文本是逐 token(词元)产生的,但传统的同步 HTTP 接口会等整个回答生成完毕才一次性返回。一个 500 字的回答,模型生成需要 8-10 秒,这段时间用户只能盯着加载动画。

流式输出的核心机制:服务器不等结果全部生成,而是每生成一个 token 就立即推送给客户端,浏览器边接收边渲染,形成"打字机"效果。

对比项同步调用SSE 流式
首字响应8-10 秒(等全部生成)0.3 秒(首个 token 即推)
用户感知长时间空白等待实时打字机效果
实现复杂度⭐ 简单⭐⭐⭐ 需处理流
超时风险高(长文易触发网关超时)低(持续有数据流)

💡 流式输出不改变模型的生成速度,只改变用户感知速度。但对体验的提升是决定性的——ChatGPT、文心一言、通义千问全部采用流式。

环境准备

测试环境:JDK 17、Spring Boot 3.2、Spring AI 1.0.0、Ollama 0.5.4,Windows 11,8GB 显存。

  • JDK 17+

  • Spring Boot 3.2+

  • Ollama 本地部署(零成本测试)

  • Maven

# 拉取本地对话模型(约 4.7GB)
ollama pull qwen2.5:7b
​
# 验证模型可用
ollama run qwen2.5:7b "你好"
<!-- pom.xml 核心:Spring AI BOM + Ollama starter -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>1.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>
​
<dependencies>
    <!-- Spring AI Ollama 集成,封装了流式调用接口 -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!-- Web Flux 提供 Flux 响应式流支持,SSE 必需 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webflux</artifactId>
    </dependency>
</dependencies>
# application.yml:配置 Ollama 连接
spring:
  ai:
    ollama:
      base-url: http://localhost:11434
      chat:
        model: qwen2.5:7b
        options:
          temperature: 0.7    # 创造性,0-1,越高越发散
          num-predict: 500    # 最大生成 token 数

方案一:传统同步调用(对比基准)

先写一个同步接口作为对比基准,理解它的缺陷:

@RestController
@RequestMapping("/api/chat")
public class ChatController {
​
    // OllamaChatModel 由 spring-ai-ollama-starter 自动注入
    private final OllamaChatModel chatModel;
​
    public ChatController(OllamaChatModel chatModel) {
        this.chatModel = chatModel;
    }
​
    /**
     * 同步调用:阻塞直到模型生成完整回答
     * 问题:500 字回答需等 8-10 秒,期间连接空转
     */
    @PostMapping("/sync")
    public Map<String, String> syncChat(@RequestBody Map<String, String> body) {
        String userMessage = body.get("message");
​
        // call() 是阻塞调用,等全部生成完才返回
        String response = chatModel.call(userMessage);
​
        return Map.of("answer", response);
    }
}

实测问题:用 curl 测试,问"解释 Java 的垃圾回收机制",10.2 秒后才返回完整答案。这段时间前端无法给用户任何反馈,体验极差。

方案二:SSE 流式输出(核心方案)

Spring AI 提供了 stream() 方法,返回 Flux<ChatResponse>——一个响应式流,每个 token 到达时触发一次推送。

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.ollama.api.OllamaChatModel;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.Map;
​
@RestController
@RequestMapping("/api/chat")
public class ChatController {
​
    private final OllamaChatModel chatModel;
​
    public ChatController(OllamaChatModel chatModel) {
        this.chatModel = chatModel;
    }
​
    /**
     * SSE 流式输出:模型每生成一个 token 就推送给前端
     *
     * 关键点:
     * 1. produces = TEXT_EVENT_STREAM_VALUE 声明 SSE 协议
     * 2. stream() 返回 Flux<ChatResponse>,非阻塞
     * 3. map() 提取每个 token 的文本内容
     */
    @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> streamChat(@RequestBody Map<String, String> body) {
        String userMessage = body.get("message");
​
        return chatModel.stream(userMessage)                          // 发起流式请求
                .map(response -> {
                    // 每条 response 包含一个 token 的增量内容
                    String chunk = response.getResult().getOutput().getText();
                    return chunk == null ? "" : chunk;                // 空 chunk 跳过
                })
                .filter(chunk -> !chunk.isEmpty())                    // 过滤空内容
                .onErrorResume(e -> Flux.just("\n[错误] " + e.getMessage())); // 异常兜底
    }
}

流式调用的数据流是这样的:

用户请求 → Spring AI → Ollama → 模型生成 token1
                                    ↓ Flux 推送
                                 浏览器渲染 token1
                            模型生成 token2
                                    ↓ Flux 推送
                                 浏览器渲染 token2
                                   (循环直到生成结束)

⚠️ 踩坑点stream() 必须配合 WebFlux 的 Flux 使用,不能返回普通 String。如果项目是纯 MVC(无 WebFlux 依赖),需要额外引入 spring-boot-starter-webflux,否则会报 No supporting bean of type WebClient 错误。

添加系统提示词控制风格

实际业务中,模型回答要符合特定角色(客服、技术助手等)。用 Prompt 对象封装系统提示词:

import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import java.util.List;
​
/**
 * 带角色设定的流式对话
 * SystemMessage 固定模型人设,每次请求复用
 */
@PostMapping(value = "/stream-with-role", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamWithRole(@RequestBody Map<String, String> body) {
    String userMessage = body.get("message");
​
    // 系统提示词:固定模型角色,不随用户输入改变
    SystemMessage system = new SystemMessage("""
            你是一个资深 Java 后端工程师助手。
            规则:
            1. 回答要简洁,直接给出代码和关键解释
            2. 不确定的内容明确说"需要进一步确认"
            3. 代码必须标注语言类型
            """);
​
    UserMessage user = new UserMessage(userMessage);
​
    // Prompt 把系统提示词和用户输入组合
    Prompt prompt = new Prompt(List.of(system, user));
​
    return chatModel.stream(prompt)
            .map(response -> {
                String chunk = response.getResult().getOutput().getText();
                return chunk == null ? "" : chunk;
            })
            .filter(chunk -> !chunk.isEmpty())
            .onErrorResume(e -> Flux.just("\n[错误] " + e.getMessage()));
}

前端对接:接收 SSE 流

后端用 SSE 推送,前端用原生 fetch + ReadableStream 接收(不需要 EventSource,因为请求是 POST):

<!-- index.html:打字机效果前端 -->
<textarea id="input" placeholder="输入问题..."></textarea>
<button onclick="ask()">提问</button>
<div id="output"></div>
​
<script>
async function ask() {
    const message = document.getElementById('input').value;
    const output = document.getElementById('output');
    output.textContent = '';  // 清空上次结果
​
    // 用 fetch 发起 POST 请求,手动读取流式响应
    const response = await fetch('/api/chat/stream', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ message: message })
    });
​
    // 获取可读流的 reader,逐块读取
    const reader = response.body.getReader();
    const decoder = new TextDecoder('utf-8');
​
    while (true) {
        const { done, value } = await reader.read();
        if (done) break;
​
        // SSE 格式:每个数据块以 "data:" 开头
        const text = decoder.decode(value, { stream: true });
        // 解析出实际内容,拼接到输出区,形成打字机效果
        const lines = text.split('\n');
        for (const line of lines) {
            if (line.startsWith('data:')) {
                output.textContent += line.slice(5);  // 去掉 "data:" 前缀
            }
        }
    }
}
</script>

💡 为什么不用 EventSource? 原生 EventSource 只支持 GET 请求,无法传 JSON body。用 fetch + ReadableStream 可以发送 POST,并手动解析 SSE 协议格式。

实测数据对比

用同一个问题"解释 Spring Bean 的生命周期"测试两种方案,qwen2.5:7b 本地模型:

指标同步 /sync流式 /stream差异
首字响应时间10.2 秒0.3 秒提升 97%
完整答案返回10.2 秒10.5 秒流式略慢(协议开销)
用户等待感知持续空白即时反馈体验质变
500 字答案 Token 数~520~520模型一致

💡 测试环境:JDK 17 + Spring Boot 3.2 + Spring AI 1.0.0 + Ollama qwen2.5:7b,Windows 11,RTX 4060 8GB 显存。每个指标实测 5 次取中位数。

关键结论:流式的总耗时几乎相同,但首字响应从 10.2 秒降到 0.3 秒,提升 97%。用户在 0.3 秒后就开始看到答案逐字出现,不再有"卡死"感。

生产环境注意事项

1. 连接中断处理

用户可能中途关闭页面,流被中断。WebFlux 会自动清理,但建议加超时控制:

import java.time.Duration;
​
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestBody Map<String, String> body) {
    String userMessage = body.get("message");
​
    return chatModel.stream(userMessage)
            .map(response -> response.getResult().getOutput().getText() == null
                    ? "" : response.getResult().getOutput().getText())
            .filter(chunk -> !chunk.isEmpty())
            .timeout(Duration.ofSeconds(60))                          // 单 token 间隔超 60s 则中断
            .onErrorResume(e -> Flux.just("\n[超时或中断] " + e.getMessage()));
}

2. 并发与资源

Ollama 本地部署默认串行处理请求,并发时会排队。生产环境用 OpenAI/通义等云端 API 无此限制。

部署方式并发能力成本
Ollama 本地串行,1 个请求排队免费(需 GPU)
OpenAI API高并发按 token 计费
通义/文心 API高并发按量计费

3. Nginx 反向代理配置

生产环境通常前置 Nginx,默认会缓冲响应,导致 SSE 失效。必须关闭缓冲:

# nginx.conf:SSE 必须关闭 proxy_buffering
location /api/chat/stream {
    proxy_pass http://backend:8080;
    proxy_buffering off;              # 关键:关闭缓冲,否则 token 会攒一批才发
    proxy_cache off;                  # 关闭缓存
    proxy_set_header Connection '';   # 清除 Connection 头,允许长连接
    proxy_http_version 1.1;           # SSE 需要 HTTP/1.1
    chunked_transfer_encoding on;     # 允许分块传输
}

⚠️ 高频踩坑:忘了关 proxy_buffering 是 SSE 最常见的故障——前端表现为"卡几秒后一次性出现一大段文字",失去打字机效果。

常见问题

Q: 报错 No supporting bean of type WebClient

A: 项目缺少 WebFlux 依赖。Spring AI 的 stream() 依赖响应式编程,必须在 pom.xml 加入 spring-boot-starter-webflux

Q: 前端收到的数据是一块一块的,不是逐字?

A: 两个原因:① 中间有 Nginx 没关 proxy_buffering;② 前端 reader.read() 读到的块可能包含多个 token,需要按 data: 分行解析(见前端代码示例)。

Q: 流式调用和上一篇 Token 缓存能结合吗?

A: 可以。缓存命中时直接返回完整答案(走同步接口),未命中时走流式。两者不冲突,可以按场景选择接口。

Q: 为什么我的首字响应还是几秒?

A: 检查模型加载状态。Ollama 首次调用某模型时需要加载到显存,耗时较长(7B 模型约 5 秒)。预热后首次请求就快了:启动后先发一个空请求 ollama run qwen2.5:7b "" 预加载。

总结

  • 流式输出的本质:模型边生成边推送,不改变总速度,只改变首字响应时间(10.2 秒 → 0.3 秒,提升 97%)

  • Spring AI 实现核心chatModel.stream() 返回 Flux<ChatResponse>,配合 TEXT_EVENT_STREAM_VALUE 产出 SSE

  • 前端接收:用 fetch + ReadableStream 手动解析(EventSource 不支持 POST)

  • 生产必做:Nginx 关闭 proxy_buffering,否则流式失效

  • 本地开发用 Ollama 零成本,生产用云端 API 支持高并发

用同一个问题实测,同步调用让用户干等 10 秒,流式让用户 0.3 秒就看到答案。这就是为什么所有主流 AI 产品都采用流式——成本几乎为零,体验提升是质变的。

你的项目里大模型调用是同步还是流式?如果还是同步,最可能卡在哪一步?欢迎评论区交流 👇

参考

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐