我第一次把自建推理服务挂到网关后面,配置从 Nginx 抄来改改,十分钟就"上线"了。
上线第二天早上客服转来一个投诉:生成的内容显示到一半就断了,前半段还在,后半段是空白。
查下来是流式响应和超时设置打架了。大模型网关和普通反向代理不是一类东西——普通代理转发的是"一个请求对应一个完整响应",大模型网关要处理的是一个可能长达几十秒、分很多片的响应。
坑一:流式响应不能用普通的读超时判断
普通 Nginx 反代有一个 proxy_read_timeout,两个相邻数据块之间的最大间隔,超过就断开。这对大模型场景是灾难。
大模型的流式输出有个天然的停顿:模型在"想"的那几秒,一个字节都不发。首 token 慢的时候,从请求发出到第一个 token 到达可能十几秒,连接全程静默。
如果 proxy_read_timeout 是默认的 60 秒,遇到思考很久的请求就断了。更麻烦的是表现:客户端已经拿到前半段,看起来"成功了一半",但响应其实被截断了,客户端无法从内容本身判断异常。
location /v1/chat/completions {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
# 关掉缓冲,否则流式会被攒成一块再吐
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
proxy_buffering off 这行绝对不能漏。 我漏了它,症状是"客户端完全收不到流式内容,全部生成完了一次性返回"——Nginx 把分片攒在缓冲区里,等上游关闭连接才发出来。开发时用 curl 测试看起来是对的,因为 curl 等的就是完整响应;换成前端 EventSource 才发现根本不是流式。
后端框架也有一份责任。用 FastAPI 的 StreamingResponse 时,一定要确认上游迭代器是真正的 yield,而不是先 await 拿到完整结果再切片返回。
坑二:重试会造成重复计费和重复内容
网关最天真的写法是:上游返回 5xx 或者超时,就重试。
问题在于,流式请求一旦开始输出,重试就意味着前面的内容已经发出去了。重试一次,客户端会收到两遍开头的内容,聊天场景里还会重复渲染。
更贵的是计费。自建推理不按 token 收费,但网关转商业 API 时,重试就是实打实的重复扣费。
我的处理方式是按"响应体是否已开始下发"来区分:
async def forward_with_retry(request, upstream, max_attempts=3):
for attempt in range(max_attempts):
started = False
async def guard():
nonlocal started
async for chunk in upstream(request):
started = True
yield chunk
try:
async for chunk in guard():
yield chunk
return
except (httpx.ReadTimeout, httpx.ConnectError) as e:
# 已经开始下发了,重试会产生重复内容,只能断开
if started:
raise
if attempt == max_attempts - 1:
raise
await asyncio.sleep(2 ** attempt * 0.3) # 指数退避 + 抖动
更严格的做法是只在"连接建立失败"时重试,拿到响应头就不重试。上游返回 5xx 说明它已经处理了一部分请求,重试语义本来就不干净。
顺带说一句,我见过一个网关把 429 也放进重试逻辑,结果上游限流时,网关自己把流量放大成原来的几倍,把上游彻底打死。限流响应不能重试,要退避并降级。
坑三:连接池才是网关的真实瓶颈
网关是 async 的,瓶颈不在网关 CPU,而在网关到上游的连接池。
我给网关配了 100 个并发连接,上游是 8 并发的 vLLM 实例。理论上 100 个请求能进来,实际 92 个卡在连接池排队。
更糟的是这个排队不体现在响应时间里——客户端看到的是"请求发出去了,很久才有反应",网关日志里没有排队这项。排查时我在网关前面加了个请求计数,才看出排队占了总耗时 60%。
上游的并发能力必须显式告诉网关,两边要对齐:
# 网关侧:连接数不要超过上游能吃下的量
limits = httpx.Limits(
max_connections=16, # 对齐上游并发
max_keepalive_connections=8,
keepalive_expiry=30,
)
client = httpx.AsyncClient(
limits=limits,
timeout=httpx.Timeout(
connect=5.0, # 建连超时短一点,快速失败好过堆积
read=None, # 读超时关掉,由流式超时逻辑接管
write=10.0,
pool=5.0, # 池等待超时,这个必须有
),
)
pool=5.0 是关键。httpx 默认的池等待是无限长的,请求可以无限期卡在排队上。设一个池超时,让排队超过 5 秒的请求快速失败——快速失败比无限排队好,客户端还能重试,卡住的请求只占资源。
坑四:网关自己变成了单点
一个典型的架构错误:所有流量走网关,网关和它的上游推理服务跑在同一台机器上。
这样一来,网关的 CPU 被推理服务抢占,网关的内存泄漏会拖垮推理服务,反过来也一样。
后来我把网关拆成独立的、无状态的部署单元,用编排拉多个副本,前面挂负载均衡。网关应该能随时被杀掉重启而不影响业务。
还有个小坑:网关机器的文件描述符。每个 SSE 长连接占一个 fd,加上游连接池,1000 个并发用户就是几千个 fd。默认 ulimit 常常只有 1024:
# 检查当前限制
ulimit -n
# 临时调高(重启后失效)
ulimit -n 65535
# 持久化
echo "* soft nofile 65535" >> /etc/security/limits.conf
echo "* hard nofile 65535" >> /etc/security/limits.conf
我是在压测到 800 并发时,日志刷出一片 Too many open files 才发现的。
坑五:健康检查把模型打死了
很多网关模板自带健康检查,每几秒打一次 /health。如果你的 /health 里包含"实际发一个推理请求",这个检查会持续消耗 GPU,还在处理真实流量时插队。
我的健康检查只做两件事:进程活着、端口通。模型状态另外暴露:
@app.get("/health")
async def health():
# 只检查进程,不碰模型
return {"status": "ok"}
@app.get("/ready")
async def ready():
# 模型是否加载完毕,由启动流程设置这个标志
if not MODEL_READY:
raise HTTPException(503, detail="model loading")
return {"status": "ready"}
区分 /health 和 /ready 很关键:前者回答"进程还活着吗",后者回答"能接流量了吗"。
一个反向代理 + 网关的分层
最后是我现在的分层方式:
客户端
↓
CDN / 负载均衡(处理 TLS、连接数上限)
↓
网关层(无状态,多副本,鉴权、限流、路由)
↓
推理服务(有状态,GPU)
网关层做与模型无关的事:鉴权、限流、配额、路由、按模型名分发。推理层只管推理。
分开之后的好处很直接:换模型、改并发策略、调限流阈值都不用动推理服务,重启推理服务也不掉连接。
收尾
回过头看,那次"内容显示到一半断掉"的故障,本质是用同步请求的心智模型去理解异步流式。网关的每个参数——超时、缓冲、连接池、重试、fd 限制——都要按"响应可能持续几十秒且分片到达"重想。
具体到超时秒数、连接池大小、fd 上限这些值,跟模型大小、上下文长度、上游并发强相关,得在真实链路上压一遍才准。落到你的实例上以官方文档和实测为准。