近期DeepSeek等国产大模型热度持续攀升,其关注度甚至超过了OpenAI(被戏称为CloseAI)。在SpringBoot3.x环境中,可以使用官方的Spring AI轻松接入,但对于仍在使用JDK8SpringBoot2.7.3的企业级应用来说,往往需要自定义实现。特别是当大模型团队返回的数据格式不符合标准SSE规范时,更需要灵活处理。本文将分享我们的实战解决方案。


引入Gradle依赖

核心依赖说明:

  • spring-boot-starter-web:基础Web支持
  • spring-boot-starter-webflux:响应式编程支持(WebClient所在模块)
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-webflux'

WebClient配置要点

初始化时特别注意Header配置:

@Bean
public WebClient init() {
return WebClient.builder()
.baseUrl(baseUrl)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + openAi)
// ️ 必须设置为JSON格式
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build();
}

关键踩坑点:初始设置MediaType.TEXT_EVENT_STREAM_VALUE会导致请求失败,必须使用APPLICATION_JSON_VALUE


核心处理逻辑

流式请求入口

@GetMapping(value = "/stream/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChatEnhanced(@RequestParam("prompt") String prompt) {
// 请求体构建
String requestBody = String.format("""
{
"model": "%s",
"messages": [{"role": "user", "content": "%s"}],
"stream": true
}
""", model, prompt); return webClient.post()
// 请求配置
.uri("/v1/chat/completions")
.bodyValue(requestBody)
.accept(MediaType.TEXT_EVENT_STREAM)
.retrieve()
.bodyToFlux(DataBuffer.class) // 关键配置点
.transform(this::processStream)
// 重试和超时配置
.retryWhen(Retry.backoff(3, Duration.ofSeconds(1)))
.timeout(Duration.ofSeconds(180));
// 错误处理
.doOnError(e -> log.error("Stream error", e))
.doFinally(signal -> log.info("Stream completed: {}", signal));
}

技术原理说明

当使用bodyToFlux(DataBuffer.class)时:

  • 获得原始字节流控制权
  • 避免自动SSE格式解析(适用于非标准响应)
  • 动态数据流处理:类似Java Stream,但数据持续追加

非标准SSE数据处理

核心处理流程

private Flux<String> processStream(Flux<DataBuffer> dataBufferFlux) {
return dataBufferFlux
.transform(DataBufferUtils::join) // 字节流合并
.map(buffer -> { // 字节转字符串
String content = buffer.toString(StandardCharsets.UTF_8);
DataBufferUtils.release(buffer);
return content;
})
.flatMap(content -> // 处理粘包问题
Flux.fromArray(content.split("\\r?\\n\\r?\\n")))
.filter(event -> !event.trim().isEmpty()) // 过滤空事件
.map(event -> { // 格式标准化处理
String trimmed = event.trim();
if (trimmed.startsWith("data:")) {
String substring = trimmed.substring(5);
return substring.startsWith(" ") ? substring.substring(1) : substring;
}
return trimmed;
})
.filter(event -> !event.startsWith("data:")); // 二次过滤
}

三大关键技术点

  1. 粘包处理

    通过split("\\r?\\n\\r?\\n")解决网络传输中的消息边界问题,示例原始数据:

    data:{response1}\n\ndata:{response2}\n\n
  2. 格式兼容处理

    自动去除服务端可能返回的data:前缀,同时保留Spring自动添加SSE前缀的能力

  3. 双重过滤机制

    确保最终输出不包含任何残留的SSE格式标识


️ 特别注意

当接口设置produces = MediaType.TEXT_EVENT_STREAM_VALUE时:

  • Spring WebFlux会自动添加data: 前缀

  • 前端收到的格式示例:

    data: {实际内容}
  • 若手动添加

    data:

    前缀会导致重复:

    data: data: {错误内容}  //  错误格式

️ 完整实现代码

// 包声明和导入...

@Service
@Slf4j
public class OpenAiService {
// 配置项和初始化
private String openAiApiKey = "sk-xxxxxx"; private String baseUrl = "https://openai.com/xxxx"; private String model = "gpt-4o"; private WebClient webClient; @PostConstruct
public void init() {
webClient = WebClient.builder()
.baseUrl(baseUrl)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + openAiApiKey)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build();
} @GetMapping(value = "/stream/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChatEnhanced(@RequestParam("prompt") String prompt) {
// 构建请求体
String requestBody = String.format("""
{
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "%s"}],
"stream": true
}
""", prompt); // 发送流式请求
return webClient.post()
.uri("/v1/chat/completions")
.bodyValue(requestBody)
.retrieve()
.onStatus(HttpStatusCode::isError, response ->
response.bodyToMono(String.class)
.flatMap(error -> Mono.error(new RuntimeException("API Error: " + error)))
)
.bodyToFlux(DataBuffer.class)
.transform(this::processStream)
.retryWhen(Retry.backoff(3, Duration.ofSeconds(1)))
.timeout(Duration.ofSeconds(180))
.doOnError(e -> log.error("Stream error", e))
.doFinally(signal -> log.info("Stream completed: {}", signal));
} private Flux<String> processStream(Flux<DataBuffer> dataBufferFlux) {
return dataBufferFlux
// 使用字节流处理
.transform(DataBufferUtils::join)
.map(buffer -> {
String content = buffer.toString(StandardCharsets.UTF_8);
DataBufferUtils.release(buffer);
return content;
})
// 按 SSE 事件边界,防止粘包的问题
.flatMap(content -> Flux.fromArray(content.split("\\r?\\n\\r?\\n")))
// 过滤空事件
.filter(event -> !event.trim().isEmpty())
// 规范 SSE 事件格式
.map(event -> {
String trimmed = event.trim(); // 由于webflux设置了"produces = MediaType.TEXT_EVENT_STREAM_VALUE",
// 所以在返回数据时会自动添加“data:”,因此如果返回的格式带了“data:”需要手动去除
if (trimmed.startsWith("data:")) {
trimmed = trimmed.replaceFirst("data:","").trim();
}
return trimmed;
})
.filter(event -> !event.startsWith("data:"));
}
}

SpringBoot 2.x 接入非标准SSE格式大模型流式响应实践 🚀的更多相关文章

  1. SQL 将非标准日期格式转换成标准格式,进行条件判断

    a.JLDate为非标准日期格式: 例: 2011-8-28 0:00:000011-8-28 0:00:000111-8-4 0:00:00 select CONVERT(varchar(50),C ...

  2. 关于非标准json格式转变为json对象

    eval('(' + tempData + ')') 只需要这一句

  3. 用 #include “filename.h” 格式来引用非标准库的头文件

    用 #include “filename.h” 格式来引用非标准库的头文件(编译器将 从用户的工作目录开始搜索) #include <iostream> /* run this progr ...

  4. 3.非标准的NDEF格式数据解析--IsoDep

    1.使用目的:正常开发是针对NDEF格式数据进行开发,但实际情况并非如此,以厦门公交卡为例,厦门公交卡保存的是非NDEF格式数据.其类型是IsoDep类型. 2.非标准的NDEF格式数据流程:当厦门公 ...

  5. IEEE754标准浮点格式

    两种基本浮点格式:单精度和双精度.IEEE单精度格式具有24位有效数字,并总共占用32 位.IEEE双精度格式具有53位有效数字精度,并总共占用64位 两种扩展浮点格式:单精度扩展和双精度扩展.此标准 ...

  6. Linux BSP非标准HDMI分辨率

    Linux BSP非标准HDMI分辨率 Intrinsyc公司发布了它的一个新的Linux BSP软件的发布 打开-Q820 开发套件基于Linux内核版本.支持的软件功能包括HDMI输出,可以支持标 ...

  7. [FFMpeg] 非标准分辨率视频Dump YUV注意事项

    背景 做视频编解码相关开发的过程中我们经常会遇到要把视频原始YUV数据保存下来查看的情况. 使用FFMpeg对视频解码之后原始图片数据都是保存在AVFrame这一结构中,很多时候我们都按照图像的长宽来 ...

  8. Python解析非标准JSON(Key值非字符串)

    采集数据的时候经常碰到一些JSON数据的Key值不是字符串,这些数据在JavaScript的上下文中是可以解析的,但在Python中,没有该部分数据的上下文,无法采用json.loads(JSON)的 ...

  9. 标准 DateTime 格式字符串

    标准 DateTime 格式字符串 MSDN 标准 DateTime 格式字符串包含一个标准 DateTime 格式说明符字符,该字符表示自定义 DateTime 格式字符串.格式字符串最终定义由格式 ...

  10. [Effective JavaScript 笔记]第29条:避免使用非标准的栈检查属性

    许多js环境都提供检查调用栈的功能.调用栈是指当前正在执行的活动函数链.在某些旧的宿主环境中,每个arguments对象含有两个额外的属性:arguments.callee和arguments.cal ...

随机推荐

  1. 07C++选择结构(1)——教学

    一.基础知识 1.关系运算符 因为我们要对条件进行判断,必然会用到关系运算符: 名称 大于 大于等于 小于 小于等于 等于 不等于 符号 > >= < <= == != 关系表 ...

  2. R数据分析:cox模型如何做预测,高分文章复现

    今天要给大家分享的文章是 Cone EB, Marchese M, Paciotti M, Nguyen DD, Nabi J, Cole AP, Molina G, Molina RL, Minam ...

  3. java.time 的纪年方式

    Date date = new Date(); Instant instant = date.toInstant(); Chronology chronology = HijrahChronology ...

  4. 【Javaweb】基础开发流程与介绍

    本文档写于2022年7月29日,由于个人水平有限,可能存在一些问题,因此仅供参考 @萌狼蓝天 JavaWeb基础开发流程 1.确定系统和功能 在此以"宠物管理系统"为例,要开发一个 ...

  5. Spring Boot中通过RabbitTemplate主动pull(get)消息的例子

    import java.util.Properties; import java.util.function.Consumer; import org.slf4j.Logger; import org ...

  6. 智谱开源CogAgent的最新模型CogAgent-9B-20241220,全面领先所有开闭源GUI Agent模型

    在现代数字世界中,图形用户界面(GUI)是人机交互的核心.然而,尽管大型语言模型(LLM)如ChatGPT在处理文本任务上表现出色,但在理解和操作GUI方面仍面临挑战,因此最近一年来,在学界和大模型社 ...

  7. [转]CLion 2019去掉灰色参数提示(parameters hints)

    众所周知,clion是一个很好用的c plus plus IDE,刚装好的clion默认的设置多少有一些不符合口味的地方,在查看代码或者敲代码的时候看到如下这样的灰色提示,我是有点受不了的: 之前用的 ...

  8. com.mysql.cj.jdbc.Driver和com.mysql.jdbc.Driver的区别

    今天写东西测试的时候发现一个问题,如下: application.yml中数据源是这样配置的: 第一反应就是记忆中连接mysql的驱动不都是com.mysql.jdbc.Driver吗?com.mys ...

  9. 巧技拾遗 | JavaScript 中 Array.every 和 Array.map 的巧妙结合

    这几天在跟着学一点 vue3 + TypeScript 中表单验证的实例,看到一个实现,觉得非常巧妙. 需求概述 我们有一个列表 funcArr ,里面存放函数,比如 funcArr = [ func ...

  10. 题解:AT_abc386_d [ABC386D] Diagonal Separation

    分析题面,发现题目求的是是否存在一个白点被 \((1, 1)\) 和任意一个黑点围成的矩形内. 先将所有黑点按 \(x\) 坐标排序. 枚举所有的白点. 找到所有横坐标不比该白点横坐标小的所有黑点的纵 ...