前端用 fetch + ReadableStream 接大模型的流式输出,跑到一半断了、中文变乱码、进度条卡死——这多半不是模型挂了,而是中间层把流缓冲了,或者前端解码姿势不对。按出现频率,这三招基本能救回来。
第一招:关掉反向代理的缓冲
Nginx 默认会攒满 buffer 才下发响应,对普通 JSON 没问题,对流式就是灾难:表现成”等很久突然一次性刷出”,甚至连接超时直接断流。在流式接口所在的 location 关掉它:
location /v1/chat/stream {
proxy_pass http://backend;
proxy_buffering off;
proxy_cache off;
gzip off;
# 关键响应头,告诉所有中间层别缓冲
add_header X-Accel-Buffering no;
}
如果是 Cloudflare 等 CDN 在前面,记得在边缘也给该路径设 X-Accel-Buffering: no,否则 CDN 层还会再缓冲一轮。
第二招:UTF-8 多字节别在中间切断
流式是按 chunk 二进制下发的,一个中文可能被切成两半。如果用 new TextDecoder().decode(value) 不带 stream 模式,半截字符会乱码甚至抛错。正确写法累积 buffer 并开 stream:
const decoder = new TextDecoder();
let acc = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
acc += decoder.decode(value, { stream: true }); // 关键:stream:true
render(acc); // 增量渲染
}
第三招:监听断开 + 可取消
用户切走页面或重发消息时,旧的流还在后台烧 token。用 AbortController 在组件卸载/重发时 abort,并 catch 网络错误做重试或提示:
const ctrl = new AbortController();
const res = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ messages }),
signal: ctrl.signal,
});
// 循环里 try/catch 网络错误
// 组件卸载:ctrl.abort()
SSE 场景还可记下 lastEventId,断线后用它续传,而不是把半句话当最终结果。
踩坑实录
接某国产大模型时,本地 curl 流式完全正常,前端一接就”显示到一半没了”。抓包看响应头带着 Content-Encoding: gzip,而且网关 Nginx 开着 proxy_buffering:4KB 的 buffer 没攒满,连接就超时被切断,半句中文直接丢。在网关层给 /v1/chat/stream 加上 X-Accel-Buffering: no 并关掉 gzip 之后才稳定。教训很明确:流式接口和常规 JSON 接口的配置不能共用一套,否则你以为是模型不稳,其实是管道在憋。
三招对照表
| 现象 | 根因 | 修法 |
|---|---|---|
| 等很久突然全刷出 / 断流 | Nginx/CDN 缓冲了流 | proxy_buffering off + X-Accel-Buffering: no |
| 中文变乱码、解码报错 | 多字节被 chunk 切断 | TextDecoder decode 带 stream:true |
| 切页后 token 还在烧 | 没 abort 旧流 | AbortController 卸载时取消 |
常见问题
Q:本地好端端的,一上服务器就断?
十有八九是网关/Nginx 的缓冲或 gzip,照第一招在边缘关掉。
Q:用 EventSource 还是 fetch?
EventSource 只支持 GET 且自带重连,但大模型接口多是 POST,所以普遍用 fetch + ReadableStream。
Q:断流后怎么续?
SSE 记 lastEventId 续传;fetch 流没有原生续传,前端做”重试整段”或后端按 checkpoint 重发更稳。
你接的是哪家模型的流式接口、断在哪一步?欢迎到 fenij.com 留言,把响应头和报错贴出来,我帮你对着三招定位。