Python asyncio 服务卡死排查复盘

5 阅读5分钟

Python asyncio 服务卡死排查复盘:别急着优化,先看清问题在哪

一个 AI Agent 后端服务在压测时反复卡死的排查过程。踩了不少坑,最大的教训不是技术问题,而是排查方法本身。


背景

我们有一个基于 Python asyncio 的后端服务,核心是一个 ReAct Agent 循环:LLM 流式推理 → 调用工具 → 拿结果 → 继续推理,结果通过 WebSocket 实时推送给前端。

单用户跑得好好的,5 并发压测就开始出问题——服务周期性卡死,健康检查超时,容器被 watchdog 反复重启。

排查过程(走了很多弯路)

第一反应:一定是并发问题

服务卡死,5 并发才出现,1 并发没事——直觉告诉我这是并发竞争问题。于是开始从代码角度分析:全局锁竞争?WebSocket 队列积压?线程池满了?

我让 AI 帮我分析代码,它给出了一堆"可能的瓶颈点"——每个都逻辑自洽,每个都不是根因。 我花了大量时间逐个写 benchmark 证伪,来回折腾。

转折点:加了一个 event loop watchdog

折腾了很久后,我终于做了一件早该做的事——加观测

一个简单的 event loop watchdog:每 5 秒在 event loop 上调度一个回调,如果实际间隔远大于 5 秒,说明 loop 被阻塞了。

async def _watchdog_loop(self):
    while True:
        t0 = time.monotonic()
        await asyncio.sleep(5.0)
        lag = time.monotonic() - t0 - 5.0
        if lag > 1.0:
            logger.warning("Event loop blocked for %.1fs", lag)

结果一跑,watchdog 报出了 274 秒的阻塞。这不是"高并发下锁竞争导致的微秒级延迟",这是 event loop 被彻底冻结了。

真正的根因

py-spy dump 抓活进程调用栈,真相浮出水面——CPU 采样 37% 的时间花在一个 JSON 修复函数里。

问题出在 Agent 框架的一个默认配置:流式工具参数解析

LLM 调用工具时,工具的参数是一段 JSON。在流式模式下,这段 JSON 不是一次性返回的,而是像打字一样逐 token 吐出来:

chunk 1: {"city":
chunk 2: {"city": "东
chunk 3: {"city": "东京",
chunk 4: {"city": "东京", "budget":
...

框架默认开启了"流式解析"——每收到一个 chunk,就尝试用 json_repair 把当前这段残缺 JSON 补全并解析,好处是上层可以实时看到工具参数在逐步填充。

代价是:第 1 个 chunk 扫描 1 个字符,第 2 个扫描 2 个,第 N 个扫描 N 个——经典的 O(n^2)。再叠加每次 copy.deepcopy 拷贝完整内容,一次长工具调用上千个 chunk,CPU 直接被打满,event loop 卡死。

而且每个 chunk 都会触发一次 WebSocket 事件推送,单请求产生 800+ 事件帧,进一步淹没 event loop。

修复很简单:关掉流式解析,等流结束后一次性解析。一行配置改动,问题消失。

为什么 1 并发没事

不是并发导致了问题,而是 1 并发时事件量恰好没超过处理能力。根因是单请求的事件处理量本身就在临界点上,并发只是把它推过了阈值。

不是锅里同时炒的菜太多,而是火本来就不够旺,一个菜就快炒不动了。


教训:先观测,后分析

技术修复不难,难的是找对方向。我在排查中犯了一个典型错误:

AI 非常擅长从代码中找"可能的问题",每一个分析都逻辑自洽,但它是在穷举可能性,不是在定位根因。我顺着这些"可能"逐个排查,浪费了大量时间。

真正让排查突破的,是两个最基础的操作:

  1. 加 watchdog → 发现不是"微秒级竞争"而是"loop 冻结数百秒"
  2. py-spy dump 抓调用栈 → 直接看到 37% CPU 卡在哪个函数

这两步加起来 10 分钟,但我是在折腾了几天之后才做的。

补充:如果观测 CPU load,也能更快定位到问题。

排查性能问题的正确顺序:先观测(watchdog、metrics)→ 抓现场(py-spy、strace)→ 再分析代码。不要让代码分析替代对真实现场的观测。


附:ReAct Agent Loop 是怎么跑的

如果你不熟悉 AI Agent 的运行方式,这里补充一下上下文。

ReAct(Reasoning + Acting)是目前主流的 Agent 架构。它不是 LLM 一次性回答问题,而是一个循环

用户: "帮我搜一下东京的酒店,顺便算一下从机场过去的路线"

循环第 1 轮:
  [Reasoning] LLM 分析问题 → 决定先搜酒店
              → 输出: tool_use: search_hotels({"city": "东京"})
  [Acting]    执行 search_hotels → 返回酒店列表

循环第 2 轮:
  [Reasoning] LLM 看到酒店结果 → 决定再算路线
              → 输出: tool_use: compute_routes({"from": "成田机场", ...})
  [Acting]    执行 compute_routes → 返回路线

循环第 3 轮:
  [Reasoning] LLM 看到所有结果 → 信息够了,生成最终回复
              → 输出: 纯文本回复
  循环结束

每一轮的 Reasoning 都是一次 LLM API 调用。在流式模式下,LLM 的回复是逐 token 返回的——无论是文本还是工具调用的参数 JSON。这就是为什么流式解析会产生大量 chunk 事件。

几个关键特征:

  • 工具调用之间有天然的"呼吸窗口":等待工具执行时 LLM 不输出,event loop 有时间处理其他任务
  • 纯文本输出没有呼吸窗口:LLM 连续输出一整段长文本(比如生成一个 HTML 页面),每个 token 都是一个事件,中间没有暂停
  • 循环次数不可预测:LLM 自己决定调几次工具、什么时候停,max_iters 只是上限保护