说明:本文讨论的是 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 本,附每本适合的阶段
资料是我自己整理的,放在下面这个码上,扫码即可获取:



添加时备注「大模型」,优先通过。
拿到之后建议先看学习路线图那一份,先定位自己在哪个阶段,再决定学什么,比一上来就啃框架效率高得多。



