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

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

相关推荐
liulilittle6 小时前
Linux AI 开发环境搭建实录
linux·ai·llm·agent·hyper-v·dev·opencode
happy_king_zi7 小时前
Milvus 生产环境部署,优化,日常维护中遇到的问题
llm·milvus
桃西西呀8 小时前
一句话换个词序意思就全变,大模型是怎么读出门道的?手搓一个 Transformer 看清楚
人工智能·llm·ai编程
KimLiu9 小时前
LCODER之AI Agent开发实战一 :问数项目智能体搭建(3)元数据知识库的构建
langchain·llm·agent
Together_CZ9 小时前
在线蒸馏(OPD)、递归自我改进(RSI)与递归自我学习(RSL)整体学习理解
llm·agent·opd·rsi·在线蒸馏·rsl·递归自我学习
AINative软件工程9 小时前
LLM 应用的 Bulkhead 隔离工程实践:用舰壁模式防止一个功能的过载拖左整个 AI 系统
后端·llm·ai编程
武子康18 小时前
小智断网后还能做什么?沿一次唤醒看清设备与服务端的分工
人工智能·llm·agent
米小虾21 小时前
加了 20 条示例反而变差:你的 few-shot 提升,可能只是 prompt 变长的功劳
人工智能·llm
程序猿编码1 天前
不用 GPU!RK3588 跑视觉大模型,Qwen3.5-2B VLM 端侧落地
大模型·llm·rk3588·qwen·推理·vlm
刘马想放假1 天前
RK3588 使用 rk-llama.cpp + RKNPU2 部署 Qwen3.5:从零开始的 NPU 本地大模型实战
人工智能·开源·llm