LLM 流式调用工具类 LlmSseHelper解析

0 阅读6分钟

项目地址

前端:github.com/seapack-hub…

后端:github.com/seapack-hub…

LlmSseHelper类是整个 LLM 流式调用的底层通信层,负责 HTTP 连接管理、SSE 协议解析、流式/同步两种调用模式。下面从架构设计、核心方法、内部类三个维度做深度分析。

一、整体架构定位

┌─────────────────────────────────────────────────┐
│  业务层(ChatService / AgentService / ...)      │
│  调用 createConnection() + readChunks()          │
│  通过 Consumer<Chunk> 回调处理每个数据块          │
└──────────────────────┬──────────────────────────┘
                       │ 依赖
┌──────────────────────▼──────────────────────────┐
│  LlmSseHelper(本类)                            │
│  ┌────────────────┐ ┌──────────┐ ┌────────────┐ │
│  │createConnection│ │readChunks│ │  callSync  │ │
│  │  建连+发请求    │ │ 流式读取  │ │ 同步调用   │ │
│  └────────────────┘ └──────────┘ └────────────┘ │
│  ┌────────────────┐ ┌──────────────────────────┐│
│  │   parseChunk   │ │        Chunk (内部类)    ││
│  │  解析SSE数据块  │ │  封装delta/usage/done    ││
│  └────────────────┘ └──────────────────────────┘│
└─────────────────────────────────────────────────┘
                       │
                       ▼
              LLM APIOpenAI 兼容)

设计意图:将 HTTP 通信、SSE 协议解析、连接管理 等横切关注点从业务代码中抽离,业务层只需关心"拿到 chunk 后做什么",不用关心底层协议细节。

二、方法深度分析

(一) 建立连接与请求放松

public HttpURLConnection createConnection(String url, String apiKey, Map<String, Object> requestBody) throws Exception {

    // 创建连接
    HttpURLConnection connection = (HttpURLConnection) URI.create(url).toURL().openConnection();
    //设置请求方式为POST
    connection.setRequestMethod("POST");
    connection.setRequestProperty("Content-Type", "application/json");
    //设置APIKey
    connection.setRequestProperty("Authorization", "Bearer " + apiKey);
    connection.setDoOutput(true);
    //超时连接时长30秒
    connection.setConnectTimeout(30000);
    //读取时长5分钟
    connection.setReadTimeout(300000); // 5 分钟读取超时

    byte[] body = objectMapper.writeValueAsBytes(requestBody);
    try (OutputStream os = connection.getOutputStream()) {
        os.write(body);
        os.flush();
    }
    return connection;
}
关注点分析
连接超时 30s合理。建连阶段主要受 DNS 解析和网络延迟影响,30s 足够覆盖绝大多数场景
读取超时 5min偏大但合理。流式场景下模型生成复杂内容时,两个 chunk 之间可能有较长间隔(如思考型模型的推理阶段),5min 能避免误断
setDoOutput(true)必须设置,否则 getOutputStream()会抛异常。这是 Java HTTP 客户端的一个"坑"
os.flush()确保请求体数据全部写入底层 socket 缓冲区,避免数据滞留
连接生命周期连接创建后不在本方法内关闭,由调用方负责关闭(readChunks 结束后 disconnect()),这是正确的资源管理模式

(二) SSE 流式读取 —— readChunks()

SSE协议解析逻辑

SSE(Server-Sent Events)的标准格式为:

data: {"choices": [{"delta": {"content": "你好"}}]}

data: {"choices": [{"delta": {"content": "世界"}}]}

data: [DONE]

本方法的解析策略是**逐行读取 + 匹配 data: 前缀**,这是 SSE 协议最简化的解析方式。

流初始化
try (BufferedReader reader = new BufferedReader(
        new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {

为什么需要三层包装?

原始字节流(byte)
    ↓ InputStreamReader(byte → char,指定编码)
字符流(char)
    ↓ BufferedReader(加缓冲区 + readLine)
按行读取(String
  1. InputStream 只能按字节读,无法识别"行"的概念
  2. InputStreamReader 解决了**编码问题,但仍需逐字符读取**
  3. BufferedReader 内部维护了一个默认 8KB 的字符缓冲区,readLine() 从缓冲区中查找换行符 \n,避免了频繁的系统调用,性能远优于逐字符读取
层级对象作用
最内层conn.getInputStream()获取 HTTP 响应的原始字节输入流。此时 HTTP 200 已确认,流中包含 SSE 格式的文本数据
中间层new InputStreamReader(..., StandardCharsets.UTF_8)字节流 → 字符流的桥接器。指定 UTF-8 编码,确保中文等多字节字符正确解码
最外层new BufferedReader(...)为字符流添加缓冲区,并提供 readLine()
方法,可以按行读取
主循环
String line;
while ((line = reader.readLine()) != null) {

readLine() 的阻塞特性

  1. readLine() 是阻塞调用:如果服务端还没有发送下一行数据,当前线程会一直挂起等待,直到:
  • 收到一行数据(返回该行字符串)
  • 流结束(返回 null)
  • 触发 readTimeout(抛出 SocketTimeoutException)
  1. 这意味着取消检查(cancelFlag)不是即时的——在 readLine() 阻塞期间,即使外部将 cancelFlag 设为 true,也要等当前行返回后才能检测到

SSE 协议中的空行: SSE 协议用空行(\n\n)分隔不同的事件。readLine() 读到空行时返回空字符串 "",不会匹配line.startsWith("data:"),自然被跳过。这是正确的行为。

if (line.startsWith("data:")) {
    String data = line.substring(5).trim();
    //空数据和结束标记处理
    if (data.isEmpty()) {
      continue;
    }
  if ("[DONE]".equals(data)) {
    log.info("LLM 流式响应收到 [DONE],结束读取");
    //构造一个 done=true 的特殊 Chunk
    Chunk doneChunk = new Chunk();
    doneChunk.setDone(true);
    //通过 onChunk.accept() 回调通知业务层
    onChunk.accept(doneChunk);
    break;
  }

:::info

  1. line.startsWith("data:"):匹配 SSE 的数据行前缀
  2. line.substring(5):截取 "data:" 之后的内容(跳过 5 个字符:d-a-t-a-:)
  3. .trim():去除前后空白字符,兼容 "data: {...}" (有空格) 和 "data:{...}" (无空格) 两种格式
  4. 构造一个 done=true 的特殊 Chunk
  5. 通过 onChunk.accept() 回调通知业务层(业务层收到 done=true 后可以执行收尾操作,如关闭前端 SSE 连接)
  6. break 跳出循环

:::

取消机制
if (cancelFlag != null && cancelFlag.get()) {
    break;
}

每读完一行就检查一次取消标志

使用 AtomicBoolean 保证跨线程可见性,break 后 BufferedReader 会在 try-with-resources 中自动关闭,进而关闭底层输入流

错误处理分层
public void readChunks(HttpURLConnection conn, AtomicBoolean cancelFlag, Consumer<Chunk> onChunk) throws Exception {
        // 先检查 HTTP 响应码,非 200 时读取错误流并抛出异常
        int responseCode = conn.getResponseCode();
        if (responseCode != 200) {
            String errorBody = new String(
                    conn.getErrorStream() != null
                            ? conn.getErrorStream().readAllBytes()
                            : new byte[0],
                    StandardCharsets.UTF_8);
            throw new RuntimeException("LLM API 返回错误: HTTP " + responseCode + ", body=" + errorBody);
        }
        ...
        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
            String line;
            while ((line = reader.readLine()) != null) {
                ...
                // 兼容 "data: {...}" 与 "data:{...}"(无空格)两种格式
                if (line.startsWith("data:")) {
                    ...
                    // 检测流式响应中的错误(OpenAI 兼容格式:data: {"error": {...}})
                    if (chunk.containsKey("error")) {
                        throw new RuntimeException("LLM API 流式响应包含错误: " + data);
                    }
                    try {
                       ...
                    } catch (RuntimeException re) {
                        throw re;
                    } catch (Exception e) {
                        log.warn("解析 LLM 响应块失败: {}", e.getMessage());
                    }
                }
            }
        }
        log.info("LLM 流式响应读取结束: 有效chunk数={}, 耗时={}ms", chunkCount, System.currentTimeMillis() - readStart);
    }
错误类型处理方式
HTTP 非 200读取 errorStream,抛出 RuntimeException
流中包含 {"error": {...}}检测到 error字段后抛异常
JSON 解析失败catch (Exception e)只打 warn 日志,跳过该 chunk 继续读取,不中断整个流

最后一种处理策略值得注意:它选择了容错优先,单个 chunk 解析失败不会导致整个对话中断。这在生产环境中是合理的,因为 LLM API 偶尔可能返回格式异常的 chunk。

(三) callSync() — 同步调用

public Map<String, Object> callSync(String url, String apiKey, Map<String, Object> requestBody) throws Exception {
    HttpURLConnection connection = createConnection(url, apiKey, requestBody);
    try {
        int responseCode = connection.getResponseCode();
        if (responseCode != 200) {
            // ... 错误处理
        }
        byte[] responseBytes = connection.getInputStream().readAllBytes();
        return objectMapper.readValue(responseBytes, Map.class);
    } finally {
        connection.disconnect();
    }
}
  1. 复用了 createConnection() 建连,避免重复代码
  2. finally 中确保连接关闭,资源安全
  3. 使用 readAllBytes() 一次性读取完整响应,适用于 stream=false 的场景
  4. 错误处理逻辑与 readChunks() 一致,保持统一

(四) parseChunk() — SSE 数据块解析

private Chunk parseChunk(Map<String, Object> chunk) {
    Chunk result = new Chunk();

    // 提取 delta content: choices[0].delta.content
    List<Map<String, Object>> choices = (List<Map<String, Object>>) chunk.get("choices");
    if (choices != null && !choices.isEmpty()) {
        Map<String, Object> choice = choices.get(0);
        Map<String, Object> delta = (Map<String, Object>) choice.get("delta");
        if (delta != null && delta.get("content") != null) {
            result.setDeltaContent(delta.get("content").toString());
        }
    }

    // 提取 usage: usage.prompt_tokens / usage.completion_tokens
    Map<String, Object> usage = (Map<String, Object>) chunk.get("usage");
    if (usage != null) {
        result.setPromptTokens(usage.get("prompt_tokens") != null
                ? ((Number) usage.get("prompt_tokens")).intValue() : null);
        result.setCompletionTokens(usage.get("completion_tokens") != null
                ? ((Number) usage.get("completion_tokens")).intValue() : null);
    }

    // 无效 chunk 过滤
    if (result.getDeltaContent() == null && result.getPromptTokens() == null) {
        return null;
    }
    return result;
}

OpenAI 兼容 API 的响应结构

{
  "choices": [{"delta": {"content": "你好"}, "index": 0}],
  "usage": {"prompt_tokens": 10, "completion_tokens": 5}
}

:::info parseChunk 精确匹配了这个结构,提取两个关键信息:

choices[0].delta.content → 增量文本

usage.prompt_tokens / **completion_tokens **→ token 用量

:::

((Number) usage.get("prompt_tokens")).intValue()

这里先将值强转为 Number,再调用 intValue()。这样做比直接强转 Integer 更安全,因为 JSON 反序列化时数字可能是 Integer、Long 或 Double,统一走 Number 接口可以避免 ClassCastException。

三、数据流总结

前端请求
  ↓
业务 Service 调用 createConnection()
  ↓
HTTP POSTLLM API(stream=true)
  ↓
readChunks() 开始逐行读取 SSE 流
  ↓
┌─ 每读一行 ──────────────────────────────────┐
│  检查 cancelFlag → 被取消?→ break           │
│  匹配 "data:" 前缀                           │
│  ├── "[DONE]" → 构造 done Chunk → 回调 → break │
│  ├── 含 "error" → 抛异常                      │
│  └── 正常 JSONparseChunk() 解析            │
│       ├── 提取 delta.contentChunk.deltaContent │
│       ├── 提取 usage → Chunk.promptTokens/completionTokens │
│       └── 回调 onChunk.accept(result)         │
└──────────────────────────────────────────────┘
  ↓
connection.disconnect()
  ↓
业务 Service 处理统计、保存、关闭 SSE

这个类是整个流式对话系统的通信基石,设计清晰、职责明确,是一个典型的"工具类抽取"最佳实践。