首页 独立开发实战内容详情

Agent 任务停不下来?协作停止 + 看门狗双保险设计

2026-11-03 1 zhuadmin

核心要点

  • LLM 调用真的会挂死:OpenAI client 的 `timeout=600` 管不住 DNS 解析和 TCP 建连阶段,一个卡死的任务能堵死整个队列。
  • 协作停止:API 置 failed 标志 + 每个节点入口跑 `_guard` 检查,用户点“停止”后在下一个节点边界精准刹车。
  • 看门狗兜底:worker 层用 daemon 线程包住节点执行,超时直接判死,用户不在场也能自愈。
  • 双保险缺一不可:协作停止精确但只到节点边界,watchdog 粗暴但全覆盖,两层叠起来才敢睡觉。

---

一、事故现场:一个任务挂了 64 分钟

那天下午用户在群里甩我一张截图:任务状态“运行中”,卡在“搭骨架”这一步,已经 64 分钟。

我第一反应是翻日志——没有报错,没有重试,最后一次 LLM 请求发出去之后一片死寂。这就是挂死最恶心之处:它不是失败,失败起码会抛异常,它只是永远不回来。

更糟的是队列。这个任务占着 worker,后面的新任务全部排队。用户点“停止任务”,按钮转了个圈,状态纹丝不动。

能强杀吗?不能。进程里跑的不是裸函数,是一整条带状态的执行图,强杀等于丢掉全部中间产物,用户 20 分钟的上下文直接火葬场。

那到底卡在哪?卡在一次 `client.chat.completions.create()` 上。这个调用我明明设了 `timeout=600`。

二、为什么 timeout=600s 救不了你

先说结论:httpx 的 timeout 只覆盖“请求已发出、等响应”的阶段,DNS 解析和建连在部分路径上根本不吃这套。

httpx 的 timeout 其实有四个维度:connect、read、write、pool。一句 `timeout=600` 是把四个全设成 600——听起来很安全对吧?

问题出在更底层。Python 的 DNS 解析默认走 `getaddrinfo`,这是个阻塞的 C 调用,GIL 期间任何 Python 层的 timeout 都拿它没辙。碰上网络抖动、DNS 服务器半死不活,它能挂到你怀疑人生。

async 版本能逃掉吗?理论上网络库会把 DNS 丢线程池,但默认线程池就那么几个线程,高并发下一样堵。说人话就是:你设的 timeout 是给“点完菜等上菜”用的,但从你家到饭店的路上堵死了,外卖平台管不着。

这次踩坑给我的教训就一句:timeout 要设,但它只是第一道纸糊的防线,别赌运气。

三、第一层保险:协作停止

协作停止的本质:不硬杀,而是在节点边界插旗,让任务自己走到旗子跟前主动倒下。

设计就两个动作。第一,API 层暴露一个 `POST /tasks/{id}/stop`,干的事很朴素:把任务状态标成 `failed`,写一条取消原因,落库。

第二,每个节点函数的第一行统一跑 `_guard`:

```python

async def build_skeleton(state: AgentState) -> dict:

await _guard(state.task_id) # 状态不是 running 就抛 TaskCancelled

result = await call_llm(...)

return {"skeleton": result}

```

`_guard` 查一次库,发现状态已经是 failed,直接抛自定义异常。异常往上冒,被执行器接住,任务优雅退出,中间状态完整保留。

为什么要等节点边界,不立刻杀?因为一个节点内部是“调 LLM + 写状态”的原子操作,中途打断,状态就是脏的。边界处是状态一致的安全点。

代价也明摆着:任务卡在长节点里出不来时(比如那 64 分钟),协作停止鞭长莫及。所以它精准、干净,但不够快。

这就需要第二层。

四、第二层保险:看门狗

看门狗的思路很糙:我不懂你在干嘛,我只知道你超时还没交卷,那就当挂了处理。

实现上,worker 把节点执行丢进线程池,自己拿秒表盯梢:

```python

future = pool.submit(run_node, node_fn, state)

try:

result = future.result(timeout=NODE_TIMEOUT)

except concurrent.futures.TimeoutError:

mark_task_failed(task_id, reason="watchdog: node timeout")

挂死线程留作孤儿,daemon 属性保证进程退出时被回收

```

超时了怎么办?Python 的线程没有真 kill,这是硬伤。我的处理是:任务判 failed、状态落库、worker 继续接新活。那个挂死的线程就让它挂着——daemon 线程,进程重启时系统统一收割。

孤儿线程不算泄漏吗?算,但一次泄漏一个卡死线程,换整个队列不被堵死,这笔账怎么算都划算。真积到一定量级,监控告警,进程重启,一了百了。

关键取舍就一个:NODE_TIMEOUT 要比正常节点最慢耗时再放宽一截。我按 P99 耗时乘 3 设的,宁可晚杀,不可错杀。

五、两层怎么配合

日常情况下协作停止干全部的活:用户点停止,几百毫秒后任务在下一个节点边界干净退出,状态完整,还能从断点续跑。

看门狗平时趴着不动。只有任务真的挂死在节点内部、协作停止够不着的时候,它才咬一口。

谁先出手有讲究吗?有。watchdog 判死前先查任务是否已被用户手动停过——停过的算正常退出,没停过的才算异常挂死,两者告警等级完全不同。

上线后我给自己定了条规矩:任何涉及外部调用的改动,先答一句“它挂死了谁负责”。答不上来的,不许上。

顺便说个排查时的土办法:我习惯开着本站的 倒计时 挂在副屏,设个“5 分钟无进展就回去看日志”的闹钟,免得盯着终端发呆干耗时间。

FAQ

Q:timeout 参数到底还要不要设?

设,必须设。它能拦住 read/write 阶段的九成挂起。但 DNS 和建连阶段的洞它堵不上,所以它是防线之一,不是唯一防线。

Q:协作停止为什么不做成实时中断,非要等节点边界?

节点内一半在调 LLM、一半在写状态,中途打断状态就是脏的,重跑会撞上半成品数据。边界处停,慢半拍,换来状态永远干净。

Q:孤儿线程越积越多,进程不就慢慢死了?

会。所以 watchdog 判死必须打监控指标,孤儿线程超阈值就触发进程重启。daemon 线程在进程退出时会被回收,重启成本几秒,比队列堵死 64 分钟便宜多了。

  • 评论列表 (0条)

 暂无评论,快来抢沙发吧~

发布评论