高并发 LLM 流式网关实战:彻底解决 SSE 假死、Prefix Cache 击穿与孤儿 Token 算力泄漏

12次阅读
没有评论

共计 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% 以上?

原则:不变的静态内容放最前,动态变化的放最后。

层级 内容类型 变化频率 放置

正文完
 1
评论(没有评论)
Copyright©2013-2025 Nas的天空 nassky.top 版权所有
 Theme by Puock