SSE 流式响应 Chunk 被截断问题的排查与修复
一、问题现象
在对接大模型(通义千问 Qwen3-30B)的 SSE 流式接口时,前端偶尔收到不完整的 JSON chunk 。例如,一个完整的 chat.completion.chunk 响应被拆成了两段:
第一段(截断):
json
{"id":"a69ac2a5d9844bf98f311e2aa2051ccc","object":"chat.completion.chunk","created":1776931631,"model":"qwen3-30b-a3b-apcgjkoa","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"logprobs":null,"finish_reason":"stop","matched_stop":null}],"usage":{"prompt_tokens":898,"total_tokens":1846,"
第二段(续接):
json
completion_tokens":948,"prompt_tokens_details":null}}
这显然不是一个合法的 JSON,导致前端解析失败。
二、问题定位:是大模型返回的还是后端代码导致的?
2.1 排除大模型侧
大模型的 SSE 协议规范是:每个事件以 data: 开头,以 \n 结尾。一个完整的 chunk 一定是一整行 ,中间不会插入换行符。从截断的位置来看("total_tokens":1846," 后直接截断),这不是大模型主动拆分的,因为 JSON 字段中间不可能有 \n。
2.2 锁定后端代码
后端使用 WebClient + DataBuffer 接收流式数据,关键代码如下:
java
.bodyToFlux(DataBuffer.class) // ① 以 DataBuffer 粒度接收
.map(buffer -> buffer.toString(UTF_8)) // ② 每个 buffer 转字符串
.flatMap(chunk -> Flux.fromArray(chunk.split("\n"))) // ③ 按换行符拆分
.filter(line -> !line.trim().isEmpty()) // ④ 过滤空行
问题出在第 ③ 步。
三、根因分析
3.1 DataBuffer 的边界与 SSE 行边界不对齐
HTTP 响应是通过 TCP 流传输的,底层会将数据切分成任意大小 的 DataBuffer。一个 DataBuffer 可能包含:
- 0.5 行(不完整)
- 1 行(恰好对齐)
- 1.5 行(跨行)
- 多行
而 SSE 协议的语义单位是行 (以 \n 分隔),DataBuffer 的边界与行的边界没有任何保证。
3.2 具体拆分过程
假设大模型返回了一行完整的 SSE 数据:
data: {"id":"xxx",...,"total_tokens":1846,"completion_tokens":948,...}\n
但 TCP 传输时被切分成了两个 DataBuffer:
| Buffer | 内容 |
|---|---|
| Buffer1 | data: {"id":"xxx",...,"total_tokens":1846," |
| Buffer2 | "completion_tokens":948,...}\n |
旧代码对每个 Buffer 独立 执行 split("\n"):
| Buffer | split("\n") 结果 |
|---|---|
| Buffer1 | ["data: {\"id\":\"xxx\",...,\"total_tokens\":1846,\""] --- 不含 \n,整体作为一行 |
| Buffer2 | ["\"completion_tokens\":948,...}",""] --- 遇到 \n,拆成两段 |
结果:原本完整的 JSON 行被错误地拆成了两个不完整的片段,分别作为独立行传递给下游处理。
3.3 为什么不是必现的?
是否截断取决于 DataBuffer 的大小与 SSE 行大小是否恰好"错位"。在网络波动、响应体大小变化时,截断概率不同,因此表现为偶现。
四、修复方案
4.1 核心思路:行缓冲器
不再对每个 DataBuffer 独立按 \n 拆分,而是累积缓冲,直到遇到完整的换行符再输出一行。
4.2 实现:LineBuffer 内部类
java
/**
* 行缓冲器:累积不完整的行,直到遇到换行符
*/
private static class LineBuffer {
private final StringBuilder sb = new StringBuilder();
public Flux<String> push(String chunk) {
sb.append(chunk);
String content = sb.toString();
int newlineIndex;
Flux<String> result = Flux.empty();
while ((newlineIndex = content.indexOf('\n')) != -1) {
String line = content.substring(0, newlineIndex);
content = content.substring(newlineIndex + 1);
result = Flux.concat(result, Flux.just(line));
}
sb.setLength(0);
sb.append(content); // 不完整的尾部继续暂存
return result;
}
public Flux<String> flush() {
String remaining = sb.toString();
sb.setLength(0);
if (!remaining.isEmpty()) {
return Flux.just(remaining);
}
return Flux.empty();
}
}
4.3 修复后的流程
java
LineBuffer lineBuffer = new LineBuffer();
// ...
.bodyToFlux(DataBuffer.class)
.map(buffer -> buffer.toString(UTF_8))
.flatMap(chunk -> lineBuffer.push(chunk)) // 替换原来的 split("\n")
.filter(line -> !line.trim().isEmpty())
修复后的处理过程:
| 步骤 | Buffer1 到达 | Buffer2 到达 |
|---|---|---|
| push | sb = "...\"total_tokens\":1846,\"" → 无 \n → 返回 Flux.empty() |
sb = "...\"total_tokens\":1846,\"completion_tokens\":948,...}\n" → 有 \n → 输出完整行 |
| 结果 | 数据暂存,不输出 | 输出完整的 JSON 行 |
五、修改清单
共修改 3 个流式方法,所有改动一致:
| 方法 | 用途 | 改动 |
|---|---|---|
sendPostServerSentEventByStream |
PPT 流式生成 | 新增 LineBuffer,替换 split("\n") |
sendPostByBaiLianStream |
百炼平台流式对话 | 新增 LineBuffer,替换 split("\n") |
sendPostByStream |
通用流式请求 | 新增 LineBuffer,替换 split("\n") |
每个方法的改动点:
- 在
try块内新增LineBuffer lineBuffer = new LineBuffer(); - 将
.flatMap(chunk -> Flux.fromArray(chunk.split("\n")))替换为.flatMap(chunk -> lineBuffer.push(chunk))
六、扩展思考
6.1 为什么不直接用 WebClient 的 SSE 解码器?
WebClient 提供了 .bodyToFlux(new ParameterizedTypeReference<ServerSentEvent<String>>() {}) 可以自动解析 SSE 协议。但本项目需要在 DataBuffer 层面做自定义处理(如注入 id、提取 content 拼接 responseBody 等),因此选择了手动解析。
6.2 LineBuffer 的线程安全性
每个 Flux 链路创建独立的 LineBuffer 实例(方法内局部变量),Reactor 的 Flux 默认在单线程上串行处理(除非显式切换调度器),因此 StringBuilder 不存在并发问题。
6.3 flush() 方法的使用
flush() 用于在流结束时输出缓冲区中可能残留的最后一行(不以 \n 结尾的数据)。在当前场景中,SSE 协议保证每行以 \n 结尾,因此 flush() 主要作为防御性编程的手段。如需启用,可在 .doOnComplete() 之前追加:
java
.concatWith(lineBuffer.flush())
七、总结
| 旧方案 | 新方案 | |
|---|---|---|
| 拆分策略 | 每个 DataBuffer 独立 split("\n") |
LineBuffer 跨 Buffer 累积,按 \n 边界输出 |
| 行完整性 | ❌ 不保证 | ✅ 保证 |
| JSON 截断 | 偶现 | 不会发生 |
| 改动量 | - | 每个方法 +2 行 |
核心教训:在处理流式文本协议(SSE、NDJSON 等)时,永远不要假设网络传输的数据块边界与协议消息边界对齐。必须实现行级缓冲,确保在协议语义上完整地处理每一条消息。
示例完整的流式:
java
// 在需要序列化的地方替换
String jsonStr = objectMapper.writeValueAsString(requestBody);
log.info("----BaiLian=---发送POST请求--流式请求的Body----{}", jsonStr);
if (!modelUrl.contains("/chat/completions")) {
modelUrl += "/chat/completions";
}
// 使用线程安全的行缓冲器
LineBuffer lineBuffer = new LineBuffer();
return WebClient.builder()
.baseUrl(modelUrl)
.clientConnector(new ReactorClientHttpConnector(httpClient))
// ⚠️ 不再需要设置 maxInMemorySize!
.build()
.post()
.headers(httpHeaders -> httpHeaders.putAll(headers))
.bodyValue(Objects.requireNonNull(jsonStr))
.acceptCharset(StandardCharsets.UTF_8)
.retrieve()
// 👇 关键:改用 DataBuffer 流
.bodyToFlux(DataBuffer.class)
.map(buffer -> {
try {
// 将当前 buffer 转为字符串(只处理这一小块)
return buffer.toString(StandardCharsets.UTF_8);
} finally {
DataBufferUtils.release(buffer); // 必须释放!
}
})
// 👇 使用行缓冲器累积不完整的行
.flatMap(chunk -> lineBuffer.push(chunk))
// 过滤空行
.filter(line -> !line.trim().isEmpty())
// 👇 以下是你原有的业务逻辑,逐行处理
.doOnNext(line -> {
if (!line.contains("[DONE]")) {
lastChunkRef.set(line);
}
})
.map(line -> {
String newResult = line.replaceAll("^data:\\s*", ""); // 更安全地移除开头的 "data:"
// 注入 id
if (newResult.contains("true")) {
newResult = newResult.replace("true", "true, \"id\": " + finalId);
}
// 提取 content 并拼接到 responseBody
if (responseBody != null && !newResult.equals("[DONE]")) {
try {
var jsonObj = com.alibaba.fastjson2.JSONObject.parseObject(newResult);
var choices = jsonObj.getJSONArray("choices");
if (choices != null && !choices.isEmpty()) {
var delta = choices.getJSONObject(0).getJSONObject("delta");
if (delta != null) {
String valueS = delta.getString("content");
if (StrUtil.isNotBlank(valueS)) {
responseBody.append(valueS);
}
}
}
} catch (Exception e) {
// 日志记录更好,但保留你的静默处理
}
}
return newResult;
})
.doOnComplete(() -> {
String lastChunk = lastChunkRef.get();
threadPoolTaskExecutor.execute(() -> {
if (lastChunk != null) {
// 记录最后一个chunk,返回的是携带模型调用token消耗信息
handleLastChunk(lastChunk, finalId, finalLoginUser, finalSelectModel, smartId);
}
// 采集好最终的内容,使用funcation回调调用方法增强业务记录日志
funcation.apply(responseBody.toString());
});
});
} catch (Exception e) {
log.error("BaiLian=---发送POST请求--流式请求异常--{}", e.getMessage(), e);
return Flux.error(new ServiceException("系统走神了,请稍后再试..."));
}