版权与内容来源声明
本文为原创整理。文中涉及官方文档、开源仓库、论文与公开报道的内容,均在附表 A 中标注来源;引用官方原文保持原样,不作改写。文中命令、版本号与界面截图以本文成文时的实测/核验结果为准,标注「待验证」的部分请以你本地环境实际输出为判断依据。本文不推荐任何不合规的软件获取方式,也不对任何收益结果作承诺。转载请注明出处。

137 不是崩溃,是 SIGKILL 留下的签名。

137 - 128 = 9,9 是 SIGKILL。143 - 128 = 15,15 是 SIGTERM。130 - 128 = 2,2 是 SIGINT。三个数字是同一类事件的三个版本:进程没走完自己的收尾逻辑,被外部信号截断了。你在 Agent 的沙箱里跑一条命令,拿到 137 转头去翻业务代码找空指针,方向从一开始就错了——业务代码里没有任何一行会导致 137。

137 和 143 都是被杀,区别只在谁下的手

137 和 143 同属「被信号终止」,差别是信号编号不同,而这个编号决定了进程有没有机会说最后一句话。

128 加上信号编号,就是你在 shell 里看到的那个数

SIGKILL 是 9,SIGTERM 是 15,SIGINT 是 2,这三个编号在 POSIX 上是固定的,Python 的 signal 模块直接把它们暴露成常量。把它们各自加 128:130、137、143 就是你会在日志里撞见的三个数,它们不是「错误等级」,是「第几号信号」。

这个换算关系一旦记住,日志里的怪数字就有了统一的读法:先减 128,剩下的就是信号编号,再拿编号去查是谁发的、默认动作是什么。137 减完是 9,9 是 SIGKILL;143 减完是 15,15 是 SIGTERM。两个数字的区别不是严重程度,是「下手的方式」。

137 和 139 的分界:一个没机会说话,一个是自己摔了

真正意义上的「崩了」是 139。139 - 128 = 11,11 是 SIGSEGV,进程访问了不该访问的地址,内核当场把它按死。这是进程自己犯的错,栈上通常留下现场。

137 正好相反:SIGKILL 的定义就是不可捕获、不可阻塞、不可忽略。Python 官方文档对 SIGKILL 的原文只有两句——“Kill signal.” 和 “It cannot be caught, blocked, or ignored.”。一个信号连 try/except 都进不去,进程连写一行 “我快死了” 到日志的机会都没有。

这就是为什么 137 的日志总是断在半句。你去看那个进程最后写了什么,大概率是上一次成功处理的记录,而不是崩溃原因——因为它根本来不及写原因。反过来,143 的进程是能接住 SIGTERM 的,它可以选择落盘、关连接、写一行 “received SIGTERM” 再退出。143 出现时日志里通常有收尾痕迹,137 出现时是一片空白,这个差别本身就是判断依据。

137 只能证明被 SIGKILL,不能证明是谁发的

同一个 137 至少有五种来源:人手动 kill -9、容器运行时因内存超限触发 OOM kill、docker stop 宽限期结束后强杀、Kubernetes 驱逐、CI 平台的超时清理。这些来源产生的退出码完全一致。

Docker 官方文档在 --stop-timeout 一节里写得很清楚:超时之后容器会被 “forcibly killed with a SIGKILL signal”。也就是说,一个正常 docker stop 的容器,只要它没在宽限期内退干净,最终也会以 137 收场。所以「137 就是 OOM」这个推论不成立,它只对了一半——OOM 一定导致 137,但 137 不一定是 OOM。

要定位是谁动的手,得去信号发出方那一侧找证据:cgroup 的内存事件计数、内核日志里的 OOM 记录、编排平台的事件流。进程自己的退出码里没有这个信息,永远不会有。

下面这个脚本把几种典型退出码一次跑出来,不做任何解释性替换。

🧪 实测环境:macOS (darwin) / bash 3.2 / Python 3.13.12

#!/bin/bash
# 逐个跑出真实退出码,不做任何解释性替换
run() {
  desc="$1"; shift
  "$@" >/dev/null 2>&1
  printf '%-34s -> %3d\n' "$desc" "$?"
}

run "exit 0"                     bash -c 'exit 0'
run "exit 1"                     bash -c 'exit 1'
run "exit 256(超出 0-255)"      bash -c 'exit 256'
run "命令不存在"                  bash -c 'definitely_not_a_command_xyz'
run "脚本无执行权限"              bash -c 'f=$(mktemp); echo "exit 0" > $f; chmod 644 $f; $f'
run "被 SIGKILL(kill -9)"       bash -c 'sleep 5 & p=$!; kill -9 $p; wait $p'
run "被 SIGTERM(kill -15)"      bash -c 'sleep 5 & p=$!; kill -15 $p; wait $p'
run "被 SIGINT(Ctrl-C)"         bash -c 'sleep 5 & p=$!; kill -2 $p; wait $p'
run "python 子进程被 SIGKILL"     python3 -c 'import subprocess,sys,time
p=subprocess.Popen([sys.executable,"-c","import time;time.sleep(5)"])
time.sleep(0.2); p.kill(); print("returncode =", p.wait(), flush=True)' 
echo "--- 后台进程被 kill 后,shell 记录的 \$? ---"
bash -c 'sleep 30 & p=$!; kill -9 $p; wait $p; echo "wait 返回值: $?"'

脚本输出(stdout)如下:

exit 0                             ->   0
exit 1                             ->   1
exit 256(超出 0-255)         ->   0
命令不存在                    -> 127
脚本无执行权限              -> 126
被 SIGKILL(kill -9)           -> 137
被 SIGTERM(kill -15)          -> 143
被 SIGINT(Ctrl-C)             -> 130
python 子进程被 SIGKILL        ->   0
--- 后台进程被 kill 后,shell 记录的 $? ---
wait 返回值: 137

stderr 上还会多一行 bash: line 1: <pid> Killed: 9 sleep 30(pid 由系统分配,每次不同)。这行不是脚本打印的,是 bash 自己的作业控制消息;wait 返回值: 137 才是脚本里 echo 出来的。两者放在一起,正好说明同一件事有两个观察者:shell 报「这个后台作业被信号 9 杀了」,脚本报「wait 拿到 137」。只抓 stdout 的采集器会把这一行丢掉,于是「被谁杀的」这条线索就只剩一个数字。

126 和 127:命令找到了跑不起来,和根本没找到

126 和 127 都属于「命令没跑起来」,但一个卡在权限,一个卡在查找,排查方向完全相反。

GNU Bash 手册的原文把两者的定义并排放在一起:

If a command is not found, the child process created to execute it returns a status of 127. If a command is found but is not executable, the return status is 126.

注意措辞:「not found」对应 127,「found but is not executable」对应 126。这是一个二分法,不存在第三种模糊地带。

126:找到了,但内核不让它跑

found but is not executable 里的 “not executable” 比字面意思宽。它覆盖的是「路径确实存在,但 execve 失败」这一整类情况。我在同一台机器上试了三种成因,全部落在 126:

成因具体做法失败原因
少了执行位chmod 644 之后直接执行权限位不足,execve 被拒
解释器不存在shebang 写成 #!/nonexistent/sh解释器路径解析不到
内容不是可执行格式可执行位给了,内容是二进制垃圾,或者干脆是个目录不是可执行文件格式

这三种情况在 shell 里表现一致:126。所以 126 的消息不该读成「权限不够」,该读成「文件在这儿,但没法作为程序启动」。挂载点带 noexec 时也是 126——文件权限看着是 755,就是跑不起来,这一条在容器里特别常见。

127:连找都没找到

127 只有一个含义:shell 在 PATH 里翻遍了,没有这个命令名。常见成因是 PATH 没带上某个目录、虚拟环境没激活、镜像里少装了一个包、Node 版本切换后全局 bin 不在路径里。

它和 126 的分界很干脆:126 说明「这个名字解析到了某个路径」,127 说明「这个名字根本没解析出来」。which 一下就能确认——127 的场景里 which 也是空。

两个码分别把排查方向指到哪

拿到 127,去查 PATH、安装状态、镜像内容、shell 的初始化脚本。拿到 126,去查 ls -l 的权限位、mount 的挂载选项、脚本第一行的 shebang、以及这个文件到底是不是一个 ELF/Mach-O。

反过来做就是典型的浪费:看到 127 去 chmod +x,命令压根没找到,改权限改不出一个文件来;看到 126 去重新安装,文件本来就在,重装完权限还是那个权限。这两个码的价值就在于它们把「环境没装好」和「装好了但起不来」分成了两条互斥的路径。

128+N 只是 shell 的约定,内核给的是负数

128+N 是 bash 换算出来的表示法,不是内核返回的退出码。这句话决定了你在 Python 里该怎么判。

GNU Bash 手册的原话是:

When a command terminates on a fatal signal whose number is N, Bash uses the value 128+N as the exit status.

主语是 Bash。同一页还写着退出状态落在 0 到 255 之间,shell 会在 126 以上使用一些特殊值。也就是说,128+N 是 shell 这一层为了把「被信号终止」塞进一个 0-255 的整数里,自己做的一次编码。内核通过 waitpid 交出来的是另一个东西:一个打包过的状态字,里面记录了「正常退出」还是「被信号终止」,以及具体是第几号信号。

Python 给的是 -N,不是 137

Python 的 subprocess 没有沿用 shell 的编码,它把内核那层状态字拆开,直接告诉你信号编号,并且加上负号。文档原文:

A negative value -N indicates that the child was terminated by signal N (POSIX only).

所以同一个事件,shell 说 137,Python 说 -9。这不是两个事件,是一个事件的两套方言。

🧪 实测环境:macOS (darwin) / bash 3.2 / Python 3.13.12

import subprocess, sys, signal
p = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(5)"])
p.send_signal(signal.SIGKILL); print("SIGKILL ->", p.wait())
p = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(5)"])
p.terminate(); print("terminate ->", p.wait())
p = subprocess.Popen([sys.executable, "-c", "raise SystemExit(3)"])
print("exit(3) ->", p.wait())
SIGKILL -> -9
terminate -> -15
exit(3) -> 3

最后一行是关键对照:进程自己调 SystemExit(3) 正常退出,returncode 就是 3,和 shell 看到的 3 一致。只有「被信号终止」这一支,两套读法才分叉。

同一个进程,两套读法对不上就会读成两件事

最常见的误判有两行,一行在 Python 里,一行在 shell 里:

if rc == 137:            # 永远不会成立,rc 是 -9
    alert("OOM killed")
if [ "$rc" -lt 0 ]; then # 永远不会成立,shell 给的是 0-255
    echo "killed by signal $(( -rc ))"
fi

Python 里的判据必须是 rc < 0,信号编号是 -rc;shell 里的判据必须是 rc > 128,信号编号是 rc - 128。写反了不会报错,只会永远不命中——比报错更麻烦,因为监控看起来一切正常。

同一个坑还有第三种形态:exit 256 返回 0。退出码只保留低 8 位,256 取模之后就是 0。任何「用 256 或 1000 表示致命错误」的约定,都会在上游被静默读成成功。上面实测块里 exit 256(超出 0-255) -> 0 那一行就是这个后果的现场演示:写的人以为标了一个比 1 严重得多的错误等级,读的人看到的是「一切正常」。

子进程返回码对照卡:把 shell 与 Python 两套判定写成一张可打印的对照卡,放在资料包里,扫码即可获取:

配套资料获取

包一层就丢码:为什么外层照样返回 0

外层返回 0,是因为外层进程自己活得好好的,它没被任何信号碰过。

回看第一个实测块里的这一行:python 子进程被 SIGKILL -> 0。内层确实被 SIGKILL 打死了,returncode 是 -9,但外层那个 python3 -c 只是把 -9 打印出来,然后正常结束。run() 函数测的是外层的 $?,所以拿到 0。

137 消失的位置通常在包装层,不在内核。内核把信号如实交给了直接父进程,父进程如果选择「我只负责报告,不负责传递」,这条信息就断在这儿了。

三层结构里,码在哪一层被吃掉

Agent 执行外部命令,链路通常有三层:Agent 逻辑 → 工具封装 → subprocess → 真正的命令。每一层都可能改写退出码。我把这几层单独跑了一遍,输出留在 e04_b.out 里。

🧪 实测环境:macOS (darwin) / bash 3.2 / Python 3.13.12

#!/usr/bin/env python3
# 同一个「子进程被信号打死」的事件,穿过不同层之后,父进程读到的数字各不相同
import subprocess, sys

def run(label, argv):
    p = subprocess.Popen(argv)
    print(f"{label} -> {p.wait()}", flush=True)

kill_self = ("import os,signal,resource; "
             "resource.setrlimit(resource.RLIMIT_CORE,(0,0)); "
             "os.kill(os.getpid(), signal.SIGKILL)")
segv_self = kill_self.replace("signal.SIGKILL", "signal.SIGSEGV")

inner_kill = [sys.executable, "-c", kill_self]
inner_segv = [sys.executable, "-c", segv_self]

run("SIGKILL 直接跑(不包壳)",            inner_kill)
run("SIGKILL 包 bash 并 exit $? 透传",     ["bash", "-c", '"$@"; rc=$?; exit $rc', "bash"] + inner_kill)
run("SIGKILL 包 bash 但结尾 exit 0",       ["bash", "-c", '"$@" ; exit 0', "bash"] + inner_kill)
run("SIGKILL 包 bash 结尾多写一条 echo",   ["bash", "-c", '"$@"; echo done', "bash"] + inner_kill)
run("SIGSEGV 直接跑(真崩溃)",            inner_segv)
run("SIGSEGV 包 bash 并 exit $? 透传",     ["bash", "-c", '"$@"; rc=$?; exit $rc', "bash"] + inner_segv)
SIGKILL 直接跑(不包壳) -> -9
SIGKILL 包 bash 并 exit $? 透传 -> 137
SIGKILL 包 bash 但结尾 exit 0 -> 0
done
SIGKILL 包 bash 结尾多写一条 echo -> 0
SIGSEGV 直接跑(真崩溃) -> -11
SIGSEGV 包 bash 并 exit $? 透传 -> 139

第四行那个孤零零的 done 属于再下一行那次调用——它是内层 bash 里那条 echo 自己打到 stdout 的,比父进程的打印更早落地。这恰好演示了「多写一条命令」是怎么把退出码顶掉的:bash 的退出状态永远取最后一条命令,echo done 成功,所以整体是 0。

层级调用方式父进程读到
零层直接 subprocess.Popen(inner)-9
一层,显式透传bash -c '"$@"; rc=$?; exit $rc'137
一层,写了 exit 0bash -c '"$@" ; exit 0'0
一层,末尾多一条 echobash -c '"$@"; echo done'0

这张表里最值得盯的是第一行和第二行:同一个事件,一层之差,-9 变成 137。两者都算「保住了信息」,只是换了方言。第三、四行才是真正的丢失——信号信息被覆盖成了「成功」。

包装层的一行判据

判断一个包装层有没有问题,只看一件事:内层进程的 returncode 有没有原样出现在这一层的返回值里。

具体到代码,就是不要让退出码有一个默认值。result.get("exit_code", 0) 这种写法在键缺失时补 0,等于把「不知道」写成了「成功」。正确做法是让它保持 null,并额外带上一个 killed_by_signal 字段;上层看到 null 才会去问「到底发生了什么」,看到 0 就再也不会问。

很多 Agent 工具的返回结构长这样:{"ok": true, "stdout": "...", "stderr": "..."}。ok 的语义如果是「工具函数没抛异常」,那么子进程被 SIGKILL 也会是 ok: true——工具函数确实没抛异常,它只是返回了一个被杀的进程结果。这种情况不是 bug,是接口设计把两个不同的问题(调用是否完成 / 命令是否成功)压成了一个布尔值。

一张表:判崩了还是被杀,看哪一位

判「崩了」还是「被杀」,看的不是数字大小,是这一位落在哪一段。

边界对照表:同一个数字,可能是两个来源给的

概念谁给出的数值形态典型误判
正常退出进程自己 exit(n),n 取低 8 位0(成功)/ 1-125(自定义)把 exit 256 当成严重错误,实际是 0
命令未找到bash 定义127去改文件权限,其实文件不存在
命令不可执行bash 定义126去重装依赖,其实文件已在、只是起不来
被信号 N 终止(shell 视角)bash 约定,128+N129-165当成「错误等级」,去查业务逻辑
被信号 N 终止(Python 视角)内核状态字,subprocess 拆包负数 -N(-9、-15、-11)写 rc == 137 判 OOM,永不命中
被 SIGKILL内核投递,来源不限shell 137 / Python -9断言「一定是 OOM」,忽略人为 kill -9
被 SIGSEGV内核投递,进程自身越界shell 139 / Python -11与 137 混为一谈,其实这才是「崩」

这张表的用法是从下往上读:先看有没有负号,有负号就是信号,信号编号决定后面所有推断;没有负号再看是不是 126/127,这两个是「没跑起来」而不是「跑失败」;剩下的才是命令自己的退出码。

四条可直接照抄的判定

rc < 0:进入信号分支,-rc 就是信号编号,9 是 SIGKILL、15 是 SIGTERM、11 是 SIGSEGV、2 是 SIGINT。

rc in (126, 127):先查 PATH 和权限,别去读业务日志。127 查「有没有装」,126 查「装的能不能执行」。

rc == 137:只能说「被 SIGKILL 终止」,不能说原因。要定位来源,去 cgroup 内存事件、内核日志、编排平台事件里找,进程退出码里没有答案。

包装层必须透传内层码:内层 returncode 原样放进返回值,缺失时保持 null 而不是补 0;一行判据是「删掉这层包装,上游读到的数字会不会变」。

子进程返回码对照卡:把 shell 与 Python 两套判定写成一张可打印的对照卡,放在资料包里,扫码即可获取:

配套资料获取

附表 A:本文引用事实与出处对照表

事实出处本文位置
命令未找到返回 127;找到但不可执行返回 126(原文 “If a command is not found, the child process created to execute it returns a status of 127. If a command is found but is not executable, the return status is 126.”)GNU Bash Reference Manual, 3.7.5 Exit Status,https://www.gnu.org/software/bash/manual/html_node/Exit-Status.html第二章
进程因信号 N 终止时,Bash 用 128+N 作为退出状态(原文 “When a command terminates on a fatal signal whose number is N, Bash uses the value 128+N as the exit status.”)同上第一章、第三章
退出状态落在 0 到 255 之间,126 以上为 shell 使用的特殊值同上第一章 exit 256 实测
Popen.returncode 为负值 -N 表示子进程被信号 N 终止(原文 “A negative value -N indicates that the child was terminated by signal N (POSIX only).”)Python 3 文档 subprocess,https://docs.python.org/3/library/subprocess.html第三章
SIGKILL 为 “Kill signal. It cannot be caught, blocked, or ignored.”;SIGTERM 为 “Termination signal.”;SIGINT 默认转为 KeyboardInterruptPython 3 文档 signal,https://docs.python.org/3/library/signal.html第一章
--stop-timeout 超时后容器被强制杀死(原文 “If the container does not exit after the timeout elapses, it’s forcibly killed with a SIGKILL signal.”)Docker CLI 参考 docker container run,https://docs.docker.com/reference/cli/docker/container/run/第一章
本文全部退出码数值(126/127/130/137/139/143、exit 256 → 0、returncode -9/-15/-11)均由本机脚本真实跑出本文脚本:work/exp1107/e04_exitcode.sh、work/exp1107/e04_b.py第一章、第三章、第四章实测块

写在最后:这篇用到的资料

写这篇文章时,把相关的官方文档和源码又翻了一遍,顺手也整理了几份配套的东西:

  • 大模型学习路线图:从零基础到能自己动手做 Agent,按阶段说明每一步该学什么、哪些可以先跳过
  • 《LangChain + LangGraph + MCP 智能体开发实战》视频课:7 个模块,从私有化部署、Embedding+RAG 到 MCP+Agent 全流程
  • AI 大模型知识库(在线可查):Agent Skills 从入门到落地、Claude Skills 完全指南等专题,按目录浏览即可
  • 640 套 AI 大模型行业报告 + 经典 PDF 书籍:看行业落地案例和别人怎么做的时候用得上
  • 大模型零基础到精通教学视频:跟着敲一遍,比只读文档快得多

资料是我自己整理的,放在下面这个码上,扫码即可获取:

配套资料获取
课程目录:私有化部署与 LangChain
课程总目录:7 个模块
在线知识库:Agent Skills 创建全流程
课程目录:LangGraph 开发智能体
在线知识库:Claude Skills 完全指南

添加时备注「AI」,优先通过。

资料按「先路线、再动手、最后查漏」的顺序整理好了,建议先看学习路线那一份,照着它挑一条适合自己当前基础的路径再往下看。

Logo

更多推荐