阶段3(上):Python/asyncio/FastAPI 专题的课程、企业场景、面试题和可运行示例
This commit is contained in:
1 parent
b8010a74f3
commit
7f52513ffc
24 files changed
+2817
-33
No files matched your search
@@ -0,0 +1,112 @@
|
||||
---
|
||||
id: python.asyncio.coroutines-tasks
|
||||
title: 协程、任务和事件循环:和 JS Promise 对照
|
||||
type: lesson
|
||||
domain: python
|
||||
stack: [asyncio]
|
||||
tags: [协程, 事件循环, Task, await, async for]
|
||||
level: basic
|
||||
prerequisites: [python.python.syntax-for-js]
|
||||
est_hours: 2
|
||||
related: [python.asyncio.concurrency-control, python.asyncio.interview]
|
||||
week: [4]
|
||||
order: 1
|
||||
---
|
||||
|
||||
# 协程、任务和事件循环:和 JS Promise 对照
|
||||
|
||||
## 解释说明
|
||||
|
||||
| JavaScript | Python asyncio | 区别 |
|
||||
| --- | --- | --- |
|
||||
| 事件循环由运行时自动提供 | `asyncio.run(main())` 显式启动 | Python 脚本默认是同步的 |
|
||||
| `async function f()` | `async def f():` | |
|
||||
| 调用 async 函数**立即开始执行**,返回 Promise | 调用 `f()` 只得到**协程对象,不会执行** | 必须 `await` 或包装成任务 |
|
||||
| `Promise.all([...])` | `await asyncio.gather(...)` 或 `TaskGroup` | |
|
||||
| `Promise.race` | `asyncio.wait(..., return_when=FIRST_COMPLETED)` | |
|
||||
| `setTimeout` 包装的延时 | `await asyncio.sleep(1)` | **不要用 `time.sleep`**,会阻塞整个循环 |
|
||||
| 后台执行不等待 | `asyncio.create_task(coro)` | 要保存任务引用,否则可能被垃圾回收 |
|
||||
| `for await (const x of stream)` | `async for x in stream:` | 流式读取模型输出常用 |
|
||||
| `AbortController` | `task.cancel()`、`asyncio.timeout()` | |
|
||||
|
||||
**核心概念**
|
||||
|
||||
- **协程(coroutine)**:`async def` 定义的函数调用后得到的对象,在 `await` 处可以暂停,把控制权交还给事件循环。
|
||||
- **任务(Task)**:被事件循环调度执行的协程。`create_task` 之后,它会在当前协程下一次 `await` 时开始运行。
|
||||
- **事件循环**:单线程,不断地挑选“已经可以继续执行的任务”来运行。所以**任何一段不 await 的长时间计算或阻塞调用,都会让所有任务停下来**。
|
||||
|
||||
**适用场景**:IO 密集(网络请求、数据库、文件)。CPU 密集计算不会因为 asyncio 变快。
|
||||
|
||||
## 案例
|
||||
|
||||
RAG 接口需要同时做三件事:向量检索、关键词检索、查询用户权限。顺序执行约 300ms + 200ms + 100ms = 600ms;用 `gather` 并发执行,总耗时约等于最慢的那个,300ms。
|
||||
|
||||
## 代码示例
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
|
||||
async def vector_search(q: str) -> list[str]:
|
||||
await asyncio.sleep(0.3) # 模拟网络 IO
|
||||
return [f"向量结果:{q}"]
|
||||
|
||||
|
||||
async def keyword_search(q: str) -> list[str]:
|
||||
await asyncio.sleep(0.2)
|
||||
return [f"关键词结果:{q}"]
|
||||
|
||||
|
||||
async def user_scopes(user_id: int) -> set[str]:
|
||||
await asyncio.sleep(0.1)
|
||||
return {"hr", "public"}
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# 1. 顺序执行:约 0.6 秒
|
||||
start = time.perf_counter()
|
||||
await vector_search("年假")
|
||||
await keyword_search("年假")
|
||||
await user_scopes(1)
|
||||
print(f"顺序:{time.perf_counter() - start:.2f}s")
|
||||
|
||||
# 2. 并发执行:约 0.3 秒;结果顺序和参数顺序一致
|
||||
start = time.perf_counter()
|
||||
vec, kw, scopes = await asyncio.gather(vector_search("年假"), keyword_search("年假"), user_scopes(1))
|
||||
print(f"并发:{time.perf_counter() - start:.2f}s", vec, kw, scopes)
|
||||
|
||||
# 3. 只调用不 await:协程不会执行,并且会有 "coroutine was never awaited" 警告
|
||||
coro = vector_search("x")
|
||||
print(type(coro).__name__) # coroutine
|
||||
coro.close() # 演示用:手动关闭,避免警告
|
||||
|
||||
# 4. 后台任务:先启动,做别的事,再取结果
|
||||
task = asyncio.create_task(vector_search("后台"))
|
||||
await asyncio.sleep(0) # 让出一次控制权,任务开始执行
|
||||
print("先做别的事")
|
||||
print(await task)
|
||||
|
||||
# 5. 异步迭代:模拟模型流式输出
|
||||
async def stream_tokens(text: str):
|
||||
for ch in text:
|
||||
await asyncio.sleep(0.01)
|
||||
yield ch
|
||||
|
||||
print("".join([t async for t in stream_tokens("流式输出")]))
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## 易错点
|
||||
|
||||
- **忘记 `await`**:拿到的是协程对象,函数根本没执行,常见表现是“结果是 `<coroutine object ...>`”。
|
||||
- **在 async 函数里用 `time.sleep`、`requests.get`、同步数据库驱动**:整个服务卡住。用 `asyncio.sleep`、`httpx.AsyncClient`、异步驱动,或者 `asyncio.to_thread` 包装。
|
||||
- **`create_task` 后不保存引用**:任务可能被垃圾回收而中途消失;也看不到它抛出的异常。
|
||||
- **在已经运行的事件循环里再调用 `asyncio.run`**(比如在 Jupyter、FastAPI 的 async 路由里):会报错,直接 `await` 即可。
|
||||
- **以为 asyncio 是多线程**:它是单线程的协作式调度,共享变量在两个 `await` 之间是安全的,但跨 `await` 仍可能被别的任务修改。
|
||||
|
||||
## 面试常问
|
||||
|
||||
见 [asyncio 面试题](interview.md):协程和线程的区别、事件循环原理、asyncio 适合什么场景、为什么不能在协程里调用阻塞函数。
|
||||
@@ -0,0 +1,138 @@
|
||||
---
|
||||
id: python.asyncio.concurrency-control
|
||||
title: 并发控制:gather / TaskGroup、Semaphore、超时、取消、阻塞调用
|
||||
type: lesson
|
||||
domain: python
|
||||
stack: [asyncio]
|
||||
tags: [TaskGroup, Semaphore, timeout, 取消, to_thread, 背压]
|
||||
level: intermediate
|
||||
prerequisites: [python.asyncio.coroutines-tasks]
|
||||
est_hours: 3
|
||||
related: [python.asyncio.scenario.concurrent-llm-calls, python.asyncio.interview, backend.go.errors-concurrency]
|
||||
week: [4, 5, 12]
|
||||
order: 2
|
||||
---
|
||||
|
||||
# 并发控制:gather / TaskGroup、Semaphore、超时、取消、阻塞调用
|
||||
|
||||
## 解释说明
|
||||
|
||||
**等待一组任务的三种方式**
|
||||
|
||||
| 写法 | 一个失败时 | 适合 |
|
||||
| --- | --- | --- |
|
||||
| `gather(*coros)` | 第一个异常立即抛给调用方,**其他任务继续运行**(不会被取消) | 简单的并发 |
|
||||
| `gather(*coros, return_exceptions=True)` | 异常作为结果返回,不抛出 | 允许部分失败 |
|
||||
| `async with asyncio.TaskGroup() as tg:`(3.11+) | **取消其余任务**,异常合并为 `ExceptionGroup` 抛出 | 要么全部成功、要么全部取消 |
|
||||
|
||||
**限制并发**:`asyncio.Semaphore(n)`,`async with sem:` 包住真正的调用。没有限制时,一次性并发 1000 个请求会触发下游限流、耗尽连接池。(对照 Go:带缓冲的 channel 当信号量,或 `errgroup.SetLimit`。)
|
||||
|
||||
**超时**
|
||||
|
||||
- `async with asyncio.timeout(秒):`(3.11+)或 `await asyncio.wait_for(coro, 秒)`;超时抛 `TimeoutError`,内部的任务会被取消。
|
||||
- 分清**单次调用超时**和**整体截止时间**(例如接口必须 10 秒内返回,内部每次模型调用最多 5 秒)。
|
||||
|
||||
**取消**
|
||||
|
||||
- `task.cancel()` 会在任务下一个 `await` 点抛出 `CancelledError`。
|
||||
- 捕获 `CancelledError` 做清理后**必须重新抛出**,否则取消被吞掉,调用方以为任务还在正常运行。
|
||||
- `except Exception` 不会捕获 `CancelledError`(它继承自 `BaseException`),这是有意设计的。
|
||||
|
||||
**阻塞调用**:必须用同步库(某些 SDK、文件解析、CPU 计算)时,`await asyncio.to_thread(func, 参数)` 放到线程池执行,不阻塞事件循环。CPU 密集且耗时长的用进程池。
|
||||
|
||||
**背压**:生产者比消费者快时,用有界的 `asyncio.Queue(maxsize=n)`,队列满时 `put` 会等待。
|
||||
|
||||
## 案例
|
||||
|
||||
批量为 2000 篇文档生成摘要:用 Semaphore 限制最多 8 个并发请求(符合模型服务商的速率限制);每次调用 30 秒超时;限流错误(429)指数退避重试;单篇失败记录下来,最后汇总重跑,而不是让整个批次失败。
|
||||
|
||||
## 代码示例
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
|
||||
async def fetch(i: int) -> int:
|
||||
await asyncio.sleep(0.05 * (i % 3))
|
||||
if i == 4:
|
||||
raise ValueError(f"任务 {i} 失败")
|
||||
return i * 10
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# 1. gather + return_exceptions:部分失败也拿到所有结果
|
||||
results = await asyncio.gather(*(fetch(i) for i in range(6)), return_exceptions=True)
|
||||
print([r if not isinstance(r, Exception) else f"错误:{r}" for r in results])
|
||||
|
||||
# 2. TaskGroup:一个失败,其余被取消,异常以 ExceptionGroup 抛出
|
||||
try:
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
tasks = [tg.create_task(fetch(i)) for i in range(6)]
|
||||
except* ValueError as eg: # except* 处理 ExceptionGroup(3.11+)
|
||||
print("TaskGroup 失败:", [str(e) for e in eg.exceptions])
|
||||
print("被取消的任务数:", sum(t.cancelled() for t in tasks))
|
||||
|
||||
# 3. Semaphore 限制并发
|
||||
sem = asyncio.Semaphore(2)
|
||||
running = 0
|
||||
|
||||
async def limited(i: int) -> None:
|
||||
nonlocal running
|
||||
async with sem:
|
||||
running += 1
|
||||
assert running <= 2
|
||||
await asyncio.sleep(0.01)
|
||||
running -= 1
|
||||
|
||||
await asyncio.gather(*(limited(i) for i in range(10)))
|
||||
print("Semaphore:同时运行的任务从未超过 2 个")
|
||||
|
||||
# 4. 超时
|
||||
try:
|
||||
async with asyncio.timeout(0.1):
|
||||
await asyncio.sleep(1)
|
||||
except TimeoutError:
|
||||
print("超时")
|
||||
|
||||
# 5. 取消:清理后重新抛出
|
||||
async def worker() -> None:
|
||||
try:
|
||||
await asyncio.sleep(10)
|
||||
except asyncio.CancelledError:
|
||||
print("收到取消,释放资源")
|
||||
raise # 必须重新抛出
|
||||
|
||||
t = asyncio.create_task(worker())
|
||||
await asyncio.sleep(0.01)
|
||||
t.cancel()
|
||||
try:
|
||||
await t
|
||||
except asyncio.CancelledError:
|
||||
print("任务已取消:", t.cancelled())
|
||||
|
||||
# 6. 同步阻塞函数放到线程里执行
|
||||
def parse_pdf_sync(path: str) -> str:
|
||||
import time
|
||||
time.sleep(0.1) # 模拟同步库的阻塞调用
|
||||
return f"{path} 的文本"
|
||||
|
||||
print(await asyncio.to_thread(parse_pdf_sync, "manual.pdf"))
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
把这些组合起来(并发上限 + 单次超时 + 重试 + 部分失败 + 整体截止时间)的完整实现和测试见 [examples/fanout](examples/fanout/)。
|
||||
|
||||
## 易错点
|
||||
|
||||
- **用 `gather` 以为失败时会取消其他任务**:不会,其他任务继续跑(可能继续消耗模型额度)。需要“一个失败全部取消”用 TaskGroup。
|
||||
- **吞掉 `CancelledError`**:任务无法被取消,服务关闭时卡住。
|
||||
- **没有并发上限**:一次性创建成千上万个请求,下游限流、连接池耗尽、内存上涨。
|
||||
- **只设整体超时不设单次超时**(或反过来):一个慢请求占满全部时间,或者总时长失控。
|
||||
- **重试不区分错误类型**:参数错误(400)重试多少次都一样失败,只重试超时、429、5xx。
|
||||
- **在 `to_thread` 里跑 CPU 密集任务**:受 GIL 限制,没有并行效果,用进程池。
|
||||
|
||||
## 面试常问
|
||||
|
||||
见 [asyncio 面试题](interview.md):gather 和 TaskGroup 的区别、怎么限制并发、超时和取消如何实现、怎么在异步代码中调用同步库。
|
||||
@@ -0,0 +1,100 @@
|
||||
"""并发调用多个下游(例如大模型 / 检索 / 第三方 API):并发上限、单次超时、有限重试、部分失败、整体截止时间。
|
||||
|
||||
只用标准库。运行:python fanout.py;测试:python -m pytest -q test_fanout.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class RetryableError(Exception):
|
||||
"""可以重试的错误(限流 429、服务暂时不可用 503 等)。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Result(Generic[T]):
|
||||
key: str
|
||||
value: T | None = None
|
||||
error: str | None = None
|
||||
attempts: int = 0
|
||||
|
||||
|
||||
async def call_with_retry(fn: Callable[[], Awaitable[T]], *, timeout: float, retries: int,
|
||||
base_delay: float = 0.05) -> tuple[T, int]:
|
||||
"""单次调用带超时;遇到超时或可重试错误时指数退避重试。返回 (结果, 尝试次数)。"""
|
||||
for attempt in range(1, retries + 2):
|
||||
try:
|
||||
async with asyncio.timeout(timeout): # Python 3.11+
|
||||
return await fn(), attempt
|
||||
except (TimeoutError, RetryableError):
|
||||
if attempt > retries:
|
||||
raise
|
||||
await asyncio.sleep(base_delay * 2 ** (attempt - 1) * (1 + random.random() / 2))
|
||||
raise AssertionError("不会执行到这里")
|
||||
|
||||
|
||||
async def fan_out(jobs: dict[str, Callable[[], Awaitable[T]]], *, concurrency: int, timeout: float,
|
||||
retries: int = 1, deadline: float | None = None) -> list[Result[T]]:
|
||||
"""并发执行所有任务,最多 concurrency 个同时进行;单个失败不影响其他任务。
|
||||
deadline:整体截止时间(秒),到时未完成的任务被取消并标记为超时。"""
|
||||
sem = asyncio.Semaphore(concurrency)
|
||||
results = {k: Result[T](key=k) for k in jobs}
|
||||
|
||||
async def run(key: str, fn: Callable[[], Awaitable[T]]) -> None:
|
||||
async def counted() -> T: # 记录尝试次数(失败时也能看到试了几次)
|
||||
results[key].attempts += 1
|
||||
return await fn()
|
||||
|
||||
async with sem:
|
||||
try:
|
||||
results[key].value, _ = await call_with_retry(counted, timeout=timeout, retries=retries)
|
||||
except asyncio.CancelledError:
|
||||
results[key].error = "整体超时,已取消"
|
||||
raise # 取消必须继续向上传播
|
||||
except Exception as e: # 单个任务失败只记录,不影响其他任务
|
||||
results[key].error = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
|
||||
|
||||
tasks = [asyncio.create_task(run(k, fn)) for k, fn in jobs.items()]
|
||||
done, pending = await asyncio.wait(tasks, timeout=deadline)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
await asyncio.gather(*pending, return_exceptions=True) # 等待取消完成,避免任务泄漏
|
||||
return [results[k] for k in jobs]
|
||||
|
||||
|
||||
# ---------------- 演示 ----------------
|
||||
def fake_llm(name: str, delay: float, fail_times: int = 0) -> Callable[[], Awaitable[str]]:
|
||||
state = {"left": fail_times}
|
||||
|
||||
async def call() -> str:
|
||||
await asyncio.sleep(delay)
|
||||
if state["left"] > 0:
|
||||
state["left"] -= 1
|
||||
raise RetryableError("429 Too Many Requests")
|
||||
return f"{name} 的回答"
|
||||
|
||||
return call
|
||||
|
||||
|
||||
async def demo() -> None:
|
||||
jobs = {
|
||||
"摘要": fake_llm("摘要", 0.10),
|
||||
"分类": fake_llm("分类", 0.10, fail_times=1), # 第一次被限流,重试后成功
|
||||
"翻译": fake_llm("翻译", 2.00), # 太慢,单次超时
|
||||
"关键词": fake_llm("关键词", 0.05),
|
||||
}
|
||||
start = time.perf_counter()
|
||||
for r in await fan_out(jobs, concurrency=2, timeout=0.5, retries=1):
|
||||
print(f"{r.key}: {r.value or r.error}(尝试 {r.attempts} 次)")
|
||||
print(f"总耗时 {time.perf_counter() - start:.2f}s")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(demo())
|
||||
@@ -0,0 +1,60 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from fanout import RetryableError, fake_llm, fan_out
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def test_concurrency_limit():
|
||||
active, peak = 0, 0
|
||||
|
||||
def job():
|
||||
async def call():
|
||||
nonlocal active, peak
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
await asyncio.sleep(0.05)
|
||||
active -= 1
|
||||
return "ok"
|
||||
return call
|
||||
|
||||
res = run(fan_out({str(i): job() for i in range(10)}, concurrency=3, timeout=1))
|
||||
assert peak == 3 and all(r.value == "ok" for r in res)
|
||||
|
||||
|
||||
def test_retry_then_success():
|
||||
[r] = run(fan_out({"a": fake_llm("a", 0.01, fail_times=1)}, concurrency=1, timeout=1, retries=1))
|
||||
assert r.value == "a 的回答" and r.attempts == 2
|
||||
|
||||
|
||||
def test_partial_failure():
|
||||
async def boom():
|
||||
raise ValueError("参数错误") # 不可重试的错误:只尝试一次
|
||||
|
||||
res = run(fan_out({"ok": fake_llm("ok", 0.01), "bad": boom, "slow": fake_llm("slow", 1)},
|
||||
concurrency=3, timeout=0.1, retries=1))
|
||||
by = {r.key: r for r in res}
|
||||
assert by["ok"].value == "ok 的回答"
|
||||
assert by["bad"].error == "ValueError: 参数错误"
|
||||
assert by["slow"].error == "TimeoutError"
|
||||
|
||||
|
||||
def test_retry_exhausted():
|
||||
[r] = run(fan_out({"x": fake_llm("x", 0.01, fail_times=5)}, concurrency=1, timeout=1, retries=2))
|
||||
assert r.value is None and "RetryableError" in r.error
|
||||
|
||||
|
||||
def test_overall_deadline_cancels():
|
||||
start = time.perf_counter()
|
||||
res = run(fan_out({"fast": fake_llm("fast", 0.01), "slow": fake_llm("slow", 5)},
|
||||
concurrency=2, timeout=10, deadline=0.2))
|
||||
assert time.perf_counter() - start < 1
|
||||
by = {r.key: r for r in res}
|
||||
assert by["fast"].value and by["slow"].error == "整体超时,已取消"
|
||||
|
||||
|
||||
def test_retryable_error_is_exception():
|
||||
assert issubclass(RetryableError, Exception)
|
||||
@@ -4,12 +4,38 @@ title: asyncio 异步编程
|
||||
type: index
|
||||
domain: python
|
||||
stack: [asyncio]
|
||||
tags: []
|
||||
tags: [asyncio, 协程, 并发, 超时]
|
||||
week: [4, 5, 12]
|
||||
---
|
||||
|
||||
# asyncio 异步编程
|
||||
|
||||
协程、任务、并发控制、超时与取消。
|
||||
AI 应用的大部分时间在“等”:等模型生成、等检索、等数据库。asyncio 让一个进程同时等很多件事。你熟悉 JS 的 `async/await` 和 Promise,概念几乎可以直接迁移;不同点在于 Python **需要显式运行事件循环**、**同步阻塞调用会卡住整个循环**、以及任务取消和超时的写法。
|
||||
|
||||
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
|
||||
## 学习路线
|
||||
|
||||
| 顺序 | 课程 | 级别 | 预计 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | [协程、任务和事件循环:和 JS Promise 对照](01-coroutines-tasks.md) | 基础 | 2 小时 |
|
||||
| 2 | [并发控制:gather / TaskGroup、Semaphore、超时、取消、阻塞调用](02-concurrency-control.md) | 进阶 | 3 小时 |
|
||||
|
||||
## 企业场景
|
||||
|
||||
- [并发调用多个模型和外部接口:限并发、超时、重试、部分失败](scenarios/concurrent-llm-calls.md)
|
||||
|
||||
## 面试题
|
||||
|
||||
- [asyncio 面试题](interview.md)
|
||||
|
||||
## 示例代码
|
||||
|
||||
- [examples/fanout](examples/fanout/):并发上限、单次超时、指数退避重试、部分失败、整体截止时间取消,附 6 个测试,只用标准库
|
||||
|
||||
## 在 16 周计划中
|
||||
|
||||
- [第 4 周:Python 速成(asyncio 是重点)](../../../plan/week04-python.md)、[第 5 周:RAG 服务](../../../plan/week05-rag-1.md)、[第 12 周:Agent 持久化](../../../plan/week12-agent-m3-persistence.md)
|
||||
|
||||
## 官方资料
|
||||
|
||||
- asyncio 文档(中文):https://docs.python.org/zh-cn/3/library/asyncio.html
|
||||
- 协程与任务:https://docs.python.org/zh-cn/3/library/asyncio-task.html
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
id: python.asyncio.interview
|
||||
title: asyncio 面试题
|
||||
type: interview
|
||||
domain: python
|
||||
stack: [asyncio]
|
||||
tags: [asyncio, 协程, 并发, 面试]
|
||||
level: intermediate
|
||||
related: [python.asyncio.coroutines-tasks, python.asyncio.concurrency-control]
|
||||
week: [4, 16]
|
||||
---
|
||||
|
||||
# asyncio 面试题
|
||||
|
||||
## 基础
|
||||
|
||||
### 【基础】协程、线程、进程有什么区别?
|
||||
|
||||
**参考答案**:进程有独立的内存空间,切换和通信开销大,能利用多核;线程共享进程内存,由操作系统抢占式调度,在 CPython 中受 GIL 限制不能并行执行 Python 代码;协程是用户态的、协作式调度的执行单元,在 `await` 处主动让出控制权,切换开销极小,单线程即可同时处理大量 IO 等待。
|
||||
|
||||
### 【基础】asyncio 适合什么场景?不适合什么场景?
|
||||
|
||||
**参考答案**:适合 IO 密集、并发连接多的场景:调用大模型 API、HTTP 请求、数据库查询、WebSocket / SSE 长连接。不适合 CPU 密集计算(单线程,无法加速),这类任务用多进程或交给 C 扩展库。
|
||||
|
||||
### 【基础】调用 `async def` 函数但没有 `await` 会怎样?
|
||||
|
||||
**参考答案**:只会创建协程对象,函数体不会执行,Python 会给出 “coroutine was never awaited” 警告。这一点和 JS 不同——JS 的 async 函数调用后立即开始执行。
|
||||
|
||||
### 【基础】为什么在协程里不能使用 `time.sleep` 或 `requests`?
|
||||
|
||||
**参考答案**:事件循环是单线程的,阻塞调用会让整个线程停住,所有其他协程都无法运行,服务的并发能力退化为 1。应该用 `asyncio.sleep`、`httpx.AsyncClient` 等异步库;只能用同步库时用 `asyncio.to_thread` 放到线程池执行。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】`asyncio.gather` 和 `TaskGroup` 有什么区别?
|
||||
|
||||
**参考答案**:`gather` 在某个任务抛异常时立即把异常抛给调用方,但其余任务继续运行;加 `return_exceptions=True` 则把异常作为结果返回。`TaskGroup`(3.11+)是结构化并发:一个任务失败时取消组内其他任务,等它们全部结束后,以 `ExceptionGroup` 抛出所有异常,不会留下“孤儿任务”。
|
||||
|
||||
### 【进阶】怎么限制并发数?
|
||||
|
||||
**参考答案**:用 `asyncio.Semaphore(n)`,在每个任务的实际调用外层 `async with sem:`;或者用固定数量的 worker 从 `asyncio.Queue` 取任务。注意多个请求要共享同一个 Semaphore;多进程 / 多实例时需要 Redis 等全局限流。
|
||||
|
||||
### 【进阶】asyncio 的取消机制是怎样的?
|
||||
|
||||
**参考答案**:`task.cancel()` 请求取消,任务在下一个 `await` 点收到 `CancelledError`。任务可以捕获它做清理(关闭连接、记录状态),但应该重新抛出,否则取消失效。`asyncio.timeout` / `wait_for` 超时也是通过取消内部任务实现的。`CancelledError` 继承自 `BaseException`,所以 `except Exception` 不会误吞它。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】需要对 1 万条文本调用大模型生成摘要,服务商限制每分钟 600 次请求,怎么设计?
|
||||
|
||||
**参考答案**:用 Semaphore 或 worker 池限制并发,再加速率控制(令牌桶,每秒约 10 次);每次调用设超时;对 429、超时、5xx 做带抖动的指数退避重试,429 响应如果带 `Retry-After` 就按它等待;单条失败记录下来,最后汇总重跑;记录进度(已完成的 ID)以便中断后续跑;结果写入数据库而不是全放内存。规模更大时用消息队列分发给多个 worker。
|
||||
|
||||
### 【场景题】FastAPI 接口里调用了一个同步的 SDK,发现服务在压测时吞吐很低,为什么?怎么改?
|
||||
|
||||
**参考答案**:在 `async def` 路由里直接调用同步 SDK,阻塞了事件循环,所有请求排队。改法:换成 SDK 的异步版本;或者 `await asyncio.to_thread(sdk.call, ...)`;或者把路由定义为普通 `def`,FastAPI 会把它放到线程池执行。然后重新压测确认。
|
||||
|
||||
### 【场景题】一个异步任务在服务关闭时总是卡住,可能是什么原因?
|
||||
|
||||
**参考答案**:任务里捕获了 `CancelledError` 却没有重新抛出(或者用了裸 `except:`),导致取消无效;或者任务在执行不响应取消的阻塞调用(同步 IO、`to_thread` 中的线程无法被强制停止);或者有后台任务没有被追踪和取消。修复:正确传播取消、给阻塞调用设超时、在应用的生命周期关闭阶段统一取消并等待后台任务。
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
id: python.asyncio.scenario.concurrent-llm-calls
|
||||
title: 并发调用多个模型和外部接口:限并发、超时、重试、部分失败
|
||||
type: scenario
|
||||
domain: python
|
||||
stack: [asyncio, fastapi]
|
||||
tags: [并发, 大模型调用, 超时, 重试, 限流, 部分失败]
|
||||
level: intermediate
|
||||
prerequisites: [python.asyncio.concurrency-control]
|
||||
est_hours: 2
|
||||
related: [python.asyncio.interview, backend.go.scenario.batch-third-party-calls]
|
||||
week: [5, 12]
|
||||
---
|
||||
|
||||
# 并发调用多个模型和外部接口:限并发、超时、重试、部分失败
|
||||
|
||||
## 业务背景与问题
|
||||
|
||||
客服工单系统里,每张新工单需要并行做四件事:摘要(大模型)、分类(大模型)、情绪识别(小模型服务)、查客户等级(CRM 接口)。第一版是顺序调用,单张工单处理要 8~15 秒;改成直接 `gather` 之后变快了,但遇到新问题:
|
||||
|
||||
- 早高峰一次进来几百张工单,同时发出上千个请求,模型服务商返回大量 429(限流);
|
||||
- 某一个接口卡住时,整张工单一直没有结果;
|
||||
- 任何一个调用报错,整个 `gather` 抛异常,其余已经成功的结果也丢了。
|
||||
|
||||
## 约束
|
||||
|
||||
- 模型服务商有速率限制(每分钟请求数和 token 数)。
|
||||
- 工单处理结果允许部分缺失(例如情绪识别失败,不影响分配客服),但必须标明缺失项。
|
||||
- 每张工单的处理总时长不超过 20 秒,超时的部分以后台任务补齐。
|
||||
|
||||
## 方案设计
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
T[新工单] --> F[fan_out]
|
||||
F -->|Semaphore 限并发| S[摘要 LLM]
|
||||
F --> C[分类 LLM]
|
||||
F --> E[情绪服务]
|
||||
F --> R[CRM 接口]
|
||||
S & C & E & R --> M[合并结果:成功的字段 + 失败原因]
|
||||
M --> D[分配客服]
|
||||
M -->|有失败项| Q[补偿队列:稍后重试]
|
||||
```
|
||||
|
||||
1. **全局并发上限**:所有模型调用共享一个 `Semaphore`(按服务商限额设置),而不是每张工单各自限制。多进程 / 多实例部署时,进一步用 Redis 做全局限流。
|
||||
2. **单次超时 + 整体截止时间**:每次调用 5 秒超时;每张工单整体 20 秒,到时取消未完成的调用。
|
||||
3. **有选择地重试**:只对超时、429、5xx 做指数退避重试(加随机抖动),最多 1~2 次;参数错误不重试。
|
||||
4. **部分失败**:每个子任务的结果单独记录成功值或错误原因,不让一个失败影响全部;失败项进入补偿队列。
|
||||
5. **可观测**:记录每个子任务的耗时、重试次数、失败原因,用于调整并发和超时参数。
|
||||
|
||||
## 关键代码
|
||||
|
||||
完整实现和测试见 [examples/fanout](../examples/fanout/):
|
||||
|
||||
```python
|
||||
async def fan_out(jobs, *, concurrency, timeout, retries=1, deadline=None):
|
||||
sem = asyncio.Semaphore(concurrency)
|
||||
results = {k: Result(key=k) for k in jobs}
|
||||
|
||||
async def run(key, fn):
|
||||
async with sem:
|
||||
try:
|
||||
results[key].value, _ = await call_with_retry(fn, timeout=timeout, retries=retries)
|
||||
except asyncio.CancelledError:
|
||||
results[key].error = "整体超时,已取消"
|
||||
raise # 取消要继续传播
|
||||
except Exception as e: # 单个失败只记录
|
||||
results[key].error = f"{type(e).__name__}: {e}"
|
||||
|
||||
tasks = [asyncio.create_task(run(k, fn)) for k, fn in jobs.items()]
|
||||
done, pending = await asyncio.wait(tasks, timeout=deadline)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
return [results[k] for k in jobs]
|
||||
```
|
||||
|
||||
演示输出(并发 2、单次超时 0.5 秒、重试 1 次):
|
||||
|
||||
```text
|
||||
摘要: 摘要 的回答(尝试 1 次)
|
||||
分类: 分类 的回答(尝试 2 次) ← 第一次 429,重试成功
|
||||
翻译: TimeoutError(尝试 2 次) ← 太慢,两次都超时
|
||||
关键词: 关键词 的回答(尝试 1 次)
|
||||
```
|
||||
|
||||
## 取舍
|
||||
|
||||
| 方案 | 优点 | 缺点 |
|
||||
| --- | --- | --- |
|
||||
| 顺序调用 | 简单 | 慢 |
|
||||
| 裸 `gather` | 快、代码少 | 无并发上限、一个失败全部失败 |
|
||||
| **Semaphore + 超时 + 重试 + 部分失败(本方案)** | 可控、稳定 | 代码多一些,参数要调 |
|
||||
| 消息队列 + 独立 worker | 削峰、可水平扩展、失败可重放 | 架构更复杂,结果是异步的 |
|
||||
|
||||
请求量继续增大时,把“工单 → 子任务”拆成队列消息,由 worker 池消费,Semaphore 换成 Redis 全局限流。
|
||||
|
||||
## 易错点
|
||||
|
||||
- 每个请求各自创建一个 Semaphore:等于没有限制。
|
||||
- 重试不加抖动:所有任务在同一时刻重试,再次触发限流。
|
||||
- `except Exception` 之后不区分错误类型就重试:参数错误被重试多次,浪费额度。
|
||||
- 捕获 `CancelledError` 后不重新抛出:整体截止时间失效。
|
||||
- 截止时间到了只是“不等了”,没有取消任务:后台还在消耗模型额度。
|
||||
- 只记录“失败”不记录原因:无法判断是限流、超时还是参数问题。
|
||||
|
||||
## 面试怎么讲
|
||||
|
||||
> “工单要同时调用几个模型和接口。我用 asyncio 并发,但加了全局 Semaphore 控制并发数以符合服务商的限流;每次调用有超时,只对超时和 429 做带抖动的指数退避重试;每个子任务单独记录结果和错误,单个失败不影响其他,失败项进补偿队列;整体有截止时间,到时取消剩余调用。处理时间从顺序调用的 8 到 15 秒降到了约等于最慢的那个调用。”
|
||||
|
||||
注意:面试中提到的耗时数据要来自你自己项目的实际测量,不要编造。和 Go 版本的同类场景对照:[批量调用第三方接口(Go)](../../../backend/go/scenarios/batch-third-party-calls.md)。
|
||||
Reference in new issue
Block a user