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 非常擅长从代码中找"可能的问题",每一个分析都逻辑自洽,但它是在穷举可能性,不是在定位根因。我顺着这些"可能"逐个排查,浪费了大量时间。
真正让排查突破的,是两个最基础的操作:
- 加 watchdog → 发现不是"微秒级竞争"而是"loop 冻结数百秒"
- 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只是上限保护