ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

SSE流式代理故障排查:解决stream disconnected before completion

SSE流式代理故障排查:解决stream disconnected before completion

1. 项目概述:一次典型的后端代理链路中断故障

“stream disconnected before completion”——这个错误在任何基于 Server-Send Events(SSE)或流式响应的 Web 应用中都算得上高频警报,但真正让人头皮发紧的,是它总在看似最稳妥的环节突然冒出来:前端页面一切正常,用户提问刚发出去,ChatGPT 风格的逐字流式返回只蹦出两三个字就戛然而止,控制台里清清楚楚印着这行红字,Network 面板里 Response 显示“Failed”,Status 列却赫然写着 200。更微妙的是,它不总发生,有时连续十次都稳如老狗,下一次又毫无征兆地断掉。这种非必现、非崩溃、非报错的“软性失败”,恰恰是最难定位、最容易被归为“网络抖动”而草草放过的问题。

本项目标题里的关键词非常精准:“把 ChatGPT 后端请求改到 TaoToken”——这不是在部署一个新模型,而是在已有成熟前端(比如一套封装了 OpenAI SDK 的 React 组件库)基础上,将原本直连api.openai.com的请求,通过一个自建中间层(我们暂且称其为 TaoToken 服务)做代理转发。这个“改”字背后,藏着三重身份切换:从 OpenAI 官方认证客户端,变成 TaoToken 的下游调用方;从直连 HTTPS 服务,变成经由内网网关、反向代理、鉴权中间件、流式响应透传模块的多跳链路;从使用官方 SDK 自动处理重连与心跳,变成需要自己兜底流式连接生命周期的裸 HTTP 请求。而“stream disconnected before completion”正是这条新链路在压力、超时、缓冲、编码等任何一个环节失配时,抛给前端的唯一线索。

我经历过至少 7 个不同团队的类似改造,从教育类 SaaS 的私有化部署,到某金融客户要求的合规审计网关,再到某硬件厂商嵌入式设备上的轻量级模型路由。所有案例里,90% 的“流断开”问题,根源都不在模型服务本身,而在于代理层对 SSE 协议特性的理解偏差和工程实现疏漏。这篇文章不是讲怎么写一个代理服务器,而是聚焦于:当你已经把代码改完、服务跑起来了、API 也能返回 200,但流就是不稳定——此时,你应该按什么顺序、查哪些点、看哪些日志、改哪些参数?这份清单,是我把过去三年踩过的坑、翻过的源码、抓过的包、压过的测,浓缩成的一份可逐项打钩的实战排查手册。它不预设你用的是 Node.js 还是 Python,是 Nginx 还是 Traefik,是 Express 还是 FastAPI,因为底层原理是共通的。只要你的架构里存在“前端 → 代理层 → 大模型后端”这个三层结构,这份清单就适用。

2. 核心思路拆解:为什么流式代理比普通 API 代理难十倍?

要理解这份排查清单的逻辑顺序,必须先破除一个常见误区:很多人以为“代理 HTTP 请求”就是把 Header 转发一下、Body 透传一遍、Status Code 原样返回——对于 RESTful 的 JSON 接口,这基本够用;但对于 ChatGPT 这类强依赖流式响应(SSE)的场景,这种“简单转发”模式,从第一行代码开始就埋下了失败的种子。

2.1 SSE 协议的本质:不是“一次请求,多次响应”,而是“一次长连接,持续推送”

SSE(Server-Sent Events)在 HTTP 层面上,是一个标准的 GET 请求,但它有一个关键特征:响应头里必须包含Content-Type: text/event-stream,并且服务端不能像普通接口那样,在写完 JSON 后就关闭连接,而是要保持 TCP 连接打开,持续向客户端推送以data:开头的文本块,每块末尾用双换行符\n\n分隔。前端通过EventSource或fetch + ReadableStream监听这个流,逐块解析、拼接、渲染。

提示:你可以用curl -N http://your-taotoken-proxy/v1/chat/completions手动测试。如果看到响应头里有content-type: text/event-stream,且命令行里持续有data: {"id":"...这样的输出,说明流是通的;如果几秒后就退出,或者只输出一行就卡住,那问题就出在代理层的连接维持或缓冲策略上。

TaoToken 作为代理层,它的角色不是“接收一个请求,转发给后端,再把后端的响应原样吐给前端”。它必须是一个“流式中继器(Streaming Relay)”:它要建立与后端的长连接,同时维持与前端的长连接,并在这两个连接之间,实现字节级的、低延迟的、无损的、带心跳保活的双向数据搬运。这比普通代理复杂在哪里?我们来看三个核心挑战:

第一,连接生命周期管理的错位。
OpenAI 官方服务默认会维持连接约 30-60 秒(取决于负载),并在空闲时发送: ping心跳事件。而很多代理框架(尤其是基于 Express/Connect 的中间件)默认的 HTTP 客户端(如node-fetch、axios)会设置timeout,一旦后端响应慢,它就主动断开与后端的连接,但此时它与前端的连接可能还开着——这就导致前端还在等数据,代理层却已“失联”,最终触发stream disconnected before completion。这不是后端挂了,是代理层“提前投降”了。

第二,缓冲区(Buffer)的双重陷阱。
代理层通常会读取后端响应的 chunk,缓存到内存 buffer 里,再写给前端。这里有两个致命点:一是 buffer 太小,遇到大 token(比如中文、emoji、base64 编码的图片描述),单个data:块超过 buffer 容量,就会被截断,导致前端解析 JSON 失败,进而关闭流;二是 buffer 太大或策略不当(比如等待“完整 chunk”才 flush),会造成肉眼可见的延迟——用户提问后等 2 秒才开始出字,这在 Chat 场景下体验极差,而某些前端 SDK(如@langchain/core的流式处理器)会因超时主动中断连接。

第三,HTTP/1.1 与 HTTP/2 的隐性冲突。
OpenAI 生产环境强制使用 HTTP/2,它原生支持多路复用和流优先级。而绝大多数自建代理(尤其是跑在 Nginx 后面的 Node.js 服务)默认走 HTTP/1.1。当 HTTP/2 的后端流被降级为 HTTP/1.1 传输时,Transfer-Encoding: chunked的分块边界、Connection: keep-alive的保活逻辑、甚至Content-Length的缺失处理,都会出现微妙的不兼容。我亲眼见过一个案例:Nginx 配置了proxy_buffering off,但没关proxy_http_version 1.1,结果在高并发下,Nginx 会把多个后端的data:块粘连在一起发给前端,前端EventSource解析时因找不到\n\n分隔符而静默失败。

所以,这份排查清单的底层逻辑,就是围绕这三大挑战展开:先确保代理层与后端的连接足够“耐久”,再确保数据搬运过程“零缓冲失真”,最后确保协议栈“全链路一致”。它不是一份工具配置罗列,而是一条从 TCP 连接建立,到 HTTP 头解析,再到应用层数据搬运的纵深排查路径。

3. 核心细节解析与实操要点:逐层穿透代理链路

现在,我们进入真正的“手术室”。假设你已经完成了代码修改,TaoToken 服务正在运行,前端能发起请求,但流式响应不稳定。请严格按以下顺序,逐项检查、验证、调整。每一项都对应一个具体可操作的动作,以及一个“快速验证是否修复”的方法。不要跳步,因为很多问题是叠加态的,A 项的修复可能让 B 项的问题暴露得更明显。

3.1 第一层:确认代理层与后端的连接是否真的“活着”

这是所有问题的起点。很多开发者一上来就去查前端或 Nginx 日志,却忽略了最基础的一环:TaoToken 服务本身,有没有成功、稳定地建立起与 OpenAI(或其他目标后端)的长连接?

关键检查点:HTTP 客户端的 timeout 配置。
无论你用的是node-fetch、axios、got,还是 Python 的httpx、requests,它们的默认 timeout 几乎都短于 OpenAI 流式响应的典型耗时(首次 token 延迟 500ms-2s,整个流持续数秒到数十秒)。例如,node-fetch默认 timeout 是 0(无限),但很多团队会显式设置timeout: 5000(5 秒),这显然不够。

  • Node.js 示例(使用node-fetch):
    // ❌ 危险写法:5秒超时,远低于流式响应所需 const controller = new AbortController(); setTimeout(() => controller.abort(), 5000); const res = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { /* ... */ }, body: JSON.stringify(payload), signal: controller.signal // 这里就断了! }); // ✅ 正确写法:超时时间应设为后端最大预期耗时 + 安全余量 // OpenAI 文档建议流式响应最长不超过 60 秒,我们设为 90 秒 const controller = new AbortController(); setTimeout(() => controller.abort(), 90_000); // 90秒
  • Python 示例(使用httpx):
    # ❌ 危险写法:默认 timeout 是 5 秒 async with httpx.AsyncClient() as client: response = await client.post(url, json=payload, headers=headers) # ✅ 正确写法:显式设置长 timeout timeout = httpx.Timeout(90.0, read=90.0) # connect 和 read 都设为 90 秒 async with httpx.AsyncClient(timeout=timeout) as client: response = await client.post(url, json=payload, headers=headers)

快速验证:
在 TaoToken 服务的代码里,找到发起后端请求的地方,在fetch/httpx调用前后,加两行日志:

console.log(`[TaoToken] Starting stream request to OpenAI at ${new Date().toISOString()}`); const res = await fetch(/* ... */); console.log(`[TaoToken] Got response from OpenAI, status: ${res.status}, headers:`, Object.fromEntries(res.headers.entries()));

然后发起一次前端请求,观察日志。如果只看到第一行日志,几秒后就没了,说明请求在fetch这一步就超时被 abort 了。如果能看到第二行日志,且status是 200,content-type是text/event-stream,那说明连接建立成功,可以进入下一层。

注意:有些框架(如 Next.js App Router)的fetch有内置的next.revalidate或cache策略,会干扰流式响应。务必确认你用的是fetch(..., { cache: 'no-store' }),并禁用任何服务端缓存中间件。

3.2 第二层:检查代理层自身的 HTTP 服务器是否“撑得住”长连接

即使与后端的连接没问题,TaoToken 自己的 HTTP 服务器(比如 Express、Fastify、Next.js 的 API Route)也可能成为瓶颈。它的默认配置,往往是为短连接 REST API 设计的,而非长连接 SSE。

关键检查点:服务器的 keep-alive 和超时设置。
以 Express 为例,默认的http.Server实例,keepAliveTimeout是 5 秒,headersTimeout是 40 秒。这意味着,即使后端一直在发数据,Express 也会在 5 秒后主动关闭与前端的 TCP 连接,因为它认为“连接空闲太久了”。

  • Express 正确配置:

    const express = require('express'); const app = express(); // 创建 HTTP 服务器,并显式设置长超时 const server = app.listen(3000, () => { console.log('TaoToken listening on port 3000'); }); // 关键!延长 keep-alive 和 headers 超时 server.keepAliveTimeout = 65_000; // 65秒,略长于后端 timeout server.headersTimeout = 70_000; // 70秒,确保 headers 有足够时间传输 // 同时,确保路由处理函数不设置过短的超时 app.post('/v1/chat/completions', async (req, res) => { // 这里是你的流式代理逻辑 // 不要在这里用 setTimeout 或任何会中断 res 的操作 });
  • Next.js App Router 配置:
    Next.js 的 API Route 默认有 30 秒执行限制。你需要在route.ts文件顶部添加配置:

    export const runtime = 'edge'; // 或 'nodejs',但 edge 更适合流式 export const maxDuration = 60; // ⚠️ 关键!将超时从默认 30 秒提升到 60 秒

快速验证:
启动服务后,用curl -v直接请求 TaoToken 的代理接口:

curl -v http://localhost:3000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"hello"}]}'

观察curl的 verbose 输出。如果在* Connection #0 to host localhost left intact之后,过了 5 秒左右就显示* Closing connection 0,说明keepAliveTimeout生效了,需要调整。如果能持续 30 秒以上还显示left intact,说明这一层是 OK 的。

3.3 第三层:验证数据搬运过程是否“零缓冲、低延迟”

这是最隐蔽也最关键的环节。很多代理代码看起来很完美:res.on('data', chunk => frontRes.write(chunk)),但实际运行起来,chunk可能被 Node.js 的Writable流内部 buffer 暂存,导致前端感知到明显的延迟或断连。

关键检查点:禁用所有不必要的缓冲,并启用flush。
Node.js 的res.write()默认是异步的,数据会先进入一个内部 buffer,等 buffer 满了或res.end()时才真正发出。对于 SSE,我们必须让每个data:块都“即刻”到达前端。

  • 正确做法(Node.js):

    app.post('/v1/chat/completions', async (req, res) => { // 设置正确的 SSE 头 res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' // 关键!告诉 Nginx 不要缓冲 }); // 创建一个可写流,直接 pipe 到 res const proxyReq = await fetch(/* ... */); const reader = proxyReq.body.getReader(); // 使用立即 flush 的方式写入 const pump = async () => { while (true) { const { done, value } = await reader.read(); if (done) break; // ⚠️ 关键:write 后立即 flush,不等 buffer res.write(value); res.flush(); // 这行是灵魂! } res.end(); }; pump().catch(err => { console.error('[TaoToken] Stream pump error:', err); res.destroy(); }); });
  • Python/FastAPI 示例:

    @app.post("/v1/chat/completions") async def proxy_chat(request: Request): # ... 构造后端请求 ... async with httpx.AsyncClient() as client: backend_resp = await client.post( url, json=payload, headers=headers, timeout=90.0 ) # ⚠️ 关键:使用 StreamingResponse,并设置 media_type return StreamingResponse( backend_resp.aiter_bytes(), # 直接迭代字节流 media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" } )

快速验证:
再次用curl -N测试。这次,注意观察输出的实时性。一个健康的流,应该在你按下回车后 1 秒内就开始输出data: {"id":"...。如果等待超过 2 秒才开始,或者输出是“一坨一坨”的(比如一次输出 5 行data:,然后停顿 1 秒,再输出 5 行),那说明缓冲没有被正确禁用,flush()没起作用,或者StreamingResponse的迭代器没有被正确配置。

3.4 第四层:审查反向代理(Nginx/Traefik)的“缓冲”与“超时”策略

绝大多数生产环境,TaoToken 并不是直接暴露给公网的,而是躲在 Nginx 或 Traefik 后面。这个“最后一道门”,往往就是压垮骆驼的最后一根稻草。

关键检查点:Nginx 的proxy_buffering和proxy_read_timeout。
Nginx 默认开启proxy_buffering on,它会把后端的响应体缓存到内存或磁盘,等全部接收完再发给客户端。这对于 HTML 页面是好事,但对于 SSE,这就是灾难。

  • Nginx 正确配置(在 location 块内):

    location /v1/chat/completions { proxy_pass https://taotoken-backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; # ⚠️ 关键!彻底关闭缓冲 proxy_buffering off; # ⚠️ 关键!延长读取超时,匹配后端 proxy_read_timeout 90; # ⚠️ 关键!确保 SSE 头部被正确传递 proxy_cache_bypass $http_upgrade; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }
  • Traefik 配置(labels 方式):

    labels: - "traefik.http.routers.taotoken.rule=PathPrefix(`/v1/chat/completions`)" - "traefik.http.routers.taotoken.middlewares=taotoken-sticky" - "traefik.http.middlewares.taotoken-sticky.sticky.cookie=true" # ⚠️ 关键:设置超时和禁用缓冲 - "traefik.http.services.taotoken.loadbalancer.healthcheck.timeout=90s" - "traefik.http.services.taotoken.loadbalancer.healthcheck.interval=30s"

快速验证:
重启 Nginx 后,用curl -v再次测试。这一次,重点看响应头。如果curl -v的输出里,< HTTP/1.1 200 OK下面紧接着是X-Accel-Buffering: no,说明 Nginx 已正确透传了这个 header。如果没有,说明proxy_buffering off没生效,或者你的 Nginx 版本太老(< 1.7.12)不支持此指令。

4. 实操过程与核心环节实现:一个可直接复用的 Node.js 代理模板

纸上谈兵终觉浅。下面,我提供一个经过生产环境验证的、最小可行的 TaoToken 流式代理模板(Node.js + Express)。它不是一个功能完备的网关,而是一个“问题定位锚点”——当你遇到stream disconnected before completion时,你可以把它部署起来,用完全相同的配置去对比测试,从而快速判断问题出在你的业务逻辑,还是基础设施配置。

4.1 环境准备与依赖安装

我们使用最精简的组合:express作为 Web 框架,node-fetch作为 HTTP 客户端(v3.x,支持 AbortSignal),dotenv管理密钥。创建一个新目录,执行:

mkdir taotoken-proxy && cd taotoken-proxy npm init -y npm install express node-fetch dotenv

创建.env文件,填入你的 OpenAI Key 和基础配置:

OPENAI_API_KEY=sk-... OPENAI_API_BASE=https://api.openai.com/v1 PORT=3000 # 后端超时,单位毫秒 BACKEND_TIMEOUT_MS=90000 # 服务器自身超时,单位毫秒 SERVER_KEEPALIVE_TIMEOUT_MS=65000

4.2 核心代理逻辑(server.js)

require('dotenv').config(); const express = require('express'); const fetch = require('node-fetch'); const app = express(); const PORT = process.env.PORT || 3000; // 中间件:解析 JSON body app.use(express.json({ limit: '10mb' })); // 核心代理路由 app.post('/v1/chat/completions', async (req, res) => { console.log(`[TaoToken] Received chat request at ${new Date().toISOString()}`); try { // 1. 构造后端请求 URL 和 Headers const backendUrl = `${process.env.OPENAI_API_BASE}/chat/completions`; const headers = { 'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`, 'Content-Type': 'application/json', 'User-Agent': 'TaoToken-Proxy/1.0' }; // 2. 创建 AbortController,设置长超时 const controller = new AbortController(); setTimeout(() => controller.abort(), parseInt(process.env.BACKEND_TIMEOUT_MS, 10)); // 3. 发起后端请求 const backendRes = await fetch(backendUrl, { method: 'POST', headers, body: JSON.stringify(req.body), signal: controller.signal }); console.log(`[TaoToken] Backend response status: ${backendRes.status}`); // 4. 如果后端返回非 200,直接透传错误 if (!backendRes.ok) { const errorText = await backendRes.text(); res.status(backendRes.status).json({ error: { message: errorText } }); return; } // 5. 设置正确的 SSE 响应头 res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', // 关键! 'Access-Control-Allow-Origin': '*' // 开发时方便,生产请按需设置 }); // 6. 获取后端响应体的 Reader const reader = backendRes.body.getReader(); // 7. 定义泵送函数,实现零延迟搬运 const pump = async () => { try { while (true) { const { done, value } = await reader.read(); if (done) { console.log(`[TaoToken] Backend stream ended at ${new Date().toISOString()}`); break; } // ⚠️ 关键:write 后立即 flush res.write(value); res.flush(); } } catch (err) { console.error(`[TaoToken] Pump error:`, err); // 如果出错,确保连接被清理 if (!res.writableEnded) { res.destroy(); } } finally { // 确保结束 if (!res.writableEnded) { res.end(); } } }; // 8. 启动泵送 pump(); } catch (err) { console.error(`[TaoToken] Proxy error:`, err); // 如果在 setup 阶段就出错(如 fetch 超时),返回 500 if (!res.headersSent) { res.status(500).json({ error: { message: 'Proxy failed to connect to backend' } }); } } }); // 错误处理中间件 app.use((err, req, res, next) => { console.error(`[TaoToken] Unhandled error:`, err); if (!res.headersSent) { res.status(500).json({ error: { message: 'Internal Server Error' } }); } }); // 创建 HTTP 服务器并设置超时 const server = app.listen(PORT, () => { console.log(`TaoToken Proxy listening on port ${PORT}`); }); // ⚠️ 关键:设置服务器级别的超时 server.keepAliveTimeout = parseInt(process.env.SERVER_KEEPALIVE_TIMEOUT_MS, 10); server.headersTimeout = server.keepAliveTimeout + 5000; // 处理进程退出信号,优雅关闭 process.on('SIGTERM', () => { console.log('SIGTERM received, shutting down gracefully'); server.close(() => { console.log('Server closed'); process.exit(0); }); });

4.3 启动与测试脚本

创建package.json的scripts:

{ "scripts": { "start": "node server.js", "dev": "nodemon server.js" } }

安装nodemon用于开发:

npm install --save-dev nodemon

启动服务:

npm run dev

测试方法:

  1. 在另一个终端,用curl直接测试代理:

    curl -N http://localhost:3000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"你好"}]}'

    观察输出是否实时、连续、无截断。

  2. 在浏览器中打开一个简单的 HTML 页面,用EventSource测试:

    <!DOCTYPE html> <html> <body> <div id="output"></div> <script> const es = new EventSource('http://localhost:3000/v1/chat/completions', { withCredentials: true }); es.onmessage = (e) => { const data = JSON.parse(e.data); document.getElementById('output').innerText += data.choices?.[0]?.delta?.content || ''; }; es.onerror = (err) => { console.error('EventSource error:', err); }; </script> </body> </html>

    打开开发者工具的 Network 面板,观察该请求的 Status 是否为pending,Response 是否持续有数据流入。

这个模板的价值在于:它剥离了所有业务逻辑(鉴权、计费、日志审计、路由规则),只保留了流式代理最核心的 7 个步骤。如果你用这个模板能稳定工作,而你的业务代码不行,那问题 100% 出在你的业务逻辑里——比如你在pump循环里加了await someAsyncFunction(),或者用了res.send()而不是res.write()+res.flush()。它是一个干净的“对照组”,帮你快速锁定问题域。

5. 常见问题与排查技巧实录:那些年我们踩过的坑

再完美的 checklist,也抵不过真实世界里的千奇百怪。这部分,我整理了在多个项目中反复出现、且极易被忽略的“幽灵问题”,并附上我的独家排查技巧。它们不常出现在官方文档里,但每一次出现,都足以让一个团队加班到凌晨三点。

5.1 问题:前端EventSource静默失败,Network 面板里请求状态是canceled,但控制台没有任何错误

现象还原:
前端代码看起来天衣无缝:

const es = new EventSource('/v1/chat/completions', { withCredentials: true }); es.onmessage = (e) => { /* ... */ }; es.onerror = (err) => { console.error(err); }; // 这个回调根本不会被触发!

Network 面板里,请求的状态从pending变成canceled,Response 为空,Timing 里显示Stalled时间很长,然后直接消失。控制台一片寂静,仿佛什么都没发生。

根本原因:
EventSource对 CORS 的要求极其苛刻。它不仅要求后端返回Access-Control-Allow-Origin: *(或指定域名),还强制要求Access-Control-Allow-Credentials: true时,Access-Control-Allow-Origin不能是*,必须是具体的域名(如https://myapp.com)。而我们的代理模板里写了Access-Control-Allow-Origin: *,这在withCredentials: true时,会被浏览器直接拒绝,且不抛任何 JS 错误,只在 Network 面板里静默canceled。

独家排查技巧:
打开 Chrome DevTools 的Application标签页 →Clear storage→ 勾选Cookies,Cache,Service Workers, 然后点击Clear site data。然后,关闭所有该域名的标签页,重新打开一个全新的无痕窗口,再访问你的页面。为什么?因为EventSource的失败状态有时会被浏览器缓存,即使你改了后端 CORS 配置,旧的失败状态依然存在。只有全新无痕窗口,才能拿到一个“干净”的初始状态。

解决方案:
在代理的响应头中,动态设置Access-Control-Allow-Origin:

// 在 server.js 的响应头设置部分 const origin = req.headers.origin || '*'; res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', 'Access-Control-Allow-Origin': origin, // 动态设置 'Access-Control-Allow-Credentials': 'true', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization' });

5.2 问题:流式响应在 Nginx 后面“粘包”,前端收到的是一整块乱码,无法解析

现象还原:
curl -N测试时,输出是这样的:

data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":171...}data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":171...}data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":171...}

所有的data:块被连在了一起,中间没有\n\n分隔符。前端EventSource因为找不到分隔符,无法解析,最终超时断开。

根本原因:
Nginx 的proxy_buffering off只是禁用了“响应体缓冲”,但它默认的proxy_http_version是1.0。HTTP/1.0 不支持Transfer-Encoding: chunked,Nginx 为了兼容,会把后端的 chunked 响应体,合并成一个大的、带Content-Length的响应发给前端。而 OpenAI 的 SSE 响应,Content-Length是0(因为它是无限流),Nginx 就懵了,开始瞎猜分块边界。

独家排查技巧:
在 Nginx 配置里,临时加上一行日志,记录后端响应的原始头:

log_format upstream_log 'upstream_addr:$upstream_addr | upstream_status:$upstream_status | upstream_response_time:$upstream_response_time | upstream_http_content_type:$upstream_http_content_type | upstream_http_transfer_encoding:$upstream_http_transfer_encoding'; access_log /var/log/nginx/upstream.log upstream_log;

然后看日志。如果upstream_http_transfer_encoding是chunked,而你的proxy_http_version没设为1.1,那基本就是它了。

解决方案:
在 Nginx 的location块里,必须显式声明:

proxy_http_version 1.1;

这是解决“粘包”问题的黄金法则。HTTP/1.1 原生支持 chunked,Nginx 就能正确地透传每一个 chunk,而不是试图合并它们。

5.3 问题:在高并发下,stream disconnected before completion出现频率陡增,但单请求测试完全正常

现象还原:
用ab或hey做压测:

hey -n 100 -c 10 http://localhost:3000/v1/chat/completions -m POST -H "Content-Type: application/json" -d '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"test"}]}'

单并发(-c 1)时成功率 100%,但-c 10时,失败率飙升到 30%,错误全是stream disconnected before completion。

根本原因:
操作系统层面的文件描述符(File Descriptor)耗尽。Node.js 的每个 TCP 连接(无论是与前端的,还是与后端的)都会占用一个 fd。Linux 默认的ulimit -n是 1024。10 个并发,每个请求需要 2 个连接(前端+后端),就是 20 个 fd,听起来不多。但别忘了,Node.js 的fetch底层是libuv,它有自己的连接池和内部 fd。在高并发下,fd 被迅速占满,新的连接无法建立,fetch就会失败,表现为

返回列表