LLM 流式调用工具类 LlmSseHelper解析

项目地址

前端:github.com/seapack-hub...

后端:github.com/seapack-hub...

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

一、整体架构定位

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

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

二、方法深度分析

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

typescript 复制代码
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)的标准格式为:

typescript 复制代码
data: {"choices": [{"delta": {"content": "你好"}}]}

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

data: [DONE]

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

流初始化
typescript 复制代码
try (BufferedReader reader = new BufferedReader(
        new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {

为什么需要三层包装?

typescript 复制代码
原始字节流(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() 方法,可以按行读取
主循环
typescript 复制代码
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:"),自然被跳过。这是正确的行为。

typescript 复制代码
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 跳出循环

:::

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

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

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

错误处理分层
typescript 复制代码
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() --- 同步调用

typescript 复制代码
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 数据块解析

typescript 复制代码
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 的响应结构

typescript 复制代码
{
  "choices": [{"delta": {"content": "你好"}, "index": 0}],
  "usage": {"prompt_tokens": 10, "completion_tokens": 5}
}

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

choices0.delta.content → 增量文本

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

:::

typescript 复制代码
((Number) usage.get("prompt_tokens")).intValue()

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

三、数据流总结

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

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

相关推荐
孟健8 小时前
Qwen3.8-27B 本地推理实测:从 14 tok/s 到 159 tok/s 的投机解码调优
人工智能·llm·ai编程
Flynt8 小时前
HN 614 分的 16.9MB 语音模型,我在 1 核 2G 上实测了一遍
llm
goehou17 小时前
AI 应用上线实战:从本地脚本到 Docker 容器化(密钥、健康检查、镜像瘦身)
docker·ai·llm·部署·教程·容器化
CopyCode17 小时前
Agent 开发不是模型训练:一个前端的入门认知
前端·llm·agent
流浪00117 小时前
大模型技术全景(十八):RAG 检索增强生成与知识时效性问题
llm·大语言模型·rag
桃西西呀18 小时前
Spring AI Alibaba 之四:拆开 ReactAgent 的图,看 ReAct 循环怎么拼出来
人工智能·spring·llm
桃西西呀19 小时前
Spring AI Alibaba 之三:graph-core 状态图引擎深拆
人工智能·spring·llm
承渊政道20 小时前
【从零开始大模型开发与微调:基于PyTorch与ChatGLM】(开源大模型ChatGLM使用详解)
人工智能·pytorch·开源·llm·chatglm
用户7196975933581 天前
Llama 3 精读|附 FP8 量化与长上下文实测
llm
架构师那点事儿1 天前
大模型如何私有化部署到生产环境
人工智能·架构·llm