共计 4027 个字符,预计需要花费 11 分钟才能阅读完成。
很多开发者在搭建本地大模型服务时,起手式通常是 ollama run。对于本地命令行尝鲜或写个单线程 Demo,Ollama 的体验确实丝滑;但只要把并发打到 10 以上,或者把服务挂载到内网团队的 Agent、沉浸式翻译和知识库流水线里,延迟会直接雪崩,首字生成时间(TTFT, Time To First Token)常常瞬间飙升到 10 秒以上,紧接着就是 Broken Pipe 连接中断。
上周我接手了一个内部知识库与代码补全网关的重构任务,硬件环境仅有一台配备单卡 RTX 4090(24GB)的 Ubuntu 边缘服务器。目标是在维持低延迟的前提下,抗住 20~30 个工程师并发调用的流式输出需求。本文记录了将底层推理框架从纯玩具方案迁移至 vLLM 与 SGLang 的实战过程,包含真实的显存压榨配置、流式反向代理踩坑以及压测对比数据。
一、推理引擎架构选型:vLLM vs SGLang
生产环境部署 LLM,首要任务就是丢弃桌面端封装工具,回归工业级推理引擎。目前开源阵营里最值得投入的两个项目是 vLLM(基于 PagedAttention)和 SGLang(基于 RadixAttention)。
- vLLM:生态极其成熟,兼容 OpenAI API 几乎零成本迁移,Chunked Prefill(分块预填充)特性在长文本输入场景表现稳定,但对于大量重复前缀(如复杂 System Prompt、多轮对话上下文)的复用率相对保守。
- SGLang:专注于结构化输出与前缀缓存(RadixAttention)。它将 KV Cache 组织成一棵基数树(Radix Tree),在多轮对话、Agent 工具调用和高重合度 System Prompt 场景下,缓存命中率惊人,TTFT 相比 vLLM 甚至能进一步压缩 30%~50%。
鉴于业务中包含了大量包含固定知识库上下文的 Prompt,我选用了 Qwen/Qwen2.5-14B-Instruct-AWQ 进行 4-bit 量化部署,显存占用约 10GB,留下约 12GB 显存专供 KV Cache 吞吐。
二、关键配置与生产级启动命令
部署大模型不是把参数拉满就行,盲目拉大 max-model-len 只会直接触发 CUDA OOM。以下是我在实操中调试出的最佳参数组合。
1. vLLM 启动脚本(注重通用与稳定性)
python3 -m vllm.entrypoints.openai.api_server \
--model /models/Qwen2.5-14B-Instruct-AWQ \
--quantization awq_marlin \
--served-model-name qwen2.5-14b \
--host 0.0.0.0 \
--port 8000 \
--trust-remote-code \
--max-model-len 8192 \
--gpu-memory-utilization 0.92 \
--enable-prefix-caching \
--enable-chunked-prefill=True \
--max-num-seqs 64 \
--swap-space 4
避坑要点:
--quantization awq_marlin:如果量化包支持 Marlin 算子,务必开启,推理吞吐直接提升约 20%。--enable-chunked-prefill:极为关键!它能将长 Prompt 切块与 Decode Token 混合调度,避免长 Prompt 抢占显卡算力导致其他流式请求卡顿。--gpu-memory-utilization 0.92:单卡环境下不要设为 0.98,给 PyTorch 激活值预留至少 1~2GB 显存,否则极易发生非受控的爆显存崩溃。
2. SGLang 启动脚本(注重前缀缓存与极致响应)
python3 -m sglang.launch_server \
--model-path /models/Qwen2.5-14B-Instruct-AWQ \
--quantization awq \
--port 8000 \
--host 0.0.0.0 \
--context-length 8192 \
--mem-fraction-static 0.88 \
--schedule-policy lpm \
--enable-mixed-chunk
SGLang 的 --schedule-policy lpm(Longest Prefix Match)能最大化命中 KV Cache,非常适合多 Agent 频繁共享 Prompt 的场景。
三、前端流量拦截:配置高并发 SSE 流式代理
在推理服务前面,通常需要架设 Nginx 作为统一网关。刚上线时很多朋友反馈:“请求是打通了,但流式输出(Server-Sent Events, SSE)完全失效,接口总是一下子憋出整篇文字,体验极差。”
这是因为 Nginx 默认开启了反向代理缓冲区(Proxy Buffering),它把模型的逐 Token 流式吐字攒成一大包才吐给前端。以下是经过生产检验的 Nginx 核心配置:
server {
listen 80;
server_name llm-internal.nassky.top;
client_max_body_size 64M;
location /v1/ {
proxy_pass http://127.0.0.1:8000/v1/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# 核心设置:关闭流式传输缓冲
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding on;
# 调整超时时间,长文本生成时防止被 Nginx 掐断
proxy_read_timeout 600s;
proxy_send_timeout 600s;
proxy_connect_timeout 60s;
}
}
四、压测实录:并发场景下的性能表现
为了探究真实工况下的瓶颈,我写了一个异步并发压测脚本,模拟 30 个并发客户端,每个请求携带固定 1500 tokens 的上下文,要求生成 200 tokens,持续压测 5 分钟。测试指标包括:TTFT(首字延迟)、ITL(Token 间延迟) 和 总吞吐量(Tokens/s)。
| 框架与配置方案 | 并发数 | 平均 TTFT | 平均 ITL | 整机总吞吐量 |
|---|---|---|---|---|
| Ollama (Qwen2.5-14B-Q4_K_M) | 30 | 8420 ms | 112 ms | 48 tok/s (频繁排队) |
| vLLM (AWQ + Chunked Prefill) | 30 | 680 ms | 32 ms | 345 tok/s |
| SGLang (AWQ + RadixAttention) | 30 | 240 ms | 29 ms | 390 tok/s |
实测数据一目了然:在固定上下文复用场景下,SGLang 的基数树缓存发挥了统治级优势,TTFT 降到了 240ms。这意味着终端用户几乎在按下回车的一瞬间,屏幕就已经开始打印输出,完全消除了等待焦虑。
五、必须小心的血泪排障记录
1. 客户端中途断开导致的显存空转(Abort 处理)
在流式传输中,用户经常会手动点击“停止生成”或直接关掉网页标签。如果你的反向代理或网关没有传递 Abort 信号,后端推理引擎会固执地把剩下的 Token 生成完毕,白白浪费单卡宝贵的算力。
如果使用 Python 编写中间层转发网关,切记要捕获 RequestDisconnected 异常,显式通知下游关闭 SSE 通道,避免模型盲目迭代:
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import httpx
app = FastAPI()
@app.post("/v1/chat/completions")
async def chat_proxy(request: Request):
client = httpx.AsyncClient(timeout=300.0)
body = await request.json()
async def event_generator():
try:
async with client.stream("POST", "http://127.0.0.1:8000/v1/chat/completions", json=body) as response:
async for chunk in response.aiter_bytes():
# 检查客户端是否已经断开
if await request.is_disconnected():
# 中断请求流,通知底层的 vLLM 终止此 Request ID
break
yield chunk
finally:
await client.aclose()
return StreamingResponse(event_generator(), media_type="text/event-stream")
2. 动态分配导致的显存碎片灾难
在持续跑了 48 小时后,我曾观察到 vLLM 突然抛出 CUDA out of memory,排查后发现,当并发请求的长短差异极大时(有的输入 50 tokens,有的输入 6000 tokens),频繁的内存分配可能导致 CUDA 显存碎片化。解决方式是在系统层面调教内存分配器:
# 在启动脚本前配置环境变量
export PYTORCH_CUDA_ALLOC_CONF="expandable_segments:True"
这行配置允许 PyTorch 分配器在遇到显存压力时按需扩展段,极大地减少了因连续内存不足而导致的误报性 OOM。
总结与选型决策树
单张消费级显卡(如 4090)不是不能扛生产负载,关键在于摆脱桌面思维,合理配置显存与推理算子。对于个人极客或小团队,给出的落地建议如下:
- 如果业务侧重于代码补全、Agent 多步调用、长 System Prompt 知识库 :毫不犹豫选择 SGLang,利用基数树缓存将首字延迟压缩至极限。
- 如果追求生态兼容性、支持多模态(