自建大模型 API 中转后最常见的故障是:后端明明是流式的,经过反向代理就变成一次性返回——客户端等十几秒没有任何输出,然后整段文字一次吐完。首字节延迟几乎等于整个生成时长,流式的意义全部丧失。
这个问题九成不在应用代码里,而在 nginx 的 proxy_buffering 默认是开启的。但真正麻烦的地方是:请求路径上会缓冲的地方不止 nginx 一个,实际有五个,而且其中一个是你用来排查的 curl 自己。如果不知道这一点,你会在错误的层上改半天配置。
先分清 SSE 和 WebSocket
这是配置写错的根源。大模型 API 的流式输出用的是 SSE(Server-Sent Events):一个普通的 HTTP 响应,Content-Type: text/event-stream,靠 chunked 编码持续往下写。它不涉及协议升级。
而网上大量"流式接口反代配置"教程给的是 WebSocket 的写法:
# ❌ 这是 WebSocket 的配置,SSE 不需要,而且硬编码有害
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
问题出在第二行硬编码了 "upgrade":它会给经过这个 location 的所有请求都带上 Connection: upgrade,包括根本没有 Upgrade 头的普通请求和 SSE 请求。Connection 是逐跳(hop-by-hop)首部,被这么滥用会破坏到上游的 keepalive,某些上游实现还会因此行为异常。
正确的写法是用 map 按客户端是否真的请求升级来取值:
# http 块内
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# location 内
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
如果这个 location 只服务 SSE、完全没有 WebSocket,那更简单——两行都不要写,改成让 nginx 复用到上游的连接:
proxy_http_version 1.1;
proxy_set_header Connection "";
请求路径上真实存在的五个缓冲点
proxy_buffering 和 gzip。最容易误导排查的是最右边那格——不加 -N 的 curl 自己会缓冲输出,你会以为是服务端问题。第五个缓冲点最坑人,所以单独强调:curl 默认会缓冲输出。用不带 -N 的 curl 去验证流式,看到的永远是"一次性返回",无论服务端配得对不对。排查流式问题的第一条纪律是所有 curl 都加 -N(等价于 --no-buffer)。
症状长什么样
用 curl 的计时字段可以把这件事量化,不用凭感觉:
curl -N -o /dev/null -s \
-w "首字节 %{time_starttransfer}s / 总计 %{time_total}s\n" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-mini","stream":true,"messages":[{"role":"user","content":"数到二十"}]}' \
https://your-domain/v1/chat/completions
首字节 ≈ 总计→ 被完整缓冲了首字节 ≪ 总计→ 流式正常
另一个免费信号在响应头里:
curl -N -D - -o /dev/null -s ... | grep -iE 'content-length|transfer-encoding|content-type'
出现 Content-Length 就说明响应被完整缓冲过——只有知道全部长度才能写出这个头。正常流式应该是 Transfer-Encoding: chunked(HTTP/1.1)或 HTTP/2 的分帧,加上 Content-Type: text/event-stream。
三层二分排查法
不要直接去猜哪一层,按下面这棵树走,两三条命令就能定位。核心思路是从最里层往外逐层加一跳,第一次出问题的那跳就是元凶。
nginx 侧的完整配置
location /v1/ {
proxy_pass http://127.0.0.1:3000/v1/;
# 关掉响应缓冲 —— 解决问题的主开关
proxy_buffering off;
proxy_request_buffering off;
proxy_cache off;
# gzip 压缩也会攒数据,流式 location 里必须关
gzip off;
# chunked 需要 HTTP/1.1;清空 Connection 让 nginx 复用上游连接
proxy_http_version 1.1;
proxy_set_header Connection "";
# 长生成会超过默认的 60s,放宽读超时
proxy_read_timeout 600s;
proxy_send_timeout 600s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
几个容易漏的点:
gzip off不是可选项。 nginx 的 gzip 模块要攒够数据才能压缩,开着它等于重新引入缓冲。很多人只关了proxy_buffering就以为完事,结果症状减轻但没消失,就是 gzip 在作祟。proxy_read_timeout默认 60 秒。 长上下文生成很容易超,超了会收到截断的响应或 504,且现象酷似"流式坏了"。proxy_request_buffering off管的是请求方向,上传大 prompt 时有用,和响应流式无关但顺手一起配。
不想改 nginx 配置?用响应头
如果你没有 nginx 的配置权限,或者不想为一个接口改全局配置,应用可以自己让 nginx 关掉缓冲——返回这个响应头:
X-Accel-Buffering: no
nginx 识别这个头并对该响应禁用缓冲。这是 nginx 特有的机制,非常实用:开关从运维侧移到了应用侧,随代码走,不会因为换机器忘配而复发。
Caddy 侧
Caddy 的 reverse_proxy 对 text/event-stream 会自动刷新,多数情况下开箱即用。如果确实需要强制:
your-domain.com {
reverse_proxy 127.0.0.1:3000 {
flush_interval -1
}
}
flush_interval -1 表示每次写入立即刷新。另外 Caddy 默认会对文本类型启用压缩,如果显式配了 encode gzip,记得把流式路径排除掉,理由和 nginx 的 gzip off 一样。
应用侧:反代配对了也可能不流式
如果排查树的第一步就不通过,那是应用自己没有 flush。反代关掉缓冲只是"不主动攒",它没法把应用攒着不发的数据变出来。Go 里的标准写法:
func stream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no") // 顺手把 nginx 也关掉
for chunk := range chunks {
fmt.Fprintf(w, "data: %s\n\n", chunk)
flusher.Flush() // 少了这行,前面全白配
}
fmt.Fprint(w, "data: [DONE]\n\n")
flusher.Flush()
}
注意 w.Header().Set 必须在第一次 Write 之前调用,否则不生效——这是另一个安静失败的经典来源。
相关阅读
- new-api 部署选型:2G 小内存机器上裸二进制比 Docker 省多少 —— 反代前面那一层,部署形态怎么选
- API 中转站的限流为什么不能信 X-Forwarded-For —— 本文配置里那行
X-Real-IP为什么不能省