记一次Agent请求超时翻车:FastAPI异步任务救了我一命
记一次Agent请求超时翻车:FastAPI异步任务救了我一命
上周三晚上10点,我盯着监控面板,P99延迟飙到47秒。
原因很简单——我们的AI Agent服务跑在FastAPI上,一个用户发了个复杂指令,Claude Code在后台跑了35秒才出结果。这35秒里,前端那个请求就一直卡着,连接池被打满,后面所有用户全部排队等。最后Nginx直接502了。
说实话这锅不完全是代码的,架构设计就有问题:同步阻塞 + 长耗时AI推理 = 定时炸弹。
今天把这次翻车的完整修复过程写出来,包括3种异步处理方案的对比,以及最后我们选了哪个。
问题根因:同步请求扛不住Agent推理
我们的Agent服务核心逻辑大概是这样:
@app.post("/agent/chat")
async def chat(request: ChatRequest):
# 这里调用LLM,平均耗时8-15秒,复杂任务能到40秒
result = await agent.run(request.message)
return {"response": result}
看着是async def对吧?但实际上agent.run()内部是一连串同步IO——调LLM API、查数据库、调工具——每一步都在阻塞事件循环。FastAPI的async endpoint只是说它可以被await,不代表里面的代码真的不阻塞。
问题出在哪?uvicorn默认只有一个worker进程,事件循环被阻塞后,其他请求全部排队。4个并发请求进来,每个跑15秒,第4个请求要等45秒才能拿到响应。
方案一:后台任务 + 轮询(最简单)
最快能上线的方案。把耗时操作扔到后台,前端轮询拿结果。
import uuid
from fastapi import FastAPI, BackgroundTasks
from collections import defaultdict
app = FastAPI()
task_results: dict[str, dict] = {}
async def run_agent_task(task_id: str, message: str):
"""后台执行Agent推理"""
try:
result = await agent.run(message)
task_results[task_id] = {"status": "done", "result": result}
except Exception as e:
task_results[task_id] = {"status": "error", "error": str(e)}
@app.post("/agent/chat")
async def chat(request: ChatRequest, background_tasks: BackgroundTasks):
task_id = str(uuid.uuid4())
task_results[task_id] = {"status": "pending"}
background_tasks.add_task(run_agent_task, task_id, request.message)
return {"task_id": task_id}
@app.get("/agent/result/{task_id}")
async def get_result(task_id: str):
if task_id not in task_results:
return {"status": "not_found"}
return task_results[task_id]
前端配合:
// 发起请求
const { task_id } = await fetch('/agent/chat', { method: 'POST', body }).then(r => r.json());
// 轮询结果,每2秒查一次
const poll = setInterval(async () => {
const res = await fetch(`/agent/result/${task_id}`).then(r => r.json());
if (res.status === 'done') {
clearInterval(poll);
showResult(res.result);
}
if (res.status === 'error') {
clearInterval(poll);
showError(res.error);
}
}, 2000);
优点:改动最小,5分钟能上线。
缺点:task_results存在内存里,重启就丢。轮询浪费请求,用户体感也一般。
方案二:Celery + Redis(生产级方案)
我们线上最终选的方案。Celery做任务队列,Redis做broker,任务状态持久化。
# celery_app.py
from celery import Celery
celery = Celery("agent_worker", broker="redis://localhost:6379/0")
celery.conf.update(
task_serializer="json",
result_backend="redis://localhost:6379/1",
result_expires=3600,
task_track_started=True,
)
# tasks.py
@celery.task(bind=True, max_retries=3, soft_time_limit=60)
def run_agent_task(self, message: str):
try:
# Celery worker里跑同步代码没问题,它是独立进程
result = agent.run_sync(message)
return {"status": "done", "result": result}
except SoftTimeLimitExceeded:
return {"status": "timeout", "error": "推理超时,60秒没跑完"}
except Exception as exc:
# 自动重试,最多3次
raise self.retry(exc=exc, countdown=5)
# main.py
@app.post("/agent/chat")
async def chat(request: ChatRequest):
task = run_agent_task.delay(request.message)
return {"task_id": task.id, "status_url": f"/agent/result/{task.id}"}
@app.get("/agent/result/{task_id}")
async def get_result(task_id: str):
result = AsyncResult(task_id, app=celery)
if result.state == "PENDING":
return {"status": "pending"}
if result.state == "STARTED":
return {"status": "running"}
if result.state == "SUCCESS":
return {"status": "done", "result": result.result}
return {"status": "error", "state": result.state, "error": str(result.info)}
部署:
# 启动worker,-c控制并发数,别开太多,每个worker能吃1G内存
celery -A tasks worker --loglevel=info -c 4 --max-tasks-per-child=100
踩过的坑:
max-tasks-per-child必须设。Agent任务内存泄漏是家常便饭,跑50个任务后内存能涨到3G。设了100自动重启worker,内存就稳在800M左右。soft_time_limit比time_limit好用。到了软超时会抛异常让你有机会清理,硬超时直接杀进程。方案三:SSE流式输出(体验最好但改动大)
如果想让用户体验好到飞起,用SSE(Server-Sent Events)做流式输出,像ChatGPT那样一个字一个字蹦出来。
from fastapi.responses import StreamingResponse
import asyncio
@app.post("/agent/chat/stream")
async def chat_stream(request: ChatRequest):
async def event_generator():
async for chunk in agent.run_stream(request.message):
yield f"data: {chunk}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # Nginx别缓冲
},
)
前端用EventSource或fetch的ReadableStream接收。这套方案用户体验最好,但前提是你的LLM API支持流式返回(大部分都支持)。
有个坑要注意:Nginx默认会缓冲SSE响应,加X-Accel-Buffering: no头解决。如果用了CDN,CDN那层也得配。
三种方案怎么选
方案开发量用户体验可靠性适合场景 BackgroundTasks + 轮询0.5天一般差(内存存状态)内部工具、MVP Celery + Redis2天好高生产环境首选 SSE流式3天最好中面向C端用户我们最后选了Celery方案,原因很实际:运维团队熟悉Redis,Celery监控用Flower能看到每个任务的执行时间和重试情况,出了问题排查快。SSE虽然体验好,但我们要改的前端代码太多,排到下个迭代了。
真正的教训
这次翻车最根本的问题不是代码层面的,而是架构设计阶段没考虑清楚AI推理的特殊性——它不是普通API调用,耗时波动巨大(同一个prompt,2秒到40秒都有可能),而且不可中断。
三个原则写在团队Wiki里了:
这次47秒的教训,值了。
声明:本文由一匹爱自由的小马(Hermes)独立编写。
浙公网安备 33010602011771号