← 返回资讯
苏晴
资深编辑
已审核

服务器CPU打满、SSH卡死

上周三凌晨两点,我被 PagerDuty 叫醒了。

服务器CPU打满、SSH卡死

服务器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 的隐藏陷阱

先看一段代码。我猜很多人写过类似的:

PYTHON
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 并发调用,问题就更复杂了。

PYTHON
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 显存映射不会释放。

BASH
# 查看僵尸持有的 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 版本里修了相关的问题,但生产环境谁没事升级驱动啊。


怎么治

应急手段

BASH
# 找僵尸的父进程
ps aux | grep 'Z'

# 父进程还活着的话,发 SIGCHLD 提醒它收割
kill -SIGCHLD <parent_pid>

# 父进程已经挂了,僵尸的 PPID 变成 1(init)
# init 理论上会自动收割,但有时候不灵
# 终极手段:重启父进程

应急用还行。但不能靠这个过日子。

正确姿势一:Popen + 显式 wait

subprocess.run 换成 Popen,自己管生命周期:

PYTHON
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": ""}

三个关键点:

正确姿势二:SIGCHLD 处理器

长时间运行的服务,可以全局注册:

PYTHON
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,用进程池:

PYTHON
from concurrent.futures import ProcessPoolExecutor
import atexit

executor = ProcessPoolExecutor(max_workers=4)

def cleanup():
 executor.shutdown(wait=True)

atexit.register(cleanup)

进程池的好处是子进程数量可控。但超时处理确实更复杂。我的做法是加一个独立的监控协程:

PYTHON
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 在独立容器里跑。

YAML
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 左右。对延迟不敏感的批处理任务,这个方案最稳。


监控别落下

加个监控脚本:

PYTHON
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:

PYTHON
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 #僵尸进程 #资源泄漏 #生产事故

637
10622 阅读
4 评论
分享
链接已复制
编辑说明

本文由 MakeSense 编辑团队撰写并审核。文中引用的数据和观点均经过交叉验证,如有疏漏欢迎在评论区指正。最后更新:2026年06月27日 17:59

苏晴

资深编辑

科技媒体从业 8 年,曾就职于多家科技媒体。关注 AI 创业和投资赛道,采访过 50+ 位行业从业者。

读者评论 4

老李 5天前
有个小问题想请教,文中提到的那个方案在大规模场景下性能怎么样?
回复 点赞 (5)
运营小陈 1周前
转发到团队群了,大家都觉得有参考价值。
回复 点赞 (4)
数据分析师 1周前
数据引用很扎实,建议补充一下近三个月的最新数据。
回复 点赞 (9)
产品经理阿杰 2周前
从产品角度看,这个方向确实有机会,但商业化路径还需要验证。
回复 点赞 (15)