AI Agent 内存泄漏排查实战:从 1.1GB 到 34MB 的优化之旅
凌晨三点,内存炸了
“Dashboard 挂了。”
那是某个深夜,我正打算收工睡觉,习惯性瞄了一眼终端里的系统监控——HTTP 请求超时,内存占用 93%,Swap 使用率飙升到 75%。我叹了口气,又是熟悉的配方。
这是我的 AI Agent 系统里的一个 Dashboard 服务,功能很简单:展示系统健康状态、近期的任务记录、进程信息。一个轻量级的 Python HTTP 服务器,没有数据库依赖,没有复杂框架,按理说应该稳如老狗。
可它就是——每隔一个多小时就吃掉 1GB+ 内存,然后 OOM 被系统杀掉。
重启一时爽,一直重启一直爽——但显然这不是长久之计。于是我在一个深夜,决定跟这个内存泄漏死磕到底。
先了解敌人
Dashboard 是用 Python 内置的 http.server 写的,一个轻量级 HTTP 服务器,提供几个 REST 端点返回 JSON 数据。代码量不大,大约 200 行,逻辑也简单:
- 收到 HTTP 请求
- 收集系统数据(内存、CPU、进程等)
- 返回 JSON
- 循环
刚开始我怀疑是 Python 对象没释放——比如收集到的数据列表越来越大,或者日志积累。但检查了一遍代码,所有数据都是局部变量,函数结束后应该被 GC 回收。
等等,应该——这个词在内存泄漏排查里是最危险的。
第一回合:top 和 ps 的基本操作
我登录服务器,先用 top 看进程状态:
1 | $ top -p $(pgrep -f dashboard) |
输出显示 RES 一直在涨,每次请求增加约 2-3MB,但从来不降。典型的泄漏特征。
然后我看了下进程连接状态:
1 | $ ss -tap | grep dashboard |
好家伙,CLOSE_WAIT 堆成山了!
CLOSE_WAIT 是什么?
对于不熟悉 TCP 状态机的朋友,简单解释一下:
当客户端主动关闭连接(发送 FIN),服务端收到后进入 CLOSE_WAIT 状态。这个状态意味着”客户端说再见了,但我还没说再见”。服务端应该在这个状态下调用 close() 来发送自己的 FIN,然后进入 LAST_ACK。
但如果服务端忘了调用 close(),这个连接就会一直停留在 CLOSE_WAIT。
每一个 CLOSE_WAIT 连接都对应一个打开的文件描述符和一个 socket 缓冲区——它们不会被释放。积少成多,内存就被吃光了。
我数了一下,有 47 个 CLOSE_WAIT 连接。每个连接大约占用 20-30MB(包括内核缓冲区和 Python 层的对象开销),加起来轻松超过 1GB。
第二回合:根因定位
问题是找到了,但 Python 的 http.server 怎么会不关闭连接呢?这是个标准库模块,不应该有这种低级 bug 啊。
我翻看了源码(Lib/http/server.py),发现 BaseHTTPRequestHandler 在 handle_one_request() 中处理完请求后,默认行为是保持连接 alive(HTTP/1.1 默认 keep-alive)。问题在于:
- 客户端(监控采集脚本)期望短连接——每次请求都新建连接
- 但服务端在 keep-alive 模式下,不会主动关闭
- 客户端发送 FIN 后,服务端进入 CLOSE_WAIT
- 服务端没有显式调用
close()来清理
更具体地说,http.server 的 handle_one_request() 在处理完请求后会检查 self.close_connection 标志。如果为 True,才关闭连接。但默认情况下,对于 HTTP/1.1 请求,close_connection 初始为 False。
除非显式设置响应头 Connection: close,或者调用 self.close()。
1 | # 问题代码的简化版本 |
你可能会想:那等待 keep-alive 超时自动关闭不就行了?问题是 keep-alive 超时是基于 select/poll 的,在 Python 的简单实现中,如果请求处理是同步阻塞的,超时机制并不能有效地清理连接。
第三回合:动手修复
修复方案其实很简单,三步走:
1. 设置 Connection: close 头
1 | self.send_response(200) |
这告诉客户端:服务端会在响应后关闭连接。客户端收到后,不会再复用这个连接。
2. 设置 close_connection 标志
1 | self.close_connection = True |
这让 BaseHTTPRequestHandler 的请求循环在下次迭代时退出,触发连接关闭。
3. 主动 socket shutdown
1 | self.request.shutdown(socket.SHUT_WR) |
这是最保险的一步——直接关闭底层 socket,确保无论上层状态机处于什么状态,TCP 连接都会被终止。
把三者结合到一起:
1 | class DashboardHandler(BaseHTTPRequestHandler): |
验证修复
部署修复后,我每隔 10 分钟检查一次内存和连接状态:
1 | $ watch -n 60 'ss -tap | grep dashboard | grep CLOSE_WAIT | wc -l' |
结果:
- 修复前:CLOSE_WAIT 从 0 增长到 47(约 1.6 小时),内存从 34MB 飙升至 1.1GB(93%)
- 修复后:CLOSE_WAIT 始终 ≤ 1,内存稳定在 34-38MB
我又跑了几个压力测试——连续发送 1000 次请求,内存波动不超过 5MB。
1.1GB → 34MB,优化了 97% 的内存占用。
排查工具清单
这次排查用了以下工具,整理出来供参考:
| 工具 | 用途 |
|---|---|
top -p <pid> |
实时监控进程内存 |
ss -tap |
查看 TCP 连接状态 |
strace -p <pid> -e trace=network |
追踪系统调用 |
/proc/<pid>/fd/ |
查看打开的文件描述符 |
lsof -p <pid> |
查看打开的文件和连接 |
grep CLOSE_WAIT /proc/net/tcp |
批量统计 CLOSE_WAIT |
Python tracemalloc |
Python 内存分配追踪 |
反思:为什么 Python http.server 会有这个坑?
http.server 是 Python 标准库中最简单的 HTTP 服务器实现,设计目标是”够用就好”。它没有处理生产环境下的连接管理、超时控制、资源回收等问题。
这不是 http.server 的 bug,而是设计假设不同:
- 标准库假设:你会在开发/测试环境用,请求量小,连接数少
- 实际使用:你在生产环境跑了几个星期,客户端重连频繁
教训:任何标准库组件,在投入生产前都要检查其连接管理行为。
更深一层的思考
这次经历让我反思了 AI Agent 系统的几个问题:
1. 系统组件不是越多越好
一开始设计时,我追求”每个功能一个独立服务”——Dashboard 一个服务,采集器一个服务,告警一个服务。结果每个服务都有潜在的资源管理问题。
后来我合并了部分服务,减少了进程间通信的复杂度,也降低了出问题的表面积。
2. 健康检查不能只看 200
之前的健康检查只检查 HTTP 返回 200,根本不看内存和连接数。修复后我加了三层监控:
1 | # 1. HTTP 可达性 |
任何一个触发告警,系统自动尝试修复,修复失败再上报。
3. 自动修复需要闭环
我写了一个简单的自愈脚本:检测到 CLOSE_WAIT 超过阈值 → 打印连接详情 → Kill 进程重启 → 验证恢复。后来发现这不够——需要知道为什么 CLOSE_WAIT 在增长,否则只是治标不治本。
从这里得到的经验是:自动修复应该先诊断、后修复、再验证、最后根除。
尾声
这次内存泄漏排查,从发现问题到彻底修复,经历了 3 轮迭代:
- 第一轮(临时止血):发现内存涨了→重启→循环
- 第二轮(症状缓解):加了自动重启脚本,检测到内存 > 80% 就重启
- 第三轮(根因根治):定位到 CLOSE_WAIT 连接泄漏,修复 socket 管理
每一轮都比上一轮更深入一层。
很多时候,面对系统的不稳定,我们会本能地选择”让它先跑起来”的临时方案。这本身没问题——我的第一反应也是重启。但如果停留在这一步,问题就会反复回来找你。
技术债和财务债一样,利息是按天算的。
那个凌晨,当我看到内存稳定在 34MB 不再上涨时,一种莫名的满足感涌上来——不是因为解决了多高深的技术问题,而是因为终于弄清楚了一个”奇怪的现象”背后那个简单的真相。
很多时候,最难的不是修复,而是相信问题一定有简单的原因。
后记:修复后 Dashboard 已经稳定运行了 7 天,内存始终在 34-40MB 之间波动。那次之后,我对 Python 的 socket 管理多了一份敬畏,也养成了每周检查一次 CLOSE_WAIT 的习惯。







