大家好,我是Zack。
前几天我让 agent 写个批量重命名的脚本,跑完它跟我说「已完成,全部处理成功」。
我去目录里一看,一个文件都没动。
翻日志才发现:脚本第 1 行的 cd 就失败了,后面十几行全在空转,最后那句 echo "ALL DONE" 倒是老老实实打印出来了。
退出码是 0。agent 看到退出码 0,就认为一切正常。
这就是我今天想聊的事:agent 跑命令,你不给它装护栏,它就会带着错误一路跑到终点,然后告诉你成功了。
我原来的做法
以前我给 agent 派活的流程特别简单:说清楚要干什么,让它写脚本,它跑,我看结果。
问题出在「看结果」这一步。我看的是它打印出来的最后一行,它看的也是。
我自己先做了一遍这个实验,用一个 4 行的小脚本:
cd /tmp/nonexistent-dir-xyz
echo "step2 假装继续工作"
echo "step3 完成"
echo "ALL DONE"
跑出来是这样:
step2 假装继续工作
step3 完成
ALL DONE
退出码: 0
第 1 行 cd 到一个不存在的目录,报错了。但后面三行照跑不误,最后还打印了 ALL DONE,退出码是 0。
任何以退出码判断成败的程序,到了这里都会认为任务成功。agent 当然也一样。
我后来翻了下自己的工作区,scripts/ 下面 4 个 shell 脚本,4 个都没有 set -e。
也就是说我自己的每个脚本,都是这种「第 1 行挂了还继续跑」的状态。这个比例让我有点坐不住。
除了假成功,还有第二个问题:卡住。
不加超时的话,一条命令卡在等网络或者等输入,agent 就一直挂着。你不知道它在等什么,也不知道要不要等,只能干耗着。我有一次等了十几分钟,最后发现是包管理器在等一个 Y/N 确认。
换成三道护栏
护栏一:脚本第一行必须是 set -euo pipefail
这一行加在 shell 脚本开头,意思是:任何一条命令失败就停,用了没定义的变量也停。
三个开关各管一摊:
-e:某条命令返回非 0,立刻退出,不往下跑-u:引用了没定义过的变量,立刻退出-o pipefail:a | b这种管道,a 失败了整个管道算失败,不能靠 b 的成功蒙混过去
同样的脚本,加上这一行之后:
set -euo pipefail
cd /tmp/nonexistent-dir-xyz
输出只剩一行报错,退出码 1,后面那两行压根没执行。
-u 我也单独验了。引用 $UNDEF_VAR 时,不加它会被当成空字符串,命令静悄悄变成 ls /tmp/guardrail-demo/;加了直接报 unbound variable,退出码 1。
我现在给 agent 的提示词里写死一句:
写 shell 脚本,第一行必须是
set -euo pipefail,结尾必须有明确的退出码检查,不要用echo "完成"假装成功。
护栏二:会等的命令一律套超时
凡是联网、装依赖、跑测试、重启服务这类可能卡住的命令,前面都要加超时。
Linux 上直接 timeout 60 npm test,60 秒没跑完就杀掉。
macOS 没有 timeout 这个命令(我机器上 which gtimeout 也是空的),用 perl 兜:
perl -e 'alarm 60; exec @ARGV' npm test
我实测过:一条要跑 60 秒的命令,套上 alarm 5,5 秒就被杀掉,退出码 142。
退出码 124(GNU timeout)或 142(SIGALRM)都代表超时,agent 读到这个码就知道是超时不是失败,不会瞎改代码。这一条很重要——报错信息里得说清楚是「超时」还是「错了」,否则 agent 会把超时当成 bug 去改,越改越乱。
还有个更隐蔽的:命令卡在等交互确认。给包管理器加非交互参数就行——npm 加 --yes,apt-get 加 -y,git clone 前面加 GIT_TERMINAL_PROMPT=0。
护栏三:同一处改三遍还没对,就叫停
这是给 agent 自己的行为设的护栏。
同一段代码,agent 改三遍还没改对,我就叫停。不是让它再试第四次,是让它先说清楚:它认为问题在哪。
三遍都错,通常说明方向从一开始就错了。让它继续改,只会把代码越改越脏,最后还得整体回滚。
叫停之后的动作是固定的三步:
git checkout -- <那个文件> # 回滚到第一次改之前
git status --porcelain # 确认工作区干净了
# 然后重新描述问题:我期望什么、实际是什么、报错原文是什么
不重新描述就直接让它重来,它大概率会走回同一条错路。因为它的上下文里还留着前面三遍的错误假设。
配套的还有一个:动大工程之前,先让它写一个能跑的最小复现。几十行代码,只重现那一个 bug,不碰主工程。
复现跑不出来,说明问题还没定位清楚,这时候动主工程纯属浪费。
前后对比
我把这套护栏装上之后的差别:
| 项 | 装之前 | 装之后 |
|---|---|---|
| 失败发现时机 | 全部跑完,看最后一行 | 出错那一行当场停 |
| 假成功 | 退出码 0,被告知「已完成」 | 退出码 1,报错原文直接抛出来 |
| 卡住 | 干等十几分钟不知道在等啥 | 60 秒自动杀掉,退出码 142 |
| 改错方向 | 连改七八遍,代码越改越脏 | 三遍叫停,回滚后重新描述 |
| 定位成本 | 在大工程里翻日志 | 20 行 repro 先跑出报错 |
省下来的不是那几分钟,是「以为跑完了,其实什么都没干」这种返工。
那个批量重命名的脚本,装完护栏之后第 1 行就停了,报错是 cd: No such file or directory。我一看就知道是我把目录路径写错了,30 秒改完。
两条能迁移的原则
第一,判断成败要看「退出码」而不是「有没有报错文字」。
shell 脚本默认不会因为报错而停。它只关心最后一条命令的退出码。所以你的脚本不管多长,只要最后一行是 echo,那它的退出码永远是 0。
写脚本的人和调用脚本的 agent,都容易被这个骗到。
第二,护栏要设在「失败发生的那一刻」,不是设在「全部跑完之后」。
验收是事后的,护栏是事中的。事后发现第 1 行就错了,中间那十几分钟已经花掉了。
这条不光对 agent 成立。人写代码也一样:测试要跑在每次保存之后,不是跑在下班之前。
最后
这三道护栏都不复杂,就是三个习惯:脚本第一行加 set -euo pipefail、会等的命令套超时、同一处改三遍就叫停。
我踩的坑是:agent 说「跑完了」,指的只是「命令执行完了」,不是「事情办成了」。
你有没有被 agent 的「已完成」骗过?评论区聊聊你踩的是哪种假成功。
公众号:Zack说AI