大模型并发连接数与 TPM 联合限流:防范 GPU 显存 OOM 的自适应保护
在高并发大模型推理集群的生产运维中,直接沿用传统微服务基于 QPS(每秒请求数)的限流策略往往会引发灾难性事故。普通 HTTP 接口处理 1000 QPS 可能轻而易举,但大模型推理是典型的重资源、长连接、高显存消耗场景。若 10 个请求同时携带了 32k 的超长上下文,瞬间爆发的 Prefill 矩阵计算与庞大的 KV Cache 显存分配,足以直接将单机 8 卡 H800 的显存打崩,引发系统的 OOM 连锁崩溃。
必须跳出 QPS 的惯性思维,构建“并发活跃连接数(Concurrency Slots)”与“每分钟 Token 吞吐量(Tokens Per Minute, TPM)”的双维联合限流防护网,并引入基于 GPU 显存水位的自适应动态调谐。
单一 QPS 维度的破产分析
传统 RPC 服务单次调用耗时通常在毫秒级,显存占用固定。而在 Transformer 自回归生成架构下,资源开销呈现出极端的不确定性:
- 显存占用的非线性与动态性:KV Cache 显存大小直接由
(Batch_Size × Context_Length)决定。即便采用 PagedAttention 机制分页管理显存,一个并发数不高但平均长度达 16k 的长文档分析流量,其显存占用也能达到常规短对话请求的数十倍。 - Decode 过程的长时间资源锁定:大模型生成以流式逐字输出为主,单次请求持续时间可能从数百毫秒拉长至数分钟。如果仅看 QPS,瞬间涌入 50 个请求看似不高,但若这 50 个连接全部进入长文本生成,并发占用的 KV Cache 显存池将迅速耗尽,直接导致引擎触发请求抢占(Preemption)、显存换入换出(Swap to CPU),令 P99 延迟飙升至不可用状态。
- Prefill 与 Decode 的显存争抢:Prompt Prefill 属于计算密集型,瞬时显存开销巨大;Token Decode 属于显存带宽密集型,持续占用显存空间。若无 TPM 约束,突发的超大 Prompt 会瞬间挤占正在 Decode 的请求显存,造成 GPU 显存分配器抛出致命异常。
并发连接与 TPM 联合治理模型
为了在保障 GPU 吞吐最大化的同时坚决守住不 OOM 的底线,网关层必须执行双维联动拦截:
- 并发连接数(Concurrency Control):限制正在推理中的全局最大活跃连接数。此指标直接锚定 GPU 实例可同时承载的批处理上限(Max Batch Size),确保 KV Cache 页表中始终保留最小安全余量。
- TPM 漏桶(Token Per Minute Control):基于 Token 计数对流量进行平滑。区分计费与限流模型中的 Prompt Token(可精确计算)与 Completion Token(基于
max_tokens预占,流式结束后按实际值回补)。 - 显存水位自适应反馈(Adaptive Feedback):静态配置永远无法适配复杂的生产流量分布。网关通过轻量 Sidecar 或专用 Metrics 接口,每秒拉取后端推理节点的
gpu_kv_cache_usage_ratio(KV 缓存使用率)与num_requests_waiting(排队请求数)。当显存水位超过 85% 告警阈值时,网关动态缩减并发窗口与 TPM 配额;当水位降至 70% 以下时,平滑恢复基准配额。
Go 1.27.1 联合自适应限流器实现
以下展示网关核心层拦截管道中的双维限流器,集成了信号量并发控制、Token 预占回退机制与显存自适应调谐接口:
package limiter import ( "context" "errors" "sync" "sync/atomic" "time" ) var ( ErrConcurrencyLimitExceeded = errors.New("inference concurrency slots exhausted") ErrTokenRateLimitExceeded = errors.New("tpm rate limit exceeded, try again later") ) type AdaptiveConfig struct { BaseMaxConcurrency int32 BaseTPM int64 MaxKVUsageThreshold float64 // 例如 0.85 MinKVUsageThreshold float64 // 例如 0.70 } type JointAdaptiveLimiter struct { config AdaptiveConfig // 动态调整后的当前上限 currentMaxConcurrency atomic.Int32 currentTPM atomic.Int64 // 活跃连接控制 activeConnections atomic.Int32 // TPM 令牌桶 mu sync.Mutex tokenBucket float64 lastLeakTime time.Time // 后端显存与负载指标 kvUsage atomic.Uint64 // 存储 float64 的位表示 } func NewJointAdaptiveLimiter(cfg AdaptiveConfig) *JointAdaptiveLimiter { l := &JointAdaptiveLimiter{ config: cfg, lastLeakTime: time.Now(), } l.currentMaxConcurrency.Store(cfg.BaseMaxConcurrency) l.currentTPM.Store(cfg.BaseTPM) l.tokenBucket = float64(cfg.BaseTPM) return l } // UpdateBackendMetrics 接收后端 GPU 监控指标,执行自适应调谐 func (l *JointAdaptiveLimiter) UpdateBackendMetrics(kvUsageRatio float64) { l.kvUsage.Store(uint64(kvUsageRatio * 10000)) maxConn := float64(l.config.BaseMaxConcurrency) maxTPM := float64(l.config.BaseTPM) if kvUsageRatio > l.config.MaxKVUsageThreshold { // 显存高危,激进退避:削减 40% 并发与 TPM 阈值 scale := 0.6 l.currentMaxConcurrency.Store(int32(maxConn * scale)) l.currentTPM.Store(int64(maxTPM * scale)) } else if kvUsageRatio < l.config.MinKVUsageThreshold { // 显存安全,恢复满血配置 l.currentMaxConcurrency.Store(l.config.BaseMaxConcurrency) l.currentTPM.Store(l.config.BaseTPM) } else { // 处于缓冲区间,按线性比例动态缩放 ratio := (l.config.MaxKVUsageThreshold - kvUsageRatio) / (l.config.MaxKVUsageThreshold - l.config.MinKVUsageThreshold) scale := 0.6 + 0.4*ratio l.currentMaxConcurrency.Store(int32(maxConn * scale)) l.currentTPM.Store(int64(maxTPM * scale)) } } // Acquire 尝试预占并发 Slot 与预估 Token 配额 func (l *JointAdaptiveLimiter) Acquire(ctx context.Context, estimatedTokens int64) (func(actualTokens int64), error) { // 1. 活跃并发检查 limit := l.currentMaxConcurrency.Load() if l.activeConnections.Add(1) > limit { l.activeConnections.Add(-1) return nil, ErrConcurrencyLimitExceeded } // 2. TPM 令牌消耗检查 l.mu.Lock() now := time.Now() elapsed := now.Sub(l.lastLeakTime).Seconds() l.lastLeakTime = now // 补充令牌 currentLimitTPM := float64(l.currentTPM.Load()) ratePerSecond := currentLimitTPM / 60.0 l.tokenBucket += elapsed * ratePerSecond if l.tokenBucket > currentLimitTPM { l.tokenBucket = currentLimitTPM } if l.tokenBucket < float64(estimatedTokens) { l.mu.Unlock() l.activeConnections.Add(-1) return nil, ErrTokenRateLimitExceeded } l.tokenBucket -= float64(estimatedTokens) l.mu.Unlock() // 3. 返回清理与按实结算闭包 var once sync.Once release := func(actualTokens int64) { once.Do(func() { l.activeConnections.Add(-1) // 若实际消耗 Token 小于预占估算,将多扣除的 Token 返还令牌桶 diff := estimatedTokens - actualTokens if diff > 0 { l.mu.Lock() l.tokenBucket += float64(diff) currentTPM := float64(l.currentTPM.Load()) if l.tokenBucket > currentTPM { l.tokenBucket = currentTPM } l.mu.Unlock() } }) } return release, nil }生产避坑与架构调优细节
在真实的大规模生产环境中落地此联合限流机制,需要重点防范以下工程陷阱:
- 长 Prompt 预拒绝机制:很多业务方会误传超大无效文档(例如 128k 纯空格或爬虫脏数据)。网关层必须在做 Token 编码(TikToken / Tokenizer 预解析)之前,先设定 Raw Request Body 长度硬上限。如果估算 Prompt Token 已经超过单节点单次推理的最大上下文窗口限制(Context Window Limit),在网关侧直接响应 400 Bad Request 阻断,严禁放行至后端挤占显存。
- 客户端异常断连后的配额补偿:流式传输时,客户端随时可能关闭网络连接。若网关未监听
req.Context().Done(),下游 GPU 将持续进行无效计算,而网关计数器也会持续被虚挂。网关感知到客户端断连后,必须立即通过 Cancel 信号通知后端停止推理,并立即触发释放闭包,避免连接池假死。 - 多租户借贷与防饿死策略:在多业务共用一套大模型集群时,核心链路(如在线客服、实时代码补全)与后台离线链路(如离线数据摘要、文档批量 Embedding)绝不能使用完全相同的限流配额。必须为高优先级租户预留保底并发通道(Guaranteed Slots),低优先级流量采用突发共享池(Burstable Pool),并在显存利用率告警时首先对离线流量实施熔断式降级。