(08)为什么我的 Agent 一跑后台服务就卡死

上一篇说工具里最能闯祸的是能执行任意命令的 Bash。这篇讲它给我挖的一个大坑------一个让我排查方向从头错到尾的坑。

现象:agent 卡住,60 秒后还杀不掉

agent 在干一个活,中途要起一个本地服务(类似 python proxy.py 这种,起来之后一直跑、给后面的步骤用)。它在命令后加了个 & 想让它后台跑。

然后,整个 agent 卡住了。

我给 Bash 工具设了个超时:一条命令 60 秒还不结束,就判定出问题、杀掉。可这次,60 秒到了,杀不掉------那进程赖在那儿,agent 彻底卡死在那一步。

💥 我一头扎进了"怎么杀进程"

我盯着"杀不掉"三个字,一头扎进了进程管理的细节:研究 kill 为什么没生效、怎么处理进程组、怎么把它和子进程一锅端、加锁防重复杀......折腾大半天。

然后我停下来,问了自己一个早该问的问题:这个进程,本来就不该被杀啊。

agent 起这服务,是要让它一直跑着 给后面用的。我却在吭哧吭哧研究"怎么杀得更干净"------我在认认真真地解决一个根本不该发生的问题。

真正的根因:我没区分"命令"和"服务"

退一步看,根子特别清楚:我的 Bash 工具,把所有命令都当成"会结束的命令"来对待了。

可命令分两种:

  • 会跑完的 :lsnpm install、跑个测试,执行完就退出。等它、给它设超时,天经地义。
  • 压根不打算结束的:起服务、开守护进程,它的"正常状态"就是一直运行。给它设超时、到点杀,完全南辕北辙。

我把第二种当第一种处理,于是"等结束 → 等不到 → 超时 → 杀"这条链,从第一步就错了。

修法 :让 Bash 工具不再"一刀切等到死再杀",而是认清一条命令其实有三条路径可走。

一条命令的三条路径

我把执行逻辑改成了下面这样(伪代码,贴着真实分支写的):

ts 复制代码
// 路径 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" })
}
graph TD Cmd[一条命令进来] --> Q{你明说扔后台吗} Q -->|是 run_in_background| BG1[立刻登记 返回 task_id 和 output_path] Q -->|没说| Race[同时等三件事 谁先到] Race -->|命令自己跑完| Done[返回输出] Race -->|用户点停| Kill[整棵进程树一起杀] Race -->|到点超时| BG2[不杀 自动转后台 返回 task_id 继续跑]

最妙的是路径 3 ------它才是开头那个坑的真正解药。一条命令到点了还没结束,我不再一刀切杀掉 ,而是默认它"要么是个服务、要么只是跑得慢",于是自动把它转成后台任务 :登记下来、让它继续跑,给 agent 返回一个 task_id + 一个输出文件路径。agent 之后想看进展就 Read(output_path),想停就 TaskStop(task_id)

这样,无论 agent 有没有显式说"扔后台",长服务都不会被误杀 ,而 agent 也不会傻等------回过头看开头那个 proxy.py 卡死,根子就是当年只有"等→杀"两条路、缺了路径 3 这条。

后台进程不是"扔了就不管"

"扔后台"不等于"撒手不管"。我给每个后台任务建了个登记表,一条记录长这样(精简版,真实字段就这些):

ts 复制代码
interface BgTask {
  bgId: string            // 会话内唯一 id,形如 bg_<nanoid>
  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 hellopwd 这种必然瞬间完成的也一样。没报错、没日志、没提示,就默默返回空,特别像"命令没输出",让人反复重试还摸不着头脑。重启进程才能恢复。

根因 :还记得上面那个 Promise.race([跑完, 超时, 用户停]) 吗?那个"用户停"靠的是一个取消信号(AbortSignal)。用户点取消时它被置成 aborted------本该这一轮用完就丢。可它残留、还被复用到了后续命令上 :于是后面每条命令一进来,race 一看"信号已经是 aborted 了",二话不说直接走 abort 分支返回 -1,命令根本没机会跑。

最损的是它静默 :我当时追了好久,一度以为进程崩了,psecho 全试一遍,全 -1。最后(说出来好笑)是往 bash 代码里随手加了行 console.log、触发了热重启,Bash 才活过来------这才反应过来是个残留状态,重启即愈。

修法 :两条。一是每条命令用的取消信号必须是这一轮自己的、用完即弃 ,绝不复用上一轮那个可能已 abort 的(正是第 10 篇说的:取消信号的生命周期得划清楚)。二是失败别再静默------给 agent 一个看得见的错误(错误码 + 建议),而不是默默返回 -1 让它对着空气重试。

教训 :一个"静默失败"比"报错失败"危险得多。 报错你至少知道哪儿炸了;静默返回个空,你会朝着完全错误的方向查上半天。

这件事真正的教训,跟进程无关

修复本身不难。难的是我花了大半天才意识到自己在解决错误的问题

我后来把这次经历总结成一条方法,贴在显眼处:

排查问题,先问"它当时想干嘛",再问"代码哪儿错了"。

如果我一开始就先想清楚"agent 起这命令是要长期用的",而不是被"杀不掉"这个表面现象牵着走,我根本不会去碰那堆进程管理代码。一个缺陷可能真实存在,但你未必该走到那个分支。

这也是为什么,我现在让 AI 查 bug,会特意加一句:先别急着找代码哪儿错,先告诉我这操作本来想达成什么。 你给它的问题怎么定性,就框死了它往哪儿使劲------这点,后面讲"和 AI 协作"那几篇还会反复提。

小结一下

  • Bash 最大的坑,是把长跑服务 当成会结束的命令 ,于是"等待---超时---杀"整条链从头就错;修法是区分两类,后台那类显式登记、别等也别杀;
  • "扔后台"不是撒手不管:要能登记、看输出、干净地停;
  • 🤔 后台服务故意做成跨轮独立活,但删会话/退程序时要收掉,且这个"故意"必须标注 + 测试钉死;
  • 💥 用户一取消,残留的 abort 信号让后续命令全静默返 -1------取消信号要用完即弃,且失败别静默;
  • 最大的教训:排查先问 intent,别被表面现象带着精修一个不该走的分支。

既然聊到"让 agent 自己跑命令",那道绕不开的关就来了:有些命令是危险的,总不能让它想跑就跑。下一篇:让 AI 自己跑命令,但危险动作得先问我一声。

相关推荐
飘尘1 小时前
前端转全栈(Java 后端)必须要知道的:开发中的锁机制与分布式并发控制
前端·后端·全栈
亲亲小宝宝鸭1 小时前
前端性能监控:web-vitals
前端·性能优化·监控
leeyi1 小时前
ADK 入门:不写图,也能搭 Agent
aigc·agent·ai编程
陆枫Larry2 小时前
可滚动页面背景填不满:`height: 100vh` vs `min-height: 100vh`
前端
阿里云云原生2 小时前
AI 开发新常态:当 Cursor、Claude、Codex 并行,如何统一管理散落的 Skill 资产?
云原生·ai编程
Patrick_Wilson2 小时前
Squash Merge 的血缘陷阱:为什么删掉的代码又活了过来
前端·git·程序员
kyriewen3 小时前
今天的科技圈,全在抢英伟达的饭碗
前端·面试·ai编程
Databend3 小时前
在 AWS 中国峰会逛了一天,我在 Databend 展台看到了 Agent 数据基础设施的新思路
数据库·人工智能·agent