让 AI 写 shell 脚本总在三处翻车:set -e、引号、路径空格怎么排查

2026-07-29

数据截至 2026-07。文中的 shell 行为按 GNU bash 描述,不同发行版自带的 shell 与工具版本存在差异,具体选项和退出码语义以你机器上 man bashman find 等官方文档的最新说明为准。

多数人把这类事故归因成「AI 不会写 shell」,这个归因是错的。 AI 写出来的 shell 语法基本正确,shellcheck 也常常一片绿。真正的问题在于:shell 的默认语义是「尽量往下跑」,而模型学到的是「让脚本读起来完整、优雅」。这两件事在正常路径上完全一致,只有在异常路径上才分岔——某一步失败了,脚本不停;某个变量是空的,展开成了别的东西;某个路径带空格,被切成了两个参数。于是你看到的现象不是「脚本报错」,而是「脚本报告成功,但结果是错的」。排查这类问题,第一步不是读代码,是先确定它到底属于哪一类分岔。

先说清本篇和站内几篇的分工:脚本在 Windows 上编辑、在 Linux 上执行时出现的 \r 相关怪象,属于换行符层面的问题,看 CRLF 换行问题;脚本跑到某条命令停住不动、既不报错也不退出,那是等待交互输入,看 交互命令挂住。这篇只管一件事:脚本跑完了,但行为和你以为的不一样。

一、先划清边界:AI 能写到哪一步,哪一步必须你来定

把「写 shell 脚本」拆成四个环节,责任归属其实很清楚。

第一个环节是骨架和调用序列。要调哪些命令、顺序怎么排、参数长什么样,这一层 AI 做得好,尤其是你记不清 tarrsyncjq 的具体开关时。这一步交出去没问题。

第二个环节是失败语义。哪一步失败了必须停、哪一步失败了可以忽略、停下来之后要不要清理已经产生的中间态——这一层是业务决策,模型没有你的上下文,它给出的默认往往是「全部失败都停」或者「全部都不停」,两个极端都不是你要的。这一步必须你来定。

第三个环节是输入的取值范围。变量可能为空吗?路径可能带空格、中文、换行吗?文件数量可能是零吗?这一层同样是你的领域知识。模型倾向于假设输入是「正常的」,而生产环境里不正常才是常态。

第四个环节是破坏性动作的边界。rm -rfgit reset --hardDROP、覆盖写入——这一层无论如何都要人过一遍眼。让自动化流程去执行这类脚本时,权限边界怎么收,可以参考 Agent 权限太大 里的处理方式,这里不重复。

一句话概括:让 AI 写「做什么」,你自己定「什么时候不做、失败了怎么办」。

二、set -e:它不是你以为的那个开关

绝大多数 AI 生成的脚本会在开头写上 set -e,然后你就默认「出错会停」。实际上 set -e 有一堆不触发的场景,这些场景恰好是脚本里最常出现的写法。

管道只看最后一条命令。 curl ... | jq ...curl 失败了,只要 jq 正常退出,整条管道的退出码就是 0,set -e 不会触发。要改这个行为得加 set -o pipefail

条件语境里的失败不算失败。 写在 ifwhile 条件里的命令,写在 &&|| 左边的命令,以及被 ! 取反的命令,失败都不会让脚本退出。这是 shell 的设计,不是 bug。所以 check_something && do_something 这种链式写法一旦前半段失败,脚本会安静地跳过后半段继续往下。

赋值会吞掉退出码。 local result=$(some_command) 这一行,退出码来自 local 这个内建命令本身,几乎总是 0,some_command 失败了也看不出来。要保留退出码得拆成两行:先 local result,再 result=$(some_command)。这一条我见过太多次,AI 尤其爱写成一行。

未定义变量默认是空串。 这不归 set -e 管,归 set -u。没有 set -u 的时候,一个拼错的变量名会展开成空,然后 rm -rf "$BUILD_DIR/dist" 就变成了 rm -rf "/dist"

所以稳妥的头部是这三行:

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

IFS 那行是把默认的「空格、制表符、换行」缩减为「制表符、换行」,让未加引号的展开不再按空格切分。它不是万能的,但能挡掉一部分空格问题。

要注意 set -euo pipefail 也有代价:某些命令返回非零是正常语义,比如 grep 没匹配到就返回 1,diff 有差异就返回 1。这些地方需要显式放行:

count=$(grep -c 'pattern' file.txt || true)

或者把它放进 if 里判断。别的写法都会让脚本在正常路径上莫名其妙地退出,然后你花半小时找「哪里出错了」——其实没出错。

三、引号:一个字符的成本

shell 的变量展开会做两件事:按 IFS 切分成多个词(word splitting),以及对结果做通配符展开(globbing)。加双引号,这两件都不做。不加,两件都做。

AI 写脚本时不加引号的概率不低,因为在它见过的示例代码里,变量名往往是 $file$dir 这种短名,路径是 /tmp/test,加不加引号结果一样。真实环境里不一样。

几个必须记住的点:

"$@"$@"$*" 是三个东西。"$@" 把每个参数原样传递,是唯一正确的转发写法;$@ 会对每个参数再切一次;"$*" 把所有参数拼成一个字符串。写包装脚本时用错这个,调用方传进来的带空格参数就废了。

数组展开必须写成 "${arr[@]}"。少一对引号,含空格的元素就散架。

[ $x = "yes" ]$x 为空时会变成 [ = "yes" ],语法错误。[ "$x" = "yes" ] 才对。bash 里更省事的是用 [[ ]],它内部不做 word splitting,但为了可移植性,我还是建议一律加引号,别指望记住哪种括号安全。

命令替换的结果也要引号:"$(dirname "$path")"。这里的内层引号是合法的,shell 会正确处理嵌套。

验证方法很直接——shellcheck 对未加引号的展开会给出 SC2086 之类的提示,把这一步接进你的提交检查里,比人眼看有效得多。

四、路径空格:从「能跑」到「删错东西」只隔一层

路径带空格是引号问题的重灾区,但它还有几种引号救不了的写法。

for f in $(ls) 这行永远是错的。 ls 的输出经过 word splitting,含空格的文件名会被拆成多个词;文件名里如果有通配符字符还会被二次展开。正确写法是 glob:

for f in ./*; do
  [ -e "$f" ] || continue
  echo "$f"
done

那行 [ -e "$f" ] || continue 是防止目录为空时 glob 没匹配到、$f 变成字面量 ./*

递归查找要用 NUL 分隔。 文件名里理论上除了 / 和 NUL 什么都能有,包括换行符。所以:

find . -type f -name '*.log' -print0 | xargs -0 -n1 gzip

这里有个容易被忽略的前提:xargs 后面必须跟一个真正的可执行程序或脚本,它是通过 exec 起新进程的,看不到你在当前脚本里定义的 shell 函数。AI 生成的版本很爱写 xargs -0 -n1 process_one,而 process_one 恰好是同一个文件里定义的函数,运行时你会得到一句 command not found。要在 xargs 里用函数,得显式起一个 bash:xargs -0 -I{} bash -c 'process_one "$1"' _ {},并且函数要先 export -f process_one。绕这么大一圈,不如直接用循环。

或者在 bash 里用 while read

while IFS= read -r -d '' f; do
  process_one "$f"
done < <(find . -type f -print0)

IFS= 是防止首尾空格被吃掉,-r 是防止反斜杠被当转义符,-d '' 是按 NUL 分隔。三个都缺一不可,AI 生成的版本经常只写对其中一两个。

破坏性命令加保护。 任何 rm -rf 前面,把变量非空作为硬前提:

: "${BUILD_DIR:?BUILD_DIR is required}"
rm -rf "${BUILD_DIR:?}/dist"

${VAR:?} 在变量为空或未定义时会直接报错退出,这是比 set -u 更贴身的一道锁。多写这几个字符的成本,和误删一个目录的成本不在一个量级。

五、判别表:从现象反推成因

拿到一个「行为不对但没报错」的脚本,先用下面这张表定位,再动手改代码。

现象大概率成因怎么验证处置动作
脚本报告成功,但产物缺失或是旧的中间某步失败被 set -e 漏掉(管道 / 条件语境 / 赋值吞码)bash -x script.sh 跑,看每步实际执行;在关键步骤后打印 $?set -o pipefail;把 local x=$(cmd) 拆两行;把 a && b 改成显式 if
只有含空格或中文的路径处理失败变量展开未加引号,或用了 for f in $(ls)造一个名为 a b c.txt 的测试文件重跑;shellcheck 看 SC2086全部展开加双引号;循环改 glob 或 find -print0
报错说参数数量不对 / 语法错误变量为空后展开成零个词,导致命令参数错位在出错行前 echo "[$var]",看是不是空set -u;关键变量用 ${VAR:?} 断言
删掉了不该删的目录变量为空或拼错,路径拼接成了根路径复盘时把 rm 换成 echo rm 空跑一遍${VAR:?} 保护;破坏性命令前加 dry-run 开关
本地跑正常,CI 或服务器上行为不同解释器不同(sh 不是 bash)、PATH 不同、locale 不同echo "$0 $BASH_VERSION"command -v 查关键命令、locale明确写 #!/usr/bin/env bash 并用 bash 调用;见 运行环境不一致
某个变量在子进程里读不到变量没 export,或在管道的子 shell 里赋值循环体内和循环结束后各 echo "$NAME" 一次:内有值、外为空即是子 shell 问题;调子进程读不到则用 env | grep NAME 看有没有 export需要传给子进程的显式 export;避免在 cmd | while read 里改外层变量,改用进程替换
脚本卡住不动,无输出命令在等交互输入交互命令挂住加非交互开关、重定向 stdin
报错提示带 \r 或命令名末尾多字符换行符是 CRLFCRLF 换行问题转换换行符、配置 Git 属性

表里最值得先做的一步是 bash -x。它把每条实际执行的命令连同展开后的参数打出来,绝大多数「我以为它会走这条分支」的误判,看一眼就现形。输出太长的话重定向到文件再翻:

bash -x ./deploy.sh > /tmp/trace.log 2>&1

六、拿到 AI 生成脚本后的五分钟验收

这套动作我建议固定下来,每次都做,顺序不要调。

第一步,静态检查。 shellcheck script.sh,把 warning 也当成要处理的。再跑一次 bash -n script.sh 做语法解析,不执行。

第二步,读三个地方。 只读三个地方:头部的 set 行、所有 rm/mv/> 覆盖写的位置、所有变量展开有没有引号。别通读全文,通读容易被流畅的代码带过去。

第三步,造脏输入。 建一个临时目录,放进去一个 a b.txt、一个中文名文件、一个空目录,让脚本在这上面跑一遍。这一步能抓出的问题比读代码多。

第四步,空跑破坏性动作。 给脚本加一个 DRY_RUN 开关,破坏性命令统一走一个包装函数:

DRY_RUN="${DRY_RUN:-0}"

run() {
  if [ "$DRY_RUN" = "1" ]; then
    printf '[dry-run]'
    printf ' %q' "$@"
    printf '\n'
  else
    "$@"
  fi
}

run rm -rf "${BUILD_DIR:?}/dist"

这里不要图省事写成 RUN="echo [dry-run]" 再用 $RUN rm -rf ... 那种前缀变量的形式。未加引号的 $RUN 会被 word splitting 和 globbing 一起处理,而 [dry-run] 在 shell 眼里是一个字符类通配符——当前目录里只要存在名为 dryun- 的单字符文件,这个前缀就会被替换成那个文件名,你的 dry-run 提示当场变成一句谜语。用函数包一层既避开了这个坑,printf ' %q' 又能把参数里的空格转义显示出来,路径对不对一眼可见。

第一次跑一定带 DRY_RUN=1,看打印出来的路径对不对。特别是路径由多个变量拼接而成的时候,%q 会把空串拼出来的 // 或者意外的空格暴露得很清楚。

第五步,在目标环境跑一次。 本地和目标机器的 shell 版本、PATH、locale 都可能不同,本地绿不代表线上绿。

这五步加起来花不了五分钟,比事后从日志里倒推便宜得多。如果你的脚本会被自动化流程反复调用,还值得把 shellcheck 接进提交前检查。

七、什么时候别再折腾

有几种情况,继续让 AI 改脚本是在浪费时间,应该换条路。

改到第三轮还在同一个地方出错,停。 典型表现是你说「这里空格没处理」,它改了一处,另一处又冒出来;再说一次,它把第一处改回去了。这说明它对这段逻辑的整体约束没建立起来,继续对话只会来回震荡。这时候的正确动作是自己动手把那一段重写,或者把脚本拆小——一个做完整流程的三百行脚本,换成五个各管一件事的小脚本,每个都容易验证。相关的判断依据可以看 AI 修不好的思维循环

脚本开始承载状态和分支逻辑,停。 shell 适合做「一串命令的编排」。一旦出现嵌套三层的条件、需要处理 JSON、需要重试和幂等控制、需要精确的错误分类,它就不再是合适的工具了。这时候不是脚本没写好,是选错了语言。换成 Python 或者别的什么,用真正的异常机制和数据结构,比在 shell 里模拟这些东西省事得多。判断线很简单:如果你需要一个数组的数组,或者需要 try/finally,就该换了。

破坏性动作的验证成本超过手工执行,停。 一个一次性的数据迁移,你要花两小时验证脚本安全,不如打开终端手工敲二十分钟,每一步看着结果。脚本的价值在复用,一次性的事情不必脚本化。

回滚点怎么留。 在动手改脚本之前,先确认三件事:脚本本身在版本控制里(改坏了能 git diff 看清改了什么);脚本操作的目标有备份或快照;破坏性动作有 dry-run 路径。这三件缺任何一件,就先补齐再改,别边改边试。

八、避坑清单

坑一:把 shellcheck 全绿当成安全。 之所以会踩,是因为静态检查只能看语法和常见模式,看不出业务语义——它不知道你这个 rm 的目标应该是哪个目录。避法:静态检查是及格线不是终点,破坏性动作永远要人工过目加 dry-run。

坑二:set -e 写了就撒手。 之所以会踩,是因为这个开关的名字太像「出错就停」,而实际语义有一堆例外。避法:把管道、条件语境、赋值这三类例外记牢,头部统一用 set -euo pipefail,并且对预期返回非零的命令显式 || true

坑三:测试用的路径永远是 /tmp/test 之所以会踩,是因为干净路径不会触发切分问题,开发环境天然干净。避法:把「带空格、带中文、空目录、零匹配」四种脏输入固化成测试夹具,每个脚本都过一遍。

坑四:让 AI 一口气写三百行。 之所以会踩,是因为长脚本里每一处的错误概率会累加,而人的审查注意力在第一百行以后就衰减了。避法:一次让它写一个函数或一段流程,验证过再写下一段;超过一百行就考虑拆文件。

坑五:用 sh 执行 bash 脚本。 之所以会踩,是因为不少系统上 /bin/sh 指向的是精简 shell,[[ ]]、数组、local、进程替换都可能不支持,而 AI 默认按 bash 的能力写。避法:shebang 明确写 #!/usr/bin/env bash,调用时也用 bash script.sh,别用 sh script.sh

坑六:在管道的 while 循环里改外层变量。 之所以会踩,是因为管道右侧在子 shell 里执行,循环里的赋值出了循环就没了,而现象是「变量莫名其妙是空的」。避法:改用进程替换 while ... done < <(cmd),或者把结果写进临时文件再读。

坑七:脚本里硬编码路径和凭据。 之所以会踩,是因为 AI 会照着对话里出现过的具体路径直接写死,而你只是随口举了个例。避法:路径统一走变量并在头部集中声明;任何看起来像密钥的东西都走环境变量,不进脚本文件,也不要贴进对话上下文。

坑八:相信脚本末尾的 echo "全部完成" 之所以会踩,是因为这行 echo 只证明脚本执行到了最后一行,不证明中间每步都成功——这正是本篇开头那个错误归因的来源。避法:成功提示要基于实际校验,比如检查产物文件存在且非空、检查服务返回 200,而不是基于「跑到这儿了」。

收束:一张自检清单

shell 的坑不在语法难,在于它的默认行为是「宽容」,而你要的是「严格」。AI 学到的是前者的写法,你要补的是后者的约束。这个差值不会因为模型变强就自动消失,因为它本质上是你的业务决策,不是知识问题。

每次拿到新脚本,过一遍这七条:

  • 头部有 set -euo pipefail 吗?预期返回非零的命令处理了吗?
  • 所有变量展开都加双引号了吗?"$@" 写对了吗?
  • 有没有 for f in $(ls) 这类写法?递归查找用 NUL 分隔了吗?
  • 破坏性命令的路径变量有 ${VAR:?} 保护吗?
  • 有 dry-run 开关吗?第一次跑用它了吗?
  • 用带空格、带中文的脏输入测过吗?
  • 在真正的目标环境上跑过一次吗?

七条都能答「是」,这个脚本就可以交给自动化去反复执行了。答不上的那几条,就是你下一次事故的位置。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。