共计 3835 个字符,预计需要花费 10 分钟才能阅读完成。
上个月我们将内部多 Agent 业务切到自建的 vLLM 和 SGLang 集群上,刚上线压测就遇到了诡异现象:前端用户明明早就关掉了网页或者点击了“停止生成”,后台两张 H800 的显卡风扇依然啸叫,显存常年维持在 95% 以上,推理队列里堆满了已经被抛弃的历史请求。更要命的是,前端反馈“流式打字机效果像便秘一样”,隔上三四秒才突然崩出一大坨字。
这绝不是模型推理能力的问题,而是绝大多数工程师在从“Demo 演示”走向“生产级 LLM 流式代理(Streaming Proxy)”时必然会踩中的三大工程深坑:Nginx 缓冲导致的假流式 、 客户端断开后模型继续狂跑的“孤儿 Token 泄漏”,以及 无意识破坏 Prompt 局部性导致的 KV Cache(Prefix Caching)彻底击穿。
今天抛开那些空泛的概念,直接上代码和压测配置,把这几个隐蔽的性能杀手挨个揪出来干掉。
一、Nginx 反代 SSE:为什么你的打字机效果变成了“便秘”?
流式响应依赖 Server-Sent Events (SSE) 或 `chunked` 分块传输编码。很多同学直接拿以前反代 Web API 的 Nginx 模板套在 LLM 网关前面,结果首字时间(TTFT, Time To First Token)从预期的 300ms 暴增到 4000ms 以上。
核心原因在于 Nginx 默认开启了 proxy_buffering。当后端推理引擎一字一句吐出 data: {"token": "hello"} 时,Nginx 会默默把这些几十字节的小包存入内存 Buffer,直到积攒到 4k/8k 或者响应结束才一股脑 flush 给浏览器。这在传统 HTTP 场景下是减少系统调用的良药,但在流式交互中就是灾难。
这是我们线上验证过的 Nginx 关键虚拟主机配置段:
server {
listen 443 ssl http2;
server_name llm-gateway.internal;
# 关键点 1:必须关闭 proxy_buffering 并禁用缓存
location /v1/chat/completions {
proxy_pass http://vllm_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
# 彻底关闭 Nginx 内部缓冲区
proxy_buffering off;
proxy_cache off;
# 强制通知下游应用层不要缓冲
proxy_set_header X-Accel-Buffering "no";
# 长连接保活与超时配置(推理长文本容易触发超时中断)proxy_read_timeout 600s;
proxy_send_timeout 600s;
proxy_connect_timeout 10s;
# 禁用分块传输缓冲,保证 TCP 立即发出小包
chunked_transfer_encoding on;
tcp_nodelay on;
}
}
这里有两个容易忽略的细节:
tcp_nodelay on;:直接禁用 Nagle 算法。LLM 生成通常是一个 Token 一个包,如果不开启该参数,操作系统内核还会再拖延数十毫秒等待拼包。proxy_set_header X-Accel-Buffering "no";:若网关前端还有二级代理(比如 CDN 或 Ingress-Nginx),这个 Header 可以透传并指示上层网关同样关闭缓冲。
二、捕获断连:终结消耗百万算力的“孤儿 Token”
在生产环境下,约有 18% 到 30% 的用户请求会被中途截断——要么是用户扫了两行发现不对劲直接按了“Stop”,要么是切换了标签页、弱网断线。如果不做主动断流熔断,后果非常严重:
客户端虽然断开了 HTTP 连接,但后端的 FastAPI/Uvicorn 依然在按部就班地从推理引擎拉取 SSE 消息;而 vLLM 根本不知道下游断开,还在拼命消耗算力计算剩下的 2000 个 Token。这部分被白白浪费的算力就是“孤儿 Token”。
FastAPI 流式代理的标准断流实现
很多人写流式代理喜欢用纯粹的异步生成器,却忘了监听底层连接状态。我们需要利用 FastAPI 的 Request.is_disconnected(),配合 asyncio.Task 实现上下游联动销毁:
import asyncio
import httpx
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import StreamingResponse
app = FastAPI()
UPSTREAM_VLLM_URL = "http://127.0.0.1:8000/v1/chat/completions"
# 复用全局异步客户端,防止每个请求都进行 TCP/TLS 握手
client = httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=5.0))
@app.post("/v1/chat/completions")
async def chat_proxy(request: Request):
payload = await request.json()
# 强制将请求标记为 stream
payload["stream"] = True
req_headers = {
"Content-Type": "application/json",
"Authorization": request.headers.get("Authorization", "")
}
async def event_generator():
upstream_response = None
try:
# 建立与后端 vLLM 的异步流式请求
req = client.build_request("POST", UPSTREAM_VLLM_URL, json=payload, headers=req_headers)
upstream_response = await client.send(req, stream=True)
if upstream_response.status_code != 200:
error_body = await upstream_response.aread()
yield f"data: {error_body.decode('utf-8')}\n\n"
return
# 逐行读取上游 SSE 数据
async for chunk in upstream_response.aiter_raw():
# 核心杀招:每次吐包前检查客户端是否已经断开
if await request.is_disconnected():
# 记录排障日志,直接 break 跳出循环触发 finally
print("[WARN] 客户端主动断开连接,正在熔断上游推理任务...")
break
if chunk:
yield chunk
except asyncio.CancelledError:
print("[INFO] 协程被强制取消 (CancelledError)")
raise
finally:
# 清理资源:关闭上游流连接,向 vLLM 释放 TCP 链接
# vLLM 检测到客户端断开后,其内部 Scheduler 才会自动取消该 Sequence 的 Decode
if upstream_response is not None:
await upstream_response.aclose()
print("[DEBUG] 上游推理连接已成功释放")
return StreamingResponse(event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
}
)
在这段代码里,upstream_response.aclose() 至关重要。它会主动掐断通向 vLLM 的 HTTP 流。vLLM 内部的 AsyncLLMEngine 捕获到连接异常后,会在下一个 Step 调度时将该请求的 seq_id 标记为 ABORTED,立即释放其占用的 KV 块,避免继续 Decode 产生无用计算。
三、Prefix Caching 击穿救赎:别让你的动态参数毁掉 KV 缓存
目前主流的推理引擎(vLLM 的 Automatic Prefix Caching、SGLang 的 RadixAttention)都能将多轮对话中的相同前缀缓存在显存中。命中了缓存的请求,Prefill 阶段的计算量几乎是 0,首字延迟直接从秒级压到几十毫秒。
但在排查业务线 Agent 时,我发现他们的 Cache Hit Rate(缓存命中率)居然只有 1.4%。一番代码审计后,找到了罪魁祸首——很多开发者喜欢在 System Prompt 顶部随手塞当前时间:
# 错误示范:这行代码会彻底毁掉整个集群的 Prefix Cache
system_prompt = f"你是一个专业助手。当前系统时间是:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
每过一秒钟,整个 Prompt 的首部 Token 序列就完全变了。由于 Radix Tree 是从第一个 Token 开始逐级匹配的,头部 Token 的哪怕一个字符变化,都会导致后方所有的 Prompt 无法命中任何 KV Cache!
如何重构 Prompt 提升命中率至 85% 以上?
原则:不变的静态内容放最前,动态变化的放最后。
| 层级 | 内容类型 | 变化频率 | 放置
正文完
评论(没有评论)
|
|---|