137 不是崩了,是被杀了:四个退出码的语义边界
版权与内容来源声明
本文为原创整理。文中涉及官方文档、开源仓库、论文与公开报道的内容,均在附表 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+Nas the exit status.
主语是 Bash。同一页还写着退出状态落在 0 到 255 之间,shell 会在 126 以上使用一些特殊值。也就是说,128+N 是 shell 这一层为了把「被信号终止」塞进一个 0-255 的整数里,自己做的一次编码。内核通过 waitpid 交出来的是另一个东西:一个打包过的状态字,里面记录了「正常退出」还是「被信号终止」,以及具体是第几号信号。
Python 给的是 -N,不是 137
Python 的 subprocess 没有沿用 shell 的编码,它把内核那层状态字拆开,直接告诉你信号编号,并且加上负号。文档原文:
A negative value
-Nindicates that the child was terminated by signalN(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 0 | bash -c '"$@" ; exit 0' | 0 |
| 一层,末尾多一条 echo | bash -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+N | 129-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 默认转为 KeyboardInterrupt | Python 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 书籍:看行业落地案例和别人怎么做的时候用得上
- 大模型零基础到精通教学视频:跟着敲一遍,比只读文档快得多
资料是我自己整理的,放在下面这个码上,扫码即可获取:
添加时备注「AI」,优先通过。
资料按「先路线、再动手、最后查漏」的顺序整理好了,建议先看学习路线那一份,照着它挑一条适合自己当前基础的路径再往下看。
更多推荐








所有评论(0)