后台 agent 跑完没报错也没产物,怎么判断它其实已经失败了
数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。
静默失败几乎从来不是模型不听话,而是你的流程里没有任何一个环节负责回答”这次到底有没有产出”。 大多数人一遇到”agent 说完成了但仓库里什么都没有”,第一反应是换模型、改写任务描述、加一堆”务必落盘”的强调句。这些动作在少数情况下有效,但它们绕过了真正的问题:任务的成功判定权被交给了 agent 自己,而它对”成功”的定义只到”我认为我做完了”,不包含”文件确实存在于目标路径、内容通过校验、变更已经推到你能看到的地方”。只要判定权不收回来,同一类故障会换着现象反复出现。
本站另外三篇讲的是不同层面的事:Cursor Background Agent 后台异步代理配置全解 和给 GitHub Issue 分配 @copilot 自动写 PR 分别讲这两个产品后台任务怎么配、能力边界在哪,Agent 上线之后的日常运维要盯什么 讲长期跑起来之后的日常运维节奏;这一篇不谈某个产品怎么点,只谈一件事——当一次后台执行既没有报错也没有产物时,你按什么顺序把成因收敛到一类,以及事后加哪几层兜底让它下次不再静默。
一、先分因:四类静默失败的信号长什么样
“没报错也没产物”是一个复合现象,往下拆只有四类,判别的入口都在”到底有没有痕迹”。
第一类是写回失败。执行过程是真的发生了,改动也真的产生过,但它活在一个临时环境里,最后那一步没能把成果送出来:凭据不对、推送被拒、目标分支保护、或者容器直接被回收。特征是有执行记录、没有远端痕迹。
第二类是目标跑偏。产物是有的,但不在你要的位置:写进了子目录、写进了另一个包、写进了被忽略规则吞掉的路径。特征是提交存在、--stat 里没有你要的文件名。这类最容易被误判成”没干活”,实际上是干了活但你看不见。
第三类是阻塞挂起。进程还在,日志停在某一步再也不动。绝大多数是在等一个永远不会到来的输入:交互式确认、需要人工授权的登录、或者一个没设超时的网络请求。特征是”进程活着、时间在走、输出不增长”。
第四类是退出码被吞。某个上游调用失败了,返回的是 401/403/429/500 之类的状态,但脚本没有检查、没有让它冒泡,于是整条流水线一路”成功”到底,只是什么都没做。特征是结束得异常快,产物为空或只剩一个空壳。
把这四类摆清楚之后,判别就不再需要猜。下面这张表是我排查时实际走的顺序,从上往下第一条命中就停。
| 现象 | 大概率成因 | 怎么验证 | 处置动作 |
|---|---|---|---|
| 任务显示完成,远端没有任何新分支或提交 | 写回失败:凭据缺失、推送被拒、沙箱回收 | git ls-remote --heads origin 看分支是否真的存在 | 把”写回成功”作为任务成功的唯一判定;重新用显式路径落盘并本地提交后再推 |
| 有提交,但改动里没有目标文件 | 目标跑偏:工作目录不对,或路径被忽略规则吞掉 | git show --stat <sha>;git check-ignore -v <path> | 修忽略规则或把绝对路径写进任务约束;校验改成按路径清单逐条核 |
| 日志停在某一步,进程仍在,输出不增长 | 阻塞挂起:等交互输入或网络无超时 | Linux 上 strace -p <pid> 看最后一次系统调用是不是停在 read(0,(等 stdin);是网络等待则 cat /proc/<pid>/wchan 落在读等待、ss -tnp | grep <pid> 看到连接 ESTAB 却无流量 | 全流程走非交互参数;每个外部调用设连接与读取超时;父层加硬超时 |
| 很快”成功”退出,产物为空 | 退出码被吞:4xx/5xx 没有冒泡 | 打开请求级日志看状态码;set -euo pipefail;curl 加 -f | 状态码映射为非零退出;429 走退避重试,401/403 立即停止不重试 |
| 文件只写了一半,构建或格式校验不过 | 输出被截断或写入过程中断 | 跑一次构建;检查文件尾是否残缺、是否含 <<<<<<< 冲突标记 | 校验不过一律判失败;把任务拆到单次输出能写完的粒度 |
| 本地能跑,agent 环境同样的命令失败 | 环境差异:代理未生效、自签证书导致证书链校验失败、缺环境变量 | 打印非敏感的环境变量键名清单;curl -v https://… 看 TLS 报错语义 | 把 CA 证书与代理变量纳入环境准备步骤,并在任务开头做一次连通性自检 |
顺带说一句区域限制的现实:部分海外工具与模型的官方服务对中国大陆有区域限制、不支持直连,这类环境里”没报错也没产物”往往就是连接根本没建立。市面上存在第三方中转,我不背书也不给具体渠道,但你需要知道:中转链路会引入自签证书、请求被改写、超时行为不一致等额外变量,排查时应当把它当作可疑因素单独隔离,而不是混在业务逻辑里一起猜。
二、超时判定:谁来宣布这次执行已经死了
阻塞类故障之所以拖人,是因为没有任何一方有权宣布死亡。agent 自己不会承认卡住,你又不确定它是不是在做慢活。解决办法只有一个:在外层设一条硬线,越线就杀,杀了就当失败处理。
阈值怎么定,各家产品对单次执行的时长规则不同且会调整,以官方最新说明为准;你自己那条线不要抄别人的,按历史成功任务的耗时分布取一个明显的上界,并且不同任务类型分开设——跑测试和改一行文档不该共用一个数。
外层包一层通用超时即可,不依赖任何产品特性:
# 这里故意不开 -e:开了之后 timeout 一返回非零,脚本当场退出,下面的分支永远走不到
set -uo pipefail
code=0
timeout --signal=TERM --kill-after=30s "$AGENT_TIMEOUT" ./run-agent.sh || code=$?
if [ "$code" -eq 124 ] || [ "$code" -eq 137 ]; then
echo "agent timeout, treat as FAILED" >&2
fi
exit "$code"
这段有两个坑值得单独说。一是 set -e:很多人习惯性把 set -euo pipefail 写在最上面,结果超时那一刻整个脚本直接以非零码退出,你精心写的”判定为超时并标记失败”的分支一次都没执行过,外面看到的仍然是一次语焉不详的失败。要么像上面这样用 || code=$? 把非零码接住(-e 对紧跟 || 的命令不生效,这是它少数的豁免情形之一),要么把执行部分拆成独立的子脚本、由一个没开 -e 的调用方去接它的退出码。别指望”写进函数里就自动免疫 -e”:函数被直接调用时,函数体内某一步失败照样会把整个脚本带走;只有当这个函数本身出现在 ||、&& 或 if 的条件位置上时,-e 才在它体内被抑制。这条规则值得你在自己的机器上用三行脚本亲手验一次,比记结论牢。二是退出码不止一个:GNU coreutils 的 timeout 在发出信号后正常收场时返回 124,但如果被执行的进程忽略了 TERM、最后是被 --kill-after 阶段的 KILL 结束的,退出码会是 137(128+9)。只判 124 就会把最顽固的那类卡死漏掉。另外 timeout 是 GNU coreutils 提供的,macOS 自带的 BSD 环境里默认没有这个命令,需要另行安装 coreutils 后用 gtimeout,或者在父层用别的方式实现硬超时。
同时,超时要和”进度”绑,而不是只和”总时长”绑:一个持续输出日志、持续提交中间产物的长任务是健康的,一个没有任何进度信号的短任务反而更可疑。这就是下一节心跳存在的理由。
三、产物校验:只认磁盘上的东西,不认自述
这是整篇最关键的一层。任务是否成功,由一段和 agent 无关的校验代码判定,输入只有目标路径和内容规则,不读它的输出文本。
校验至少要覆盖三件事:路径清单是否齐、每个文件是否满足最低结构约束、以及仓库状态是否符合预期。第三件常被忽略,但正是它抓住”目标跑偏”这一类。
import pathlib, subprocess, sys
# 关键:路径基准取仓库根,不要依赖脚本被调用时的当前目录,
# 否则校验脚本自己也会跟着"目录跑偏"一起判错。
root = pathlib.Path(
subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True, text=True, check=True,
).stdout.strip()
)
expected = [
"src/content/article/foo.md",
"src/lib/parser.ts",
]
missing, empty = [], []
for rel in expected:
p = root / rel
if not p.is_file():
missing.append(rel)
elif p.stat().st_size == 0:
empty.append(rel)
if missing or empty:
print("MISSING:", missing, file=sys.stderr)
print("EMPTY:", empty, file=sys.stderr)
sys.exit(1)
print("artifacts ok")
用 is_file() 而不是 exists(),是因为后者对目录同样返回真:agent 偶尔会把本该是文件的路径创建成目录,exists() 会放它过关。
再加一条仓库侧的确认,用来区分”改了但没提交”和”根本没改”:
# 执行前先把基线记下来,别用 HEAD~1 当基线
BASE_SHA=$(git rev-parse HEAD)
# …执行 agent…
git status --porcelain # 有改动没提交?
git diff --stat "$BASE_SHA" -- src/ # 相对基线到底动了哪些文件
为什么不用 HEAD~1:agent 这一趟可能提交了零次、一次,也可能提交了五次,HEAD~1 只能覆盖”正好一次提交”这一种情况,别的情况下你比对的是一个错误的基线,得出的结论比不比对更危险;在只有一个初始提交的仓库里,HEAD~1 还会直接报错退出。把执行前的 HEAD 存成变量,这几种情况就统一了。
判读规则也简单:git status --porcelain 输出为空、同时 git diff 相对基线也看不到目标路径,就是没干活;status 输出非空说明改动落在了工作区但没进提交,写回链路断在提交这一步。两种处置完全不同,别混在一起重试。
有一个细节代价很高,而且方向常被记反。git add . 或 git add <目录> 这类批量形式,遇到被忽略规则命中的文件是静默跳过并以 0 退出的,终端上一片祥和;接着 git commit 提交的就是一个不含目标文件的提交,或者干脆报一句没有可提交内容就过去了,最终远端干干净净。反过来,显式写路径时 git 的态度要明确得多:git add path/to/new.md 如果这个路径被忽略规则命中,会直接报错退出并提示需要 -f;git commit -m x path/to/new.md 如果这个路径还没进索引,会以 pathspec 不匹配报错退出。
所以”新文件要显式列路径提交”的理由不是显式路径更容易成功,而是显式路径会把失败变成响亮的失败,批量形式才是把失败变哑的那个。落盘之后再用 git show --stat 反查一次提交内容,这一趟才算核完。这类”命令成功但语义失败”是静默失败的温床,凡是这种命令都要配一个反查动作。
四、心跳与失败可见化:让状态变成可查询的数据
心跳的作用不是监控好看,而是把”卡住”变成一个可判定的事实。做法很轻:在执行的每个阶段边界上报一次,带阶段名和时间戳,写进一个你能查的地方。
beat() {
curl -fsS -m 5 -X POST "$HEARTBEAT_URL" \
-H 'Content-Type: application/json' \
-d "{\"task\":\"$TASK_ID\",\"stage\":\"$1\"}" || true
}
beat start
./prepare.sh || exit 1; beat prepared
./run-agent.sh || exit 1; beat generated
./verify.sh || exit 1; beat verified
这里的 || exit 1 不是凑数:如果写成 ./prepare.sh && beat prepared 三行并列,prepare.sh 失败时它只是跳过自己那次心跳,后面两行照跑,最后你会看到一条”缺了中间某个阶段却仍然跑完”的诡异记录——环境都没准备好就去执行,本身就是静默失败的经典造法。要么像上面这样每一步失败即退,要么在脚本顶部开 set -e(但要注意上一节说的那个坑:开了 -e 就别指望在同一层里接住退出码做判定)。
另外注意两点:心跳请求自己要带超时(-m),并且失败不能拖垮主流程(末尾的 || true)——监控把业务搞挂是很常见的自伤。有了阶段心跳,“最后心跳的阶段名”就直接告诉你卡在哪一步,不用再翻长日志。
失败可见化则是把结果落成一条结构化记录:任务标识、结束状态、最后阶段、校验缺失的路径清单、退出码、以及可复现的命令行。判定为失败时主动推一条通知出来,宁可多推也不要静默。这里有个反直觉的取舍:默认状态应该是失败,只有校验通过才翻成成功。很多流水线反过来,默认成功、出错才标失败,于是任何被吞掉的错误都会变成一次假绿。
成本侧也别落下:反复静默重试是纯烧钱,每次都跑到一半空手而归。把执行的调用量和结论记在一起,才看得出哪类任务的失败率在恶化,口径可参考《Agent 跑着跑着账单失控:几个常见原因》。
五、什么情况下别再折腾了
排查是有止损点的,以下几条一命中就停手,换路比继续调更省。
同一任务在同一环境连续三次以上以相同阶段的相同现象结束,且期间你只改了任务描述、没改环境和校验。这说明变量根本不在描述里,继续改措辞是在噪声上做实验。停下来,把任务缩到最小可复现范围,本地手工跑一遍那条命令,先确认它在这台机器上能成。
校验通过率不稳定,而产物质量本身也不稳定。这时你面对的是两个叠加问题,先把校验做严、把失败变成硬失败,再谈质量。质量问题混在可见性问题里排查,结论永远不可靠。
改动已经落进仓库但半成品状态难以判断。回滚点很明确:如果这次执行的所有改动都在一次提交里,git revert <sha> 干净退回;如果散在多次提交里,先拉一个分支保存现场再重置目标分支,别在污染的工作区里继续叠加。判断依据是”你能不能一句话说清现在仓库和上次已知良好状态的差异”——说不清就回滚。
任务本身不适合放后台。需要多轮澄清、需要你看着中间结果做决定、或者依赖只在你本机才有的凭据与设备,这类交给后台执行就是在赌,改成人机协同的交互式流程更快。什么任务该交出去、什么该自己盯着,《Agent 里的人工介入怎么设计:不是加个确认按钮》里那套分界比我在这里三言两语讲得清楚。
执行环境处在你无法完全观测的位置。如果连日志、退出码、网络出口都拿不全,先花时间把可观测性补上,不要在盲区里排查。补可观测性看着像绕远路,实际是唯一能收敛的路。
六、避坑清单:为什么会踩,以及怎么避
把 agent 的自述当成功依据。 会踩,是因为它的收尾文本读起来非常像验收报告,语气笃定、条目齐全,人天然会信。避法:验收只跑独立校验脚本,任何自述在流水线里都不参与判定,最多作为排查线索。
用日志里没出现 error 当健康信号。 会踩,是因为被吞掉的错误压根不会打印,日志确实”干净”。避法:干活的那层脚本用 set -euo pipefail 打底,管道里每一环的退出码都要看,curl 一律带 -f,让 4xx/5xx 变成非零退出(-f 的作用就是把 HTTP 错误状态转成 curl 的非零退出码,而不是照常返回 0 并把错误页写进输出;具体码值对照 man curl 的退出码一节)。这里要跟前面的超时判定分层看:执行层开 -e 让错误立刻冒泡,负责判定和善后的外层不开 -e、改用 || code=$? 把码接住,两种角色别塞进同一个脚本的同一层。
只重试不分因。 会踩,是因为重试成本低、偶尔能碰对。避法:按状态码分流——429 退避重试是合理的,401/403 属于凭据或权限问题,重试一万次也不会变对,应当立刻停并报警。重试策略的细节在《Agent 执行失败后怎么恢复:重试不是万能的》里展开过。
校验只查文件存在。 会踩,是因为存在性检查最好写。避法:加上非空、结构可解析、能过构建这三层。一个只有 frontmatter 的空壳文件,存在性检查会给你一个绿灯。
任务范围一次给太大。 会踩,是因为一次交完看着效率高。避法:按”单次执行能写完且能独立校验”的粒度切,范围大的任务被截断的概率显著上升,而截断恰好是最难从外部看出来的失败形态。
心跳请求不设超时。 会踩,是因为它平时毫秒级返回,没人想到它会挂。避法:加 -m,并且失败静默忽略,监控不能成为新的阻塞源。
默认状态是成功。 会踩,是因为多数流水线模板就是这么写的。避法:初始化时先写入失败状态,校验通过再改写为成功。这一条改动量最小、收益最大。
收束
静默失败的本质是判定权错位。把成功的定义从”agent 认为做完了”改成”独立校验确认产物齐、内容过、变更可见”,四类故障里的三类会自己浮出水面;剩下的阻塞类,靠外层硬超时加阶段心跳解决。这几层机制的代码量都很小,靠 shell 和一段校验脚本就能搭起来,但它把”偶发的诡异消失”变成了”可以归因的失败记录”,这中间的差别就是能不能规模化用后台执行。
上线前对着这张单子过一遍:目标产物路径清单是否已写死在校验脚本里;校验是否覆盖非空与可解析;任务状态是否默认失败、校验通过才翻绿;外层是否有硬超时且超时按失败处理;阶段心跳是否落盘并且自带超时;4xx/5xx 是否会变成非零退出、401/403 是否禁止重试;新文件是否用显式路径提交且提交后反查过内容;失败时是否有一条带可复现命令的通知推出来。八条都是”是”,你就不会再遇到查无实据的静默失败了。