ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

那天下午两点,值班群突然跳出内存告警:网关容器RSS冲到了3.2G,OOM Killer在几分钟后出手,Pod进入CrashLoopBackOff。这个网关平时1G限额都用不到,全靠“配置简单、响应快”活着,怎么会突然被打穿?事后来看,导火索是上游把GPT-4o退役下线了。平台此前把大量业务流量钉在GPT-4o上,模型退役后所有请求被切到替代模型后端,单次响应耗时从400ms涨到接近3s,token上限也更高。就是从那天下午开始,我们原本“看起来毫无问题”的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.8s,P99达到6s
15:10内存使用超过80%,触发告警RSS持续爬升,GC线程活跃
15:35容器RSS达到3.2G,OOM 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_key=API_KEY) # 每次请求都新建 resp = await client.chat.completions.create( model="some-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_key=API_KEY, timeout=Timeout(timeout=35.0)) async def call_llm(prompt: str) -> str: resp = await client.chat.completions.create( model="some-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_factory=lambda: asyncio.Semaphore(150)) pending_queue: asyncio.Queue = field(default_factory=lambda: asyncio.Queue(maxsize=50)) gateway_state = GatewayState() async def handle_request(request): # 有界队列做背压,队列满了直接返回503 try: await asyncio.wait_for(gateway_state.pending_queue.put(None), timeout=0.1) except asyncio.TimeoutError: return JSONResponse(status_code=503, 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 信号量限流与连接池大小:参数该按什么公式定

网关重构最关键的问题:信号量设多少?连接池大小设多少?这不能拍脑袋。这里用到一个经典排队论公式:Little's Law。

并发连接数 = QPS × 平均响应时间

代入这次事故的真实数据:

  • 迁移前:QPS=50,平均RT=0.4s,需要并发连接数 = 50 × 0.4 = 20
  • 迁移后:QPS=50,平均RT=3s,需要并发连接数 = 50 × 3 = 150

看到这组数字就明白了。原来的连接池容量按20配置就能跑得很流畅,因为旧模型RT短。新模型RT一长,需要的并发连接数直接变成150,原连接池根本不够用,所有请求都在排队等待连接。

所以重构后的参数计算方法是:

  • 按线上峰值QPS和P95 RT估算所需并发连接数N = QPS × RT
  • 信号量大小按N的80%到90%设置,预留一部分给重试和突发
  • 连接池容量比信号量稍大,比如信号量150,连接池180,防止信号量释放后连接还没归还导致的瞬时饥饿

连接池配置示例:

client = AsyncOpenAI( api_key=API_KEY, max_retries=0, # 重试交给网关层 timeout=Timeout( connect=5.0, read=30.0, write=10.0, pool=10.0, ), )

顺带一提,Timeout里pool参数很多人不设。它控制从连接池获取连接的最大等待时间。如果连接池一直满着,pool超时会让请求快速失败,而不是无限等待。这个参数在慢下游场景下异常重要。

3.3 超时金字塔与熔断降级:宁可超时,不可雪崩

网络超时不应该只设一个总超时,我建议分层设置,形成一个超时金字塔。

  • connect timeout:5s,只负责TCP建连。模型服务一般在内网,5秒足够,超过基本就是网络异常。
  • read timeout:30s,等待响应首字节。模型推理耗时长,这个值要宽一些。
  • pool timeout:10s,从连接池取连接的等待时间。连接池满就快速失败,不要无限等。
  • total timeout:35s,整体请求上限。防止多个分层超时叠加后总耗时过长。

这套金字塔你能明确知道一个请求挂在哪一层,而不是笼统地报“请求超时”。

熔断我这里用了固定窗口计数,比较简单。核心参数三个:错误率阈值、最小请求数、熔断时长。

# circuit_breaker.py import time class CircuitBreaker: def __init__(self, error_threshold=0.5, min_requests=20, cooldown=10.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, json=payload) 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连接池没有复用全局单例client,lifespan中close
只有总超时没有分层无法定位卡点,浪费等待时间connect/read/pool/total四层超时

5. 这次重构我最想说的三句话

整个事故从爆发到完成网关重构,前后用了一周。复盘时我一直在想,如果当初能早点意识到“模型退役不是配置切换而是容量变化”,很多弯路都不用走。这里分享三点个人很深的体会。

第一,换模型前一定要做容量回归。模型版本退役、模型切换、prompt模板大改,都会改变RT和token分布,这些变量直接影响网关并发数。不要因为“接口签名没变”就觉得没有影响。

第二,SDK源码值得花时间读。你不需要记住每个类的方法,但至少要知道你用的client内部有没有连接池、重试机制默认是什么、非流式调用的内存模型是什么。这些东西就是高并发场景下的隐形地雷。

第三,网关设计一定要有背压和快速失败机制。无界队列是内存炸弹,无限重试是雪崩放大器。宁可让上游看到503,也不要让请求在内存里排队等到OOM。这轮重构后,我们网关的内存曲线变得非常平稳,下次再遇到模型切换,至少不用担心服务被打爆了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进