(08)为什么我的 Agent 一跑后台服务就卡死
上一篇提到 Bash 工具虽然功能强大,但稍有不慎就会引发严重问题。本文将深入剖析它曾为我设下的一个陷阱——一个让排查方向从始至终都偏离核心的棘手状况。

现象:agent 卡住,60 秒后还杀不掉
agent 在执行一个任务时,中途需要启动一个本地服务(例如 python proxy.py 这类,启动后持续运行,为后续步骤提供服务)。它在该命令后附加了 & 符号,意图使其在后台运行。
然而,整个 agent 随即陷入停滞。
我为 Bash 工具设定了超时机制:若某条命令在 60 秒内仍未结束,则判定为异常并执行终止操作。但这一次,当 60 秒时限到达后,进程未能被成功终止——它仍然顽固地占据资源,导致 agent 在该步骤彻底锁定。
我一头扎进了“怎么杀进程”
我紧盯“无法杀死”这个现象,全身心投入到进程管理的细节钻研中:分析 kill 命令为何失效、研究如何处理进程组、探索如何将目标进程及其子进程一并清除、添加锁机制以防止重复终止……花费了大半天时间。
然后我停下脚步,向自己提出了一个早就该思考的问题:这个进程,本身就不应该被终止啊。
agent 启动这个服务,目的正是让它持续运行以供后续环节使用。而我却在辛辛苦苦地研究“如何更彻底地杀死它”——我正专注地解决一个根本不该出现的问题。
真正的根因:我没区分“命令”和“服务”
退一步审视,根本原因非常清晰:我的 Bash 工具,将所有命令都统一视作“会结束的命令”来对待。
然而,命令实质上分为两类:
- 会跑完的:例如
ls、npm install、运行测试脚本,执行完毕后自动退出。等待它们完成、为其设置超时,是合乎逻辑的。 - 压根不打算结束的:例如启动服务、运行守护进程,其“正常状态”就是持续运转。为这类任务设置超时、到期后执行终止,是完全错误的做法。
我把第二种类型当作第一种来处理,因此“等待结束 → 等不到 → 超时 → 终止”这一处理链条,从第一步就发生了偏差。
修法:让 Bash 工具不再采用“统一等待直至超时再终止”的策略,而是识别出每条命令实际上存在三条不同的处理路径。
一条命令的三条路径
我将执行逻辑修改为如下形式(伪代码,紧密贴合实际分支):
复制代码// 路径 1:你明说"扔后台"——立刻登记、立刻返回,根本不等
if (args.run_in_background === true) {
register(task)
return backgrounded({ task_id, output_path }) // 给个 id + 输出文件路径就走
}// 路径 2 / 3:同时等"三件事",谁先到算谁
const who = await Promise.race([
whenClosed, // 命令自己跑完了
timeoutAfter(120_000),// 默认 2 分钟到点(不是 60 秒了)
whenAborted, // 用户点了"停"
])if (who === "closed") return result // 路径 2:正常跑完,返回输出
if (who === "aborted") { treeKill(pid); ... } // 用户停:整棵进程树一起杀// 路径 3(关键!):到点超时——不杀,而是"提拔"成后台任务
if (who === "timeout") {
register(task) // 留在登记表里,继续让它跑
return backgrounded({ task_id, output_path, reason: "timeout" })
}
最为关键的是路径 3——它才是解决最初那个陷阱的真正解药。当一条命令到达超时时间仍未结束时,我不再一刀切地将其终止,而是默认它“要么是一个服务,要么只是运行得比较慢”,因此自动将其转换为后台任务:进行登记、让它继续运行,并向 agent 返回一个 task_id 和一个输出文件路径。agent 后续若想查看进度,可使用 Read(output_path);若想停止它,则使用 TaskStop(task_id)。
这样一来,无论 agent 是否明确声明“扔到后台”,长时间运行的服务都不会被错误地终止,同时 agent 也无需无谓地等待——回想开头的 proxy.py 卡死事件,其根本原因正是当时只有“等待→终止”这两条路径,缺少了路径 3。
后台进程不是“扔了就不管”
“放到后台”不等于“完全放任不管”。我为每个后台任务建立了一个登记表,每条记录的结构如下(精简版,实际字段一致):
复制代码interface BgTask {
bgId: string // 会话内唯一 id,形如 bg_
sessionId: string // 属于哪个会话(删会话时按它收割)
command: string // 实际跑的命令
status: "running" | "completed" | "failed" | "killed"
pid: number | undefined // 进程号 —— 停它的时候要用
stdoutPath: string // 输出落到这个文件(不塞内存,见第 5 篇大对象那条)
stderrPath: string
stdoutReadOffset: number // 已经读到输出的第几个字节(增量推送用)
}
围绕这张表,“管理好它”包含三个具体操作:
- 登记:
register(task)将其存入一个Map<bgId, BgTask>,同时会校验sessionId的有效性(防止混淆)。查找任务时使用get(bgId)或list()。 - 看输出:服务的 stdout/stderr 直接写入文件(
stdoutPath),不占用内存。一个 1 秒轮询器持续监控这些文件,借助stdoutReadOffset记录“上次读取到的位置”,仅将新增的内容部分推送给前端(实现增量更新,避免重复发送全部内容)。若 agent 需要查看完整输出,使用Read(stdoutPath)即可。 - 能停:掌握
pid后,停止进程时使用treeKill(pid, "SIGKILL")——注意是 tree-kill,这样能将进程及其产生的子进程整棵树一同终止,而非仅仅杀死父进程从而遗留僵尸子进程。当会话关闭时,registry.shutdown()会遍历表中所有状态为running的任务,逐一执行 treeKill 后清空。
缺少其中任何一环,后台进程要么变成无法追踪的野进程,要么变成无法清除的僵尸。
这个后台服务,该活多久
这里存在一个需要明确决策的设计问题:一个后台服务,应该与“当前这一轮对话”同生共死,还是独立于对话而存在?
我的策略是:有意将后台服务设计为“跨轮次独立存活”——即使 agent 的当前轮次结束,它启动的服务仍在运行,下一轮对话中仍然可用。这才符合“启动一个服务给后续步骤使用”的初衷。
但“独立存活”不能演变为“永远无人管理”。因此,我为其设定了两条边界:
- 整个会话被删除 → 该服务必须随之停止(避免遗留无主的野进程占用端口);
- 程序整体退出 → 所有后台进程必须有序收尾(依赖进程退出时的兜底清除机制)。
这实际上是第十篇中“级联取消”思想的另一面:大多数“需要等待和运行”的组件应当跟随停止,但少数有意持续运行的组件,必须被明确标识出来。
用户点了一次“取消”,之后所有命令都静默失败
再讲述一个尤为隐蔽的陷阱——而且它恰好发生在上面提到的 race 逻辑之上。
现象:用户在某个轮次点击了“取消”按钮。然后——该会话中后续执行的所有 Bash 命令,全都无声无息地返回 exitCode: -1,且输出内容为空,即使是 echo hello、pwd 这类必定瞬间完成的命令也是如此。期间没有报错、没有日志、没有提示,仅默默返回空结果,很容易让人误以为是“命令没有输出”,从而导致反复重试却仍不明所以。只有重启进程才能恢复正常。
根因:还记得上面那个 Promise.race([跑完, 超时, 用户停]) 吗?其中“用户停”的功能依赖于一个取消信号(AbortSignal)。当用户点击取消时,该信号被置为 aborted 状态——本应在此轮次使用完毕后即被丢弃。然而,它残留了下来,并在后续命令中被复用了:于是后续每条命令一开始执行时,race 发现“信号已经是 aborted 状态”,便不经任何真正执行,直接跳转到 abort 分支并返回 -1,命令根本没有机会运行。
最有害的地方在于它的静默特性:我花费了很长时间追踪这个问题,一度怀疑是进程崩溃了,尝试执行 ps、echo 等所有基础命令,结果全部返回 -1。最终(说起来有点好笑)是在 Bash 代码中随意添加了一行 console.log,触发了热重启,Bash 才恢复活力——这才意识到是残留状态问题,重启即能解决。
修法:包含两点。第一是每条命令所使用的取消信号必须是属于当前轮次自身的、用完即弃,绝不复用上一轮那个可能已处于 aborted 状态的信号(这正是第 10 篇中强调的:取消信号的生命周期必须划分清楚)。第二是失败时不要再保持静默——向 agent 返回一个可见的错误信息(包含错误码和建议),而非默默返回 -1 让其对着空白内容进行无效重试。
教训:一个“静默失败”比“报错失败”危险得多。 报错至少能让你知道问题出在哪里;而静默返回一个空结果,会让你朝着完全错误的方向花费数小时进行排查。
这件事真正的教训,跟进程无关
修复本身并不困难。真正的难点在于,我耗费了大半天时间才意识到自己正在解决一个错误的问题。
我后来将这次经历总结为一个方法,并贴在显眼之处:
如果我在最初就能想清楚“agent 启动这条命令是为了长期使用”,而不是被“杀不掉”这个表面现象牵着鼻子走,我根本就不会去触碰那些进程管理代码。一个缺陷可能确实存在,但这并不意味着你必须走到那个分支上去处理它。
这也是为什么,我现在让 AI 检查 bug 时,会特意加上一句:先别急着找代码哪里错了,先告诉我这个操作原本想要达到什么目的。 你如何定性一个问题,就框定了它努力的方向——这一点,在后续讨论“与 AI 协作”的相关篇章中还会反复提及。
小结一下
- Bash 最大的陷阱,在于把长时间运行的服务当作会自然结束的命令,从而导致“等待—超时—终止”这一整条处理链从起点就错了;修正方法在于区分两类任务,后台类型的任务应明确登记,既无需等待也不应被终止;
- “扔后台”不等于完全放任:需要能够登记、查看输出、干净地停止;
- 后台服务有意设计为跨轮次独立存活,但在删除会话或程序退出时必须进行清理,并且这种“有意设计”必须被标注并经过测试确认;
- 用户一旦执行取消操作,残留的 abort 信号会导致后续所有命令静默地返回 -1——取消信号必须用完即弃,并且失败时不应保持静默;
- 最重要的教训:排查问题前先问清楚意图,不要被表面现象牵着鼻子走,去精修一个本不该进入的分支。
既然聊到了“让 agent 自行运行命令”,那么一个不可避免的关卡就是:某些命令具有危险性,不能让其随意执行。下一篇将讨论:让 AI 自己运行命令,但涉及危险操作时,必须先征得我的同意。