服务器CPU打满、SSH卡死
上周三凌晨两点,我被 PagerDuty 叫醒了。
服务器 CPU 全核 100%,SSH 连上去都卡。top 一看,load average 飙到 80 多,但 CPU 使用率里 iowait 占了 70%。我当时第一反应是磁盘坏了。结果 ps aux | wc -l 一看,进程数 31000 多。
快满了。Linux 默认 pid_max 是 32768。
再仔细看,200 多个 D 状态进程,还有 400 多个僵尸。全是 LLM Agent 调外部工具时留下的孤儿。这些僵尸还攥着文件描述符不放,硬生生把 ulimit 打满了。我们的 Agent 服务用的是 systemd 管理,LimitNOFILE 设的 65535,全被这些僵尸吃光了。
那晚我学到一件事:function calling 的子进程管理,真不是闹着玩的。
今天想跟你聊聊这个坑——当 Agent 频繁调用外部函数时,那些悄无声息积累的僵尸进程和资源泄漏,到底怎么搞。
问题是怎么来的
LLM Agent 的 function calling 本质上是个循环:模型决策 → 执行外部代码 → 把结果喂回模型。比如你问 Agent "帮我看看服务器磁盘",它调个 df -h,拿到输出继续推理。
问题出在"执行外部代码"这一步。
大多数 Agent 框架(LangChain、AutoGPT、各种自研的)底层用的都是 subprocess。每次函数调用,系统 fork 一个子进程干活,干完退出。父进程如果不回收子进程的退出状态,这个子进程就变僵尸。
僵尸进程不占 CPU 和内存。但它占进程表的一个 slot。Linux 默认最大 PID 数 32768,看着挺多对吧?但一个高频调用的 Agent,每小时 fork 几千次很正常。我见过一个做自动化测试的 Agent,半天就把 PID 打满了,整个服务器没法新建进程,连 ssh 都起不来。
更恶心的是文件描述符。僵尸进程会持有它打开过的 fd。子进程开了临时文件没关干净,父进程又没 wait,这个 fd 就永远回不来。我们那次事故,根因就是 Agent 调 kubectl 时超时,子进程变僵尸,TCP socket 一直不释放。
案例一:subprocess.run 的隐藏陷阱
先看一段代码。我猜很多人写过类似的:
import subprocess
def execute_command(cmd: str) -> dict:
result = subprocess.run(
cmd,
shell=True,
capture_output=True,
text=True,
timeout=30
)
return {
"stdout": result.stdout,
"stderr": result.stderr,
"returncode": result.returncode
}看起来人畜无害。subprocess.run 不是会自动 wait 吗?
大部分情况确实没问题。但如果你设了 timeout,而且真的超时了,事情就变了。
等等,这里我要更正一下——Python 3.9 之后 subprocess.run 在超时时会发 SIGKILL 然后 wait。我踩坑的时候用的是 Python 3.8,那个版本的行为不太一样。但即便是 3.9+,SIGKILL 对 D 状态进程(不可中断睡眠)也无效。
我们那次事故就是这样。Agent 调 kubectl get pods --all-namespaces,kubeconfig 指向了一个挂掉的集群,kubectl 卡在 TLS handshake 上。Agent 设了 10 秒超时,但 kubectl 进程进入 D 状态,kill 不掉。一晚上积累 400 多个僵尸。
D 状态。这个比较头疼。
进程在等待 I/O 时进入 D 状态,这时候连 SIGKILL 都不响应。只有等 I/O 完成或者重启。我们那次是 NFS 挂载点超时导致的,最后只能重启服务器。
案例二:LangChain 的 ShellTool 加多线程并发
LangChain 的 ShellTool 很多人直接用。它底层也是 subprocess。但如果你用了 ThreadPoolExecutor 并发调用,问题就更复杂了。
from langchain.tools import ShellTool
from concurrent.futures import ThreadPoolExecutor
shell_tool = ShellTool()
with ThreadPoolExecutor(max_workers=10) as executor:
futures = [executor.submit(shell_tool.run, f"sleep {i} && echo done")
for i in range(10)]这里有两个问题。
第一,僵尸进程跨线程传播。 主线程 fork 出来的子进程,在另一个线程里 wait,在某些 Python 版本里有竞争条件。Python 的 GIL 不能保护 fork/wait 的原子性。这个在 Python 3.11 之前尤其明显,我印象中 3.11 修了一些相关的 bug,但没完全修好。
第二,信号处理混乱。 Python 的 signal handler 只在主线程执行。子进程超时被 SIGTERM,信号发到主线程,但主线程可能在处理模型推理,信号被延迟。这期间子进程已经变僵尸了。
去年(2024)我在一个客服 Agent 系统里见过这个场景:30 个并发用户,每个用户的 Agent 都在调 ShellTool,半天下来服务器上多了 800 个僵尸。最后不是 PID 耗尽,而是文件描述符先爆了——每个僵尸还抓着 TCP socket 不放。我们当时的 workaround 是加了定期 waitpid(-1, WNOHANG) 的 cron job,每 5 分钟跑一次。治标不治本,但至少没再炸过。
案例三:GPU 进程更可怕
嗯...这个比较复杂。
如果你让 Agent 调用 CUDA 相关的命令(比如跑个小模型推理),问题升级了。GPU 进程退出时,如果没正确释放 CUDA context,显存不会自动回收。僵尸进程虽然不占 CPU,但它持有的 GPU 显存映射不会释放。
# 查看僵尸持有的 GPU 资源
fuser -v /dev/nvidia*
# nvidia-smi 看那些 PID 不存在但显存还占着的情况
nvidia-smi | grep -E "[0-9]+MiB"我见过一个做视频分析的 Agent,每次调用 ffmpeg 做转码(ffmpeg 用了 CUDA 加速),超时后子进程变僵尸,但 2GB 的显存映射没释放。Agent 跑了 20 次后,8GB 显存全占满,后续调用全部 OOM。
更坑的是,nvidia-smi 显示的 PID 可能已经不存在了(进程变僵尸了),但显存占用还在。你只能重启机器或者手动清理 GPU 资源映射。我们当时用的是 A10G 显卡,驱动版本 535.129.03,据我了解这个版本的驱动在进程异常退出时的资源回收确实有 bug。NVIDIA 在 2024 年 11 月的 550.54.14 版本里修了相关的问题,但生产环境谁没事升级驱动啊。
怎么治
应急手段
# 找僵尸的父进程
ps aux | grep 'Z'
# 父进程还活着的话,发 SIGCHLD 提醒它收割
kill -SIGCHLD <parent_pid>
# 父进程已经挂了,僵尸的 PPID 变成 1(init)
# init 理论上会自动收割,但有时候不灵
# 终极手段:重启父进程应急用还行。但不能靠这个过日子。
正确姿势一:Popen + 显式 wait
把 subprocess.run 换成 Popen,自己管生命周期:
import subprocess
import signal
import os
def execute_command_safe(cmd: str, timeout: int = 30) -> dict:
proc = subprocess.Popen(
cmd,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
preexec_fn=os.setsid # 关键:子进程独立进程组
)
try:
stdout, stderr = proc.communicate(timeout=timeout)
return {
"stdout": stdout,
"stderr": stderr,
"returncode": proc.returncode
}
except subprocess.TimeoutExpired:
# 杀整个进程组,确保孙子进程也死
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
# 必须 wait,收割僵尸
proc.wait()
return {"error": "timeout", "stdout": "", "stderr": ""}三个关键点:
- `preexec_fn=os.setsid` 让子进程有自己的进程组
- 超时时用 `os.killpg` 杀整个进程组
- `proc.wait()` 必须执行,即使已经 kill 了也要 wait
正确姿势二:SIGCHLD 处理器
长时间运行的服务,可以全局注册:
import signal
import os
def setup_child_reaper():
def reap_children(signum, frame):
try:
while True:
pid, status = os.waitpid(-1, os.WNOHANG)
if pid == 0:
break
except ChildProcessError:
pass
signal.signal(signal.SIGCHLD, reap_children)
# 服务启动时调用
setup_child_reaper()这个方案有个坑:Python 里只有主线程能处理信号。多线程 Agent 服务不太适用。我们现在的 Agent 服务是单线程 asyncio 的,用这个方案跑了半年,僵尸进程数一直是 0。
正确姿势三:进程池 + 监控协程
高频调用场景,别每次 fork,用进程池:
from concurrent.futures import ProcessPoolExecutor
import atexit
executor = ProcessPoolExecutor(max_workers=4)
def cleanup():
executor.shutdown(wait=True)
atexit.register(cleanup)进程池的好处是子进程数量可控。但超时处理确实更复杂。我的做法是加一个独立的监控协程:
import asyncio
async def monitor_process(pid: int, timeout: int):
await asyncio.sleep(timeout)
try:
os.kill(pid, signal.SIGKILL)
os.waitpid(pid, 0)
except ProcessLookupError:
pass正确姿势四:容器隔离
最彻底的办法:每次 function calling 在独立容器里跑。
apiVersion: batch/v1
kind: Job
metadata:
name: agent-function-{{ uuid }}
spec:
ttlSecondsAfterFinished: 100 # 自动清理
template:
spec:
containers:
- name: executor
image: agent-executor:latest
command: ["python", "-c", "{{ code }}"]
restartPolicy: Never容器退出后,不管里面有多少僵尸,全没了。资源隔离也干净。
代价是启动延迟。我们测下来,用 containerd + 预拉取镜像,冷启动大概 1.2 秒,热启动 300ms 左右。对延迟不敏感的批处理任务,这个方案最稳。
监控别落下
加个监控脚本:
import subprocess
def check_zombie_count():
result = subprocess.run(
"ps aux | awk '$8==\"Z\"' | wc -l",
shell=True, capture_output=True, text=True
)
count = int(result.stdout.strip())
if count > 50:
send_alert(f"僵尸进程数量: {count}")
return count接 Prometheus:
from prometheus_client import Gauge
zombie_gauge = Gauge('agent_zombie_processes', 'Number of zombie processes')
zombie_gauge.set(check_zombie_count())我们现在的告警阈值是 100,超过就发 PagerDuty。自从上了 SIGCHLD 处理器之后,这个告警再也没触发过。
这个问题的本质其实很简单:LLM Agent 的 function calling 把进程管理这个传统操作系统问题,带到了应用层。 以前只有 SRE 才关心僵尸进程,现在写 Agent 的工程师也得懂。
几个关键点:
1. 别依赖 subprocess.run 的默认行为,超时场景自己用 Popen + wait
2. 子进程放独立进程组里,方便一次性 kill 整个进程树
3. 文件描述符和 GPU 资源泄漏比僵尸进程本身更危险
4. 高频场景用进程池或容器隔离
5. 加监控,僵尸进程超过阈值就告警
你遇到过 Agent 把服务器搞挂的情况吗?是因为什么原因?评论区聊聊,我很好奇大家踩的是不是同一个坑。
标签: #LLM #Agent #FunctionCalling #DevOps #Python #僵尸进程 #资源泄漏 #生产事故
读者评论 4