Java WebFlux+Flux实现大模型流式打字机响应|百炼Agent流式调用实战,逐行源码拆解

哈喽各位后端小伙伴👋

现在几乎所有AI对话、智能报告单分析、AI客服场景,都不再使用一次性全量返回接口了。

大家日常使用ChatGPT、通义千问、阿里百炼时看到的**逐字输出、打字机效果**,底层本质就是大模型流式返回(Stream流式输出)

很多同学对接大模型时只会写同步接口一次性等待全部结果,不仅接口超时风险极高前端白屏等待时间过长,用户体验极差。

  • ✅ 什么是大模型流式响应?和普通同步接口区别在哪

  • ✅ Spring WebFlux + Flux实现流式返回底层原理

  • ✅ 百炼Agent流式接口完整业务代码逐行剖析

  • ✅ 流式输出文本清洗、异常兜底、全量结果归集实战

  • ✅ 线上开发踩坑避坑总结

一、先搞懂:为什么大模型一定要用流式返回?

1. 同步一次性返回(传统接口)

后端调用大模型,等待AI生成完所有内容后,一次性返回完整文本

  • AI生成长文本耗时可达5s-20s,接口极易超时

  • 前端长时间空白,用户以为服务卡死

  • 服务端一直阻塞等待,吞吐量极低

2. Flux流式分段返回(SSE协议)

后端接收大模型每一段生成的碎片文字,收到一段、向前端推送一段,前端实时拼接,实现打字机效果。

  • 无接口超时压力,长文本生成毫无压力

  • 前端实时展示内容,交互体验拉满

  • 基于WebFlux非阻塞响应式编程,服务端并发性能更强

3. 技术选型:为什么用Flux而不是List/Iterator?

Spring WebFlux提供两种响应式返回值:

  • Mono:0/1个元素,适用于单次结果返回

  • Flux:0/N个元素,持续推送多个数据流,完美适配大模型流式分片输出

二、业务场景说明

本次实战业务:后端对接阿里百炼Agent智能应用,上传体检报告单图片+文字提问,AI逐段返回报告单解读结果,前端实现实时打字机展示

核心能力:支持图文多模态输入、流式分片输出、自动清洗AI返回的Markdown代码块、异常统一兜底、全量回答内容归集入库。

三、完整可运行业务源码

先贴出完整源码,下文逐行拆解每一处关键逻辑:

java 复制代码
import com.alibaba.dashscope.app.Application;
import com.alibaba.dashscope.app.ApplicationParam;
import com.alibaba.dashscope.app.ApplicationResult;
import com.alibaba.dashscope.common.Message;
import com.alibaba.dashscope.common.Role;
import com.alibaba.dashscope.exception.InputRequiredException;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.alibaba.fastjson.JSON;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import io.reactivex.rxjava3.core.Flowable;

import java.util.Arrays;
import java.util.List;

/**
 * 调用百炼Agent大模型,流式返回AI识别结果
 * @param apiKey 百炼密钥
 * @param appId 百炼自建应用ID
 * @param question 用户提问
 * @param images 图片地址列表(多模态图文识别)
 * @return Flux<String> 流式分片文本
 */
private Flux<String> intentionRecognize(String apiKey, String appId, String question, List<String> images) {
    log.info("ReportOcrServiceImpl-intentionRecognize-开始调用百炼Agent分析报告单");
    try {
        // 用于归集完整的AI返回内容,最终入库保存全量回答
        StringBuilder sb = new StringBuilder();
        // 1. 封装用户对话消息
        Message message = Message.builder()
                .role(Role.USER.getValue())
                .content(question)
                .build();
        // 2. 组装百炼Agent调用请求参数,开启流式输出
        ApplicationParam param = ApplicationParam.builder()
                .apiKey(apiKey)
                .appId(appId)
                .messages(Arrays.asList(message))
                .incrementalOutput(true) // 核心配置:开启增量流式输出
                .images(images) // 传入图片,支持图文多模态识别
                .build();
        // 3. 创建百炼应用调用实例,发起流式调用
        Application application = new Application();
        Flowable<ApplicationResult> result = application.streamCall(param);

        // 4. 适配器转换:RxJava Flowable 转 Spring WebFlux Flux
        return Flux.create(sink -> {
            Flux.from(result)
                    .subscribe(item -> {
                        // 单次分片返回的文本内容
                        String output = item.getOutput().getText();
                        log.info("百炼流式分片原始数据:{}", JSON.toJSONString(item));
                        // 非空判断,过滤空分片数据
                        if (!StringUtils.isBlank(output)) {
                            // 关键:清洗AI自带的换行符、```json代码块标记
                            output = output.replace("\n", "").replaceAll("```json\\s*|\\s*```", "");
                            // 拼接全量结果,用于后续数据库存储
                            sb.append(output);
                        }
                        // 向前端推送当前分片内容,实现实时流式输出
                        sink.next(output);
                    }, error -> {
                        // 异常回调:调用大模型出现报错,关闭流
                        log.error("百炼Agent流式调用发生异常", error);
                        sink.complete();
                    }, () -> {
                        // 流正常结束回调:所有分片推送完毕,保存完整回答
                        log.info("ReportOcrServiceImpl-intentionRecognize-调用完成-完整回答:{}", JSON.toJSONString(sb.toString()));
                        sink.complete();
                    });
        });
    } catch (NoApiKeyException e) {
        log.error("百炼API密钥错误", JSON.toJSONString(e.getStackTrace()));
        throw new RuntimeException("大模型密钥配置异常", e);
    } catch (InputRequiredException e) {
        log.error("百炼请求入参缺失", JSON.toJSONString(e.getStackTrace()));
        throw new RuntimeException("大模型请求参数缺失", e);
    }
}

四、逐行核心代码深度拆解(必看)

1. 流式开关核心配置

java 复制代码
.incrementalOutput(true)

这是对接百炼流式接口最关键的一行代码

  • true:开启增量流式输出,大模型生成一段文字就实时返回一段

  • false:关闭流式,接口阻塞等待全部内容生成完毕再一次性返回

想要打字机效果,这个参数必须开启。

2. 多模态能力:图文一起传入大模型

java 复制代码
.images(images)

本次业务是体检报告单AI分析,需要同时传入图片+文字问题,百炼原生支持多模态入参,无需额外处理图片Base64,直接传入图片URL即可,适配图文结合的AI分析场景。

3. Flowable 适配 Flux:响应式流格式转换

java 复制代码
Flowable<ApplicationResult> result = application.streamCall(param); 
return Flux.create(sink -> { 
    Flux.from(result)
        .subscribe(...);
});

这里很多人会疑惑为什么要做转换:

  • 百炼SDK底层流式返回采用的是RxJava Flowable

  • 我们Spring WebFlux项目标准返回是Reactor Flux

  • 通过Flux.create桥接,sink作为发射器,把第三方SDK的数据流,统一适配为后端标准的WebFlux流式流

4. sink发射器:流式数据推送核心

  • sink.next(output):每收到一段AI分片,立刻推送给前端,实时展示

  • sink.complete():流正常结束/异常结束,关闭连接,释放服务端资源

5. AI返回内容清洗(业务刚需)

java 复制代码
output = output.replace("\n", "").replaceAll("```json\\s*|\\s*```", "");

实际开发中,大模型经常自动携带冗余格式:换行符、markdown的json代码块标记,直接返回前端会造成排版错乱。

这行代码统一清洗格式,保证前端拿到干净纯文本,不用前端二次处理。

6. 全量内容归集:流式展示+入库两不误

java 复制代码
StringBuilder sb = new StringBuilder();
sb.append(output);

流式输出是分片推送,前端只能实时看片段,无法拿到完整回答。

服务端通过StringBuilder全程归集所有分片,流结束后可以将完整AI回答存入数据库,满足溯源、历史记录查询业务需求。

7. 分层异常捕获

  • NoApiKeyException:百炼密钥为空/密钥错误

  • InputRequiredException:请求参数缺失、appId错误、消息体为空

  • 订阅onError:大模型服务内部报错、网络波动、限流熔断

全覆盖异常捕获,避免流式接口报错直接崩掉服务。

五、补充Controller层接口(直接对接前端)

Service层写完流式方法,Controller层需要指定SSE媒体类型,前端才能正常接收流式分片,完整接口代码如下:

java 复制代码
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;

@RestController
@RequestMapping("/ai/report")
public class AiReportController {

    @Autowired
    private ReportOcrServiceImpl reportOcrService;

    /**
     * 报告单AI分析流式接口
     * 必须指定 produces = MediaType.TEXT_EVENT_STREAM_VALUE
     * 开启SSE服务端推送协议,前端EventSource直接对接
     */
    @PostMapping(value = "/stream/analyze", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> streamAnalyzeReport(@RequestBody ReportQueryDTO dto) {
        return reportOcrService.intentionRecognize(
                dto.getApiKey(),
                dto.getAppId(),
                dto.getQuestion(),
                dto.getImages()
        );
    }
}

重点提醒 :接口必须增加 produces = MediaType.TEXT_EVENT_STREAM_VALUE,声明为SSE服务端推送流,否则前端无法接收分片数据,流式直接失效。


六、线上开发7个避坑总结(实战踩坑)

  1. 必须开启incrementalOutput=true,忘记配置直接变成同步阻塞接口,流式完全失效

  2. 一定要做文本清洗,大模型自带markdown格式符,不清洗前端排版直接乱掉

  3. 流必须主动complete,异常和正常结束都要关闭sink,否则会造成服务端连接泄露、连接堆积

  4. 区分分片内容和完整内容:sink.next推分片给前端,StringBuilder归集完整内容用于入库,各司其职

  5. 禁止在Flux内部做阻塞操作,流式接口是非阻塞响应式,不要加Thread.sleep、同步http调用

  6. 异常不能直接返回Mono.error,流式接口报错需要通过sink异常回调关闭流,而非抛出普通异常

  7. 前端必须使用EventSource对接,普通axios post无法接收流式分片,需要前端适配SSE协议

七、同步VS流式接口完整对比

对比维度 同步一次性返回(Mono) 流式分片返回(Flux)
接口响应时机 全部生成完毕才响应 生成一段,响应一段
接口超时风险 极高,长文本极易超时 无超时压力,长文本友好
前端体验 长时间空白,体验差 打字机实时输出,体验好
服务端性能 阻塞线程,并发差 非阻塞响应式,并发高
适用场景 短文本问答、简单内容生成 智能客服、文档分析、长文案生成

八、博主总结

现在后端对接大模型,流式输出已经是业务标配,不再是可选功能

本次基于阿里百炼Agent的真实业务代码,核心关键点就3个:

  1. 开启SDK原生流式增量输出开关

  2. RxJava流适配WebFlux标准Flux流

  3. 分片实时推送前端 + 全量内容归集入库,兼顾体验和数据溯源

这套代码可以直接复用在通义千问、百炼、OpenAI、本地大模型流式对接场景,逻辑通用,只需要替换对应SDK即可。