医疗大模型与 RAG 系统:流式高并发接入层架构实战指南

0 阅读1分钟

在面向医疗、科研等严肃业务场景落地大模型(LLM)与 Agent/RAG 架构时,高并发系统的设计范式发生了本质转变。传统的 Web 系统高并发治理主要聚焦在关系型数据库(如 MySQL 连接池、行锁与磁盘 I/O)的防护上,而大模型系统的核心瓶颈则全面转移到了“长耗时 I/O 阻塞”、“GPU 显存(KV Cache)容量限制”以及“流式长连接状态管理”上。

本文基于全链路实战架构演进,系统性梳理在“非流式异步 RAG/安全预检 + 流式大模型输出(SSE)”混合链路下,网关与接入层的高并发设计原则、核心痛点与工程落地细节。

一、 为什么大模型流式系统的高并发完全不同?

传统的微服务架构中,一次 HTTP 请求通常在几十毫秒内完成,网关基于 QPS(Queries Per Second)做令牌桶或漏桶限流即可有效保护后端。但在大模型流式(Server-Sent Events, SSE)场景下,这种模式面临全面失效:

  1. 并发模型从“瞬时”变为“长持久”:单个请求的流式生成往往持续 3 秒至 30 秒。若按常规放行 100 QPS,系统在第 3 秒就会瞬间累积 300 个并行长连接。
  2. 算力瓶颈从“数据库”变为“GPU KV Cache”:每个活跃的生成连接都需要在 GPU 上分配昂贵的 KV Cache 显存。一旦并发超载,GPU 并非变慢,而是直接抛出 CUDA out of memory 导致服务硬崩溃。
  3. 内存压力从“JVM 堆内”变为“网关堆外缓冲”:下游客户端(如移动端查房平板)若处于弱网环境,大模型高速吐出的 Token 会积压在网关操作系统的 TCP 缓冲或 Netty 写缓冲区中,极易打爆直接内存(Direct Memory)。
  4. 长耗时无状态引发算力空耗:若医生中途取消或关闭网页,若无断连级联感知,GPU 依然会盲目跑满数千 Token,浪费近 90% 的算力资源。

二、 接入层全景拓扑与数据流向

整个接入层架构采用“边缘反向代理(Nginx)+ 算力感知网关(Netty)+ 响应式编排(WebFlux)”的三层梯队设计,实现流量自外向内的逐级整形与算力阻断。

                  【医生端 / 客户端 (浏览器 / 移动端查房平板)】
                                       │
                                       ▼  HTTPS (POST /api/v1/chat/stream)
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1. 边缘反向代理层 (Nginx)                                                                    │
│    - proxy_buffering off;          (禁用输出缓冲:大模型 Token 毫秒级直达前端,消除打字卡顿)     │
│    - proxy_request_buffering off;  (禁用输入缓冲:大病历零磁盘 I/O 实时流转,支持前端取消感知)   │
│    - proxy_read_timeout 180s;      (放宽长连接超时,保障 6000 Token 长文本生成不被掐断)        │
└──────────────────────────────────────┬──────────────────────────────────────────────────────┘
                                       │ HTTP Stream (边收边转)
                                       ▼
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ 2. 网关准入与算力控制层 (Netty Gateway)                                                      │
│    ┌───────────────────────────────────────────────────────────────────────────────────┐    │
│    │ ByteBuf 堆外安全治理:入站即解析转 POJO DTO ──► 管道末端 ReferenceCountUtil.release()  │    │
│    └─────────────────────────────────────┬─────────────────────────────────────────────┘    │
│                                          ▼                                                  │
│    ┌───────────────────────────────────────────────────────────────────────────────────┐    │
│    │ GpuAdmissionHandler (基于 GPU 显存与 Token 的弹性准入限流):                        │    │
│    │   1. 提取 Header / Payload,预估 Token 开销 (Prompt*0.6 + 6000 期望输出)          │    │
│    │   2. CAS 尝试预占:currentInFlightTokens + estimatedTokens <= MAX_KV_CACHE_LIMIT  │    │
│    │   3. 检查活跃长连接:activeConnections < MAX_CONCURRENT_CHANNELS                  │    │
│    └─────────────────┬───────────────────────────────────────────────┬─────────────────┘    │
│                      │                                               │                      │
│             [额度不足 / 超出水位]                                  [准入成功]               │
│                      ▼                                               ▼                      │
│     ┌──────────────────────────────────┐            ┌──────────────────────────────────┐    │
│     │ 发送 SSE 降级事件帧 (429/Error)  │            │ 绑定配额至 Channel 属性上下文     │    │
│     │ "当前看诊高峰期,请稍候重试"     │            │ 挂载 channelInactive 断连监听器   │    │
│     │ (0 延迟快速拒止,切断连接)       │            └────────────────┬─────────────────┘    │
│     └──────────────────────────────────┘                             │                      │
└──────────────────────────────────────────────────────────────────────┼──────────────────────┘
                                                                       │ 纯净 DTO (堆内)
                                                                       ▼
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ 3. 业务响应式编排层 (Spring WebFlux / Project Reactor)                                      │
│                                                                                             │
│                         ┌─────────────────────────────────────────┐                         │
│                         │ Mono.zip(安全合规预检, 异步 RAG 混合检索) │                         │
│                         └────────────────────┬────────────────────┘                         │
│                                              │                                              │
│                    ┌─────────────────────────┴─────────────────────────┐                    │
│                    ▼ (非流式并发异步执行,耗时 ~300ms)                  ▼                    │
│      ┌───────────────────────────┐                       ┌───────────────────────────┐      │
│      │ SafetyGuardService        │                       │ AsyncRagService           │      │
│      │ - 敏感词过滤 / PII 脱敏   │                       │ - L1/L2 语义缓存穿透阻断  │      │
│      │ - 越狱检测 (Prompt Guard) │                       │ - 专科库/向量库微批检索   │      │
│      └─────────────┬─────────────┘                       └─────────────┬─────────────┘      │
│                    └─────────────────────────┬─────────────────────────┘                    │
│                                              ▼ 聚合生成                                     │
│                           PromptContext (医学权威上下文 + 提问)                               │
│                                              │                                              │
│                                              ▼ WebClient 发起 SSE 调用                      │
│                           ┌─────────────────────────────────────┐                           │
│                           │ EAS / vLLM GPU 推理集群 (Qwen-72B)   │                           │
│                           └──────────────────┬──────────────────┘                           │
│                                              │ Flux<Token> (逐字流式打字输出)                │
│                                              ▼                                              │
│    ┌───────────────────────────────────────────────────────────────────────────────────┐    │
│    │ 响应式背压与生命周期闭环:                                                         │    │
│    │ - onBackpressureBuffer():感知客户端网络,下游卡顿则自然压制大模型推理              │    │
│    │ - doFinally(signalType):正常结束 / 异常 / 医生取消 ──► 100% 触发归还 GPU Token 配额│    │
│    └───────────────────────────────────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────────────────────────────────────┘

三、 边缘层(Nginx)的核心配置深度解析

在 Nginx 层面,最关键的操作是同时关闭输出与输入缓冲,并针对大文本长任务做超时放宽。

location /api/v1/chat/stream {
    proxy_pass http://netty_gateway_cluster;
    
    # 1. 禁用输出缓冲:消除打字卡顿
    proxy_buffering off;
    proxy_cache off;
    
    # 2. 禁用输入缓冲:消除大病历磁盘 I/O,支持早感知与即时拒止
    proxy_request_buffering off;
    
    # 3. 必须支持 HTTP/1.1 长连接
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_set_header Host $host;
    
    # 4. 放宽读写超时,防止长文本推理被误杀
    proxy_read_timeout 180s;
    proxy_send_timeout 180s;
    
    # 5. 支持大病历 Payload
    client_max_body_size 20M;
}

1. 为什么关闭输出缓冲(proxy_buffering off;)?

Nginx 默认会积攒一个内存 Buffer(如 4KB/8KB)才向下游刷新。若不关闭,大模型逐字生成的 Token 会被截留在网关,前端看到的不是流畅打字,而是“卡顿数秒后突然蹦出一整大段”。

2. 为什么输入缓冲也要关闭(proxy_request_buffering off;)?

  • 消除磁盘临时文件 I/O:医疗病历与体检报告 Payload 常常达到数百 KB 甚至数 MB。Nginx 默认的 client_body_buffer_size 只有 8KB~16KB,超过部分会被迫写盘(client_body_temp_path),造成额外的磁盘 I/O 惩罚。关闭后实现全程内存流式直通。
  • 准入前置与即时拒止(Fast-Fail):关闭输入缓冲后,Nginx 收到 Header 即刻向后转发。若此时下游 GPU 显存已打满,Netty 能在客户端尚未上传完全部几兆 Body 之前,直接返回 429 并切断连接,杜绝无效等待与带宽浪费。
  • 客户端取消感知:医生点击“停止生成”或关掉页面时,连接中断信号能第一时间穿透至后端。

四、 网关层(Netty)的算力感知限流机制

大模型限流必须从“QPS 计数”升维到“并发连接数 + 在途 Token 显存配额”的双重原子管控。

1. 动态配额评估模型

进入网关时,Netty 根据请求上下文估算总消耗:

Reserved Tokens=Prompt 字符数×0.6+期望生成最大 Token\text{Reserved Tokens} = \text{Prompt 字符数} \times 0.6 + \text{期望生成最大 Token}

  • 常规问答场景:期望输出设为 1,000 Token;
  • 病历生成场景:期望输出默认设为 6,000 Token。

2. 准入控制器实现(GpuCapacityGovernor)

利用无锁 CAS(Compare-And-Swap)维护动态配额池,确保显存处于绝对安全线之下:

public class GpuCapacityGovernor {
    private static final int MAX_ACTIVE_CONNECTIONS = 25;       // 活跃连接硬上限
    private static final long MAX_IN_FLIGHT_TOKENS = 80_000L;    // KV Cache 安全 Token 总量

    private final AtomicInteger activeConnections = new AtomicInteger(0);
    private final AtomicLong currentInFlightTokens = new AtomicLong(0);

    public boolean tryAcquire(long estimatedTokens) {
        if (activeConnections.get() >= MAX_ACTIVE_CONNECTIONS) {
            return false;
        }
        while (true) {
            long current = currentInFlightTokens.get();
            if (current + estimatedTokens > MAX_IN_FLIGHT_TOKENS) {
                return false; // 超出显存上限,触发排队或拒止
            }
            if (currentInFlightTokens.compareAndSet(current, current + estimatedTokens)) {
                activeConnections.incrementAndGet();
                return true;
            }
        }
    }

    public void release(long estimatedTokens) {
        currentInFlightTokens.addAndGet(-estimatedTokens);
        activeConnections.decrementAndGet();
    }
}

3. Netty 管道切面与断连监听(ChannelInboundHandler)

将准入控制器挂载到 Netty 管道中,超载时毫秒级就地下发降级 SSE 帧;同时监听 channelInactive,防止客户端断开连接导致配额泄露。

public class GpuAdmissionHandler extends SimpleChannelInboundHandler<FullHttpRequest> {

    private final GpuCapacityGovernor governor;

    public GpuAdmissionHandler(GpuCapacityGovernor governor) {
        this.governor = governor;
    }

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) throws Exception {
        if (!req.uri().contains("/chat/stream")) {
            ctx.fireChannelRead(req.retain());
            return;
        }

        String scene = req.headers().get("X-Medical-Scene", "Record_Gen");
        long estimatedTokens = (long)(req.content().readableBytes() * 0.6) 
                               + ("Record_Gen".equals(scene) ? 6000L : 1000L);

        if (!governor.tryAcquire(estimatedTokens)) {
            sendDowngradeResponse(ctx, "【系统提示】门诊看诊并发较高,算力正在排队中,请稍候重试。");
            return;
        }

        // 将配额与当前连接绑定,便于断连时准确释放
        ctx.channel().attr(ChannelAttrKeys.ESTIMATED_TOKENS).set(estimatedTokens);
        ctx.fireChannelRead(req.retain());
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        // 关键防护:医生提前关闭页面/网络断开时,立即归还 Token 配额并通知下游中断
        Long tokens = ctx.channel().attr(ChannelAttrKeys.ESTIMATED_TOKENS).getAndSet(null);
        if (tokens != null) {
            governor.release(tokens);
        }
        super.channelInactive(ctx);
    }
}

五、 堆外内存(Direct Memory)安全治理

在 Netty 处理超长链路与异步流转时,最隐蔽的崩溃点来自于堆外内存泄露(OutOfMemoryError: Direct buffer memory)。

1. 为什么不能跨线程传递 ByteBuf?

Netty 使用基于引用计数(Reference Count)的池化堆外内存。若将含有 ByteBuf 的对象交给异步线程池(如 RAG 检索),一旦在异常分支或取消流程中漏调了 release(),JVM 的垃圾收集器(GC)根本无法回收 C-Heap 内存,导致服务运行数天后突然挂死。

2. 标准实践:入站即解包(POJO 隔离原则)

  • 在 Netty 线程内反序列化:数据一旦到达,立即在 EventLoop 内完成 JSON 解析,转换成纯净的 Java 堆内对象(DTO);
  • 当场显式释放:反序列化后立即调用 ReferenceCountUtil.release(byteBuf) 将堆外内存还给内存池;
  • 内存拷贝开销微乎其微:将 200KB 数据从堆外复制到 JVM 堆内仅耗时约 0.005 毫秒,相比大模型数千毫秒的生成耗时几乎可以忽略不计,但换来了 100% 的内存安全性。

3. 双重监控与排查机制

  • 启动参数开启采样追踪:配置 -Dio.netty.leakDetection.level=ADVANCED,Netty 会对 1% 的 ByteBuf 分配栈进行追踪,泄漏时直接在控制台输出创建位置的类名与行号。
  • 物理内存硬上限与巡检:设置 -XX:MaxDirectMemorySize=4g,通过定时任务巡检 PlatformDependent.usedDirectMemory() 水位,超 85% 触发告警。

六、 动态调速与自适应打字器实现

在大模型生成与前端展示之间,常常存在“大模型突发喷发(Burst)”与“大模型计算卡顿(Stall)”的问题。为了平衡系统吞吐与视觉体验,需实施分工治理:

[vLLM / EAS GPU] ──(SSE Chunk)──► [Netty/WebFlux] ──(TCP 流控)──► [前端自适应队列] ──► [DOM 渲染]
       ▲                               ▲                                 │
       │                               │                                 │
       └────── 响应式背压 (Backpressure) ┴─── TCP 零窗口 (Window=0) ◄────┘
  1. 反向压制(全链路背压 / 框架原生支持):

    • 当医生端设备卡顿或弱网时,操作系统的 TCP 接收窗口填满,Netty 的写缓冲区(ChannelOutboundBuffer)达到高水位线(64KB);
    • Reactor-Netty 自动暂停向下游大模型发出拉取请求;
    • 大模型推理调度器(如 vLLM)感知到输出阻塞,自动暂停该序列的 Decode 步,将 GPU 资源释放给其他并发任务。
  2. 正向视觉平滑(前端自适应打字队列 / 代码必写):

    • 前端不应“来一个 Token 渲染一个”,而应建立弹性缓冲队列。
    • 积压较多(L>30L > 30):加快打字间隔,并动态加大步长(每次吐 2~5 字),快速追赶;
    • 即将见底(L<5L < 5):微幅调慢打字速度(30ms 延至 60ms),平滑度过大模型的推理卡顿。
class SmoothTyper {
    constructor(renderCallback) {
        this.buffer = [];
        this.render = renderCallback;
        this.timer = null;
        this.baseSpeed = 30; // 基础打字间隔 30ms
    }

    feed(chunk) {
        for (const char of chunk) {
            this.buffer.push(char);
        }
        if (!this.timer) this.loop();
    }

    loop() {
        if (this.buffer.length === 0) {
            this.timer = null;
            return;
        }

        const len = this.buffer.length;
        let step = 1;
        let delay = this.baseSpeed;

        if (len > 60) {
            step = Math.ceil(len / 10);
            delay = 10;
        } else if (len > 20) {
            delay = Math.max(10, this.baseSpeed - (len - 20));
        } else if (len < 5) {
            delay = this.baseSpeed + 25;
        }

        for (let i = 0; i < step && this.buffer.length > 0; i++) {
            this.render(this.buffer.shift());
        }

        this.timer = setTimeout(() => this.loop(), delay);
    }
}

七、 总结:流式与非流式接口的架构选型对比

在实际系统建设中,并非所有接口都能流式输出(如复杂结构化质控校验)。下表总结了两种形态下的核心治理策略差异:

架构维度流式接口(SSE / Chunked Streaming)非流式接口(Non-Streaming RPC)
交互范式同步 HTTP 长连接 + 逐字分块传输首选“异步任务提交 + 轮询/通知”;次选长轮询
Nginx 策略严格关闭双向缓冲(off)允许响应缓冲;必须将超时放大至 180s+
算力限流基于“活跃连接数 + 在途 Token 总量”动态准入采用“最大额度预占 + 真实 Usage 差值瞬间补偿”状态机
内存治理入站转堆内 DTO,下行小包逐字写回下行禁止大包 JSON 入堆反序列化,采用二进制流直通
网络背压依赖 Netty 水位线与 WebFlux 响应式流控依赖轮询频次限制与任务排队漏斗