项目地址
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)
- InputStream 只能按字节读,无法识别"行"的概念
- InputStreamReader 解决了**编码问题,但仍需逐字符读取**
- 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() 的阻塞特性:
- readLine() 是阻塞调用 :如果服务端还没有发送下一行数据,当前线程会一直挂起等待,直到:
- 收到一行数据(返回该行字符串)
- 流结束(返回 null)
- 触发 readTimeout(抛出 SocketTimeoutException)
- 这意味着取消检查(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
line.startsWith("data:"):匹配 SSE 的数据行前缀line.substring(5):截取 "data:" 之后的内容(跳过 5 个字符:d-a-t-a-:).trim():去除前后空白字符,兼容 "data: {...}" (有空格) 和 "data:{...}" (无空格) 两种格式- 构造一个 done=true 的特殊 Chunk
- 通过 onChunk.accept() 回调通知业务层(业务层收到 done=true 后可以执行收尾操作,如关闭前端 SSE 连接)
- 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();
}
}
- 复用了 createConnection() 建连,避免重复代码
- finally 中确保连接关闭,资源安全
- 使用 readAllBytes() 一次性读取完整响应,适用于 stream=false 的场景
- 错误处理逻辑与 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
这个类是整个流式对话系统的通信基石,设计清晰、职责明确,是一个典型的"工具类抽取"最佳实践。