本项目最具技术挑战性的功能是 SSE 流式对话链路——从模型调用、流式推送、前端逐字渲染到异常恢复的整条管道。难点不在某个环节单独的实现,而在三个问题叠加:流经 Nginx 后被缓冲截断、客户端断开后服务端仍继续生成、中文按字节拆分导致乱码。我们花了三周排查与改造,最终用「代理层关闭缓冲 + 心跳保活 + 断连取消信号 + 流式解码」四件套把链路稳定下来。下面还原完整过程。
一、为什么这条链路最难
网关接入多家大模型,全部走 SSE 流式输出。对话场景对延迟敏感,用户要看到逐字打字效果;同时单次生成可能持续数十秒甚至更久,连接生命周期比普通接口长一个数量级。普通接口的坑在流式场景全被放大:中间件缓冲、连接超时、字符切分、客户端消失,每一个都是独立的故障源,叠加在一起时排查成本成倍上升。
二、三个具体难题
代理层缓冲。 事件到达 Nginx 后默认被攒到一定量才转发,用户看到的不是逐字输出,而是长时间停顿后蹦出一大段。这直接毁掉流式体验。
断连后资源浪费。 用户中途关掉页面,浏览器断开 TCP 连接,但服务端的生成任务感知不到,继续把剩下的 Token 全部生成完,钱花了、输出没人收。
中文乱码。 UTF-8 下一个汉字占三个字节,TCP 分包可能把一个字拆进两个数据块,前端按块解码时拼不出完整字符,出现零星乱码。
三个难题可以横向对比,各自的根因与解法差异明显:
| 难题 | 现象 | 根因 | 解法 |
|---|---|---|---|
| 代理缓冲 | 长时间停顿后一次性输出 | Nginx 默认缓冲响应体 | 关闭缓冲并禁用缓存头 |
| 断连浪费 | 关页面后仍持续生成 | 服务端未感知连接关闭 | 回调置位 + 生成循环轮询中断 |
| 中文乱码 | 流中出现零星乱字符 | UTF-8 字符被 TCP 分包拆开 | 前端流式解码 + 后端整段推送 |
三、排查过程:先定位故障端再动手
三个问题交替出现时不能一起改,按以下顺序逐个排除:
- 用
curl -N直连后端流式接口,确认无代理时事件逐条到达,排除后端本身问题; - 同样请求改走 Nginx,观察是否变成一次性返回,确认缓冲问题出在代理层;
- 打开浏览器开发者工具看事件流原文,确认每条事件是否以
data:开头、空行结尾,定位格式问题; - 在后端日志里加连接关闭回调的输出,对比前端断开时间与服务端结束时间,确认断连未感知;
- 用包含中文的长文本做多次断网复测,统计乱码出现频率,确认与字节拆分的对应关系。
四、解决方案:四件套缺一不可
4.1 代理层与响应头
Nginx 的 /api/ 配置里关闭响应缓冲与缓存,读超时调大:
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
}
后端响应头显式声明流式类型与禁止缓存,X-Accel-Buffering: no 通知 Nginx 不缓冲本响应,双重保障。
4.2 心跳保活
生成循环里超过 15 秒没有实际内容时,发送 SSE 规范允许的注释行维持连接活性,客户端 EventSource 自动忽略这类事件。这解决长文生成期间连接被代理层误判为空闲而切断的问题。
4.3 断连取消信号
连接关闭时触发 onCompletion 回调,置位中断标志;生成循环每轮检查该标志,为真立即终止模型调用并释放资源。模型调用侧再叠加超时控制,双保险:
SseEmitter emitter = new SseEmitter(300_000L);
AtomicBoolean aborted = new AtomicBoolean(false);
emitter.onCompletion(() -> aborted.set(true));
emitter.onTimeout(() -> aborted.set(true));
emitter.onError(e -> aborted.set(true));
executor.execute(() -> {
modelService.stream(reply -> {
if (aborted.get()) {
modelService.cancel();
return;
}
try {
emitter.send(SseEmitter.event().data(reply));
} catch (IOException e) {
aborted.set(true);
modelService.cancel();
}
});
});
4.4 中文流式解码
前端从 fetch 的 ReadableStream 读流,解码器开启流式模式,未完成的字节留在缓冲区等待下一块,从根源上避免多字节字符被截断成乱码。后端的推送单元也统一到完整句子或完整 Token 级别,不在一个字符中间切分。
五、多阶段生成的额外难度
流式对话解决后,文章创作器的多阶段生成又加了一层复杂度:选题、大纲、正文、润色四阶段串行,阶段内流式、阶段间要能断点续跑。阶段状态用 Redis 记录,任一阶段中断后,重连时先查状态表,从断点阶段重跑,不重跑已完成的阶段。这个编排器与对话网关共用同一套流式底座,只是把「单轮对话」换成「阶段状态机」。
六、验证与复盘
修复后做了三类验证:长文生成连续 20 轮无中断、无乱码;对话中途关页面后 3 秒内服务端确认终止并停止计费;断网重连后从断点续跑成功。复盘时沉淀了两条原则:流式接口的任何改动都要先确认「直连与走代理」两种路径行为一致;客户端断开必须设计为可探测状态,而不是靠超时被动等待。这两条原则后续所有流式功能都直接沿用。
这条链路的坑还会在升级时复发:每次升级模型 SDK 或调整 Nginx 版本,我们都会把直连与走代理的对比测试重跑一遍,再补一轮断连与乱码的混沌测试。排查顺序也固定下来,先 curl 直连看后端,再走代理看中间层,最后看前端解码,三层各占一块,出问题时按层缩小范围,比整条链路一起调快得多。
常见问题(FAQ)
Q1:客户端断开后服务端怎么及时感知?
注册 onCompletion、onTimeout、onError 回调置位标志,生成循环轮询,配合模型调用取消信号双保险。
Q2:中文乱码的根因是什么?
UTF-8 汉字占三字节,TCP 分包可能拆开字符,前端需用流式模式解码器并缓存未完成字节。
Q3:心跳包会不会干扰前端渲染?
不会。SSE 规范中冒号开头的注释行会被 EventSource 自动忽略,只用于维持连接活性。