自建大模型 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 "";

请求路径上真实存在的五个缓冲点

token 的流动方向 →应用nginxCDN若有客户端默认看代码开关flusher.Flush()默认缓冲 ⚠ 主因开关proxy_buffering offgzip off ← 第二个默认多数缓冲 ⚠开关控制台流式配置默认curl 自己缓冲 ⚠开关curl -N共 5 个缓冲点:应用 flush · proxy_buffering · gzip · CDN · 客户端
nginx 那一格是灰底的,因为它同时贡献了两个缓冲点:proxy_buffering 和 gzip。最容易误导排查的是最右边那格——不加 -N 的 curl 自己会缓冲输出,你会以为是服务端问题。

第五个缓冲点最坑人,所以单独强调:curl 默认会缓冲输出。用不带 -N 的 curl 去验证流式,看到的永远是"一次性返回",无论服务端配得对不对。排查流式问题的第一条纪律是所有 curl 都加 -N(等价于 --no-buffer)。

症状长什么样

客户端收到数据的时间轴(示意)缓冲开启↑ 全程无输出 ——————————————————→ 一次吐完缓冲关闭↑ 首字节很快,token 持续到达本图为示意,非实测数据
判据不是"感觉慢",而是一个可量化的信号:首字节时间约等于总时长。下一节给出怎么量。

用 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。

三层二分排查法

不要直接去猜哪一层,按下面这棵树走,两三条命令就能定位。核心思路是从最里层往外逐层加一跳,第一次出问题的那跳就是元凶。

① curl -N 直连 127.0.0.1:3000绕过全部反代不流式应用没有 flush改代码,见下节流式 ✓② curl -N 经 nginx(内网 IP)加上反代这一跳不流式nginx 缓冲proxy_buffering / gzip最常见的一档流式 ✓③ curl -N 经公网域名加上 CDN 这一跳不流式CDN 在缓冲去控制台配流式流式 ✓服务端没问题查客户端:漏了 -N,或 SDK 未开 stream每一步都必须带 -N,否则测的是 curl 自己的缓冲
这套方法的价值在于每次只增加一跳,所以定位是确定的,不需要靠猜。灰底的三个是终点结论。

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 之前调用,否则不生效——这是另一个安静失败的经典来源。

相关阅读