外观
Spring Boot 接 LLM 流式输出的坑
约 1174 字大约 4 分钟
2026-09-04
产品要的效果是「像聊天一样一个字一个字往外蹦」,听着就是把模型的 SSE 原样转发一遍,我估了两天工时。结果真正花时间的,全在转发之外。
最小实现
项目是 Spring Boot 3.5.3 + Java 17,直接用官方 Java SDK,别自己拼 HTTP 解析 data: 行:
<dependency>
<groupId>com.anthropic</groupId>
<artifactId>anthropic-java</artifactId>
<version>2.34.0</version>
</dependency>Controller 大概长这样:
@GetMapping(value = "/api/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@RequestParam String prompt, HttpServletResponse resp) {
resp.setHeader("X-Accel-Buffering", "no"); // 见下文
SseEmitter emitter = new SseEmitter(0L);
streamExecutor.execute(() -> {
MessageCreateParams params = MessageCreateParams.builder()
.model("claude-sonnet-4-6")
.maxTokens(4096L)
.addUserMessage(prompt)
.build();
try (StreamResponse<RawMessageStreamEvent> stream =
anthropic.messages().createStreaming(params)) {
stream.stream()
.flatMap(event -> event.contentBlockDelta().stream())
.flatMap(delta -> delta.delta().text().stream())
.forEach(td -> sendQuietly(emitter, td.text()));
emitter.send(SseEmitter.event().name("done").data("[DONE]"));
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter;
}streamExecutor 是自己建的 ThreadPoolTaskExecutor。别图省事在请求线程里跑——Tomcat 默认 200 个 worker,二十个人同时聊天、每次占住三十秒,连健康检查都挤不进来。
我以为是模型慢
本地跑得很顺,发到测试环境就变成「转圈两秒,然后一整段刷出来」。我先怀疑网络,又怀疑模型排队,把首 token 耗时打进日志绕了一圈,发现服务端第一个 delta 在 240ms 就出来了。
问题在 nginx。proxy_buffering 默认是开的,它要攒够 4k 或者等上游结束才往下发,打字机效果就被攒成了幻灯片。两头都得改:
location /api/chat/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
chunked_transfer_encoding off;
}后端再回一个 X-Accel-Buffering: no 做双保险——网关不一定归你管,响应头归你管。
客户端永远等不到结束
emitter.send() 会抛 IOException。用户关页面、切 App,连接一断异常就上来了。我最早写的是裸 send,异常一抛,后面那条 [DONE] 也发不出去,前端 EventSource 既收不到结束标记也收不到 onerror,状态机就永远卡在「正在输入」。
private void sendQuietly(SseEmitter emitter, String text) {
try {
emitter.send(SseEmitter.event().data(text));
} catch (IOException e) {
emitter.completeWithError(e); // 主动收尾,别让它悬着
throw new StreamAbortedException(e);
}
}配套的 onTimeout、onCompletion 里要把 StreamResponse 关掉,不然连接早断了,SDK 那边还在读,token 照样计费。
三条保命规则
new SseEmitter(0L)的意思是「不超时」,不是「不用管超时」,自己注册onTimeout释放资源;maxTokens必须显式设。忘设的那次,一个开放式问题吐了将近 8k 输出 token,Sonnet 4.6 的输出价是 $15/MTok,单次就是一毛多,量一上来账单很难看;- 断流重连交给前端重发,后端别做「续写」——模型不是幂等的,续不回原来那句。
HTTP 200 也可能是失败
流式接口的异常分三类,处理方式完全不同:
| 现象 | 判断方式 | 处理 |
|---|---|---|
429 限流 | AnthropicServiceException.errorType() 为 RATE_LIMIT_ERROR | 读 retry-after,指数退避 |
529 过载 | 同上,OVERLOADED_ERROR | 重试,或降级到 Haiku 4.5 |
| 内容被拒 | HTTP 200,stopReason() 是 refusal | 读 stopDetails() 的 category 给用户提示 |
第三条最阴。它不是异常,不进 catch,流正常结束、内容是空的,前端展示一片空白,用户以为服务挂了。流跑完之后补上这几行,这类工单就没了:
// 流结束后拿最终 message,别只盯着 catch 块
finalMessage.stopDetails().ifPresent(d ->
log.warn("refused: category={}, explanation={}", d.category(), d.explanation()));超时要分三段设
流式请求的耗时特征和普通接口完全不一样:建连很快,首字节几百毫秒,但整段读完可能要三十秒以上。一刀切设个 10 秒读超时,结果就是长回答永远读到一半被掐断,日志里只留下一个孤零零的 SocketTimeoutException。
三个值要分开配:连接超时 5 秒(连不上就是连不上,等再久也没用)、读超时按最长回答留 120 秒、Tomcat 的 server.tomcat.connection-timeout 别比它小。nginx 那边的 proxy_read_timeout 同理,上面那段配置里写的 300s 就是这个道理——链路上最短的那个超时说了算。
监控我只加了两个指标:首 delta 耗时和每次请求的输出 token 数。前者掉下去说明链路又被谁缓冲了,后者涨上去说明有人在 prompt 里放了「请尽可能详细」。
最后
流式接口难的不是「流」,是它把链路上每一个做了缓冲、超时、重试的中间件都暴露出来了。上线前我用这条命令挨跳验证,哪一跳开始不吐字,问题就在哪一跳:
curl -N -H 'Accept: text/event-stream' \
'https://api.internal/api/chat/stream?prompt=写一首七言绝句'-N 是关掉 curl 自己的缓冲。少这一个参数,我又能白查一个下午。