近日,多位开发者在使用FastAPI结合大型语言模型(LLM)进行流式输出时,发现当前端采用Next.js的fetch API消费数据时,StreamingResponse并未按预期逐块传输token,而是将所有生成的token缓冲至完成后再一次性返回。这一现象严重影响了实时交互体验,尤其在聊天机器人、代码生成等场景中,用户不得不等待整个响应结束才能看到内容。本文将深入分析该问题的技术成因,并给出实用的修复方案。

问题重现:流式响应为何“伪流式”?

FastAPI的StreamingResponse本意是支持异步生成器逐步产出数据,后端代码通常形如:

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import asyncio

app = FastAPI()

async def token_generator():
    for i in range(10):
        yield f"token-{i}\n"
        await asyncio.sleep(0.5)

@app.get("/stream")
async def stream():
    return StreamingResponse(token_generator(), media_type="text/plain")

而在Next.js前端,使用fetch进行消费:

const response = await fetch('/stream');
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    console.log(decoder.decode(value));
}

按照预期,应该每0.5秒收到一个“token-N”并打印。但实际观察中,控制台往往要等待5秒后一次性输出所有结果。这便是缓冲问题的典型表现:后端虽然逐块产出,前端却未能实时获取。

成因剖析:HTTP/1.1 chunked transfer与fetch的关系

问题根源并非FastAPI或Next.js的bug,而是HTTP传输与浏览器缓冲机制的共同作用

  1. 后端侧StreamingResponse默认使用HTTP/1.1的chunked transfer encoding(分块传输编码)。FastAPI会正确地将每个yield封装为一个chunk发送给客户端。但一个关键细节:若生成器未明确设置Content-Type或未在响应头中关闭某些缓冲,底层服务器(如uvicorn或gunicorn)可能会启用内部缓冲。尤其在使用gunicorn等WSGI服务器时,由于同步模型限制,流式输出可能被强制缓冲。但即使使用uvicorn,如果生成器在yield之间没有调用await asyncio.sleep(0)或类似操作,事件循环可能不会及时将chunk推送到网络层。

  2. 前端侧:Next.js的fetch在浏览器环境下,读取ReadableStream时,浏览器本身会对TCP数据进行缓冲。更重要的是,如果后端没有正确设置Cache-Control头或未启用Transfer-Encoding: chunked,浏览器可能会等待足够多的数据(如2048字节阈值)后才触发reader.read()可读事件。此外,一些代理或CDN也会介入缓冲。

  3. LLM的特殊性:LLM每次生成的token往往只有几个字符(如5-10字节),远低于常见缓冲阈值。大量小chunk叠加,极易触发网络栈的Nagle算法或操作系统的TCP延迟确认,导致数据被主动聚合后再发送。

修复方案:双端协同,打破缓冲

方案一:强制禁用Nagle算法(后端)

在FastAPI中,可通过修改底层ASGI服务器的TCP配置来禁用Nagle算法。以uvicorn为例,创建自定义配置:

import uvicorn
from fastapi import FastAPI
from fastapi.responses import StreamingResponse

app = FastAPI()

if __name__ == "__main__":
    config = uvicorn.Config(app, host="0.0.0.0", port=8000, tcp_nodelay=True)
    server = uvicorn.Server(config)
    server.run()

tcp_nodelay=True会禁用TCP段的延迟发送,确保每个chunk立即被封装为TCP报文发送。

方案二:添加响应头抑制缓冲(后端)

StreamingResponse中显式设置响应头,告诉客户端不要缓冲:

async def stream():
    return StreamingResponse(
        token_generator(),
        media_type="text/event-stream",  # 使用SSE类型,天然支持流式
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no",  # 针对Nginx反向代理
        }
    )

特别地,media_type建议设为"text/event-stream",这是Server-Sent Events的标准类型,浏览器和fetch API对其有更好的流式支持。

方案三:前端使用EventSource或改造fetch(前端)

若后端支持SSE,前端可直接用EventSource,它天然支持逐事件消费,无需手动处理ReadableStream。但SSE只支持GET请求且不能自定义Headers,因此许多LLM应用仍需POST。

对于fetch+ReadableStream模式,可强制设置请求头Accept: text/event-stream,并确保前端代码在每次reader.read()后立即处理,不进行额外缓冲。

方案四:主动填充chunk大小(后端)

若前两种方案仍不奏效,可考虑在生成器中将每个token填充到一定大小(如填充空格至256字节),消费时再去除。虽不太优雅,但能有效突破阈值。例如:

async def token_generator():
    for token in real_tokens:
        yield token.ljust(512) + "\n"
        await asyncio.sleep(0)

总结与最佳实践

解决FastAPI流式响应在Next.js fetch中的缓冲问题,核心在于关闭传输路径上的所有缓冲层。建议开发者:

  • 后端使用uvicorn启动,添加tcp_nodelay=True
  • StreamingResponse中设置media_type="text/event-stream"Cache-Control: no-cache
  • 若仍遇问题,检查前端是否通过代理或CDN(如Nginx需添加proxy_buffering off;)。
  • 考虑使用WebSocket替代HTTP流式,但会引入额外连接管理成本。

随着Server-Sent Events标准在AI领域的普及,建议LLM服务商优先采用SSE协议。对于现有项目,按照上述步骤调整,即可让token丝滑呈现,告别死板的“全有或全无”体验。