做AI对话应用的人,早晚会遇到同一个问题:超长对话跑到第几十轮,内存一路狂涨,最后进程直接被杀。我排查这类问题用的第一板斧,就是 Runtime Profiling(运行时剖析)。尤其是把对话流程抽象成执行图之后,每个节点吃多少内存、执行完有没有残留,全部可以被量化。这篇内容就围绕“执行图节点级内存监测”展开,讲清楚怎么给每个节点装上内存监测器,怎么用监测数据定位超长对话下的内存泄漏,以及我实测过的几种修复手段。适合正在做 Agent、对话流水线、RAG 管道的朋友参考,也适合被线上 OOM 逼疯的后端开发者找思路。
1. 项目概述:超长对话的内存泄漏到底从哪里冒出来
1.1 为什么我盯上了“执行图”而不是全局监控
很多团队做长对话优化,第一反应是看 free -m、看容器监控面板,发现内存涨了就重启。这种做法只解决了表面问题,因为在进程维度你只能看到“整体在涨”,根本不知道是谁在涨。把对话处理流程拆成执行图以后,情况完全不一样:一次请求从进入到返回,会依次经过会话加载、意图路由、检索、上下文压缩、LLM 调用、记忆写入等多个节点,每个节点都持有自己的临时数据。
执行图的优势在于,它是一个天然的“埋点边界”。你在每个节点的入口和出口各做一次内存采样,就能算出这个节点的内存增量;重复执行多轮之后,如果某个节点的“出口内存”比“入口内存”稳定高出不少,说明这个节点里产生了驻留对象,也就是泄漏点。这比盯着整条内存曲线猜要直接得多。
我最初是在 LangGraph 的自研执行流上做的这套方案,后来在普通 Python Pipeline 上也复用了同一套思路。不管底层是图编排引擎还是手写流程编排,节点级 profiling 的底层逻辑都一样:定位到人,才能定位到问题。
1.2 内存泄漏的典型表现与判断标准
判断一个对话系统是否内存泄漏,不应该靠感觉,而是靠曲线。我常用的方法是:跑一组固定轮数的压测,记录每轮的进程 RSS,然后观察曲线的形态。如果单轮结束后 RSS 能回到基准值,说明是瞬时分配;如果每轮都比上一轮高出固定的大小,那么基本可以判定存在对象滞留。
具体判断标准有三条:
- 内存曲线呈阶梯式上升,不是偶发尖峰,说明有对象每轮都在累积。
- 执行
gc.collect()后 RSS 不回落,说明滞留对象是“可触达”的,垃圾回收拿它没办法。 - 固定输入、固定轮次,内存峰值随轮数线性增长,且每轮增量接近常数,这就是典型的“每轮泄漏一点”。
如果只是内存占用高,但曲线平稳,那不叫泄漏,叫“吃得太多”,优化思路完全不同。把泄漏和突刺区分开,是后续做节点级 profiling 前必须完成的认知准备。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心原理:Runtime Profiling 帮我们回答哪三个问题
2.1 从“进程视角”下沉到“节点视角”
进程级监控只能回答“内存涨了没有”,节点级 profiling 要回答的是三个更具体的问题:哪个节点分配内存最多、哪个节点执行后内存不释放、长期驻留的对象到底被谁引用着。
要回答这三个问题,就不能只看 RSS,得引入对象维度的指标。我一般会同时采集四类数据:
- 瞬时增量:节点执行前后的存活对象数差值,反映单次分配强度。
- 累积增量:节点执行 N 次后仍然存活的对象数量,反映驻留情况。
- 引用链:通过 gc/objgraph 找到对象的 backref chain,确认是被谁持有。
- GC 代际对象:老年代对象数量如果持续上涨,往往就是泄漏。
这四类数据合在一起,才能还原出一个节点的完整内存行为。只看瞬时增量,你会误伤很多高并发但正常释放的节点;只看累积增量,你又很难解释为什么内存涨得这么快。
2.2 节点级 profiling 要抓的四大指标
实践下来,我最常用的一张表格是这样设计的:
| 指标 | 含义 | 常用观测工具 |
|---|---|---|
| 瞬时内存增量 | 节点执行前后存活对象的变化量 | tracemalloc、memory_profiler |
| 累积驻留量 | 执行 N 次后仍未被释放的对象大小 | tracemalloc Snapshot 对比 |
| 对象引用链 | 确认驻留对象被什么路径持有 | objgraph、gc.get_referrers |
| 老年代对象数 | GC 扫描后仍在老年代的对象数量 | gc.get_objects、gc.collect 前后对比 |
这套指标看起来简单,但很多人栽在“采样时机”上。在一次执行中,峰值内存往往出现在节点内部,而不是节点结束的时候。所以我在给节点插桩时,除了记录入口和出口,还会以固定频率采集当前存活对象快照,用来还原单节点内部的内存波动。否则你只能看到“这个节点总量很大”,看不到“这个节点内部先涨后跌”的真实过程。
3. 实操过程:给执行图里每个节点装上内存“监测器”
3.1 工具选型:tracemalloc、memory_profiler、py-spy 我各在什么场景用
先说结论:本地复现用 tracemalloc 和 memory_profiler,线上排查用 py-spy,整体曲线用 psutil。
tracemalloc 是 Python 标准库,最大的价值是能够直接告诉你某段内存是在哪个文件哪一行分配的,定位精度到代码行。代价是它对性能影响很大,开了 tracemalloc 后整体运行速度可能下降一半以上,所以只适合在压测环境用。
memory_profiler 的优势是可以用 @profile 装饰器,输出每一行的内存变化,对 Python 函数内部的分配行为非常直观。但它同样会带来较大性能开销,不适合高并发压测。
py-spy 是采样式 profiler,可以在不改代码的情况下 attach 到运行中的进程,直接把当前 Python 调用栈 dump 下来。线上进程内存暴涨的时候,用 py-spy dump --pid <pid> 抓几份栈,能快速看出所有线程正在执行的代码路径。这个工具在生产环境非常稳,我几乎每台服务都会备着。
3.2 实现一个可复用的 NodeProfiler 基类
我实际写代码时,不会依赖某个单一工具,而是把它们组合成一个轻量级的基类。核心思路是:在执行图节点外包一层装饰器,自动记录节点名、执行耗时、入口内存、出口内存、最大内存、驻留对象数量和大小。
这里给出一个简化但可以直接跑的版本:
python复制import gc
import os
import time
import tracemalloc
from functools import wraps
class NodeProfiler:
def __init__(self):
self.node_name = ""
self.start_rss = 0
self.end_rss = 0
self.peak = 0
self.snapshot_start = None
self.snapshot_end = None
def __enter__(self):
gc.collect()
tracemalloc.start()
self.start_rss = self._get_rss()
self.snapshot_start = tracemalloc.take_snapshot()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.snapshot_end = tracemalloc.take_snapshot()
self.end_rss = self._get_rss()
self.peak = max(self.peak, self.end_rss - self.start_rss)
tracemalloc.stop()
self._report()
@staticmethod
def _get_rss():
return int(open(f"/proc/{os.getpid()}/status").read().split("VmRSS:")[1].split()[0]) * 1024
def _report(self):
growth = self._compare_snapshots()
print(f"[node={self.node_name}] "
f"rss_in={self.start_rss / 1024 / 1024:.1f}MB "
f"rss_out={self.end_rss / 1024 / 1024:.1f}MB "
f"residual_objects={growth}")
def _compare_snapshots(self):
diff = self.snapshot_end.compare_to(self.snapshot_start, 'lineno')
total = sum(stat.size_diff for stat in diff if stat.size_diff > 0)
return total
节点执行时这样用:
python复制with NodeProfiler() as prof:
prof.node_name = "intent_router"
result = intent_router.execute(state)
这个基类虽然简单,但已经能覆盖大多数场景:单节点内存增量、驻留对象大小、以及节点之间的对比。更复杂的场景,比如追踪某个对象到底被谁引用,我建议在 _report 里增加 objgraph 的逻辑。
3.3 数据采集:怎么画出“节点内存占用清单”
工具就位之后,还要设计压测流程。我的做法是:准备一个固定脚本,模拟 50 轮对话,每轮对话完整跑一遍执行图的所有节点,然后输出一张节点维度的内存统计表。
表格形如:
| 节点 | 平均单次增量 | 最大单次增量 | 50轮后驻留量 | 趋势判断 |
|---|---|---|---|---|
| session_loader | 0.8MB | 1.2MB | 5MB | 疑似驻留 |
| intent_router | 0.2MB | 0.5MB | 0MB | 正常 |
| retriever | 12MB | 18MB | 20MB | 驻留+峰值高 |
| context_compress | 3MB | 4MB | 50MB | 明确泄漏 |
| llm_call | 30MB | 45MB | 10MB | 高分配但基本释放 |
| memory_writer | 1MB | 2MB | 100MB | 严重泄漏 |
这张表一旦出来,问题就清晰了:memory_writer 50轮后驻留100MB,肯定是记忆写入逻辑里存了不该存的东西;retriever 单次增量12MB,可能是检索结果太大,也可能是向量数据写到了全局缓存里。
注意一点:采集的时候不要只跑一次,至少要跑三轮相同脚本,看趋势是否一致。因为 Python GC 的时机不稳定,单轮数据可能是噪音,三轮趋势才可信。
4. 超长对话实战:从一份内存报告里挖出三个泄漏点
4.1 案例一:历史消息对象被“双重持有”
第一次跑出报告时,最扎眼的是 memory_writer 节点,每轮对话后驻留约2MB,50轮后稳定多出100MB。从节点名看,问题出在“记忆写入”逻辑。
我用 tracemalloc 对比了第1轮和第50轮的快照,发现驻留的大头是一个 list,里面装的都是 Message 对象。接着用 objgraph 找引用链:
python复制import objgraph
objgraph.show_growth(limit=20)
objgraph.find_backref_chain(sample_msg, objgraph.is_proper_module)
结果发现这条 list 同时被两个对象引用:一个是 SessionState.history,另一个是 MessageHistoryBuffer._all_messages。写入新消息时,代码里把同一份 Message 对象 append 进了两个容器,而这个容器在“滚动删除旧消息”时只清理了前者,没有清理后者。每一轮对话都让 _all_messages 多一条永久引用,泄漏就这么日积月累出来了。
修复方案也很简单:统一数据源,让 SessionState.history 直接引用 MessageHistoryBuffer 的同一个内部列表,或者滚动清理时两边同步。我实际选择了后者,因为改动更小,只加了一个同步清理函数。
4.2 案例二:LLM调用后的响应对象挂在异常堆栈里
第二个泄漏点更隐蔽。压测跑到第30轮以后,llm_call 的驻留量开始明显上涨,但单轮执行完是释放的。这种“延迟累积”的特征说明问题不在正常路径,而在异常路径。
翻代码发现,LLM 调用节点包了一个大 try...except,异常发生后把 e.__traceback__ 塞进了日志上报队列。Python 的 traceback 对象会持有整个调用帧,帧里又持有当时的 response 对象和完整请求体。也就是说,每次遇到一次超时或限流,就会有一个巨大的响应对象被日志队列“保活”下来。对话轮次越多,异常次数越多,泄漏越严重。
修复时我做两件事:第一,try 块范围缩小,只包真正可能抛异常的调用语句,不要把整个业务逻辑包进来;第二,日志上报前显式丢弃 traceback,只保留结构化字段:
python复制except Exception as e:
log_payload = {
"node": "llm_call",
"error_type": type(e).__name__,
"error_msg": str(e),
}
# 不要存 e.__traceback__,避免大对象被长期引用
logger.error(json.dumps(log_payload))
不要小看这类问题,在很多长驻服务里,异常路径的内存泄漏比正常路径多得多。因为正常路径的释放逻辑通常写得比较完整,异常路径却是“先记下来再说”,一记就把整个对象图记住了。
4.3 案例三:检索节点把临时向量数据写进了全局缓存
第三个点来自 retriever 节点。它的单次增量其实不算离谱,12MB 左右,但50轮后驻留20MB,说明有一部分数据没被回收。顺着引用链看,问题出在用 functools.lru_cache 给检索结果做缓存:同一个 query 的向量检索结果被永久缓存起来了。
超长对话里,用户的问题经常伴有时间上下文,比如“刚才说的那个方案再讲一遍”,这类 query 每次都会带上不同的会话前缀,导致缓存 key 永远不会命中,但每次检索结果都会留下来。缓存越来越大,最终和泄漏看起来一模一样。
修复方式有三种:给缓存加 maxsize、给 key 加上会话维度并在会话结束时清理、或者直接去掉缓存。我这里选的是按会话维度管理缓存,在会话关闭钩子里统一 cache_clear()。
4.4 优化前后对比:内存曲线与量化结果
三个泄漏点修复后,我用同一套 profiling 工具重新压测。优化前,第50轮时进程 RSS 已经接近4GB,曲线陡峭向上;优化后,第50轮 RSS 稳定在1.2GB,曲线趋于水平。再看节点维度,memory_writer 驻留量从100MB降到0.8MB,llm_call 的延迟驻留消失,retriever 的驻留量从20MB降到约1MB。
这份对比数据不能只写进文档,我建议直接以表格形式沉淀到 CI 报告里,每个版本跑一次内存回归,防止泄漏点换个姿势复活。
5. 常见问题与避坑清单
5.1 常见问题速查表
| 现象 | 可能原因 | 初步排查方法 | 解决方向 |
|---|---|---|---|
| 每一轮都稳定增加,增量相同 | 历史消息被多处持有 | tracemalloc 对比快照 | 统一容器、滚动清理 |
| 内存过一段时间突然暴涨 | 缓存未设置上限 | 检查 lru_cache / 全局 dict | maxsize、过期时间、会话清理 |
| 内存随异常次数增长 | traceback 持有大对象 | py-spy dump 线上栈 | 缩窄 try、避免保存完整 traceback |
| 只在高并发时涨 | 线程局部数据累积 | 压测并发轮次 | 检查 ThreadLocal 清理 |
| 调用 gc.collect 后回落 | 循环引用未及时回收 | gc.collect 前后观察 RSS | 使用弱引用、或显式断开循环 |
5.2 实操心得:这套 profiling 方案还能怎么用
这套节点级 profiling 方案跑通之后,我不只在出现泄漏时用,还把它沉淀成了日常开发的一部分。核心做法是:在 CI 里加一个“内存回归”任务,用固定场景跑20轮对话,要求每个节点的50轮驻留量不能超过阈值。阈值先根据当前版本跑出一份基准,后续代码变更如果让某个节点增量超过20%,就直接 fail。
针对踩过的坑,我额外提醒三点:
- tracemalloc 本身会显著影响性能,不要在生产环境常开。线上定位用 py-spy 采样即可。
- profiling 插桩代码也可能成为新的泄漏源,比如你把 profiler 对象挂在全局单例里忘了清理,它反而会持续持有历史快照。采样完成后,记得把 profiler 实例释放。
- 修复泄漏后,不要只在本地看一两次,要跑至少三轮以上压测。GC 和缓存行为有随机性,三针稳定才是真稳定。
最后再分享一个我从这套方案里延伸出来的小技巧:把每个节点入口的 gc.get_objects() 数量打到日志里,线上出问题时,你连 py-spy 都不用 attach,直接看日志中的对象数突变,几秒钟就能锁定大概率的泄漏节点。这个动作几乎零成本,但排查事故的时候是真的救命。
