欢迎光临
我们一直在努力

服务越跑越慢:AI 服务的资源泄漏怎么找、怎么止

说明:本文讨论的是 AI 服务运行久了变慢、内存与句柄持续上涨这类资源泄漏问题,属于工程排障方法,不涉及具体模型版本与价格。AI 领域版本迭代极快,凡涉及版本号、价格、可用性,请以你阅读时的官方页面为准。文中代码为结构示意,请按自己的技术栈调整后再上生产。

一、先分清"泄漏"和"容量不够"

上线第三天,值班的人把首字延迟的曲线拉出来,发现它从八百毫秒走成了两秒。重启,回落。第四天同样。到第七天,进程被系统杀掉,重启完一切正常,于是这次事故以"重启能修"结案。

这个结案方式最贵。它把一次本该改代码的问题,转成了一条永远修不完的运维动作。

先把边界划出来:本篇只谈一个进程在长时间运行里自己把资源吃掉这件事,不谈硬件层面的故障(掉卡、显存错误、降频),不谈容量该怎么算与怎么扩,也不谈压测方案本身。

1.1 三种曲线,三种病

把内存曲线画到 72 小时,形状基本只有三种。

阶梯:走一段平,跳一级,再走一段平。台阶通常对应一次发布、一次配置变更、或者流量结构的一次切换。跳完不回落,说明新版本或新配置本身的常驻占用变高了,更接近容量问题。

锯齿:一路涨到某个阈值,被回收或重启打回去,再涨。这是最典型的泄漏形态,涨幅和请求量正相关,回落只发生在人为干预时。

缓慢爬升:每小时涨几十兆,不重启也能撑一个月。这类最容易被当成正常波动,往往在月度容量对账时才被发现,那时已经加过两次机器了。

三者的处置完全不同:阶梯要查变更,锯齿要查泄漏,缓慢爬升要先把观察窗口拉长再判断。用同一条"内存高了就扩容"的动作去应对,只会让阶梯被机器掩盖、锯齿被重启掩盖。

实际曲线常常是叠加的:底座在缓慢爬升,中间夹着几个台阶。这时候先按台阶找变更,把台阶对应的那版回滚掉,再在剩下的平段上看爬升速度。两件事混在一起查,永远查不清。

1.2 那条真正有分辨力的判据

我们判断泄漏只看一条:空闲期回不回落。

具体做法是取凌晨两点到五点的低谷段,这时 QPS 通常降到峰值的百分之五以下。如果这段时间内存能回到前一天同一时段的水平,那多半是缓存和连接池在正常工作;如果回不去,而且每天的空闲段基线都比前一天高一截,就是泄漏。

这条判据有个前提:服务里确实存在可以释放的缓存。如果缓存是常驻的预热数据,空闲期本来就不该回落,这时判据失效,要换成第二条——按请求量归一后仍然上升。第三章会展开怎么归一。

还有一条辅助判据,说服力最强:重启后的起始值一次比一次高。重启本该回到干净状态,如果每次重启的起点都在往上挪,说明有东西漏在进程之外——临时目录里的文件、共享内存段、没关的对端连接。

1.3 重启为什么会掩盖问题

重启一次,同时治好了三种病:泄漏、内存碎片、外部依赖的坏状态。三种病被同一个动作解决,就没人再往下分辨了。

更麻烦的是重启会把曲线的计时重置。你看到的永远是一段刚重启完的漂亮曲线,看不到七天以上的趋势,而缓慢爬升恰恰只在七天以上才显形。

我们的做法是把重启次数单独画一条时间线,和内存曲线叠在同一张图上。只要某段时间重启密集,那段曲线的任何结论都不采信——先修重启,再看内存。

二、AI 服务特有的五个泄漏点

通用后端的泄漏点大家都知道,连接没关、流没关。AI 服务多出来的部分在请求形态上:请求时间长、可能被中途取消、里面还套着子进程和外部调用。这几种形态各自带来一类泄漏。

2.1 请求被取消之后,没人收尾

用户在生成到一半时关掉页面,或者网关那头超时断开,服务端的任务并不会自动停。协程还在跑,它手里的连接没还回池,写了一半的临时文件没删,拿到的锁没释放。

Python 里有个坑:finally 只在协程被正常取消时才执行。如果任务是 create_task 之后没人 await,对象被丢掉,收尾代码可能一次都不跑。这类泄漏的量级和"取消率"成正比,而取消率在流式接口上通常不低。

我们的做法是把请求入口统一包一层,所有资源在进入时登记,退出时统一释放,取消路径不依赖业务代码自觉。

还有一层更细的:流式接口返回的是生成器,客户端断开后如果没人调用 close(),生成器内部压着的帧和已经生成的缓冲会一直挂在调用栈上。判据是看活跃生成器数量,它应该等于当前在途的流式请求数,多出来的就是没关掉的。

⚠️ 代码待验证

import contextlib

@contextlib.contextmanager
def request_scope(req_id):
scope = Scope(req_id) # 登记本请求创建的连接、临时文件、锁
try:
yield scope
finally:
scope.release_all() # 正常返回和取消都从这里出去

2.2 每次新建的客户端,和没有上限的缓存

第二类来自"图省事"。HTTP 客户端、数据库连接、向量库客户端在请求函数里 new 一个,指望垃圾回收帮忙关。问题是连接对象的关闭是异步的,回收时机不确定,峰值并发下连接数会短时间翻几倍,对端先扛不住。

缓存的问题在基数。一个 dict 加 session_id 做键,键的数量和活跃会话数同阶;用"用户输入的前 N 个字"做键,键的数量和请求总数同阶,等于把流量全存进了内存。这类写法在上线当天的压测里完全看不出来,因为压测只跑十分钟。

判断标准很简单:代码里每一个缓存,都必须能指出它的上限写在哪一行。指不出来,就当成泄漏源。

2.3 子进程、浏览器实例,和只增不删的记录

第三类最隐蔽,因为它藏在工具层。

渲染 PDF、跑 OCR、调外部命令行,父进程起了子进程却不读它的输出。管道缓冲区写满之后子进程卡住不退,父进程等了很久才超时,退出码收不到,子进程留在进程表里变成僵尸。浏览器类工具更重,每个 page 上下文几十兆,context 不关就一直挂着。

还有一类不是技术问题,是设计问题:为了排查方便,在内存里维护一份"最近一千条任务"的列表,包含完整请求体和响应。这份东西只 append,从不裁剪,跑一周就是几个 G。写它的人当时想的是"出问题好查",忘了它自己会变成问题。

泄漏点典型形态观测信号
取消未收尾 流式接口中途断开 取消率与内存增量同步上升
客户端新建 请求内 new 连接对象 连接数峰值远高于并发数
缓存无上限 键里带 session 或用户输入 对象数随请求量线性增长
子进程未回收 未读管道、context 未关 僵尸进程数、子进程数不降
记录只增不删 内存里存任务历史 单对象体积大、数量单调增

三、观测:看什么才能看出泄漏

泄漏不是猜出来的,是被指标逼出来的。难点在于指标太多,绝大多数和泄漏无关。

3.1 三类指标,缺一不可

常驻内存:RSS 或者匿名内存页,看趋势不看瞬时值。

句柄与线程:打开的文件描述符数、线程数、子进程数。这三样泄漏时表现为单调上升,而且不会因为 GC 回落,比内存更干净。

对象数量:按类型统计的实例数,取增量前二十。这一项最容易被忽略,但它能区分两件完全不同的事——RSS 涨到四个 G,可能是内存碎片,也可能是真在攒对象。只有对象数在涨,才说明有东西被留住。

3.2 一定要除以请求量

绝对值没有可比性。两个版本,一个 QPS 高一倍,内存也高一倍,你能说它漏了吗?

用增量比值:(内存增量) / (请求增量),单位取 KB 每千请求。这个数在容量不变时应该是常数,持续为正且稳定,就是泄漏;围绕零上下波动,就是噪声。

这个指标还有个好处,它能把"看起来无害"的量级放大。每小时涨五十兆,听上去可以接受;换算成每千请求 0.5KB,在高峰流量下一天就是几十个 G,账立刻就不一样了。

3.3 拆开看,别揉成一个总账

同一个进程里,生成接口、嵌入接口、文件上传、健康检查的泄漏速度完全不同。揉成一个总数,快的那个会被慢的稀释。

我们按三个维度拆:接口、版本(灰度标记)、请求类型(流式与非流式)。其中健康检查要单独拎出来——它调用量最大、业务日志最少、最不容易被注意到,探针每几秒打一次,一天几十万次调用,漏一点点就是一笔大数。

⚠️ 代码待验证

# 每 30 秒采一次:RSS、句柄数、线程数、子进程数
pid=$(pgrep -f "server:app" | head -n 1)
while true; do
ts=$(date +%s)
rss=$(awk '/VmRSS/{print $NF}' /proc/$pid/status)
fd=$(ls /proc/$pid/fd | wc -l)
thr=$(awk '/Threads/{print $NF}' /proc/$pid/status)
child=$(pgrep -P $pid | wc -l)
echo "$ts,$rss,$fd,$thr,$child" >> /var/log/app/res.csv
sleep 30
done

指标类具体项泄漏时的表现误判风险
内存 RSS、匿名页 单调上升,空闲不回落 碎片也会涨
句柄 FD、线程、子进程 单调上升,GC 不影响 高并发短时会冲高
对象 各类型实例数 某几类持续堆积 缓存预热会误报

四、定位:从曲线到代码

曲线告诉你"在漏",不告诉你"漏在哪"。从曲线到具体那几行代码,中间要经过三轮筛选。

4.1 分段二分,每步只回答一个是非题

顺序是固定的:先按接口,再按版本,最后按时间窗。

先看哪个接口的每千请求增量最高,把候选范围从几十个接口缩到一个。再把这一个接口按版本拆,如果只有灰度版本在涨,问题就在这次改动里,不用往老代码翻。最后按时间窗拆,看是"从某个时刻开始涨"还是"一直在涨",前者对应一次配置或依赖变更,后者对应代码逻辑本身。

三步走完,候选通常只剩一两个函数。这时候才值得上堆快照。

二分步切的维度收敛效果判断依据
第一步 接口 几十个接口收到一个 每千请求增量最高
第二步 版本 全量代码收到一次改动 只有灰度标记在涨
第三步 时间窗 一个改动收到某个时刻 增幅从某点起跳

顺序不能反。先按时间窗切,你只会知道"从上周三开始涨",而上周三可能发了十个接口的改动。

4.2 堆快照对比:看数量,不看体积

取两个时间点的快照,按类型比增量。

关键在比 count 不比 size。size 的差值会被少数大对象淹没——一个刚加载的模型权重、一块预分配的缓冲区,就能让整个排行失真。count 的差值稳定得多,攒了一百万个小对象和攒了一百万个中等对象,在 count 上都看得见。

拿到增长最快的那批类型之后,逐个回溯分配点。多数时候你会看到同一行代码,它在请求路径上,而且没有对应的释放。

⚠️ 代码待验证

import tracemalloc

def top_growth(snap_old, snap_new, n=20):
old = {s.traceback: s.count for s in snap_old.statistics("traceback")}
rows = []
for s in snap_new.statistics("traceback"):
delta = s.count – old.get(s.traceback, 0)
if delta > 0:
rows.append((delta, s.traceback))
rows.sort(key=lambda x: –x[0])
return rows[:n] # 按数量差排序,不按字节差

4.3 复现:要的是时间,不是并发

泄漏只在时间维度上显形,加压解决不了。压到 QPS 翻三倍,跑二十分钟,什么都看不出来,反而把 CPU 打满导致曲线失真。

我们用的是长跑加回放:恒定 QPS 跑八小时以上,请求体的混合比例按线上采样。回放的价值在于能命中那些只在特定输入下才走的路径——超长输入触发的分块分支、带附件触发的转码分支,这些分支在合成流量里几乎不会被走到,而泄漏常常就藏在里面。

复现成功的标志不是"内存涨了",是"涨的速度和线上一致"。速度对上了,改完之后才有可比对象。

五、止血:重启之外的三个动作

线上已经在漏,修代码要两天。这两天里不能只靠重启。

5.1 先限流,把增速压住

泄漏的量和请求量正相关,那就在入口把量压下来。优先降的是那些"单位请求消耗资源最多"的接口——长文本生成、文件转码、带图的请求。这类接口占的流量比例往往不高,砍掉一半对业务影响有限,对泄漏速度的影响却是对折。

限流的另一个作用常被忽略:它保住了现场。直接重启会把泄漏到一半的堆清空,下次再想看就得多等一天。

5.2 滚动重启,窗口要算过

重启不是随便重启。一次性全重启,流量会瞬间压到剩下的实例上,还没起来的那些又因为冷启动慢被健康检查判死,容易连环。

我们的窗口是分批的:一批不超过总量的四分之一,批间隔大于冷启动时间加一次健康检查周期。时间放在业务低谷,和定时任务错开。

有个细节值得单独说:重启前把当前的内存和句柄数记一笔,写进当天的记录。这份记录积累两周,就能看出"每次重启的起点"是不是在往上走,这是 1.2 节那条判据的数据来源。

5.3 什么时候可以先止疼

重启是止疼药,不是治疗。但它不是任何时候都不能用。

如果泄漏速度是每小时几十兆,业务能撑一周,那先不急着重启,把观察窗口留完整,让数据说话。如果速度是每小时几个 G,几小时就 OOM,那就先重启保业务,同时把长跑环境搭起来,用回放在测试环境复现——线上不能再做实验了。

这条边界我们是这么定的:能撑过一个业务高峰的,先查;撑不过的,先重启再查。

动作适用条件代价什么时候不该用
限流降级 泄漏速度与流量强相关 部分请求失败或变慢 泄漏与流量无关时无效
分批滚动重启 单实例内存接近上限 清空现场,冷启动抖动 泄漏在进程外时治不好
扩容 阶梯型曲线、容量真不够 成本,且掩盖问题 锯齿和爬升型会一起放大
关掉可疑功能 已定位到大致的功能面 业务功能缺失 定位不明时是瞎猜

完整版资料清单:本文用到的排障指标清单、长跑基线记录模板和检查脚本,扫码即可获取:

配套资料获取

六、修复与验证

改完那一行代码,最容易犯的错是"看了一天没涨,收工"。

6.1 用长跑基线证明,不用感觉证明

修完的验证要和不修的时候比,比的对象是长跑基线:同样的 QPS、同样的混合比例、同样的时长,跑出来的每千请求增量和修复前对照。

判据不是"内存不涨了",是"增量落进噪声带"。任何服务都有缓慢上涨,缓存会热、连接会扩、解释器会有碎片,一个健康的服务每小时也会涨一点。所以要先跑一次健康基线,把这个带宽量出来,后面的结论都对着这个带说。

这里有个反复踩到的坑:修泄漏的时候顺手改了日志格式或缓存键,结果长跑数据和基线不可比,等于白跑一轮。一次只改一处,基线才有意义。

⚠️ 代码待验证

run_id : soak-20261012-a
duration : 8h
qps : 40 恒定,混合比例按线上采样
rss_start : 612 MB
rss_end : 648 MB
growth : 4.5 MB/h -> 0.11 KB/千请求
fd_start : 210
fd_end : 214
thread_peak : 96
verdict : 通过,增量在噪声带内

6.2 写一条会失败的用例

修完的当天,把复现路径写成一条自动用例。这条用例的价值不在于它现在通过,而在于它以后会失败。

写法上有个要求:断言要打在资源计数上,不要打在内存上。内存有波动,跑在 CI 里必然不稳定;改成断言"请求结束后活跃连接数回到初始值"“临时目录文件数不增加”“子进程数为零”,就稳定得多,而且失败时直接指向原因。

如果泄漏点是缓存没上限,用例就断言缓存的当前条数不超过配置值——这条断言在任何机器上都成立。

⚠️ 代码待验证

from collections import OrderedDict

class BoundedCache:
def __init__(self, max_items):
self.max_items = max_items
self._data = OrderedDict()

def get(self, key):
if key not in self._data:
return None
self._data.move_to_end(key)
return self._data[key]

def put(self, key, value):
self._data[key] = value
self._data.move_to_end(key)
while len(self._data) > self.max_items:
self._data.popitem(last=False) # 超限淘汰最旧的一条

6.3 观察期看哪个数

上线后的观察期,不要盯内存总量,盯两个数:每千请求增量、以及句柄数。

内存总量有 GC 和碎片干扰,前三天基本读不出结论。每千请求增量在流量稳定之后就有意义了,它和修复前的比值直接说明改对了没有。句柄数更直接,它只升不降就是没修好,一天之内就能判定。

观察期我们定七天。低于七天,缓慢爬升型的泄漏没有足够时间暴露,容易得出错误结论。

七、预防:把配额写进代码

同一个团队三个月内往往会遇到第二次泄漏,位置不同,成因同类。防的办法不是"下次注意",是把资源变成一个有数字的东西。

7.1 每个请求的资源预算

给每个请求定一份预算,写清楚它最多能用多少:并发连接一个、临时文件一个且退出即删、缓存条目属于全局不在请求内新增、子进程零个或一个且必须回收。

这份预算的作用不是精确计量,是让评审时有据可依。代码里出现第二个连接、出现一个没有删除路径的临时文件,评审就能直接指出来。

数字从哪来?不要拍脑袋。把线上正常请求的资源占用采样一遍,取 P99 再加一点余量,就是预算值。定得太松等于没有,定得太紧会把正常的长文本请求判成违规,最后被人加白名单绕过。

资源每请求预算释放点违反后的信号
连接 复用池内一条 请求作用域退出 连接数峰值高于并发数
临时文件 至多一个 finally 删除 临时目录文件数只增
缓存 不新增全局键 不适用 缓存条数随请求量涨
子进程 至多一个 读完输出后回收 僵尸进程数大于零
内存驻留 与输入长度成线性 请求结束即释放 单请求增量随轮次累加

7.2 长跑门禁放在哪一步

长跑不能放在合并前,八小时跑不进流水线。我们的位置是发布窗口前一天,作为灰度前的独立一步,和回归用例分开跑。

门禁的判定要机器化:跑完直接读那份记录,和基线比,超了就拦发布,不给人"这次特殊"的空间。

⚠️ 代码待验证

soak_gate:
duration: 8h
qps: 40
mix: replay/2026–10–sample.jsonl
assert:
rss_growth_per_hour_kb: 2000 # 超过即拦
fd_growth_max: 20
thread_growth_max: 10
orphan_child_max: 0
on_fail: block_release

7.3 评审时看四行

代码评审不需要看全部,看四类地方就够:所有 open 有没有对应的关闭路径、所有 create_task 有没有人持有引用、所有缓存初始化有没有上限参数、所有子进程有没有读空输出再等待。

这四条覆盖了第二章那五个泄漏点里的绝大部分。剩下的靠门禁兜底。

完整版资料清单:文中的长跑门禁配置、评审清单和指标看板字段,都整理在这份资料里,扫码即可获取:

配套资料获取

附表 A:关键取舍一览

工程取舍原因依据章节
观察窗口拉满 72 小时 一小时窗口里锯齿和缓慢爬升都看不出来 1.1
把重启次数画成独立时间线 重启重置曲线计时,会隐藏七天以上的趋势 1.3
请求入口统一登记与释放资源 取消路径散落在十几个函数里,逐个改必漏 2.1
取消路径单独写用例 只有客户端断开才触发,手工测不到 2.1
客户端对象放进程级复用 请求内新建的连接关闭是异步的,峰值会冲高 2.2
每个缓存必须写明上限所在行 无上限字典的增长基数与流量同阶 2.2
子进程先读空输出再等待 管道写满会让子进程卡住不退,变成僵尸 2.3
指标统一按每千请求归一 绝对值随流量波动,归一后才能横向比较 3.2
健康检查接口单独拆出来看 调用量最大、日志最少、最容易被稀释 3.3
只做三步二分,不上全量剖析 全量剖析开销大、噪声多,三步就能收敛到函数 4.1
堆快照只比数量不比字节 字节差被少数大对象淹没,排行失真 4.2
复现用长跑加回放,不靠加压 泄漏要的是时间维度,加压只会打满 CPU 4.3
止血先限流后重启 重启清空现场,先限流能保住证据 5.1
修复结论以长跑基线为准 短时间观察对缓慢爬升没有分辨力 6.1
预算写进代码而不是文档 文档里的配额没人执行,代码里的配额会拦住提交 7.1

附表 B:术语速查表

术语说明
RSS 进程实际占用的物理内存,含共享页,看趋势不看瞬时值
匿名内存 不含文件映射的那部分内存,更接近"进程自己攒的"
文件描述符 打开文件、套接字、管道的句柄编号,泄漏时单调上升
僵尸进程 已退出但父进程未回收的子进程,占进程表不占内存
协程取消 任务被中断的信号,收尾代码只在正常取消时执行
上下文管理器 进入时登记、退出时释放的结构,取消路径也走它
淘汰策略 缓存超限时决定丢哪条的规则,常见是丢最久未用的
堆快照 某一时刻全部对象的类型与数量记录,用于前后对比
每千请求增量 资源增量除以请求增量,泄漏判定的核心归一指标
流量回放 把线上请求体脱敏后按原比例重放,用于复现特定分支
基线漂移 每次重启后的起始值逐次抬高,说明漏在进程之外
滚动重启 分批替换实例,避免流量集中压到剩余实例
长跑门禁 灰度前跑固定时长的稳定性测试,超标即拦发布

写在最后:这篇用到的资料

写这篇文章时,我把几个模型的官方文档、参数表和实测记录都对了一遍,顺手整理成几份配套的东西:

  • 大模型学习路线图:从 LLM 基础到 Agent 开发,各阶段该学什么、用什么资料
  • 大模型全套教程:按主题分好的视频与文档清单
  • 大模型实战好书:24 本,附每本适合的阶段

资料是我自己整理的,放在下面这个码上,扫码即可获取:

配套资料获取

大模型全套教程

大模型实战好书

添加时备注「大模型」,优先通过。

拿到之后建议先看学习路线图那一份,先定位自己在哪个阶段,再决定学什么,比一上来就啃框架效率高得多。

赞(0)
未经允许不得转载:171主机测评 » 服务越跑越慢:AI 服务的资源泄漏怎么找、怎么止
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址