本文是一份可直接抄到项目里用 的实战指南:把现有同步 LLM 调用改成异步、加并发/流式/限流、单 Agent 多 tool 与多 Agent 编排,全部给出可运行代码。面向已会用 Python 调 LLM API(同步写法)、想在真实项目里落地的开发者。示例基于 OpenAI 兼容 API(ModelScope、OpenAI 等),环境与版本见第 0 节。
0. 环境(一分钟搭好)
下面示例均在此环境下跑通,复制前先装好。
- Python :3.10+(文中使用
asyncio.TaskGroup的示例需 Python 3.11+,会在该段前注明) - OpenAI 兼容客户端 :
openai>=1.0(使用AsyncOpenAI与client.chat.completions.create)
安装:
bash
pip install "openai>=1.0"
若使用 ModelScope 等兼容端点,只需配置 base_url(如 https://api-inference.modelscope.cn/v1)与 api_key,调用方式与下文一致。文中所有代码均以本节版本为准;若某段需 3.11+,会在代码前标明。
1. 为什么要上异步?先看效果再动手
实战里常见问题:批量补全、多轮对话或多 Agent 一起调时,串行 N 次 = 总耗时约 N 倍单次延迟,接口卡、页面转圈。改成异步并发后,同一批请求总耗时接近「最慢的那一次」,且不堵死线程,FastAPI 等框架下其它请求照样能处理。
你马上能得到的:下面代码把「同步连调 3 次」改成「异步并发 3 次」,跑一遍就能看到耗时明显下降;后文会按场景给出可直接复用的写法(并发、流式、限流、单/多 Agent)。
N 路并发总耗时 ≥ 单次延迟;若被 API 限流或连接数卡住,会略高,用后文的 Semaphore 限流即可。
下图:串行 vs 并发的时间差异。
LLM API 调用方 LLM API 调用方 串行:T1+T2+T3 并发:max(T1,T2,T3) par 请求1 响应1 请求2 响应2 请求3 响应3 请求1 响应1 请求2 响应2 请求3 响应3
1.5 OpenAI 两类 API 对比(选读,按需扫一眼)
做兼容 Completions 的客户端、用 ModelScope 等时,可跳过;要选型或对接 Responses 时再看。
OpenAI 当前两种主要形态:
- Chat Completions API :
POST /v1/chat/completions,经典「消息数组 in / 消息 out」接口。 - Responses API :
POST /v1/responses,面向 Agent 与多轮推理的新形态(2025 主推)。
| 维度 | Chat Completions | Responses |
|---|---|---|
| 输入/输出 | messages[](role + content) |
input / output(Items,含 message、tool 等) |
| 状态与多轮 | 无状态:每次请求都要自己把「之前几轮对话」拼成 messages 列表再发给 API | 支持 store、previous_response_id,服务端可保留上下文 |
| 能力 | 文本、视觉、Function calling、Structured Outputs | 在此基础上增加原生工具(web search、file search、code interpreter、MCP 等)、Reasoning summaries |
| 成本与表现 | 常规 | 官方称约 40%~80% 成本优化、推理类模型表现更好 |
场景建议:简单对话、与现有 ModelScope/兼容生态对接、希望完全自管历史 → 继续用 Chat Completions。新项目、需要 Agent 多步工具、希望少管多轮状态 → 可考虑 Responses。
与异步的关系 :两种 API 都可用 AsyncOpenAI 做 await client.chat.completions.create(...) 或 await client.responses.create(...);下文的并发、流式、限流等模式对两者通用。文中代码以 Chat Completions 为例,迁移到 Responses 时只需换端点和请求/响应体,异步写法不变。
2. 最小必要概念(用到再回来看)
实战时只需记住三点:async def + await 写异步函数;asyncio.run(main()) 或 FastAPI 会启动事件循环;LLM 调用是 I/O 等待,适合异步,CPU 密集的别塞进 async。
「通过 await 让出执行权」具体是啥意思? 执行到 await xxx 时,当前协程会暂停 :不再占着 CPU 往下跑,而是把「谁可以接着跑」的决定权交还给事件循环 。事件循环会去执行其它已经就绪的协程(比如别的 LLM 请求、别的 await 等到的结果);等你这句 await 等的东西(比如一次网络响应)好了,事件循环再让当前协程从 await 后面继续执行。所以叫「让出执行权」:这段时间里 CPU 去干别的事,而不是傻等这一次 I/O,这样多路请求就能在「等网络」的时间里交错推进,总耗时接近最慢的那一路。细节可查下面术语表或 asyncio 文档。
2.5 术语速查(实战遇到再回看)
基础概念
| 词条 | 说明 |
|---|---|
| event loop(事件循环) | 单线程里调度所有协程的循环:看哪个 await 的结果好了就恢复对应协程,没好的继续等。 |
| 协程(coroutine) | async def 定义的可挂起函数;遇到 await 就暂停、把执行权交回事件循环,结果就绪后从 await 后继续。 |
async / await |
async def 定义异步函数;await 表示「在此暂停等结果,期间事件循环可跑其它协程」。 |
gather |
asyncio.gather:并发执行多个协程并收集结果。 |
* / ** 解包 |
* 把可迭代对象解包为位置参数 (如 gather(*[c1,c2]) → gather(c1,c2));** 把字典解包为关键字参数 (如 f(**{"a":1}) → f(a=1))。 |
| I/O 密集 vs CPU 密集 | I/O 密集:多时间在等网络/磁盘,适合 async。CPU 密集:多时间在算数,会占满事件循环,应放线程/进程。 |
流式与限流
| 词条 | 说明 |
|---|---|
Semaphore |
asyncio.Semaphore(n):限流用,相当于 n 张通行证,拿不到就等在 async with sem 外,用完归还。 |
| SSE | Server-Sent Events,服务端向客户端推送事件,常用于流式输出。 |
| 背压(backpressure) | 流式时生产端一直塞、消费端慢,缓冲区会爆。背压 = 让生产端减速或排队(如有界 Queue(maxsize=n)),队列满就等消费端取走再放。 |
API
| 词条 | 说明 |
|---|---|
| Chat Completions API | OpenAI 兼容的「消息 in / 消息 out」接口。 |
| Responses API | OpenAI 新一代面向 Agent 的接口,支持服务端状态与原生工具。 |
3. 第一步:把同步改成异步(复制即跑)
用 OpenAI 兼容 客户端:先给一段「同步连调 3 次」的脚本,再给「异步 ainvoke + gather 并发 3 次」的等价写法;改 base_url/api_key 即可在你自己的环境跑。下面用耗时统计装饰器统一打印耗时,方便对比。
耗时统计装饰器(同步 / 异步各一个,可贴到项目里复用)
python
# 同步函数耗时装饰器
import time
import functools
def timing_sync(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
t0 = time.perf_counter()
result = fn(*args, **kwargs)
print(f"[耗时] {fn.__name__}: {time.perf_counter() - t0:.2f}s")
return result
return wrapper
# 异步函数耗时装饰器
def timing_async(fn):
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
t0 = time.perf_counter()
result = await fn(*args, **kwargs)
print(f"[耗时] {fn.__name__}: {time.perf_counter() - t0:.2f}s")
return result
return wrapper
说明 :上述装饰器只适用于「一次执行完并 return」的同步/异步函数。流式(async 生成器,内部 yield) 不能套用:调用 async 生成器得到的是生成器对象,不能直接被 await,会报错(如 TypeError: object async_generator can't be used in 'await' expression);即便能计到时间,也是等消费者把整段流迭代完才打印。若要看**首 token 时间(TTFT)**或流式过程耗时,需在生成器内部或消费侧打点,见 4.3 流式小节。
3.1 同步:连续调用 3 次(对比用)
python
# 目的:演示同步串行调用,总耗时为 3 次请求之和;带耗时统计便于和 3.2 对比。
import os
import time
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("OPENAI_API_KEY", "sk-xxx"),
base_url=os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1"),
)
model = "gpt-4o-mini" # 或你的兼容模型名
def sync_invoke(prompt: str) -> str:
resp = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
)
return (resp.choices[0].message.content or "").strip()
# 串行 3 次:总时间 ≈ t1 + t2 + t3;下面内联统计耗时(复制即跑,无需装饰器)
prompts = ["1+1=?", "2+2=?", "3+3=?"]
t0 = time.perf_counter()
results = [sync_invoke(p) for p in prompts]
print(f"[耗时] 同步 3 次: {time.perf_counter() - t0:.2f}s")
print(results)
# 若已把上文的 timing_sync 拷入本文件,也可用 @timing_sync 装饰一个 run_sync_3() 替代上面三行
3.2 异步:ainvoke + gather 并发 3 次(实战入口)
python
# 目的:同一批请求用异步并发,总耗时约等于最慢一次;带耗时统计便于和 3.1 对比。
import asyncio
import os
import time
from openai import AsyncOpenAI
aclient = AsyncOpenAI(
api_key=os.environ.get("OPENAI_API_KEY", "sk-xxx"),
base_url=os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1"),
)
model = "gpt-4o-mini" # 与同步示例一致,可替换为你的兼容模型名
async def ainvoke(prompt: str) -> str:
resp = await aclient.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
)
return (resp.choices[0].message.content or "").strip()
@timing_async # 需先定义 timing_async(见上文「耗时统计装饰器」)
async def main():
prompts = ["1+1=?", "2+2=?", "3+3=?"]
results = await asyncio.gather(*[ainvoke(p) for p in prompts])
print(results)
return results
asyncio.run(main())
关于 * :gather 接收多个协程作为位置参数 ,*[ainvoke(p) for p in prompts] 把列表解包成「一个参数一个协程」传入,等价于 gather(ainvoke("1+1=?"), ainvoke("2+2=?"), ...)。** 是把字典解包成关键字参数 (如 func(**{"a":1,"b":2}) → func(a=1,b=2)),这里用不到。
实战建议 :先把「耗时统计装饰器」里的 timing_sync / timing_async 拷到同一文件或公共模块,再跑 3.1 和 3.2,终端会打印 [耗时] 同步 3 次: x.xxs 与 [耗时] main: x.xxs,直接对比即可。后文所有示例都沿用这里的 aclient、model、ainvoke。
3.5 实战怎么量耗时(可复现对比)
用 time.perf_counter() 在调用前后打点即可。下面模板可直接贴进项目,用来对比「同步串行 vs 异步并发」的耗时。
python
# 目的:可复用的计时模板,用于对比同步串行 vs 异步并发的耗时。
# 使用场景:自测环境、博客示例、压测脚本。
import asyncio
import time
def run_sync_n_times(prompts: list, invoke_fn):
t0 = time.perf_counter()
results = [invoke_fn(p) for p in prompts]
elapsed = time.perf_counter() - t0
return results, elapsed
async def run_async_gather(prompts: list, ainvoke_fn):
t0 = time.perf_counter()
results = await asyncio.gather(*[ainvoke_fn(p) for p in prompts])
elapsed = time.perf_counter() - t0
return results, elapsed
# 使用示例(需接上文的 sync_invoke / ainvoke 与 model):
# sync_results, sync_elapsed = run_sync_n_times(prompts, sync_invoke)
# async_results, async_elapsed = asyncio.run(run_async_gather(prompts, ainvoke))
# print(f"同步: {sync_elapsed:.2f}s, 异步: {async_elapsed:.2f}s")
若你在文中贴出示例数据,建议注明环境(如 Python 版本、网络条件、模型名),便于读者复现。
4. 实战常用写法(按需复制)
以下均基于 3.2 的 aclient、model、ainvoke;单独复制某段时请先跑 3.2 完成初始化。
4.1 并发多请求 + 部分失败不拖垮整体
批量补全时希望「单条失败不影响其它」:用 return_exceptions=True,再在结果里逐个判断是否为异常(踩坑见 4.7)。
python
# 目的:并发多请求,且部分失败时仍拿到其它成功结果。
# 使用场景:批量补全时允许单条失败、多 Agent 中部分超时不影响其余。
async def gather_with_exceptions(prompts: list):
# return_exceptions=True:异常会作为结果项返回,不直接抛出
results = await asyncio.gather(
*[ainvoke(p) for p in prompts],
return_exceptions=True,
)
outs = []
for i, r in enumerate(results):
if isinstance(r, Exception):
# 记录失败,可打日志或重试
print(f"prompt {i} 失败: {r}")
outs.append(None) # 或自定义占位
else:
outs.append(r)
return outs
4.2 单次调用加超时(避免一直挂起)
单次请求包一层 wait_for,超时即放弃。超时后 asyncio 会取消 该协程(协程内部会收到 CancelledError 异常);若协程里打开了连接等资源,应在 try/finally 或捕获 CancelledError 时关闭,否则可能泄漏(见 4.7)。
python
# 目的:单次 ainvoke 超时则放弃,不无限等待。
async def ainvoke_with_timeout(prompt: str, timeout: float = 30.0) -> str:
return await asyncio.wait_for(ainvoke(prompt), timeout=timeout)
超时后 asyncio 会取消被 wait_for 的协程(其内部会收到 CancelledError);若该协程内有未关闭的连接或资源,应在 try/finally 或捕获 CancelledError 时做清理,否则可能泄漏。示例:协程内自己打开了「需关闭」的资源时,用 try/finally 保证超时或异常时也会关闭:
python
# 示例:协程内持有资源时,用 try/finally 避免超时取消导致泄漏
async def call_with_own_resource(timeout: float = 30.0):
# 假设这是你打开的连接/会话等,必须在结束时关闭
resource = open_some_connection() # 伪代码:你的 open_xxx()
try:
return await asyncio.wait_for(do_work(resource), timeout=timeout)
finally:
resource.close() # 超时取消或异常时也会执行,避免泄漏
若要在取消时做额外逻辑再重新抛出,可显式捕获 CancelledError:except asyncio.CancelledError: ...; raise,并在 raise 前做清理。
4.3 流式输出(接 SSE/前端逐字展示)
兼容 API 支持 stream=True 时,下面写法可直接用于「异步迭代 chunk → 推给前端或 CLI」。
python
# 目的:流式返回内容,适合前端 SSE 或 CLI 逐字输出。
# 使用场景:长回答、实时展示、降低首 token 延迟。
async def ainvoke_stream(prompt: str):
stream = await aclient.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
stream=True,
)
async for chunk in stream:
# 部分 chunk 仅有 finish_reason 等、无 content,用 getattr 避免 AttributeError
delta = (getattr(chunk.choices[0].delta, "content", None) if chunk.choices else None) or ""
if delta:
yield delta
流式返回的 chunk 中,部分仅有 finish_reason 等字段、无 content,属正常;只 yield 有内容的 delta 即可。
流式怎么量耗时 :@timing_async 不能用在 async 生成器上(对生成器对象做 await 会报错;且即使能跑,也是等消费者迭代完才计完)。若要首 token 时间(TTFT) ,在生成器里第一次 yield 前打点;若要整段流耗时 ,在消费侧 async for 前后打点,或在生成器内用 try/finally 在结束时打印。示例(仅示意打点位置):
python
# 首 token 时间:第一次 yield 前记 t0,yield 后打印(需 import time;aclient/model 同 4.3)
import time
async def ainvoke_stream_ttft(prompt: str):
t0 = time.perf_counter()
stream = await aclient.chat.completions.create(model=model, messages=[{"role": "user", "content": prompt}], stream=True)
first = True
async for chunk in stream:
delta = (getattr(chunk.choices[0].delta, "content", None) if chunk.choices else None) or ""
if delta:
if first:
print(f"[TTFT] {time.perf_counter() - t0:.2f}s")
first = False
yield delta
4.4 限流(Semaphore):避免瞬时打满 API
大批量或多 Agent 并发时,用 Semaphore(n) 限制「同时进行中的请求数」,其余排队。不创建线程,仍在同一事件循环内。
python
# 目的:限制同时只有 sem_limit 个 ainvoke 在跑,其余排队,避免瞬时打满 API。
# 使用场景:大批量补全、多 Agent 并发时的 QPS 控制。
sem_limit = 3
sem = asyncio.Semaphore(sem_limit)
async def ainvoke_limited(prompt: str) -> str:
async with sem: # 同时最多 sem_limit 个进入
return await ainvoke(prompt)
async def main():
prompts = [f"问题{i}" for i in range(10)]
results = await asyncio.gather(*[ainvoke_limited(p) for p in prompts])
限流概念:同时最多 sem_limit 个请求在飞,其余在 async with sem 外排队,避免瞬时打满 API。Semaphore(n) 只是限制同时运行该段代码的协程数为 n,并不创建 n 个线程;仍在同一事件循环内并发。
Semaphore 闸门 n=3
ainvoke
ainvoke
ainvoke
排队
请求1
请求2
请求3
4.5 单 Agent 内:多 tool 并发再回传
Agent 内「LLM → 执行 tool(s) → 再调 LLM」时,多个 tool 可并发执行 再汇总回传;下面是一段可直接接 Chat Completions + function calling 的 loop,注意每条 tool 结果必须带 tool_call_id(API 要求)。
python
# 目的:演示单 agent 内「LLM → 多 tool 并发 → LLM」的异步 loop。
# 使用场景:带 function/tool calling 的 agent,多工具并行执行。
import json
async def run_tool(name: str, args: dict) -> str:
# 模拟异步工具调用(如查 API、查 DB)
await asyncio.sleep(0.2)
return f"tool_{name}({args})"
async def agent_loop(user_input: str, max_turns: int = 3):
messages = [{"role": "user", "content": user_input}]
for _ in range(max_turns):
resp = await aclient.chat.completions.create(
model=model,
messages=messages,
tool_choice="auto",
tools=[{"type": "function", "function": {"name": "run_tool", "description": "run", "parameters": {"type": "object"}}}],
)
msg = resp.choices[0].message
if not getattr(msg, "tool_calls", None):
return (msg.content or "").strip()
# 多个 tool call 并发执行,避免串行阻塞;生产环境应解析 tc.function.arguments(JSON 字符串)传入
tool_results = await asyncio.gather(
*[run_tool(tc.function.name, json.loads(tc.function.arguments or "{}")) for tc in msg.tool_calls]
)
# 每条 tool call 对应一条 role="tool" 的消息,且必须带 tool_call_id;API 需要 dict,Message 用 model_dump 转成 dict(Pydantic 方法),exclude_none=True 不写入值为 None 的字段,避免多余键
messages.append(msg.model_dump(exclude_none=True))
# gather 虽并发执行、完成先后不定,但保证返回列表与传入顺序一致(见 asyncio 文档),故 zip 能一一对应
for tc, result in zip(msg.tool_calls, tool_results):
messages.append({"role": "tool", "tool_call_id": tc.id, "content": str(result)})
return ""
下一轮 LLM 必须收到每条 tool call 对应的、带 tool_call_id 的 tool 消息(与 assistant 消息里 tool_calls[].id 一致),否则 API 会报错或行为未定义。msg 是响应里的 Message 对象(Pydantic 模型),model_dump(exclude_none=True) 把它转成 API 需要的 dict,并去掉值为 None 的字段;若你用的是 Pydantic v1,则改为 msg.dict(exclude_none=True)。用 gather 并发执行多个 tool 再按顺序写回,可缩短整轮耗时。
概念上可对应下图(单 agent 内 LLM 与多 tool 并发):
LLM
tool1
tool2
tool3
LLM
4.6 多 Agent:并行 / 链式 / 分阶段
无依赖 :多个 agent 各干各的,直接 gather 并行。有依赖 :A 的结果给 B 时顺序 await,或分阶段「先 gather(A,B),再 await C(r1,r2)」。更复杂 DAG 可拓扑序分阶段或用 3.11+ 的 TaskGroup。下面三种写法可直接套到你的 agent 封装上。
python
# 目的:无依赖时多 agent 并行;有依赖时顺序 await 或分阶段 gather。
# 使用场景:多 Agent 编排、流水线式处理。
async def ainvoke_agent_a(prompt: str) -> str:
return await ainvoke("[AgentA] " + prompt)
async def ainvoke_agent_b(prompt: str) -> str:
return await ainvoke("[AgentB] " + prompt)
# 无依赖:并行
async def multi_agent_parallel(q1: str, q2: str):
r1, r2 = await asyncio.gather(ainvoke_agent_a(q1), ainvoke_agent_b(q2))
return r1, r2
# 有依赖:A 的结果给 B
async def multi_agent_chain(prompt: str):
r1 = await ainvoke_agent_a(prompt)
r2 = await ainvoke_agent_b(r1)
return r2
# 分阶段:先并行 A、B,再依赖二者结果调下一阶段
async def multi_agent_staged(prompt: str):
r1, r2 = await asyncio.gather(ainvoke_agent_a(prompt), ainvoke_agent_b(prompt))
r3 = await ainvoke_agent_b(r1 + r2) # 下一阶段依赖 r1、r2,此处用 B 示意
return r3
Python 3.11+ 可用 asyncio.TaskGroup 统一管理多任务与取消:组内任一任务抛异常会取消同组其它任务并向外传播,适合「要么全成要么全撤」的并行。示例(需 Python 3.11+):
python
# Python 3.11+:TaskGroup 统一管理多任务,任一处异常会取消同组其它任务
async def multi_agent_taskgroup(q1: str, q2: str):
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(ainvoke_agent_a(q1))
t2 = tg.create_task(ainvoke_agent_b(q2))
# 出 with 时所有任务已结束;若任一路抛异常或被动取消,异常会从 with 处向外抛,不会执行到下面这行
return t1.result(), t2.result()
# 调用方:用 try/except 接住组内任一路的异常或取消
async def main():
try:
r1, r2 = await multi_agent_taskgroup("q1", "q2")
print(r1, r2)
except asyncio.CancelledError:
raise # 取消一般应继续向外抛,让上层决定
except Exception as e:
print(f"多 Agent 中有一路失败: {e}") # 记录或降级处理
更多用法见 asyncio.TaskGroup 官方文档。
4.7 实战踩坑清单
- 同步/异步混用 :在同步函数里不能
await;在 async 里调同步 LLM 客户端(如requests或同步OpenAI)会阻塞整个事件循环 ------即这段时间内事件循环没法去执行其它协程,其它请求/任务都会卡住。错误示例:在 async 里直接sync_invoke(prompt)。正确:await asyncio.to_thread(sync_invoke, prompt)或统一改用AsyncOpenAI。 - gather 与异常 :不加
return_exceptions=True时,一个任务抛异常会导致整个gather失败;若要「部分失败不影响其它」,必须加该参数并在结果里isinstance(r, Exception)判断。 - 超时与取消 :
wait_for超时会取消协程,被取消方应处理好CancelledError,避免留下未关闭连接。 - 客户端复用 :高并发下应复用单例
AsyncOpenAI(),不要每次请求 new 一个;多进程/多线程下每个进程或线程用自己的 event loop 和 client。
python
# ❌ 错误:每次请求都 new 一个 client,连接数暴增、易被限流
async def ainvoke_bad(prompt: str):
client = AsyncOpenAI(api_key=..., base_url=...) # 每次调用都新建
return (await client.chat.completions.create(...)).choices[0].message.content
# ✅ 正确:模块或应用级单例,所有请求复用同一个 client
aclient = AsyncOpenAI(api_key=os.environ.get("OPENAI_API_KEY"), base_url=...)
async def ainvoke(prompt: str):
return (await aclient.chat.completions.create(...)).choices[0].message.content
- 依赖库 :async 路径里不要用
requests、同步openai;用httpx异步或openai.AsyncOpenAI。若必须用同步库,放到to_thread/executor。 - 可观测性:异步下多个请求的日志会交错打印,若不带 request_id/trace_id,很难看出某条日志属于哪次请求;在入口生成 id、在日志和 contextvars 里带上,排查时按 id 过滤即可。
python
# 实战样式:用 contextvars 带 request_id,日志里统一带上便于过滤
import contextvars
import logging
import uuid
request_id_ctx: contextvars.ContextVar[str | None] = contextvars.ContextVar("request_id", default=None)
async def handle_chat(prompt: str):
# set 会写入当前上下文并返回 token,供 finally 里 reset 用;入口也可从 FastAPI request.headers 取已有 id
token = request_id_ctx.set(str(uuid.uuid4())[:8])
try:
logging.info("ainvoke start", extra={"request_id": request_id_ctx.get()})
out = await ainvoke(prompt)
logging.info("ainvoke done", extra={"request_id": request_id_ctx.get()})
return out
finally:
request_id_ctx.reset(token)
# 若用 structlog / 自定义 Formatter,可从 request_id_ctx.get() 自动注入到每条日志
4.8 按场景抄代码
| 场景 | 推荐做法 |
|---|---|
| 批量补全 / 多 prompt | gather + Semaphore 限流;列表极大时分批 chunk 再 gather。用 Semaphore 可避免瞬时打满上游 API 触发限流。 |
| 流式到前端 | async 生成器 + SSE/WebSocket;注意背压,可用 asyncio.Queue(maxsize=...)。 |
| 高并发 API(如 FastAPI) | 路由内 await ainvoke(...);健康检查与优雅关闭时回收 pending task 与连接。 |
| 脚本/离线批量 | asyncio.run(main()) + gather + Semaphore;进度条用 tqdm.asyncio.tqdm。 |
| 多 Agent 编排 | 无依赖用 gather;有依赖分阶段 gather 或 TaskGroup(3.11+);复杂 DAG 用拓扑序或 Queue。 |
| 单次调用 | 无并发需求时用同步即可,不必强行异步。 |
4.9 实战 FAQ(报错时先看这里)
Q:为什么我加了 await 还是报错?
A:await 只能在 async def 函数内使用。若在同步脚本里调用异步函数,应写 asyncio.run(main()) 或把入口改为 async def main() 再 asyncio.run(main())。
Q:gather 返回的列表里为什么有 Exception?
A:用了 return_exceptions=True 时,某个协程若抛异常,这个异常不会打断 gather,而是被放进结果列表的对应位置 (其它位置仍是正常返回值)。所以返回的列表里可能既有字符串也有异常对象,需要逐个 isinstance(r, Exception) 判断并处理或记录。
Q:异步并发后反而变慢/卡住?
A:常见原因:(1)在 async 里调用了同步阻塞代码(如 requests、同步 OpenAI),阻塞了事件循环;(2)没有复用 client,连接数过多。解决:同步调用用 to_thread/executor 包一层或改用异步库;高并发下复用单例 AsyncOpenAI。
Q:报错 "coroutine was never awaited" / "object async_generator can't be used in 'await'"?
A:前者:你写了 ainvoke(prompt) 但没写 await,或把协程当普通函数调了,要 await ainvoke(prompt) 且在 async def 里。后者:把带 yield 的 async 生成器当协程去 await 了,生成器不能 await,要 async for x in ainvoke_stream(...) 消费;若只是想计耗时,见 4.3 流式小节在生成器内或消费侧打点。
Q:单次请求想设超时怎么办?
A:用 asyncio.wait_for(ainvoke(prompt), timeout=30.0) 包一层(见 4.2)。超时会抛 asyncio.TimeoutError,被包住的协程会被取消;若有自己开的连接等资源,用 try/finally 或捕获 CancelledError 做清理。
Q:Agent 里把 msg 放进 messages 再请求时报错 / 类型不对?
A:resp.choices[0].message 是 Pydantic 的 Message 对象,API 的 messages 要的是 dict 列表。应 messages.append(msg.model_dump(exclude_none=True))(openai 1.x);Pydantic v1 用 msg.dict(exclude_none=True)。见 4.5。
Q:多进程或多线程里怎么用 asyncio?
A:每个进程/线程各自跑自己的事件循环,不要跨进程共享 AsyncOpenAI 或 loop。多进程:每个进程里 asyncio.run(main()) 或起一个 loop;多线程:每线程一个 loop,或主线程跑 loop、子线程用 run_coroutine_threadsafe 提交任务。client 按进程/线程建单例即可(见 4.7 客户端复用)。
Q:想限制同时发多少请求,该用啥?
A:用 asyncio.Semaphore(n),在发请求前 async with sem:,这样同时只有 n 个在执行(见 4.4)。不要每次请求 new client,client 单例 + Semaphore 即可。
Q:报错 "This event loop is already running"?
A:说明当前环境里已经有一个在跑的事件循环(例如 Jupyter、某些框架),不能再调 asyncio.run()。在 Jupyter 里直接用 await main();在已有 loop 的环境里用 loop.run_until_complete(main()) 或把任务提交给已有 loop,不要重复创建/运行 loop。
Q:批量请求时想打进度条怎么办?
A:用 tqdm.asyncio.tqdm.as_completed 包住协程列表,或先 gather 再在循环里手动更新 tqdm。例如:from tqdm.asyncio import tqdm_asyncio; results = await tqdm_asyncio.gather(*[ainvoke(p) for p in prompts])(需 pip install tqdm)。
4.10 上线前必查:安全与成本
- 异步并发容易短时间打爆请求量,务必用 Semaphore/限流(见 4.4),避免误刷用量或触发服务端限流。
- API Key 不要写在前端或不可信环境;生产环境用环境变量或密钥管理(如 Nacos、HashiCorp Vault、云厂商密钥服务等)。
- 生产部署建议监控用量与成本,设置预算/配额告警;按 token 或请求数做限流,避免单次上线拖垮账单。
- 超时与重试 :单次请求用
wait_for设超时(4.2);重试建议带退避、限制次数,避免雪崩。 - 日志与审计:敏感内容(如完整 prompt/回复)视合规要求脱敏或仅写审计日志;request_id 贯穿全链路便于排查(见 4.7 可观测性)。
- 优雅关闭 :若跑在 Web/常驻进程里,关闭时回收 pending 任务与 HTTP 连接,避免「杀进程导致连接泄漏」。和异步的关系:lifespan 与路由跑在同一事件循环 里,shutdown 阶段才能用
await做异步清理(如关连接池);见下文 5 的示例。
5. 接到 FastAPI(实战一步)
路由里直接 await ainvoke(...),不阻塞 worker。若目前只有同步 invoke,用 await asyncio.to_thread(sync_invoke, prompt) 包一层,避免在 async 里直接调同步客户端。
python
from fastapi import FastAPI
import asyncio
app = FastAPI()
@app.post("/chat")
async def chat(prompt: str):
# 异步调用,不阻塞事件循环
return await ainvoke(prompt)
从 body 取参数 :若前端用 JSON 传参,可用 Pydantic 模型或 Body:
python
from pydantic import BaseModel
class ChatRequest(BaseModel):
prompt: str
max_tokens: int | None = 1024
@app.post("/chat/json")
async def chat_json(req: ChatRequest):
# 直接 await,不阻塞;可按需加超时 wait_for(ainvoke(req.prompt), timeout=30.0)
return await ainvoke(req.prompt)
流式接口(SSE) :需要逐字/逐 chunk 推给前端时,用 4.3 的 ainvoke_stream + StreamingResponse:
python
from fastapi.responses import StreamingResponse
async def sse_stream(prompt: str):
"""将 4.3 的 ainvoke_stream 包装为 SSE 格式,供 StreamingResponse 使用。"""
async for delta in ainvoke_stream(prompt):
yield f"data: {delta}\n\n"
@app.get("/chat/stream")
async def chat_stream(prompt: str):
return StreamingResponse(
sse_stream(prompt),
media_type="text/event-stream",
)
aclient 放 app.state 时 :若在 lifespan 里初始化 client 并挂到 app.state.aclient,路由里用 request.app.state.aclient 或通过 Depends 注入即可,避免全局变量。
优雅关闭与异步的关系 :FastAPI 和路由里的 await ainvoke(...) 共用同一个事件循环 ,所以 lifespan 的 shutdown 和路由里的 await 也在同一循环里执行------这才能在 shutdown 里用 await aclient.close() 等异步 清理,而不是只能做同步收尾。进程退出或 reload 时若不清理,正在飞的 LLM 请求可能被强杀、连接未关闭。下面用 lifespan 在 shutdown 阶段做异步清理(与上面路由共用同一 aclient)。注意 :lifespan 必须和路由挂在同一个 app 上;若要用优雅关闭,请先定义下面的 lifespan,用 app = FastAPI(lifespan=lifespan) 创建 app,再把本节所有 @app.post / @app.get 都注册在这个 app 下(即删掉本节开头单独的 app = FastAPI(),只保留一个带 lifespan 的 app)。
python
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
# startup:aclient 可在模块级或这里初始化,挂到 app.state 供路由用
yield
# shutdown:与路由同一事件循环,可在这里做异步清理
if hasattr(aclient, "close"):
await aclient.close() # 部分客户端提供,用于关闭连接池
# 若有自己维护的 pending tasks,可在这里 cancel 或 await 其结束
app = FastAPI(lifespan=lifespan)
6. 落地清单与延伸
什么时候上异步 :批量/多路调用、流式输出、高并发 API、单 Agent 多 tool、多 Agent 编排。什么时候不用:单次调用、一次性脚本。
落地时记住 :同步/异步别混用、gather 要「部分失败不拖垮」就加 return_exceptions=True 并判断结果、高并发复用单例 client、日志里加 request_id 便于排查。按场景抄:批量 → 4.1+4.4,流式 → 4.3,单/多 Agent → 4.5/4.6,Web → 5。
延伸:langchain、LlamaIndex 等有异步接口;生产可加强 trace、contextvars。
延伸阅读
- Python asyncio 官方文档:适合系统了解事件循环与协程。
- OpenAI API 文档:Chat Completions 与异步客户端用法。
全文代码基于 Python 3.10+、openai>=1.0,均可直接复制到项目中使用;TaskGroup 等 3.11+ 用法会在示例前注明。