ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Agent执行命令卡死?8个隐藏陷阱与排查实战

Agent执行命令卡死?8个隐藏陷阱与排查实战

1. 先说结论:这次卡死,我花了三个通宵才找到“真凶”

如果你是一个经常跟自动化工具、CI/CD 流水线、或者各类 Agent 框架打交道的人,看到“Agent 执行命令卡死”这个标题,估计会条件反射地想到超时设置、网络波动、权限不足这些老生常谈的问题。我一开始也是这么想的,结果被现实狠狠教育了一通。

先交代一下背景。我在维护一个基于大模型驱动的自动化任务系统(就叫它“模拟项目X”吧),Agent 需要根据用户指令动态拼接 shell 命令,然后通过子进程去执行,再把输出结果喂回给模型做下一步决策。这个链路听起来不复杂,但在一次压测中,任务随机卡死,卡死的位置既不在模型调用,也不在网络请求,而是诡异地停在了命令执行这一步。最离谱的是,同一个命令手动在终端跑得好好的,换成 Agent 去调就时不时僵住,没有任何报错,也没有超时日志,就像被点了定身术。

这篇文章不打算讲那些“检查超时时间、检查网络、检查权限”的泛泛之谈,而是要把我实际踩过的八个坑,一个一个掰开揉碎了说清楚。这八个坑全部集中在“执行命令”这一行代码的签名与底层行为上,属于那种“你不看源码永远猜不到”的隐性陷阱。如果你也在写 Agent、写自动化脚本,或者在做任何涉及子进程调用的工具,这篇文章应该能帮你省下不止三个通宵。

我先把最终的排查链路放在前面:从怀疑环境变量,到怀疑文件描述符,再到怀疑缓冲区和 shell 语法,最后锁定为一个极其隐蔽的僵尸进程句柄继承问题。这中间每一步都有充分的复现证据,不是玄学,也不是运气。

2. 第一个坑:exec 的签名比你想的要复杂——你以为的“一行”其实是一整条语义链

先聊最基础的。很多人(包括我第一次写 Agent 时)对 exec 的理解就是“把命令字符串扔给系统跑一下”,但实际上,exec 家族在绝大多数语言和框架里的签名都暗含了三层语义:“怎么解析命令”、“怎么传递参数”、“怎么继承环境与句柄”。这三件事只要有一件没想清楚,卡死就是大概率事件。

以最常见的 Python 为例,它提供了os.system、os.popen、subprocess.run、subprocess.Popen这一堆接口。表面上看,subprocess.run("ls -l", shell=True)和subprocess.run(["ls", "-l"])都能执行命令,但底层的差异天差地别:

  • shell=True意味着命令字符串会交给/bin/sh -c去解析,字符串里的特殊字符(|、>、;、&&)都会被 shell 解释。这在拼接用户输入时是灾难的起点。
  • shell=False意味着你必须自己切分参数,任何需要 shell 解释的写法都会失效,但好处是参数边界清晰,不会因为一个空格就导致语义变化。

这跟 Agent 卡死有什么关系?关系太大了。当你的 Agent 生成的命令包含管道、重定向、后台符号时,如果你用shell=False去执行,大概率直接报“找不到文件”一类的错误;而如果你用shell=True,又要面对 shell 进程本身的退出码、信号处理、乃至子进程回收问题。我第一次踩的坑就是:为了兼容 Agent 生成的复杂命令,无脑上了shell=True,结果一个带后台符号的命令直接把子进程变成了“孤儿”,Agent 却还在傻傻地等它的输出管道关闭——这就等于等了一个永远不会结束的句柄。

所以第一个坑的教训是:不要把 exec 当作“一行命令”,把它当作“一段包含解析、派生、管道、回收的完整生命周期”来设计。你需要在选择接口的那一刻,就想清楚命令可能长什么样、输出可能有多大、进程会不会后台化。否则后面的坑,你一个都躲不掉。

3. 第二个坑:缓冲区满了,进程却没有退出——管道读写的死锁逻辑

这个坑可以说是子进程编程里最经典的死锁,也是我这次 Agent 卡死的“头号嫌疑人”。先描述现象:Agent 执行一条find / -name "*.log"这类输出量极大的命令时,任务卡住,CPU 占用很低,内存也不涨,进程就像睡着了一样。

如果你没有读过管道原理,可能会以为是命令本身跑得慢。但真相是:当父进程用subprocess.Popen启动子进程时,子进程的标准输出(stdout)和标准错误(stderr)默认是通向操作系统管道的,而管道的缓冲区是有容量上限的(通常是 64KB 左右)。如果子进程输出的数据量超过这个上限,并且父进程没有及时读取管道里的数据,那么子进程的write系统调用就会被阻塞。子进程阻塞在写输出上,自然就不会继续执行后续逻辑,更不会退出。

而很多 Agent 代码的写法是:proc.wait()或proc.communicate()之前,先忙着干别的事情,或者干脆用一个异步循环去读,但读的速度跟不上写速度。于是两边都僵住了:父进程在等子进程退出,子进程在等父进程读管道,双方都在等对方,死锁形成。

我当时用的解决方案是统一改用subprocess.run加capture_output=True,因为它内部会自动处理“先读完整输出再等待退出”的顺序。但这里还有一个隐藏的更深的坑:如果你给一个高输出命令设置了超时,超时到来时run会杀掉子进程,但管道里残留的数据可能没人处理,导致后续行为异常。所以更稳妥的做法是自己在Popen之上封装一个“实时读取 + 超时控制 + 残留数据清理”的机制,不要指望库帮你把所有事都干了。

那怎么判断是这类死锁?很简单:ps -ef看父子进程是否都活着,strace或lsof看子进程是否阻塞在write上。实测下来,九成的“命令执行卡死”,根因都在这个缓冲区死锁上。

4. 第三个坑:文件描述符泄漏——子进程偷偷继承了不该继承的句柄

如果说缓冲区死锁是“明枪”,那文件描述符(file descriptor, FD)继承问题就是“暗箭”。这个坑的隐蔽程度,足以让一个十年经验的工程师怀疑人生。

先说背景。在 Unix 系系统里,fork出来的子进程会默认继承父进程的所有打开文件描述符。绝大多数语言运行时(包括 Python 的 subprocess)默认只暴露三个标准流(0、1、2),但实际上,父进程里那些“你以为关掉了”的 socket、管道、文件句柄,统统可能被子进程“带过去”。

场景是这样的:我的 Agent 框架内部有一个日志轮转模块,它持有一个始终打开的日志文件描述符。某天这个描述符被设置成了非阻塞模式,然后 Agent 启动了一个外部命令,这个命令自己不关任何句柄,于是把那个非阻塞的日志 FD 也继承了。结果,父进程里的某个组件想对这个 FD 做write,却发现写不进去(因为非阻塞模式下缓冲区一次次满,write 调用被挑逗成各种重试魔咒),而子进程一直不退出,父进程就死等。卡死的表象在命令执行,实质是“一个不该被继承的 FD 造成的跨进程阻塞”。

那怎么排查?我用了一个笨但有效的办法:卡死后不杀进程,直接去看/proc/<pid>/fd目录,把子进程打开的 FD 全部列出来,跟父进程的 FD 列表做对比。结果真的发现子进程保留着一个写日志的 FD。当时我后背就凉了,因为这说明:只要你的父进程里有任何一个“长期存活”的 FD,你的 Agent 每次执行命令,都可能把病毒带进子进程。

解决办法有两个方向:一是显式关闭继承,比如 Python 里给Popen传close_fds=True,这是推荐做法;二是给所有长期存活的 FD 设置FD_CLOEXEC(close-on-exec)标记,让它在执行 exec 时自动关闭。这两招一起上,基本能堵住这个坑。

5. 第四个坑:僵尸进程和它的句柄遗产——卡死最像“Bug”的其实是系统设计

第三个坑如果你已经排除了,那来看更诡异的一个:僵尸进程。

先解释什么是僵尸进程。当一个子进程退出后,如果父进程没有及时调用wait()(或等价方法)去回收它的退出状态,这个子进程就会变成僵尸进程——它的进程表项还在,资源没有完全释放,但已经不是“活”的了。僵尸进程本身不占用太多资源,但它的危害在于:它会让父进程的 wait 调用顺序错乱,也可能让某些框架的“等待所有子进程退出”逻辑永远无法完成。

我在 Agent 场景里遇到的是这样:框架内部会并发启动多个子任务,每个子任务都会执行命令。某个子任务执行失败退出了,但框架没来得及wait,它就变成僵尸。然后下一个任务启动时,框架用了一个“等待所有已知 pid 退出”的模型,结果发现某个 pid 是僵尸,又因为某些原因一直拿不到状态,整个调度就停在那。更麻烦的是,僵尸进程的退出状态可能引发信号处理器(signal handler)的异常,导致 Agent 主循环陷入一种类似“活锁”的状态。

这里我最想提醒的是:如果你用了任何封装好的 Agent 框架,一定要搞清楚它内部的子进程回收策略是即时 wait、异步 wait,还是压根没管。很多框架的作者只关注“启动命令”,对“回收命令”不上心,这正是坑的来源。排查方法也很直接:卡住的时候执行ps -ef | grep defunct,如果看到一堆<defunct>进程,恭喜你,中奖了。

修复方案:对于自己管理的子进程,一定要保证每个Popen都有对应的wait()或communicate()调用,任何异常分支都要兜底。对于框架内置的逻辑,如果它确实不回收,那就得在每次执行命令后手动waitpid(pid, WNOHANG),清扫战场。

6. 第五个坑:shell 的隐藏行为——sh -c不等于“在终端里执行”

我知道很多人会反驳:我用shell=True挺好,从未出过问题。那我告诉你,你的“挺好”只是因为你的命令恰好没有那么复杂。shell 模式下的坑,集中在几个小动作上,每一个都会让 Agent 卡死得莫名其妙。

第一个小动作是信号处理。/bin/sh -c启动的进程,它的信号处理方式跟你手动敲命令时不一样。你手动在终端里按Ctrl+C,信号是发给整个前台进程组的;但 Agent 用shell=True启动时,命令可能只产生一个孤儿进程组,信号根本传不过去。于是你看到的效果就是:Agent 想终止命令,杀了个寂寞,原进程还在跑,输出管道一直不关,父进程继续等。

第二个小动作是退出状态。sh -c在某些实现下不会原样传递子命令的退出码,而是返回 shell 自身的状态。如果你的 Agent 根据退出码判断“命令是否完成”,就可能把“已经完成但退出码被修正”的命令当成“失败”,或者反过来。

第三个小动作最坑:当命令尾部带了&(后台符号),sh -c会立即返回,但后台进程还在跑。你的 Agent 以为命令执行完了,开始读输出,结果输出管道还开着(因为后台进程继承了它),于是又卡在读管道上。

所以我的建议是:在 Agent 场景里,尽量不要用shell=True。如果你实在需要管道、重定向这些能力,用 Python 原生的shlex.split把命令切成参数,再用shell=False执行,或者自己实现一个“白名单 + 安全 shell 解析器”。麻烦是麻烦了点,但至少不会因为 shell 的“贴心”行为而卡死。

7. 第六个坑:环境变量与启动目录的隐形副作用

接下来这个坑,如果你是在容器里跑 Agent,几乎一定会踩。现象是:同一个命令,在宿主机上执行没问题,在容器里执行偶尔卡死;或者同一个 Agent 镜像,换个环境就卡。

先说环境变量。很多高级语言(比如 Java、Go 的一些运行时)在启动时会自查一堆环境变量,你要是漏了JAVA_HOME、PATH、LD_LIBRARY_PATH这些,它可能不会直接报错,而是尝试某种“自动检测”,在检测过程中去连 AVC 服务或者某个不可达的端口。你的 Agent 执行命令时,子进程继承了这堆“不完整的”环境,一旦触发检测逻辑,网络请求就卡在那,直到超时。

再说启动目录。Agent 经常为了组织任务而动态切换到某个工作目录,比如一个临时目录、一个挂载目录。如果这个目录有问题(权限不足、挂载点失效、处于网络文件系统),子进程在打开文件、加载共享库时就会卡死。我遇到的一个真实案例是:新版本把临时目录改到了某个 FUSE 挂载点上,结果 Agent 执行pip install时,python 在扫描site-packages时卡了整整十分钟,日志里没有任何报错。

处理方式并不复杂:显式设置环境变量白名单,并把工作目录固定到本地磁盘路径。不要依赖继承,不要依赖“应该能用”。如果你不确定某个环境变量是否必要,用env -i跑一遍最小环境,看命令是否还能正常执行——这是我特别推荐的一个复现技巧。

8. 第七个坑:并发控制缺失——你以为卡的是命令,其实是锁

这个坑发生在多任务并发时。你有 10 个 Agent 实例同时跑,每个都要执行命令,突然 5 个全卡住。单看每个任务,CPU、内存、IO 都不高,命令执行时间却长得离谱。

我先讲一个经验:很多 Agent 框架在设计时,会把“并发数”跟“进程数”画等号。比如设置并发为 8,就同时 fork 8 个 worker,每个 worker 顺序执行命令。但如果某个 worker 的命令被阻塞(比如网络请求),它占住了一个 worker 槽位,而 Agent 的任务队列还在往里塞任务,最终所有 worker 都被“半死不活”的任务占满,新的命令永远排不上队,看起来就像卡死了。

更隐蔽的是“输出文件锁”。如果你的命令会向同一个日志文件、同一个缓存目录写入,并且多个进程同时写入时,可能会触发文件锁(flock)的竞争。某个进程拿到了锁但没释放,其他进程就全部阻塞在open或write上。这个坑在 Windows 上尤为明显(文件打开后默认带共享锁),但 Linux 上如果用了某些网络文件系统也一样。

排查方法:卡死时用py-spy dump或者gstack看线程栈,如果发现卡在某个flock或fcntl系统调用上,基本就是锁竞争。解决方案也很直接:给所有 Worker 加上“命令最大等待时间”,任何命令超过预期时间就强制终止并回收;同时给共享资源分片,避免所有进程写同一个文件。

9. 第八个坑:日志、终端和伪终端——被忽略的“显示层”阻塞

最后一个坑,是那种“就算你排查三天都不一定能想到”的坑:伪终端(PTY)和终端回显。

解释一下背景。在交互式 shell 里,终端是一个 TTY 设备,它有一整套输入处理、行编辑、回显的机制。如果你的 Agent 用pexpect、expect这类库去执行命令,或者为命令分配了一个伪终端,那么命令的 stdout 和 stderr 会被引导到 PTY 上。PTY 会做行缓冲、会做echo(回显)、会处理特殊字符。一旦命令输出了回车、换行之外的某种控制序列(比如清屏、光标移动),PTY 的解析逻辑就会变得奇怪,可能导致输出卡在某个状态。

我遇到一个场景:执行top或者vim这类交互式命令时,Agent 分配了 PTY,但这些命令会进入全屏模式,一直等待键盘输入。Agent 以为命令没跑完(因为 PTY 还开着),其实进程就等在那等输入。这种卡死最“正宗”,因为它完全符合“命令执行卡死”的字面描述,但原因不是超时、不是管道、不是锁,而是你错误地为一个非交互程序分配了交互式终端。

解决办法:除非你的 Agent 明确需要与交互式命令(比如 shell、telnet、ssh)对话,否则一律不要分配 PTY,直接让它继承标准输入输出就够了。如果你确实需要 PTY,一定要设置“空闲超时”,在无输入一段时间后自动关闭 PTY,避免永久等待。

10. 排查链路复盘与我的最终修复方案

前面讲了八个坑,可能你觉得有点信息爆炸。这里我统一复盘一下完整的排查链路,给你一个可以直接“抄作业”的检查清单:

  1. 确认卡死位置:用ps -ef、strace -p、gstack分别确认卡在哪个系统调用、哪个进程上。
  2. 检查输出管道死锁:看子进程是否阻塞在write上,父进程是否没有及时读。
  3. 检查 FD 继承:列出/proc/<pid>/fd,与父进程对照,排除非标准句柄继承。
  4. 检查僵尸进程:ps -ef | grep defunct,清理战场。
  5. 检查 shell 交互行为:确认命令是否被sh -c的特殊行为影响,是否包含后台符号。
  6. 检查 PTY 伪终端:确认你的 Agent 是否为非交互命令错误分配了 PTY。
  7. 检查环境变量与工作目录:用env -i复现,排除环境缺失。
  8. 检查并发锁:用py-spy/gstack看是否锁在文件锁上。

我的最终修复方案是一套组合拳,而不是单一补丁:

  • 用subprocess.Popen替代subprocess.run,自建“启动、读取、超时、回收”的完整生命周期管理器。
  • 对每个子进程显式设置close_fds=True,并给父进程的所有长期 FD 打上FD_CLOEXEC。
  • 给所有命令设置严格超时,超时后先杀进程组(killpg),再回收管道,再waitpid。
  • 默认不启用shell=True,非用不可时增加“后台符号、信号处理、退出码”三项特判。
  • 所有并发执行器增加“命令级并发栅栏”,防止一个慢命令占满全部 Worker。
  • 容器内统一固定工作目录与最小环境变量白名单。

这套方案上线后,那个“随机卡死”的问题彻底消失。之后我又用同样的排查思路,解决了另一个项目的“下载任务卡死”问题——顺着管道和 FD 一路查下去,最后发现是父进程长期持有某个 socket 导致子进程继承后不释放。经验就是经验,踩过一次,下次就能条件反射。

最后分享一个小技巧:遇到这种“偶发、无报错、必现困难”的卡死,别急着改代码。先花半小时把进程树、FD 列表、线程栈采集下来,往往真相就在这些原始数据里。你越是想当然,越容易绕远路。

希望这篇复盘能帮你在排查子进程执行卡死时,少走几个通宵。如果你也遇到过类似“一行 exec 底下的坑”,欢迎对照这份检查清单试试。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进