SSE 流式响应 Chunk 被截断问题的排查与修复

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")

每个方法的改动点:

  1. try 块内新增 LineBuffer lineBuffer = new LineBuffer();
  2. .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("系统走神了,请稍后再试..."));
        }
相关推荐
互联网推荐官3 小时前
大模型应用开发的上下文工程与推理链路深度拆解
大数据·运维·人工智能
CCC:CarCrazeCurator3 小时前
自动驾驶泊车全面解析
人工智能·机器学习·自动驾驶
ZEGO即构3 小时前
AI教育重构教与学:RTC+AI如何赋能全学段教学场景?
人工智能·实时音视频·ai教育
我命由我123453 小时前
Java 开发 - CountDownLatch 不需要手动关闭
android·java·开发语言·jvm·kotlin·android studio·android-studio
小研说技术3 小时前
结构化输出让Agent返回可预测的格式数据
java·人工智能
Coremail邮件安全3 小时前
邮安实战,攻防有道|2026教育邮件安全实战闭门会在成都举行
人工智能
ECT-OS-JiuHuaShan3 小时前
整体论体系定理,全球开放,无法绕过
人工智能·科技·学习·算法·生活
云智慧AIOps社区3 小时前
云智慧亮相第二十八届智能体驱动的GOPS全球运维大会2026 · 深圳站!以运维智能体 Castrel AI (SRE Agent)保障系统稳定可靠!
运维·人工智能·ai agent·运维自动化·sre 智能体
Raink老师3 小时前
【AI面试临阵磨枪-27】CoT、ToT、Plan-and-Solve、Plan-and-Execute 分别是什么?适用场景?
人工智能·ai 面试