打通本地LLM落地最后一公里:FastAPI + vLLM 流式网关生产级踩坑与调优实录

4次阅读
没有评论

共计 4194 个字符,预计需要花费 11 分钟才能阅读完成。

上个月我把工作室闲置的两张 RTX 4090 拼了台 48G 显存的推理服务器,跑上了 Qwen2.5-32B-Instruct。原本以为用 vLLM 把 OpenAI 兼容接口拉起来就算大功告成,结果内网接入自研的前端应用后,同事们很快就抛来一堆暴躁反馈:“打字机怎么每次都憋十几秒一次性喷出来?”、“点了停止生成,显卡还在 100% 狂啸?”、“多几个人同时写代码,服务直接卡死 OOM 重启了”。

很多开发者在单机用终端测 curl 时一切正常,但只要上架到具备多租户、公网反代、前端打字机(SSE, Server-Sent Events)的真实交互场景,就会撞上一堆隐蔽的架构死角。这篇文章不聊基础概念,只记录我在构建这套高并发流式代理网关时踩过的几个深坑和压测落地的解法。

一、深坑排查:为什么前端的“打字机”变成了“憋大招”?

本地大模型最核心的体验在于首字延迟(TTFT)与逐字吐出的丝滑感。当我把服务接入前端,却发现原本每秒吐 40 个 token 的响应变成了漫长的等待,最后几百个字在半秒内一口气刷出来。抓包发现,问题出在链路中间的 反向代理缓冲机制 上。

无论是 Nginx 还是部分 API 网关(如 Traefik、Kong),默认策略都会尽量把后端响应收集满一个 Buffer Chunk(通常是 4k 或 8k)才一次性 flush 给客户端。对于普通 REST API 而言能提高吞吐,但对于 SSE 流式协议,这就是灾难。

解决这个问题不仅要在 Nginx 层关掉缓冲,还要在网关代码中显式声明响应头:

# /etc/nginx/conf.d/llm_gateway.conf
location /v1/chat/completions {
    proxy_pass http://127.0.0.1:8000;
    proxy_http_version 1.1;
    
    # 彻底关闭代理缓冲
    proxy_buffering off;
    proxy_cache off;
    
    # 保持长连接与分块传输
    proxy_set_header Connection "";
    chunked_transfer_encoding on;
    
    # 禁用客户端请求体的缓存,减少中间层转发延迟
    proxy_request_buffering off;
    
    # 超时时间放宽,大模型长上下文推理必须设置
    proxy_read_timeout 600s;
    proxy_send_timeout 600s;
}

同时,在 FastAPI 或自定义中间件中,返回流式响应时必须带上 X-Accel-Buffering: no,强制告知 Nginx 不要尝试做任何优化:

# FastAPI 端显式禁用 Nginx 缓冲
headers = {
    "Cache-Control": "no-cache",
    "Connection": "keep-alive",
    "Content-Type": "text/event-stream",
    "X-Accel-Buffering": "no",  # 关键指令:针对 Nginx 的免缓冲标记
}

二、架构致命缺陷:“幽灵推理”与客户端断连感知

这是我部署初期踩过最隐蔽、也是对算力浪费最严重的坑:用户在页面上点了“停止生成”或者直接关闭了浏览器标签页,后端 vLLM 依然不知疲倦地把整个上下文算完。

这被称为“幽灵推理(Ghost Inference)”。在并发场景下,用户经常连续提问并打断,如果后端没有连接感知机制,显卡就会把宝贵的算力花在已经被放弃的请求上,导致后续正常的请求在队列里无限排队。

在 Python 的异步架构中,虽然 HTTP 连接断开了,但如果你写的是简单的 for chunk in response: yield chunk,FastAPI 的生成器并不会立即抛出 CancelledError,除非它尝试往已经关闭的 Socket 写入数据失败。我们需要在异步生成器中主动监听 request.is_disconnected():

import asyncio
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import StreamingResponse
import httpx

app = FastAPI()

VLLM_UPSTREAM_URL = "http://127.0.0.1:8001/v1/chat/completions"

@app.post("/v1/chat/completions")
async def chat_proxy(request: Request):
    body = await request.json()
    # 强制将 stream 设为 True,网关统一按流式处理
    body["stream"] = True

    client = httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=5.0))

    async def stream_generator():
        try:
            async with client.stream("POST", VLLM_UPSTREAM_URL, json=body) as upstream_resp:
                if upstream_resp.status_code != 200:
                    error_detail = await upstream_resp.aread()
                    yield f"data: {{\"error\": \"Upstream error: {error_detail.decode()}\"}}\n\n"
                    return

                async for raw_chunk in upstream_resp.aiter_lines():
                    # 关键监控:每次准备吐出数据前,检测客户端连接状态
                    if await request.is_disconnected():
                        # 客户端已断开,立即终止生成,让上游连接随之 close
                        break

                    if raw_chunk:
                        yield f"{raw_chunk}\n\n"

        except asyncio.CancelledError:
            # 捕获协程被取消的信号
            pass
        finally:
            await client.aclose()

    return StreamingResponse(stream_generator(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no",
        }
    )

这段代码的核心在于,一旦 request.is_disconnected() 为真,循环直接 break,触发 finally 中的 client.aclose()。底层 HTTP 连接掐断后,vLLM 的内部调度引擎会立即感知并释放该请求占用的 KV Cache 与计算槽位。

三、vLLM 生产级启动参数压榨

解决了网络和流式控制,剩下的瓶颈全在 vLLM 本身的显存分配策略。很多同学直接 python3 -m vllm.entrypoints.openai.api_server --model ... 就上线了,这样跑起来极其危险。

在双卡 4090(24G × 2)环境下跑 Qwen2.5-32B(AWQ/GPTQ 4bit 量化版),如果不做精细化控制,并发一上来极易触发 CUDA out of memory。这是我反复压测后稳定使用的启动脚本:

#!/usr/bin/env bash
export CUDA_VISIBLE_DEVICES=0,1

python3 -m vllm.entrypoints.openai.api_server \
    --model /models/Qwen2.5-32B-Instruct-AWQ \
    --tensor-parallel-size 2 \
    --host 127.0.0.1 \
    --port 8001 \
    --max-model-len 8192 \
    --gpu-memory-utilization 0.92 \
    --max-num-seqs 64 \
    --block-size 16 \
    --swap-space 4 \
    --disable-log-requests \
    --trust-remote-code

核心参数的权衡逻辑:

  • --gpu-memory-utilization 0.92:默认是 0.90。AWQ 模型权重占用约 18GB,双卡分摊每张卡不到 10GB。留出 8% 给 CUDA Runtime 和零碎分配,剩下的全部划归 PagedAttention 的 KV Cache。
  • --max-model-len 8192:虽然模型原生支持 32k 甚至 128k,但单条 Context 越长,KV Cache 消耗越成倍暴增。在团队日常写代码、做文档分析的场景下,限制在 8192 可以大幅提升最大并发请求数(Max Concurrency)。
  • --max-num-seqs 64:设置当前实例最大能够并发处理的批次大小(Batch Size)。设得过高会导致每位用户的打字机 TPS 暴跌,设得过低会导致请求进排队队列。64 是双卡 4090 保持单人输出 TPS > 15 的平衡点。

四、实测压测数据对比

为了验证这套网关的优化效果,我使用 Locust 模拟 50 个并发用户持续发起流式对话请求(Prompt 平均长度 1500 tokens,生成长度 300 tokens),对比默认裸跑与调优后的网关表现:

测试场景 平均首字延迟 (TTFT) 平均输出速度 (TPS) 显存占用率 失败率 (断流 /OOM)
原生直连 (默认配置) 4.82s 8.4 tok/s 98.5% (偶发飙升) 18.4% (频繁超时中断)
FastAPI 断连代理 + 优化启动 0.74s 28.6 tok/s 91.8% (稳定水位) 0.0%

通过数据可以清晰看出:解决了 Nginx 缓冲和幽灵推理后,首字延迟直接从近 5 秒暴跌到了 700 毫秒左右;而显存得到了严格规整,彻底消灭了并发冲击下的服务崩塌。

总结与避坑心得

把大模型从“跑通 Demo”推进到“可用服务”,核心难点从来不在模型权重本身,而在于 网络 IO 与显存调度的协同。最后总结三条极客避坑法则:

  1. 永远不要让前端直接对接 vLLM 的端口:缺少鉴权、限流和断连取消控制的底层推理引擎,在公网环境只要被恶意刷几个超长上下文,瞬间就能拖垮显存。
  2. 谨防代理链条上的全自动缓冲机制:从内网应用到公网,查一下 CDN、Cloudflare、Nginx 是否全部开启了 SSE 穿透。只要有一个节点在缓存,你的打字机就废了。
  3. 根据显存严格限制最大
正文完
 0
评论(没有评论)