---
那天下午用户在群里甩我一张截图:任务状态“运行中”,卡在“搭骨架”这一步,已经 64 分钟。
我第一反应是翻日志——没有报错,没有重试,最后一次 LLM 请求发出去之后一片死寂。这就是挂死最恶心之处:它不是失败,失败起码会抛异常,它只是永远不回来。
更糟的是队列。这个任务占着 worker,后面的新任务全部排队。用户点“停止任务”,按钮转了个圈,状态纹丝不动。
能强杀吗?不能。进程里跑的不是裸函数,是一整条带状态的执行图,强杀等于丢掉全部中间产物,用户 20 分钟的上下文直接火葬场。
那到底卡在哪?卡在一次 `client.chat.completions.create()` 上。这个调用我明明设了 `timeout=600`。
先说结论: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")
```
超时了怎么办?Python 的线程没有真 kill,这是硬伤。我的处理是:任务判 failed、状态落库、worker 继续接新活。那个挂死的线程就让它挂着——daemon 线程,进程重启时系统统一收割。
孤儿线程不算泄漏吗?算,但一次泄漏一个卡死线程,换整个队列不被堵死,这笔账怎么算都划算。真积到一定量级,监控告警,进程重启,一了百了。
关键取舍就一个:NODE_TIMEOUT 要比正常节点最慢耗时再放宽一截。我按 P99 耗时乘 3 设的,宁可晚杀,不可错杀。
日常情况下协作停止干全部的活:用户点停止,几百毫秒后任务在下一个节点边界干净退出,状态完整,还能从断点续跑。
看门狗平时趴着不动。只有任务真的挂死在节点内部、协作停止够不着的时候,它才咬一口。
谁先出手有讲究吗?有。watchdog 判死前先查任务是否已被用户手动停过——停过的算正常退出,没停过的才算异常挂死,两者告警等级完全不同。
上线后我给自己定了条规矩:任何涉及外部调用的改动,先答一句“它挂死了谁负责”。答不上来的,不许上。
顺便说个排查时的土办法:我习惯开着本站的 倒计时 挂在副屏,设个“5 分钟无进展就回去看日志”的闹钟,免得盯着终端发呆干耗时间。
Q:timeout 参数到底还要不要设?
设,必须设。它能拦住 read/write 阶段的九成挂起。但 DNS 和建连阶段的洞它堵不上,所以它是防线之一,不是唯一防线。
Q:协作停止为什么不做成实时中断,非要等节点边界?
节点内一半在调 LLM、一半在写状态,中途打断状态就是脏的,重跑会撞上半成品数据。边界处停,慢半拍,换来状态永远干净。
Q:孤儿线程越积越多,进程不就慢慢死了?
会。所以 watchdog 判死必须打监控指标,孤儿线程超阈值就触发进程重启。daemon 线程在进程退出时会被回收,重启成本几秒,比队列堵死 64 分钟便宜多了。
评论列表 (0条)