可写简历:大模型流式输出全链路排查方案,领先90%只知道调超时的人
做过大模型流式输出的同学,大概率踩过这个坑:本地测试好好的,一上生产就经常断流、卡顿,用户打了一半字突然停了,体验极差。
然后你去查怎么解决,90%的文章只告诉你一句话:"把超时时间调长一点。"
你调了,发现该断还是断。为什么?因为流式输出断流根本不是单点问题,是从大模型客户端→后端服务→Nginx→浏览器,整条链路4个节点都可能出问题,只调一个地方的超时,当然没用。
今天给你一套全链路排查方案,每个节点的坑和解决方案都讲透,Java项目直接照着排查,改完流式输出稳定不卡。
一、先搞清楚:断流和卡顿是两回事
很多人把"断流"和"卡顿"混为一谈,但原因完全不同:
- 断流:连接直接断开,输出停在一半,前端需要重新发起请求。原因是某个节点超时断开了连接。
- 卡顿:连接没断,但数据一卡一卡的,半天出一个字。原因是某个节点开了缓冲,数据攒一批才发。
排查的时候先搞清楚是哪种,再对症下药。下面按链路顺序,逐个节点讲。
二、节点1:大模型客户端——超时和缓冲的双重坑
第一个坑在大模型客户端,很多人对接通义千问、OpenAI的流式接口时,HTTP客户端配置不对。
坑1:读超时太短 大模型流式输出,第一个Token可能要等2-5秒(模型推理时间),之后每个Token间隔几十毫秒。如果HTTP客户端的读超时设成了默认的10秒,遇到模型稍微慢一点,或者输出内容长一点,直接就超时断连了。
正确配置:
import okhttp3.OkHttpClient;
import java.time.Duration;
// 大模型流式客户端配置
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(Duration.ofSeconds(10)) // 连接超时10秒
.readTimeout(Duration.ofMinutes(5)) // 读超时5分钟!流式必须设长
.writeTimeout(Duration.ofSeconds(30))
.build();
关键:流式输出的读超时必须按最长输出时间算,一份长回答可能要输出2-3分钟,读超时至少设5分钟。
坑2:客户端开了缓冲 有些HTTP客户端默认会缓冲响应体,攒够一定大小才返回。流式输出必须关闭缓冲,逐行读取SSE事件。
正确读取方式:
import okhttp3.Response;
import okhttp3.ResponseBody;
import java.io.BufferedReader;
import java.io.InputStreamReader;
// 流式读取,逐行处理,不要用string()一次性读
try (Response response = client.newCall(request).execute()) {
ResponseBody body = response.body();
if (body == null) return;
BufferedReader reader = new BufferedReader(new InputStreamReader(body.byteStream()));
String line;
while ((line = reader.readLine()) != null) {
// 逐行处理SSE事件,实时推送给前端
if (line.startsWith("data:")) {
String data = line.substring(5).trim();
// 推送到SseEmitter
emitter.send(SseEmitter.event().data(data));
}
}
emitter.complete();
}
坑:千万别用
response.body().string(),这会把整个响应读完才返回,流式变成同步,前端直接卡死。
三、节点2:后端服务——Spring MVC的异步坑
第二个坑在SpringBoot后端,SSE流式输出的异步配置很容易踩坑。
坑1:异步请求超时太短 Spring MVC的异步请求(SseEmitter/WebAsyncTask)默认超时只有30秒,长回答直接超时。
正确配置:
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
// 创建SseEmitter时指定超时时间,单位毫秒
SseEmitter emitter = new SseEmitter(300000L); // 5分钟超时
或者全局配置:
spring:
mvc:
async:
request-timeout: 300000 # 异步请求超时5分钟
坑2:和普通接口共用线程池 流式请求占用线程时间长(一个请求可能占线程几分钟),如果和普通接口共用Tomcat线程池,几个流式请求就能把线程占满,整个服务卡死。
解决:单独分配流式线程池
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.ThreadPoolExecutor;
@Configuration
public class SseThreadPoolConfig {
@Bean("sseExecutor")
public ThreadPoolTaskExecutor sseExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("sse-stream-");
// 拒绝策略:降级为非流式输出,而不是抛异常
executor.setRejectedExecutionHandler((r, e) -> {
// 降级处理:返回完整回答而不是流式
});
executor.initialize();
return executor;
}
}
坑3:异常没捕获,连接泄漏 流式输出过程中如果前端断开连接,后端还在继续往emitter里写数据,会抛异常。如果没捕获,线程不会释放,造成连接泄漏。
正确处理:
SseEmitter emitter = new SseEmitter(300000L);
// 必须注册这三个回调,否则会泄漏资源
emitter.onCompletion(() -> {
// 连接正常结束,释放资源
log.info("SSE连接正常结束");
});
emitter.onTimeout(() -> {
log.warn("SSE连接超时");
emitter.complete();
});
emitter.onError(e -> {
log.error("SSE连接异常", e);
emitter.complete();
});
四、节点3:Nginx/网关——缓冲和超时的重灾区
第三个坑是最容易被忽略的,也是90%的人踩的坑——Nginx反向代理。
坑1:proxy_buffering 默认开启 Nginx默认会缓冲后端响应,攒够一个buffer才发给前端。这就是为什么你本地测试流式正常,一上Nginx就一卡一卡的——Nginx在攒数据。
必须关闭缓冲:
location /api/chat/stream {
proxy_pass http://backend;
# 流式输出必须关闭代理缓冲!
proxy_buffering off;
proxy_cache off;
# 关闭响应分块缓冲(部分版本需要)
chunked_transfer_encoding on;
# 超时设置(整条链路都要对应调长)
proxy_connect_timeout 10s;
proxy_read_timeout 300s; # 读超时5分钟
proxy_send_timeout 300s;
# 关键:设置响应头,告诉浏览器不要缓冲
add_header X-Accel-Buffering no;
add_header Cache-Control no-cache;
}
最关键的两行:
proxy_buffering off和add_header X-Accel-Buffering no。少了任何一行,Nginx都可能偷偷缓冲。
坑2:网关层超时 如果你用了Spring Cloud Gateway、Kong等API网关,它们的默认超时也很短,需要对应调长。
Spring Cloud Gateway配置:
spring:
cloud:
gateway:
httpclient:
connect-timeout: 10000
response-timeout: 300s # 响应超时5分钟
五、节点4:浏览器/前端——重连和心跳
第四个坑在前端,连接断开后如果没有重连机制,用户只能刷新页面。
坑1:没有心跳保活 流式输出过程中,如果大模型推理时间较长(比如复杂问题要等5秒),这段时间没有数据传输。很多网关、防火墙、CDN会自动断开长时间没有数据的连接(通常60秒无数据就断)。
解决:心跳保活
// 后端:每隔10秒发一个心跳注释事件,保持连接活跃
SseEmitter emitter = new SseEmitter(300000L);
// 启动心跳定时任务
ScheduledExecutorService heartbeat = Executors.newSingleThreadScheduledExecutor();
heartbeat.scheduleAtFixedRate(() -> {
try {
// SSE注释行,以:开头,浏览器会忽略,但能保持连接活跃
emitter.send(SseEmitter.event().comment("heartbeat"));
} catch (Exception e) {
// 连接已断开,停止心跳
heartbeat.shutdown();
}
}, 10, 10, TimeUnit.SECONDS);
// 连接结束时停止心跳
emitter.onCompletion(() -> heartbeat.shutdown());
emitter.onTimeout(() -> heartbeat.shutdown());
emitter.onError(e -> heartbeat.shutdown());
原理:SSE协议中,以冒号
:开头的行是注释,浏览器会忽略不处理,但数据确实传输了,能保持连接不被中间节点断开。
坑2:断线重连没有断点续传 前端EventSource自带断线重连,但重连后会从头开始请求,用户已经看到的内容会重复输出。
解决:断点续传
// 前端:记录已接收的偏移量,重连时带给后端
let lastOffset = 0;
const es = new EventSource('/api/chat/stream?sessionId=xxx&offset=' + lastOffset);
es.addEventListener('token', (e) => {
answerDiv.innerHTML += e.data;
lastOffset++; // 每接收一个Token,偏移量+1
});
// EventSource自带重连,会自动带上offset参数
// 后端根据offset从断点处继续输出,而不是从头开始
后端对应处理:
@GetMapping("/api/chat/stream")
public SseEmitter stream(
@RequestParam String sessionId,
@RequestParam(defaultValue = "0") int offset) {
SseEmitter emitter = new SseEmitter(300000L);
// 从会话缓存中获取已生成的回答
List<String> cachedTokens = sessionCache.get(sessionId);
// 如果offset > 0,说明是断线重连,从断点继续
if (offset > 0 && cachedTokens != null) {
// 先补发offset之后的缓存内容
for (int i = offset; i < cachedTokens.size(); i++) {
emitter.send(SseEmitter.event().name("token").data(cachedTokens.get(i)));
}
// 如果已经生成完了,直接结束
if (isGenerationComplete(sessionId)) {
emitter.complete();
return emitter;
}
}
// 继续生成剩余内容...
return emitter;
}
六、全链路检查清单(照着排查)
上线前对照这张清单逐项检查,少一项都可能断流:
| 节点 | 检查项 | 配置值 |
|---|---|---|
| 大模型客户端 | 读超时 | ≥5分钟 |
| 大模型客户端 | 响应读取方式 | 逐行流读取,禁止string() |
| 后端服务 | SseEmitter超时 | ≥5分钟 |
| 后端服务 | 线程池隔离 | 流式单独线程池 |
| 后端服务 | 异常回调 | onCompletion/onTimeout/onError全注册 |
| 后端服务 | 心跳保活 | 每10秒发注释心跳 |
| Nginx/网关 | proxy_buffering | off(必须) |
| Nginx/网关 | X-Accel-Buffering | no(必须) |
| Nginx/网关 | proxy_read_timeout | ≥5分钟 |
| 前端 | 断线重连 | EventSource自带+offset断点续传 |
📦 落地资源推荐
搭大模型测试环境需要稳定服务器,我平时调试用阿里云轻量应用服务器,2核4G跑模型推理+SpringBoot后端完全够用,新用户首年特惠性价比高并且帮忙申请到了9折券www.aliyun.com/daily-act/e…
🎤 面试话术模板
如果面试官问你"流式输出断流怎么排查",可以直接这么说:
流式断流要从全链路排查,不是单点问题。首先大模型客户端读超时要设到5分钟以上,用流读取不能一次性读string;然后后端SseEmitter超时对应调长,流式请求单独线程池隔离,注册异常回调防泄漏;最关键是Nginx层必须关闭proxy_buffering和设置X-Accel-Buffering: no,否则会缓冲导致卡顿;最后加心跳保活避免中间节点超时断开,前端做断线重连+断点续传。整条链路4个节点都检查到,流式输出就稳定了。
这段话讲完,面试官就知道你真的上线过流式功能,不是只会调接口。 关注图片上水印即Java-AI工程师,打出工具箱三字即可。
💬 本期互动
你对接流式输出的时候,踩过最头疼的坑是什么? A. Nginx缓冲问题 B. 超时自动断开 C. 线程泄漏 D. 前端重连重复输出
评论区报你的选项就行~有想刷的面试题,欢迎后台投稿。
📢 下周预告
下周开启全新的Java AI Agent 从 0 到 1 的生产实战系列,从零搭建 + 可写简历 + 生产级痛点。记得星标公众号别错过。
祝大家周末愉快!
本文属于「周五刷题营」合集