SpringBoot 2.x 接入非标准SSE格式大模型流式响应实践 🚀
近期DeepSeek等国产大模型热度持续攀升,其关注度甚至超过了OpenAI(被戏称为CloseAI)。在SpringBoot3.x
环境中,可以使用官方的Spring AI轻松接入,但对于仍在使用JDK8和SpringBoot2.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:")); // 二次过滤
}
三大关键技术点
粘包处理
通过split("\\r?\\n\\r?\\n")
解决网络传输中的消息边界问题,示例原始数据:data:{response1}\n\ndata:{response2}\n\n
格式兼容处理
自动去除服务端可能返回的data:
前缀,同时保留Spring自动添加SSE前缀的能力双重过滤机制
确保最终输出不包含任何残留的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格式大模型流式响应实践 🚀的更多相关文章
- SQL 将非标准日期格式转换成标准格式,进行条件判断
a.JLDate为非标准日期格式: 例: 2011-8-28 0:00:000011-8-28 0:00:000111-8-4 0:00:00 select CONVERT(varchar(50),C ...
- 关于非标准json格式转变为json对象
eval('(' + tempData + ')') 只需要这一句
- 用 #include “filename.h” 格式来引用非标准库的头文件
用 #include “filename.h” 格式来引用非标准库的头文件(编译器将 从用户的工作目录开始搜索) #include <iostream> /* run this progr ...
- 3.非标准的NDEF格式数据解析--IsoDep
1.使用目的:正常开发是针对NDEF格式数据进行开发,但实际情况并非如此,以厦门公交卡为例,厦门公交卡保存的是非NDEF格式数据.其类型是IsoDep类型. 2.非标准的NDEF格式数据流程:当厦门公 ...
- IEEE754标准浮点格式
两种基本浮点格式:单精度和双精度.IEEE单精度格式具有24位有效数字,并总共占用32 位.IEEE双精度格式具有53位有效数字精度,并总共占用64位 两种扩展浮点格式:单精度扩展和双精度扩展.此标准 ...
- Linux BSP非标准HDMI分辨率
Linux BSP非标准HDMI分辨率 Intrinsyc公司发布了它的一个新的Linux BSP软件的发布 打开-Q820 开发套件基于Linux内核版本.支持的软件功能包括HDMI输出,可以支持标 ...
- [FFMpeg] 非标准分辨率视频Dump YUV注意事项
背景 做视频编解码相关开发的过程中我们经常会遇到要把视频原始YUV数据保存下来查看的情况. 使用FFMpeg对视频解码之后原始图片数据都是保存在AVFrame这一结构中,很多时候我们都按照图像的长宽来 ...
- Python解析非标准JSON(Key值非字符串)
采集数据的时候经常碰到一些JSON数据的Key值不是字符串,这些数据在JavaScript的上下文中是可以解析的,但在Python中,没有该部分数据的上下文,无法采用json.loads(JSON)的 ...
- 标准 DateTime 格式字符串
标准 DateTime 格式字符串 MSDN 标准 DateTime 格式字符串包含一个标准 DateTime 格式说明符字符,该字符表示自定义 DateTime 格式字符串.格式字符串最终定义由格式 ...
- [Effective JavaScript 笔记]第29条:避免使用非标准的栈检查属性
许多js环境都提供检查调用栈的功能.调用栈是指当前正在执行的活动函数链.在某些旧的宿主环境中,每个arguments对象含有两个额外的属性:arguments.callee和arguments.cal ...
随机推荐
- C# Redis 的基本使用
C# Redis 的基本使用 -迷恋自留地 Redis 概述 在我们日常的开发中,无不都是使用数据库来进行数据的存储,由于一般的系统任务中通常不会存在高并发的情况,所以这样看起来并没有什么问题,可是一 ...
- OS之《内存管理》
程序装入方式 绝对装入:程序逻辑地址和物理地址是完全对应的.不现实 可重定位装入:装入的时候重新 计算内存地址.程序中的实际地址加上程序载入的起始地址:但是解决不了进程挂起 后重新唤醒的问题.唤醒的后 ...
- 解读GaussDB的BTree索引和UBTree索引,如何带来更强并发能力
本文分享自华为云社区<[GaussTech技术专栏]GaussDB的BTree索引和UBTree索引>,作者:GaussDB 数据库. 1. 简介 数据库通常使用索引来提高业务查询的速度. ...
- ruoyi若依前端验证码不显示的终极解决方法-20230721
搞了3天啊,查了各种资料啊. 然后使劲的看log啊,总算搞定了啊. 一般情况,本地开发环境测试没问题,部署到服务器就各种不适应,就是服务器配置的问题了. 本次这种验证码不显示,典型的nginx的配置 ...
- 05C++数据类型
一.单精度实数float 教学视频A 例程1:金字塔的底是正方形,侧面由四个大小相等的等腰三角形构成.试编一程序,输入底和高,输出三角形的面积. #include <iostream> / ...
- 一个.NET开源、易于使用的屏幕录制工具
前言 一款高效.易用的屏幕录制工具能够极大地提升我们的工作效率和用户体验,今天大姚给大家分享一个.NET开源.免费.易于使用的屏幕录制工具:Captura. 工具介绍 Captura是一款基于.NET ...
- Qt音视频开发06-海康sdk内核linux客户端
一.前言 海康sdk的示例在官方是提供了的,但是无论UI还是交互简直是宇宙无敌的垃圾,猜测应该是初学者编写的,估计练手用的,所以老早就想把这个linux支持集成到自己的示例中,既然已经支持了windo ...
- 微信团队分享:微信后端海量数据查询从1000ms降到100ms的技术实践
本文由微信技术团队仇弈彬分享,原题"微信海量数据查询如何从1000ms降到100ms?",本文进行了内容修订和排版优化. 1.引言 微信的多维指标监控平台,具备自定义维度.指标的监 ...
- 小程序IOS系统input设置maxlength时,输入到最后如果输入汉字的拼音长度超过限制会直接中断输入(bug bug)
我的解决办法:不在输入框限制长度,在提交表单的时候判断长度,欢迎大家有好的解决方法分享一下
- 网站架构核心技disruptor
一 序:本章业务场景:队列在数据结构中是一种线性表,从一端插入数据,然后从另一端删除数据.作者举例的场景有:进行异步处理.系统解耦.数据同步.流量削峰.缓冲.限流等. 前面的比较浅,总结起来,核心知识 ...