大模型高并发下Python服务OOM根因分析与网关重构实践

发布时间:2026/10/16 7:49:31
大模型高并发下Python服务OOM根因分析与网关重构实践 那天下午两点值班群突然跳出内存告警网关容器RSS冲到了3.2GOOM Killer在几分钟后出手Pod进入CrashLoopBackOff。这个网关平时1G限额都用不到全靠“配置简单、响应快”活着怎么会突然被打穿事后来看导火索是上游把GPT-4o退役下线了。平台此前把大量业务流量钉在GPT-4o上模型退役后所有请求被切到替代模型后端单次响应耗时从400ms涨到接近3stoken上限也更高。就是从那天下午开始我们原本“看起来毫无问题”的Python服务在大模型高并发链路里暴露出一连串隐蔽的坑OOM只是这些坑集中爆发的最后形态。这篇文章我会完整还原本次事故的排查过程重点钻进Python SDK源码里看大模型调用的真实开销再给出网关重构的完整方案和参数计算过程。如果你正在做AI应用网关、RAG服务或者任何通过Python调用大模型API的高并发业务这篇内容值得从头读一遍。读完你会有个很直接的感受OOM真的只是结果根因在并发模型和内存控制策略上。1. 事故全貌一次模型退役引发的连锁反应1.1 事故时间线与监控信号我们先把时间线拉出来这样后面每一步排查都有据可依。时间事件监控表现14:00运维通知GPT-4o将在次日退役无异常14:10网关配置切换流量转到替代模型平均RT从0.45s开始上升14:30流量全量切换完成平均RT稳定在2.8sP99达到6s15:10内存使用超过80%触发告警RSS持续爬升GC线程活跃15:35容器RSS达到3.2GOOM Killer触发容器重启16:00重启完成后再次OOMCrashLoopBackOff很多人看到“换了个模型”和“OOM”会一脸问号。为什么上游换一个模型对下游网关内存影响这么大这里面的传导链路是响应RT变长 → 并发请求持有连接的时间变长 → 连接池内的请求开始排队 → 排队中的request对象挂着完整的请求体、响应future、重试状态等数据 → 超时后SDK默认会重试 → 重试产生新连接和新请求对象 → 对象越积越多内存被无界堆积打爆。内存不是被单个大对象撑爆的而是被大量的小对象堆积撑爆的。这一点在后来的源码剖析里得到了确认。1.2 初步排查看起来是内存问题实际是链路设计问题第一次看到OOM我们的第一反应和大多数人一样加内存。容器限额从1G加到4G确实撑过了当天下午但在晚高峰流量上来后又一次被打爆。加内存只是把暴雷的时间往后推了根因没解决。后来我们用tracemalloc抓内存热点发现占用最大的根本不是响应体内容而是httpx连接池和请求任务对象。这个结论让排查方向彻底变了不是内存不够是并发模型不匹配新模型的延迟特征。这里有个很重要的排查经验线上出了问题先止血没错但止血的同时必须把根因挖出来。加内存、加副本、重启服务都是止血手段真正要做的是判断OOM背后的分配来源。如果内存热点在业务数据那就是数据体量问题如果内存热点在连接和任务对象那就是链路设计问题。我们属于后者。2. 从 Python SDK 源码看大模型高并发隐藏坑点2.1 连接池不会自动回收SDK 复用的前提现在的模型SDK底层基本都基于httpx比如OpenAI的Python SDK核心调用链是AsyncOpenAI内部持有AsyncClient而AsyncClient内部维护一个连接池。这个连接池的作用是复用TCP连接避免每个请求都重新握手。问题出在很多业务代码根本没有复用client。我在排查时看到类似的代码# 反面示例每次请求都创建新client async def call_llm(prompt: str) - str: client AsyncOpenAI(api_keyAPI_KEY) # 每次请求都新建 resp await client.chat.completions.create( modelsome-model, messages[{role: user, content: prompt}], ) return resp.choices[0].message.content # 注意client没有close连接池和内部buffer全泄漏这段代码在低并发、RT短的场景下问题不大。因为旧模型响应快请求来得快去得快client对象虽然泄漏但量小GC还能兜底。可一旦模型RT涨到3s同时并发一高每个未关闭的client都占用一个连接池连接池里有socket、读缓冲区、写缓冲区、TLS状态。请求在网关层快速堆积内存就开始失控。SDK源码里client对象的__del__或者析构逻辑并不保证及时释放底层连接。在事件循环里如果你没有显式await client.close()连接可能一直半开直到空闲超时。所以第一个铁律是全局单例client进程退出才关闭请求之间只复用不新建。# 正确姿势全局复用 client AsyncOpenAI(api_keyAPI_KEY, timeoutTimeout(timeout35.0)) async def call_llm(prompt: str) - str: resp await client.chat.completions.create( modelsome-model, messages[{role: user, content: prompt}], ) return resp.choices[0].message.content注意如果你在FastAPI里用了lifespan记得在关闭事件里执行await client.close()。这一句代码比加多少内存都管用。2.2 重试与排队指数退避如何把并发拖向崩溃很多大模型SDK默认开启重试OpenAI SDK的max_retries默认值是2。重试机制本身设计得不错它会处理连接错误、HTTP 429、5xx等情况采用指数退避。但在高并发且下游变慢的场景下重试就是放大器。我简化源码里的重试逻辑大致是# 简化自SDK源码的重试核心逻辑 retries 0 while True: try: return await send_request() except (APIConnectionError, RateLimitError, InternalServerError) as e: if retries max_retries: raise e wait_time backoff_factor * (2 ** retries) # 指数退避 await asyncio.sleep(wait_time) retries 1问题在哪当一个请求在连接池里排队超时SDK会认为这是连接层面的失败于是进入重试。重试的请求会重新排队而排队又因为下游慢而继续超时。反复几轮后有效请求没发出去几个等待对象倒是一大堆。我统计过事故当天单请求最多触发了3次重试意味着内存中同时存在3倍于业务量的请求上下文。高并发场景下建议把这套重试交给网关统一处理而不是让每个SDK调用各自重试。网关层面的重试可以配合熔断判断下游是“暂时抖动”还是“已经不行了”。如果下游已经不行再多的重试也只是把内存和时间耗在无意义的等待上。2.3 完整读入内存非流式响应的隐形开销SDK的非流式调用内部会把整个响应体读入内存后再解析。源码里大致是# 简化自SDK源码非流式响应获取方式 content await response.aread() # 一次读入全部内容 data json.loads(content) # 完整解析为dict model_output SomeModel(**data) # pydantic验证这个链路里单次请求的内存开销包括原始字节content、解析后的dict、pydantic模型对象以及过程中产生的临时字符串。如果某个替代模型返回的token数量比GPT-4o时代多出50%单响应体从4KB涨到20KB甚至更高并发一高内存压力就是线性放大。举个例子网关同时处理200个请求每个响应体20KB如果全部非流式读入光响应体就占用4MB听起来不多。但pydantic验证会创建大量临时对象实际峰值往往是响应体本身的5到10倍。更关键的是这类临时对象会进入老年代GC压力飙升CPU又去跑GC了请求处理更慢内存就更紧张形成负循环。如果业务真的只需要最终文本不需要结构化的usage、logprobs等字段建议直接使用原始响应和流式读取。后面网关重构部分会给出具体的流式方案。2.4 token统计与序列化容易被忽略的 CPU 与内存开销SDK内部还有一个容易被忽略的开销token统计和序列化。部分SDK实现会在每次响应后统计usage对某些中间结果做编码转换甚至为了日志把完整请求和响应体都格式化一遍。这些操作在低并发下毫秒级完成但高并发下会占用大量CPU时间片。CPU负载一高事件循环的任务处理变慢请求堆积内存再次上涨。我们后来在压测里发现同样是100并发关掉不必要的日志序列化和usage统计后吞吐提升了差不多20%内存峰值下降了15%。这个坑很隐蔽因为常规日志系统在开发环境根本看不出影响。建议生产环境不要记录完整prompt和response的字符串到结构化日志里只记录token数、耗时、错误码如果不需要usage在SDK请求参数里显式关闭相关选项日志异步化别在请求链路上同步刷盘。3. 高并发网关重构实战从“放进来再说”到“背压优先”3.1 重建并发模型从 worker 进程到 asyncio 事件循环原网关采用传统的线程池模型每个请求占用一个线程线程池大小配了200。Python线程本身有栈内存默认8MB的虚拟内存加上GIL限制CPU多核利用也一般。更重要的是当每个线程阻塞在SDK调用上时系统需要频繁切换上下文CPU时间被白白消耗。重构后我们放弃了每个请求独占线程的模式整个网关基于asyncio事件循环。单个进程内可以挂几千个协程协程的内存开销只有几KB级别远小于线程。同步阻塞的SDK调用全部换成异步调用配合uvicorn的多worker进程跑满CPU核数。网关的入口处理逻辑简化后类似这样# gateway.py import asyncio from dataclasses import dataclass, field dataclass class GatewayState: semaphore: asyncio.Semaphore field(default_factorylambda: asyncio.Semaphore(150)) pending_queue: asyncio.Queue field(default_factorylambda: asyncio.Queue(maxsize50)) gateway_state GatewayState() async def handle_request(request): # 有界队列做背压队列满了直接返回503 try: await asyncio.wait_for(gateway_state.pending_queue.put(None), timeout0.1) except asyncio.TimeoutError: return JSONResponse(status_code503, content{error: busy}) try: async with gateway_state.semaphore: return await call_model_with_stream(request) finally: gateway_state.pending_queue.get_nowait()这个模型的精髓是“背压优先”。事件循环里没有无界队列请求进来如果并发已满不是先在内存里等着而是直接返回503让上游重试或降级。宁可快速失败也不让请求在内存里越积越多。3.2 信号量限流与连接池大小参数该按什么公式定网关重构最关键的问题信号量设多少连接池大小设多少这不能拍脑袋。这里用到一个经典排队论公式Littles Law。并发连接数 QPS × 平均响应时间代入这次事故的真实数据迁移前QPS50平均RT0.4s需要并发连接数 50 × 0.4 20迁移后QPS50平均RT3s需要并发连接数 50 × 3 150看到这组数字就明白了。原来的连接池容量按20配置就能跑得很流畅因为旧模型RT短。新模型RT一长需要的并发连接数直接变成150原连接池根本不够用所有请求都在排队等待连接。所以重构后的参数计算方法是按线上峰值QPS和P95 RT估算所需并发连接数N QPS × RT信号量大小按N的80%到90%设置预留一部分给重试和突发连接池容量比信号量稍大比如信号量150连接池180防止信号量释放后连接还没归还导致的瞬时饥饿连接池配置示例client AsyncOpenAI( api_keyAPI_KEY, max_retries0, # 重试交给网关层 timeoutTimeout( connect5.0, read30.0, write10.0, pool10.0, ), )顺带一提Timeout里pool参数很多人不设。它控制从连接池获取连接的最大等待时间。如果连接池一直满着pool超时会让请求快速失败而不是无限等待。这个参数在慢下游场景下异常重要。3.3 超时金字塔与熔断降级宁可超时不可雪崩网络超时不应该只设一个总超时我建议分层设置形成一个超时金字塔。connect timeout5s只负责TCP建连。模型服务一般在内网5秒足够超过基本就是网络异常。read timeout30s等待响应首字节。模型推理耗时长这个值要宽一些。pool timeout10s从连接池取连接的等待时间。连接池满就快速失败不要无限等。total timeout35s整体请求上限。防止多个分层超时叠加后总耗时过长。这套金字塔你能明确知道一个请求挂在哪一层而不是笼统地报“请求超时”。熔断我这里用了固定窗口计数比较简单。核心参数三个错误率阈值、最小请求数、熔断时长。# circuit_breaker.py import time class CircuitBreaker: def __init__(self, error_threshold0.5, min_requests20, cooldown10.0): self.error_threshold error_threshold self.min_requests min_requests self.cooldown cooldown self.total 0 self.errors 0 self.open_until 0.0 def allow_request(self) - bool: now time.monotonic() if now self.open_until: return False return True def record_success(self): self.total 1 def record_failure(self): self.total 1 self.errors 1 if self.total self.min_requests and (self.errors / self.total) self.error_threshold: self.open_until time.monotonic() self.cooldown self.total 0 self.errors 0熔断打开期间网关直接返回503不调用上游。10秒后进入半开状态放少量请求试探下游是否恢复。这套机制把“请求堆积”变成了“快速失败”从根上切断了OOM的请求来源。3.4 流式转发与内存面控制大响应不再撑爆内存最后一块硬骨头就是响应体。我们彻底把非流式读入改成了流式转发网关不缓存完整响应体而是边读上游边写回客户端。# 核心流式转发写法 async def stream_model_response(payload: dict, response): headers {Content-Type: application/json} async with client.stream(POST, MODEL_URL, jsonpayload) as upstream: response.status_code upstream.status_code for k, v in upstream.headers.items(): response.headers[k] v async for chunk in upstream.aiter_bytes(): await response.write(chunk) await response.send()这里有个容易被忽略的细节即使你的下游客户端需要的是完整JSON而不是SSE流网关同样可以先把chunk写到临时文件或者直接把“正在写入中”的标记返回给客户端让客户端用流式解析。不要在内存里用list把所有chunk拼起来。内存面控制的核心思路是内存占用与响应体大小解耦而不是成正比。另外如果SDK解析需要usage信息不要和正文一起缓存。可以在流结束时用一个小buffer只读取body尾部的一部分或者单独通过元数据接口获取。我们在重构里就把usage提取放到了流结束后的一个小解析器里那部分内存开销可以忽略不计。4. 实操中常见的坑与排查实录4.1 内存监控看 RSS 还是看堆内排查OOM时最容易踩的坑是只知道容器RSS涨了但不知道内存到底被谁吃了。RSS包含堆、栈、共享库、socket buffer等所有东西。容器告警看RSS没问题但定位根因必须看分配源。我的建议是三层工具配合使用第一层容器RSS和CPU曲线确认异常时间段和量级。第二层进程级ps -o rss,vsz,cmd确认是不是单个worker异常。第三层Python层tracemalloc定位分配热点。watch -n 1 ps -o pid,rss,vsz,etime,cmd -p PID如果发现RSS持续上涨但回落缓慢基本可以判断是老对象堆积比如连接池、重试任务、未释放的缓存。4.2 定位内存热点tracemalloc 实战tracemalloc是Python标准库可以在压测环境抓到每个文件行号的内存分配。用法很简单import tracemalloc import asyncio async def main(): tracemalloc.start() # 在这里跑一轮压测流量 await asyncio.sleep(30) snapshot tracemalloc.take_snapshot() top_stats snapshot.statistics(lineno) print(Top 10 memory allocations:) for stat in top_stats[:10]: print(stat)我们当时用这个直接抓出了热点集中在httpx连接池和重试队列而不是业务代码。这就把排查范围从“整个项目”缩小到了“SDK和网络层”。注意tracemalloc对性能影响很大生产环境不要开只在压测环境短时间开跑完就关。4.3 复现 OOM 的压测方法复现OOM不能靠猜。我们搭了一个模拟下游起一个普通的HTTP服务故意延迟3秒返回一个大JSON然后让网关流量打过去。# fake_model_server.py import asyncio import json from fastapi import FastAPI, Request app FastAPI() app.post(/v1/chat/completions) async def fake_model(request: Request): await asyncio.sleep(3) # 模拟慢下游 return { choices: [{message: {content: x * 20000}}], usage: {total_tokens: 20000}, }然后用简单的并发脚本或者压测工具打真实网关观察RSS曲线。这个方法最大的价值是能在一个确定性的环境里验证“连接池大小、信号量、熔断参数”这三件套是否真的有效。修完一个参数跑一轮效果立竿见影。4.4 常见问题速查表症状根本原因解决方案内存无界上涨OOM后重启又涨连接池/请求队列无界堆积信号量限流有界队列快速失败请求越慢越超时重试风暴SDK指数退避重试放大关闭SDK重试网关统一重试熔断CPU不高但RT高任务积压线程池阻塞或事件循环阻塞全链路asyncio化跨进程跑满多核内存突刺出现在响应阶段非流式响应整体读入内存流式转发响应体解耦内存每次请求新建client连接池没有复用全局单例clientlifespan中close只有总超时没有分层无法定位卡点浪费等待时间connect/read/pool/total四层超时5. 这次重构我最想说的三句话整个事故从爆发到完成网关重构前后用了一周。复盘时我一直在想如果当初能早点意识到“模型退役不是配置切换而是容量变化”很多弯路都不用走。这里分享三点个人很深的体会。第一换模型前一定要做容量回归。模型版本退役、模型切换、prompt模板大改都会改变RT和token分布这些变量直接影响网关并发数。不要因为“接口签名没变”就觉得没有影响。第二SDK源码值得花时间读。你不需要记住每个类的方法但至少要知道你用的client内部有没有连接池、重试机制默认是什么、非流式调用的内存模型是什么。这些东西就是高并发场景下的隐形地雷。第三网关设计一定要有背压和快速失败机制。无界队列是内存炸弹无限重试是雪崩放大器。宁可让上游看到503也不要让请求在内存里排队等到OOM。这轮重构后我们网关的内存曲线变得非常平稳下次再遇到模型切换至少不用担心服务被打爆了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询