云计算百科
云计算领域专业知识百科平台

Python Agent 踩坑实录:asyncio.wait_for 超时后,任务真的停了吗?

Python Agent 踩坑实录:asyncio.wait_for 超时后,任务真的停了吗?

摘要:本文从一次"超时了但 CPU 还在飙"的事故出发,分析 Agent 开发中 asyncio.wait_for 超时后任务没有真正停止的四个隐蔽原因:同步阻塞代码无法被取消、CancelledError 被 except Exception 吞掉、finally 里的 await 被取消、子任务未被级联取消。文章给出超时后的正确清理姿势,以及 Agent 工具调用的超时防护方案。附完整可复用代码和排查清单,适合正在学 Python + Agent 的开发者避坑。

文章目录

    • 一、问题背景:超时了,但任务还在跑
    • 二、最小复现代码:超时了但任务还在打印
    • 三、四个隐蔽的"取消失败"场景
      • 场景一:同步阻塞代码无法被取消
      • 场景二:CancelledError 被 except Exception 吞掉
      • 场景三:finally 里的 await 被取消
      • 场景四:子任务未被级联取消
    • 四、修复方案:正确的超时与取消
      • 方案一:用 asyncio.timeout 上下文管理器(Python 3.11+)
      • 方案二:确保任务可取消
      • 方案三:超时后显式清理
    • 五、Agent 工具调用的完整超时防护
    • 六、Agent 开发中的特殊场景
      • 场景一:LLM API 调用超时
      • 场景二:流式输出的超时
    • 七、总结与排查清单
      • 核心结论
      • 排查清单
      • 推荐实践
    • 八、写在最后

一、问题背景:超时了,但任务还在跑

最近在写一个 Agent 项目,工具调用需要设置超时,避免某个工具卡死拖垮整个 Agent。

我写了这样的代码:

import asyncio

async def call_tool(tool_name: str, timeout: float = 5.0):
try:
return await asyncio.wait_for(
_execute_tool(tool_name),
timeout=timeout,
)
except asyncio.TimeoutError:
return {"error": f"工具 {tool_name} 超时"}

测试的时候发现一个诡异现象:

  • 5 秒后确实返回了超时错误;
  • 但后台的 _execute_tool 还在跑;
  • 日志里能看到工具在超时后仍然打印输出;
  • CPU 占用没有下降;
  • 如果连续触发 10 次超时,后台就累积了 10 个"僵尸任务"。

更糟的是,某些工具调用了外部 API,超时后请求并没有真正取消,外部 API 侧还在计费。

排查后发现:asyncio.wait_for 超时后,只是从等待者的视角结束了,任务本身不一定真的停了。

二、最小复现代码:超时了但任务还在打印

先看错误版本:

import asyncio

async def long_running_task():
try:
print("任务开始")
for i in range(10):
await asyncio.sleep(1)
print(f"任务运行中… {i}")
print("任务完成")
return "done"
except asyncio.CancelledError:
print("任务被取消")
raise

async def main():
try:
result = await asyncio.wait_for(long_running_task(), timeout=3.0)
print(f"结果: {result}")
except asyncio.TimeoutError:
print("超时了!")

# 等待 5 秒,观察后台是否还有输出
await asyncio.sleep(5)
print("主协程结束")

asyncio.run(main())

输出:

任务开始
任务运行中… 0
任务运行中… 1
任务运行中… 2
超时了!
任务被取消
主协程结束

看起来正常?任务被取消了。但是,如果任务里有同步阻塞代码,或者吞掉了 CancelledError,结果就完全不同。

三、四个隐蔽的"取消失败"场景

场景一:同步阻塞代码无法被取消

import asyncio
import time

async def blocking_task():
print("任务开始")
time.sleep(10) # 同步阻塞,无法被取消
print("任务完成")
return "done"

async def main():
try:
await asyncio.wait_for(blocking_task(), timeout=3.0)
except asyncio.TimeoutError:
print("超时了!")

await asyncio.sleep(1)
print("主协程结束")

asyncio.run(main())

输出:

任务开始
超时了!
主协程结束
任务完成 # 超时后仍然打印了!

原因:time.sleep(10) 是同步阻塞调用,asyncio 的取消机制只能在 await 点生效。当协程卡在同步代码里时,取消信号无法传递,任务会一直跑到同步代码结束。

这就是为什么第一篇讲 time.sleep 会阻塞事件循环——它不仅阻塞并发,还让超时机制失效。

场景二:CancelledError 被 except Exception 吞掉

import asyncio

async def swallow_cancel_task():
try:
for i in range(10):
await asyncio.sleep(1)
print(f"运行中… {i}")
return "done"
except Exception as e: # CancelledError 在 Python 3.8+ 是 BaseException,但…
print(f"捕获到异常: {e}")
return "error"

async def main():
try:
await asyncio.wait_for(swallow_cancel_task(), timeout=3.0)
except asyncio.TimeoutError:
print("超时了!")

await asyncio.sleep(8)
print("主协程结束")

asyncio.run(main())

关键知识点:

  • Python 3.8 之前,CancelledError 继承自 Exception,except Exception 会吞掉它;
  • Python 3.8+,CancelledError 继承自 BaseException,except Exception 不会捕获它;
  • 但如果代码里用了 except BaseException 或裸 except:,仍然会吞掉。

危险的写法:

try:
await something()
except BaseException: # 危险!会吞掉 CancelledError
pass

try:
await something()
except: # 更危险!裸 except 会吞掉一切
pass

正确做法:捕获 CancelledError 后必须 raise 重新抛出:

try:
await something()
except asyncio.CancelledError:
# 做一些清理
print("清理资源")
raise # 必须重新抛出
except Exception as e:
# 处理业务异常
handle_error(e)

场景三:finally 里的 await 被取消

import asyncio

async def task_with_cleanup():
try:
await asyncio.sleep(10)
return "done"
finally:
print("开始清理")
await asyncio.sleep(1) # 这个 await 会被取消
print("清理完成") # 不会执行

async def main():
try:
await asyncio.wait_for(task_with_cleanup(), timeout=2.0)
except asyncio.TimeoutError:
print("超时了!")

asyncio.run(main())

输出:

开始清理
超时了!

清理完成 永远不会打印。原因:任务被取消后,finally 里如果还有 await,会立刻再次触发 CancelledError,导致清理逻辑中断。

修复方案:用 asyncio.shield() 保护清理逻辑:

async def task_with_cleanup():
try:
await asyncio.sleep(10)
return "done"
finally:
# shield 保护清理任务不被取消
await asyncio.shield(_cleanup())

async def _cleanup():
print("开始清理")
await asyncio.sleep(1)
print("清理完成")

注意:shield 只是保护内部任务不被外部取消,但如果外层协程被强制结束,内部任务仍然可能被丢弃。生产环境建议用 asyncio.create_task 把清理任务独立出去:

async def task_with_cleanup():
try:
await asyncio.sleep(10)
finally:
# 清理任务独立运行,不依赖当前协程的生命周期
asyncio.create_task(_cleanup())

场景四:子任务未被级联取消

import asyncio

async def child_task(name: str):
try:
while True:
await asyncio.sleep(1)
print(f"子任务 {name} 运行中")
except asyncio.CancelledError:
print(f"子任务 {name} 被取消")
raise

async def parent_task():
# 启动子任务,但不保存引用
asyncio.create_task(child_task("A"))
asyncio.create_task(child_task("B"))

await asyncio.sleep(100)
return "done"

async def main():
try:
await asyncio.wait_for(parent_task(), timeout=3.0)
except asyncio.TimeoutError:
print("父任务超时!")

await asyncio.sleep(5)
print("主协程结束")

asyncio.run(main())

输出:

子任务 A 运行中
子任务 B 运行中
子任务 A 运行中
子任务 B 运行中
子任务 A 运行中
父任务超时!
子任务 A 运行中 # 父任务超时后,子任务还在跑!
子任务 B 运行中
子任务 A 运行中
子任务 B 运行中
主协程结束

原因:asyncio.wait_for 只取消 parent_task 本身,不会自动取消 parent_task 里创建的子任务。子任务变成"孤儿",继续运行直到自己结束。

这就是第三篇讲的"任务被 GC 静默取消"的反面——这里任务没被 GC,但也没被正确取消,变成了泄漏。

修复方案:

async def parent_task():
child_a = asyncio.create_task(child_task("A"))
child_b = asyncio.create_task(child_task("B"))
try:
await asyncio.sleep(100)
return "done"
finally:
# 父任务被取消时,级联取消子任务
for child in (child_a, child_b):
if not child.done():
child.cancel()
# 等待子任务真正结束
await asyncio.gather(child_a, child_b, return_exceptions=True)

Python 3.11+ 推荐用 TaskGroup,它会自动处理级联取消:

async def parent_task():
async with asyncio.TaskGroup() as tg:
tg.create_task(child_task("A"))
tg.create_task(child_task("B"))
await asyncio.sleep(100)
return "done"

四、修复方案:正确的超时与取消

方案一:用 asyncio.timeout 上下文管理器(Python 3.11+)

import asyncio

async def call_tool(tool_name: str, timeout: float = 5.0):
try:
async with asyncio.timeout(timeout):
result = await _execute_tool(tool_name)
return result
except TimeoutError:
return {"error": f"工具 {tool_name} 超时"}

async def _execute_tool(tool_name: str):
# 内部必须是可取消的异步代码
await asyncio.sleep(2)
return f"{tool_name} 的结果"

asyncio.timeout 是 Python 3.11 引入的,比 wait_for 更推荐,因为它:

  • 语义更清晰,async with 包裹的代码块超时后会被取消;
  • 支持嵌套超时,内层超时不会影响外层;
  • 与 TaskGroup 配合更好。

方案二:确保任务可取消

核心原则:所有耗时的操作都必须是可取消的异步操作。

不可取消(危险)可取消(正确)
time.sleep() await asyncio.sleep()
requests.get() await httpx.AsyncClient().get()
同步数据库驱动 异步驱动 / asyncio.to_thread
大文件同步读写 aiofiles / asyncio.to_thread
CPU 密集计算 ProcessPoolExecutor + 定期检查

注意 asyncio.to_thread:它虽然能让同步代码不阻塞事件循环,但线程一旦启动就无法被取消。asyncio.wait_for 超时后,线程会继续跑到结束:

import asyncio
import time

def _sync_blocking():
time.sleep(10) # 线程会跑完
return "done"

async def task():
return await asyncio.to_thread(_sync_blocking)

async def main():
try:
await asyncio.wait_for(task(), timeout=3.0)
except asyncio.TimeoutError:
print("超时了,但线程还在跑")

await asyncio.sleep(8)
print("线程结束了")

asyncio.run(main())

结论:to_thread 适合"不可取消但不关键"的场景,不适合"必须能取消"的场景。

方案三:超时后显式清理

import asyncio
import httpx

async def call_with_timeout(url: str, timeout: float = 5.0):
client = httpx.AsyncClient()
try:
async with asyncio.timeout(timeout):
response = await client.get(url)
return response.json()
except TimeoutError:
return {"error": "请求超时"}
finally:
# 无论成功失败,都关闭 client
await client.aclose()

关键点:超时后的清理逻辑要放在 finally 里,并且用 asyncio.shield 保护:

async def call_with_timeout(url: str, timeout: float = 5.0):
client = httpx.AsyncClient()
try:
async with asyncio.timeout(timeout):
return await client.get(url)
except TimeoutError:
return {"error": "请求超时"}
finally:
# 保护清理逻辑
await asyncio.shield(client.aclose())

五、Agent 工具调用的完整超时防护

把上面的所有能力整合到一个工具调用封装里:

import asyncio
from typing import Callable, Awaitable, TypeVar, Any

T = TypeVar("T")

class ToolTimeoutError(Exception):
"""工具超时异常"""
pass

async def call_tool_with_timeout(
tool_name: str,
tool_func: Callable[..., Awaitable[T]],
*args,
timeout: float = 10.0,
cleanup: Callable[[], Awaitable[None]] | None = None,
**kwargs,
) > T:
"""
调用工具,带超时和清理

:param tool_name: 工具名(用于日志)
:param tool_func: 工具函数(必须是可取消的异步函数)
:param timeout: 超时时间
:param cleanup: 超时后的清理函数
"""
task = None
try:
async with asyncio.timeout(timeout):
task = asyncio.create_task(tool_func(*args, **kwargs))
return await task
except TimeoutError:
# 任务可能还在运行,尝试取消
if task and not task.done():
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception):
pass

# 执行清理
if cleanup:
try:
await asyncio.shield(cleanup())
except Exception as e:
print(f"[WARN] 清理失败: {e}")

raise ToolTimeoutError(f"工具 {tool_name} 超时({timeout}s)")

使用示例:

async def search_web(query: str, client: httpx.AsyncClient):
response = await client.get(f"https://api.example.com/search?q={query}")
return response.json()

async def cleanup_http_client(client: httpx.AsyncClient):
await client.aclose()

async def main():
client = httpx.AsyncClient()
try:
result = await call_tool_with_timeout(
"search_web",
search_web,
"python agent",
client=client,
timeout=5.0,
cleanup=lambda: cleanup_http_client(client),
)
print(result)
except ToolTimeoutError as e:
print(f"工具调用失败: {e}")

六、Agent 开发中的特殊场景

场景一:LLM API 调用超时

LLM API 的超时时间通常比较长(30~60 秒),如果超时后没有正确取消,会导致:

  • 请求继续执行,token 照常消耗;
  • 连接池被占满,后续请求排队;
  • 用户侧看到"卡住",但服务端还在跑。

修复:

async def call_llm_with_timeout(prompt: str, timeout: float = 60.0):
try:
async with asyncio.timeout(timeout):
return await client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
)
except TimeoutError:
raise ToolTimeoutError(f"LLM 调用超时({timeout}s)")

注意:httpx(OpenAI SDK 底层用的库)在收到取消信号时,会主动关闭连接并中止请求。前提是请求还在"等待响应"阶段,如果已经在流式输出中,取消后已生成的部分 token 仍然会被计费。

场景二:流式输出的超时

流式输出有两个超时:

  • 首 token 超时:从发起请求到收到第一个 token 的时间;
  • token 间隔超时:两个 token 之间的最大间隔。

async def stream_with_timeout(prompt: str, first_token_timeout: float = 10.0):
stream = await client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
stream=True,
)

# 首 token 超时
try:
async with asyncio.timeout(first_token_timeout):
first_chunk = await anext(stream)
except TimeoutError:
raise ToolTimeoutError(f"首 token 超时({first_token_timeout}s)")

# 处理首 token
yield first_chunk

# token 间隔超时
async for chunk in stream:
try:
async with asyncio.timeout(token_interval_timeout):
yield chunk
except TimeoutError:
raise ToolTimeoutError(f"token 间隔超时({token_interval_timeout}s)")

实现要点:

  • 首 token 超时用 asyncio.timeout 包裹第一次 anext(stream);
  • token 间隔超时用 asyncio.timeout 包裹每次迭代;
  • 超时后抛出 ToolTimeoutError,由上层统一处理。

七、总结与排查清单

核心结论

asyncio.wait_for 超时后,只是从等待者的视角结束了,任务本身不一定真的停了。要让任务真正停止,必须满足三个条件:

  • 任务内部所有耗时操作都是可取消的异步操作(在 await 点才能收到取消信号);
  • 没有用 except BaseException 或裸 except: 吞掉 CancelledError;
  • 子任务、清理逻辑都要显式处理,不能依赖隐式行为。
  • 排查清单

    遇到"超时了但任务还在跑",按这个顺序排查:

    • 任务里有没有 time.sleep()、同步 IO、CPU 密集计算?
    • 有没有 except BaseException 或裸 except: 吞掉 CancelledError?
    • finally 里的清理逻辑有没有 await?有没有被取消?
    • 有没有 asyncio.create_task 创建的子任务?父任务取消时有没有级联取消?
    • 有没有用 asyncio.to_thread 跑同步代码?线程是否无法取消?
    • 外部 API 请求(HTTP、数据库)是否真的被中止?还是只是客户端超时?

    推荐实践

    • Python 3.11+ 优先用 asyncio.timeout + TaskGroup;
    • 所有耗时操作都用可取消的异步版本;
    • 清理逻辑放 finally,用 asyncio.shield 保护;
    • 子任务用 TaskGroup 管理,自动级联取消;
    • 工具调用统一封装超时 + 取消 + 清理(见第五节)。

    八、写在最后

    回到开头的问题:asyncio.wait_for 超时后,任务真的停了吗?

    答案是:不一定。它只是让 await 这一侧不再等待了,任务本身是否停止,取决于任务内部代码是否"配合"取消。

    这也是 Agent 开发里最容易踩的坑之一——你以为超时了,资源就释放了,实际上后台可能还挂着一堆"僵尸任务",悄悄消耗 CPU、连接池,甚至外部 API 的计费。

    希望这篇文章能帮你避开这些坑。如果你在 Agent 开发中也遇到过类似问题,欢迎在评论区交流。

    附:本文所有代码均基于 Python 3.11+ 验证,完整可运行示例已内嵌在各章节中。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Python Agent 踩坑实录:asyncio.wait_for 超时后,任务真的停了吗?
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!