Skip to main content

SSE流式解析与异步执行

上一篇拆解了 doChat 同步调用的完整流程——校验供应商、构建请求体、发送 HTTP POST、解析 JSON 响应、提取 choices[0].message.content,九个步骤一路走下来,很直接。

但在文章最后提到,AbstractOpenAIStyleChatClient 还有一个 doStreamChat 模板方法。同步调用的逻辑很好理解:发一个 HTTP POST,等响应回来,解析 JSON,返回 String。流式调用就不一样了——响应不是一次到位的,而是一行一行通过 SSE 协议推送过来的。读取过程是阻塞的(readUtf8Line() 会阻塞等待下一行数据到达),不能在 Tomcat 请求线程上跑,得提交到专用线程池异步执行。而且用户随时可能点停止生成,得有一套取消机制能中断正在进行的流式读取。

这一篇打开流式调用的黑盒,看看 doStreamChat 内部是怎么把 SSE 解析、异步执行、回调推送、取消控制串在一起的。

doStreamChat vs doChat:同步与流式的分叉点​

1. doStreamChat 模板方法​

先看 doStreamChat 的完整代码:

protected StreamCancellationHandle doStreamChat(ChatRequest request, StreamCallback callback, ModelTarget target) {
AIModelProperties.ProviderConfig provider = HttpResponseHelper.requireProvider(target, provider());
if (requiresApiKey()) {
HttpResponseHelper.requireApiKey(provider, provider());
}

JsonObject reqBody = buildRequestBody(request, target, true);
Request streamRequest = newAuthorizedRequest(provider, target)
.post(RequestBody.create(reqBody.toString(), HttpMediaTypes.JSON))
.addHeader("Accept", "text/event-stream")
.build();

Call call = httpClient.newCall(streamRequest);
boolean reasoningEnabled = isReasoningEnabledForStream(request);
return StreamAsyncExecutor.submit(
modelStreamExecutor,
call,
callback,
cancelled -> doStream(call, callback, cancelled, reasoningEnabled)
);
}

和上一篇的 doChat 放在一起对比,前半部分几乎一模一样:校验供应商配置 → 校验 API Key → buildRequestBody 构建请求体 → newAuthorizedRequest 构建 HTTP 请求。但从构建请求体开始,三个关键差异出现了:

维度doChat(同步)doStreamChat(流式)
请求体buildRequestBody(request, target, false)buildRequestBody(request, target, true),JSON 中多了 "stream": true
Accept 头无特殊设置.addHeader("Accept", "text/event-stream")
HTTP 执行当前线程同步 httpClient.newCall(request).execute()创建 Call 后交给 StreamAsyncExecutor.submit() 异步执行
响应解析HttpResponseHelper.parseJson() 一次性解析完整 JSONOpenAIStyleSseParser.parseLine() 逐行解析 SSE
结果返回直接 return String通过 StreamCallback 回调推送
取消控制无(同步阻塞等待)返回 StreamCancellationHandle,调用方可随时取消
错误处理抛 ModelClientException,由 executeWithFallback 捕获callback.onError() 通知调用方

分叉点就在 httpClient.newCall(streamRequest) 这一行——同步调用直接 .execute() 等结果,流式调用把 Call 对象交给异步执行器,自己立即返回一个取消句柄。

2. 为什么流式读取必须异步​

这个问题值得展开说说。

OkHttp 的流式响应通过 BufferedSource.readUtf8Line() 逐行读取。这个方法是阻塞调用——如果服务端还没推送新数据,readUtf8Line() 会一直等着,直到新的一行到达或者连接关闭。

大模型生成一个完整回答可能需要几十秒甚至几分钟(生成长文本时),在这段时间里,线程一直被占着。如果把流式读取放在 Tomcat 的请求处理线程上,一个流式请求就占住一个 Tomcat 线程。Tomcat 线程池默认 200 个线程,假设同时有 50 个用户在流式对话,就有 50 个线程被占住。剩下的线程要处理其他所有 HTTP 请求——页面渲染、API 调用、健康检查等等。并发一上来,线程池就满了,新请求排队等待,整个系统响应变慢。

所以必须把流式读取提交到专用线程池 modelStreamExecutor 上异步执行。Tomcat 请求线程只负责构建 HTTP 请求和创建 Call 对象,然后立即返回取消句柄,不等待流式传输完成。真正的阻塞读取在专用线程池上进行,和 Tomcat 线程池互不干扰。

StreamCallback:回调体系​

1. 接口定义​

public interface StreamCallback {

/**
* 接收一次增量内容(Delta Token 或部分片段)
*/
void onContent(String content);

/**
* 接收思考过程增量内容(如果模型支持)
* 默认空实现,未支持思考的场景可以忽略
*/
default void onThinking(String content) {
}

/**
* 整个推理流程结束(全部内容推送完毕)
*/
void onComplete();

/**
* 流式推送过程中出现异常
*/
void onError(Throwable error);
}

四个方法分工明确:

  • onContent(String content)——增量内容回调。模型每生成一段内容(可能是一个字、一个词、一句话,取决于模型的分片策略),就通过这个方法推一次。调用次数不定,由模型输出节奏决定
  • onThinking(String content)——思考过程回调。深度思考模型(比如开启了 enable_thinking 的 Qwen)会在生成正式回答之前输出推理过程,通过这个方法推送。注意它是 default 空实现
  • onComplete()——流结束通知。所有内容推送完毕后调用一次,通知上层做收尾动作(停止 loading 动画、滚动到底部等)
  • onError(Throwable error)——异常通知。网络中断、解析异常、供应商报错等场景触发

2. 调用时序​

正常流程的回调顺序:

onThinking("用户在问候")          ← 可选,深度思考模型才有
onThinking(",我应该友好回应") ← 可选
onContent("你")
onContent("好")
onContent("!")
onComplete() ← 正好一次

异常流程:

onContent("你")
onContent("好")
onError(IOException) ← 任意时刻,一次

onComplete() 和 onError() 是互斥的终态——正常结束走 onComplete(),异常结束走 onError(),不会两个都调用。

2.1 onThinking 的默认空实现​

onThinking 被声明为 default 方法,方法体为空。这个设计的意图是:大多数场景不关心模型的思考过程,只关心最终生成的内容。如果 onThinking 是一个抽象方法,所有 StreamCallback 的实现类都得写一个空的 onThinking 方法,哪怕根本用不到。default 空实现让不需要思考内容的调用方零成本忽略它——只实现 onContent、onComplete、onError 三个方法就够了。

3. 和同步调用返回值的对比​

同步调用 doChat 的返回方式是直接 return String——调用方同步阻塞等待整个回答生成完毕,然后一次性拿到完整文本。

流式调用通过 StreamCallback 异步推送——调用方不阻塞等待,增量内容逐步到达。这种模式让前端可以实现逐字输出的打字机效果:收到一个 onContent("你") 就在页面上渲染一个字,不用等整个回答生成完才显示。用户体验上,从发问到看到第一个字的延迟(首字延迟,Time to First Token)大幅缩短。

OpenAIStyleSseParser:SSE 行解析​

1. 为什么需要专门的解析器​

大模型的流式响应走 SSE(Server-Sent Events)协议,但实际上用的是一个简化子集——只用了 data: 字段,没有 event:、id:、retry: 等标准 SSE 字段。而且 data: 后面的 payload 是 JSON,和非流式响应的结构有区别:

  • 非流式响应:内容在 choices[0].message.content
  • 流式响应:增量内容在 choices[0].delta.content
  • 流结束标记 [DONE] 是 OpenAI 协议特有的约定,不在 SSE 标准里

所以需要一个专门的解析器来处理这些 OpenAI 协议特有的约定。OpenAIStyleSseParser 就是这个角色——它不是一个通用的 SSE 协议解析器,而是一个针对 OpenAI 兼容接口优化的 data-only SSE 消费器。

2. parseLine 完整代码​

@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE)
public final class OpenAIStyleSseParser {

private static final String DATA_PREFIX = "data:";
private static final String DONE_MARKER = "[DONE]";

static ParsedEvent parseLine(String line, Gson gson, boolean reasoningEnabled) {
if (line == null || line.isBlank()) {
return ParsedEvent.empty();
}

String payload = line.trim();
if (payload.startsWith(DATA_PREFIX)) {
payload = payload.substring(DATA_PREFIX.length()).trim();
}
if (DONE_MARKER.equalsIgnoreCase(payload)) {
return ParsedEvent.done();
}

JsonObject obj = gson.fromJson(payload, JsonObject.class);
JsonArray choices = obj.getAsJsonArray("choices");
if (choices == null || choices.isEmpty()) {
return ParsedEvent.empty();
}

JsonObject choice0 = choices.get(0).getAsJsonObject();
String content = extractText(choice0, "content");
String reasoning = reasoningEnabled ? extractText(choice0, "reasoning_content") : null;
boolean completed = hasFinishReason(choice0);

return new ParsedEvent(content, reasoning, completed);
}

private static boolean hasFinishReason(JsonObject choice) {
if (choice == null || !choice.has("finish_reason")) {
return false;
}
JsonElement finishReason = choice.get("finish_reason");
return finishReason != null && !finishReason.isJsonNull();
}

private static String extractText(JsonObject choice, String fieldName) {
if (choice == null) {
return null;
}
if (choice.has("delta") && choice.get("delta").isJsonObject()) {
JsonObject delta = choice.getAsJsonObject("delta");
if (delta.has(fieldName)) {
JsonElement value = delta.get(fieldName);
if (value != null && !value.isJsonNull()) {
return value.getAsString();
}
}
}
if (choice.has("message") && choice.get("message").isJsonObject()) {
JsonObject message = choice.getAsJsonObject("message");
if (message.has(fieldName)) {
JsonElement value = message.get(fieldName);
if (value != null && !value.isJsonNull()) {
return value.getAsString();
}
}
}
return null;
}

record ParsedEvent(String content, String reasoning, boolean completed) {

static ParsedEvent empty() {
return new ParsedEvent(null, null, false);
}

static ParsedEvent done() {
return new ParsedEvent(null, null, true);
}

boolean hasContent() {
return content != null && !content.isEmpty();
}

boolean hasReasoning() {
return reasoning != null && !reasoning.isEmpty();
}
}
}

parseLine 接收一行 SSE 文本,返回一个 ParsedEvent。解析流程分五步:

  1. 空行过滤:null 或空白行直接返回 ParsedEvent.empty()
  2. 剥离 data: 前缀:SSE 每行数据都以 data: 开头,去掉前缀拿到真正的 payload
  3. 识别 [DONE] 标记:OpenAI 协议约定的流结束标记,返回 ParsedEvent.done()
  4. 解析 JSON:把 payload 解析为 JsonObject,提取 choices[0]
  5. 提取字段:从 choice0 中提取 content(增量内容)和 reasoning_content(思考内容,如果启用),检查 finish_reason 判断是否完成

整个类是 private 构造的工具类(@NoArgsConstructor(access = PRIVATE)),所有方法都是 static,不维护状态——每行独立解析,无上下文依赖。

3. SSE 响应流走查:普通对话​

解锁付费内容,👉 戳​