共计 5153 个字符,预计需要花费 13 分钟才能阅读完成。
上个月我们在把公司内部的知识库 Agent 切换到自建的 vLLM 和云端 DeepSeek API 时,运维监控报警群直接炸了:显卡显存明明满了,但推理吞吐量(Tokens/s)却断崖式下跌;更头疼的是,前端用户一旦网络抖动刷新页面,后端模型服务依然在不知疲倦地全量推理剩下的 2000 个 token,白白烧光算力。
很多开发者在写 LLM 代理网关时,以为只要用 StreamingResponse 包一层 httpx.stream() 就万事大吉了。真正跑进生产环境,你会遇到三个致命坑点:客户端断连后服务端无法及时感知导致算力浪费 、 大 token 输出时的消费端背压(Backpressure)内存激增 ,以及 流式传输中如何优雅做敏感词熔断而不是事后拦截。今天我们就用纯正的异步 Python 生态,写一个工业级的大模型流式代理中间层。
一、架构全景与核心痛点拆解
直接让客户端直连 LLM 推理机(无论是自建的 vLLM 还是云厂商 API)几乎等于在裸奔。生产环境的标准拓扑通常是:前端 UI → 统一 API 网关(FastAPI)→ 上游 LLM 引擎。
在流式传输(Server-Sent Events, SSE)模式下,网关不能简单充当“透明管道”,它必须扛起这几个硬指标:
- 精准断连感知(Cancelation Token Propagation):用户关掉浏览器标签页时,网关必须立刻向模型集群发送
abort信号,释放计算上下文与 KV Cache。 - 非阻塞背压处理(Backpressure Control):弱网客户端接收速度极慢时,网关内存不能无限缓冲堆积 chunk。
- 滑动窗口实时内容合规检测:token 是一片片吐出来的,敏感词可能被切分在两个 chunk 之间(比如 chunk 1 是“非”,chunk 2 是“法”),必须在流式传输中用轻量状态机截断,而不是等生成完才打回。
二、最小化致命陷阱:客户端断连为何停不下来?
看一段典型且“有毒”的 FastAPI 流式代理代码:
# ❌ 错误示范:看似完美,实则断连即泄露
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import httpx
app = FastAPI()
@app.post("/v1/chat/completions")
async def chat_proxy(request: dict):
client = httpx.AsyncClient(timeout=60.0)
async def bad_generator():
async with client.stream("POST", "http://vllm-server:8000/v1/chat/completions", json=request) as resp:
async for chunk in resp.aiter_bytes():
yield chunk
return StreamingResponse(bad_generator(), media_type="text/event-stream")
问题在哪?当用户在浏览器端点击“停止生成”或直接切断连接时,Starlette 内部虽然感知到了底层 socket 写入失败,但上游 bad_generator 协程并没有监听底层断开事件,httpx 依然在疯狂从 vLLM 拉取数据,直到整个 prompt 生成完毕。如果并发量一高,你的 GPU 服务器有 30% 以上的算力都在为“死去的连接”打工。
三、生产级流式代理完整实现
我们需要利用 fastapi.Request.is_disconnected() 轮询状态,结合异步生成器的取消上下文(Task Cancellation)来实现上下游级联终止。同时引入一个简单的双字符滑动缓冲区解决跨 chunk 敏感词截断问题。
# proxy_gateway.py
import asyncio
import json
import logging
from typing import AsyncGenerator
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import StreamingResponse
import httpx
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("LLMGateway")
app = FastAPI(title="Production LLM Streaming Proxy")
# 复用全局高并发连接池,避免每次请求重连握手
http_client = httpx.AsyncClient(limits=httpx.Limits(max_keepalive_connections=200, max_connections=1000),
timeout=httpx.Timeout(connect=5.0, read=60.0, write=10.0, pool=10.0)
)
UPSTREAM_URL = "http://10.0.0.12:8000/v1/chat/completions"
SENSITIVE_WORDS = {"违禁词 A", "内幕消息", "非法注入"}
async def stream_processor(request: Request, body: dict) -> AsyncGenerator[bytes, None]:
client_ip = request.client.host if request.client else "unknown"
buffer_text = ""
try:
req = http_client.build_request("POST", UPSTREAM_URL, json=body)
resp = await http_client.send(req, stream=True)
if resp.status_code != 200:
err_body = await resp.aread()
logger.error(f"Upstream error [{resp.status_code}]: {err_body.decode('utf-8')}")
yield f"data: {json.dumps({'error':'Upstream engine error'})}\n\n".encode("utf-8")
return
async for chunk in resp.aiter_bytes():
# 1. 核心关键:检查客户端下行链路是否依然健在
if await request.is_disconnected():
logger.warning(f"Client {client_ip} disconnected abruptly. Aborting upstream stream!")
# 显式关闭上游响应流,触发底层连接终止
await resp.aclose()
break
# 2. 文本解析与敏感内容熔断
chunk_str = chunk.decode("utf-8", errors="ignore")
buffer_text += chunk_str
# 保持缓冲区长度为最长敏感词的 2 倍,防止内存溢出
if len(buffer_text) > 32:
buffer_text = buffer_text[-32:]
if any(bad_word in buffer_text for bad_word in SENSITIVE_WORDS):
logger.error(f"Hit sensitive keyword for client {client_ip}, truncating stream.")
yield f"data: {json.dumps({'content':'\n[系统安全规则拦截:内容已熔断]'})}\n\n".encode("utf-8")
yield b"data: [DONE]\n\n"
await resp.aclose()
break
yield chunk
except httpx.RequestError as exc:
logger.error(f"Network error to upstream: {exc}")
yield f"data: {json.dumps({'error':'Gateway timeout'})}\n\n".encode("utf-8")
finally:
# 清理工作
pass
@app.post("/v1/chat/completions")
async def chat_proxy(request: Request):
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body")
if not body.get("stream", False):
raise HTTPException(status_code=400, detail="This gateway only serves streaming requests.")
return StreamingResponse(stream_processor(request, body),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # 严禁 Nginx 缓冲 SSE 数据包
}
)
@app.on_event("shutdown")
async def shutdown_event():
await http_client.aclose()
四、关键细节与实战避坑
1. 必须干掉反向代理的 Buffer 缓冲
很多同学本地写完跑通了,一放上云测试就发现:浏览器半天没反应,过了十秒钟呼啦一下把一大段文本全部吐出来。这完全破坏了“打字机”体验。
罪魁祸首通常是上层的 Nginx。Nginx 默认会把后端返回的数据聚合成一个 buffer 再往下推。解决办法有两个:
- 在 Python 代码的 Response Header 中注入
X-Accel-Buffering: no。 - 在 Nginx 的反向代理 block 中显式关闭缓冲与代理协议调整:
location /v1/chat/completions {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
# 禁用代理缓冲,实现逐 Token 实时穿透
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
# 针对长文本推理适当放宽读写超时
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
2. 协程并发压测下的资源释放实测
我在局域网用 locust 模拟了 500 个并发长会话,并在收到第 5 个 chunk 时随机切断 40% 的客户端连接。实测数据对比非常震撼:
| 网关实现方案 | GPU 显存占用稳定度 | 无效 Token 计算比例 | 网关单机内存抖动 |
|---|---|---|---|
| 常规 StreamingResponse(无主动感知) | 维持在 94%(大量无用上下文堆积) | 约 38.2% | 线性增长,GC 延迟高 |
| 本文治理方案(断连主动 abort) | 稳定在 65%~70%(及时回收 KV Cache) | < 1.5% | 极度平稳(常量级内存复用) |
五、进阶:使用 uvloop + Granian 替换 uvicorn
Python 在做高并发 IO 转发时,事件循环的效率决定了吞吐上限。在生产 Dockerfile 中,我强烈建议用由 Rust 编写的高性能 HTTP 服务器 Granian(基于 hyper)或者带 uvloop 的 uvicorn 来承载:
# 生产启动命令推荐
# 使用 granian 替代 uvicorn,高并发下协程调度开销直降 25%
pip install granian
granian --interface asgi proxy_gateway:app --host 0.0.0.0 --port 8000 --workers 4 --threads 2 --blocking-threads 4
总结与避坑心得
大模型工程落地中,算法模型本身决定了能力的上限,而工程网关才决定了业务系统的下限。写流式代理不是简单的 while True: yield,时刻警惕 下游断连的上游级联取消,把 X-Accel-Buffering: no 刻进条件反射,才能既保证极佳的用户交互体验,又守住昂贵的 GPU 资源底线。