5.6 KiB
5.6 KiB
id, title, type, domain, stack, tags, level, prerequisites, est_hours, related, week
| id | title | type | domain | stack | tags | level | prerequisites | est_hours | related | week | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| python.asyncio.scenario.concurrent-llm-calls | 并发调用多个模型和外部接口:限并发、超时、重试、部分失败 | scenario | python |
|
|
intermediate |
|
2 |
|
|
并发调用多个模型和外部接口:限并发、超时、重试、部分失败
业务背景与问题
客服工单系统里,每张新工单需要并行做四件事:摘要(大模型)、分类(大模型)、情绪识别(小模型服务)、查客户等级(CRM 接口)。第一版是顺序调用,单张工单处理要 8~15 秒;改成直接 gather 之后变快了,但遇到新问题:
- 早高峰一次进来几百张工单,同时发出上千个请求,模型服务商返回大量 429(限流);
- 某一个接口卡住时,整张工单一直没有结果;
- 任何一个调用报错,整个
gather抛异常,其余已经成功的结果也丢了。
约束
- 模型服务商有速率限制(每分钟请求数和 token 数)。
- 工单处理结果允许部分缺失(例如情绪识别失败,不影响分配客服),但必须标明缺失项。
- 每张工单的处理总时长不超过 20 秒,超时的部分以后台任务补齐。
方案设计
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[补偿队列:稍后重试]
- 全局并发上限:所有模型调用共享一个
Semaphore(按服务商限额设置),而不是每张工单各自限制。多进程 / 多实例部署时,进一步用 Redis 做全局限流。 - 单次超时 + 整体截止时间:每次调用 5 秒超时;每张工单整体 20 秒,到时取消未完成的调用。
- 有选择地重试:只对超时、429、5xx 做指数退避重试(加随机抖动),最多 1~2 次;参数错误不重试。
- 部分失败:每个子任务的结果单独记录成功值或错误原因,不让一个失败影响全部;失败项进入补偿队列。
- 可观测:记录每个子任务的耗时、重试次数、失败原因,用于调整并发和超时参数。
关键代码
完整实现和测试见 examples/fanout:
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 次):
摘要: 摘要 的回答(尝试 1 次)
分类: 分类 的回答(尝试 2 次) ← 第一次 429,重试成功
翻译: TimeoutError(尝试 2 次) ← 太慢,两次都超时
关键词: 关键词 的回答(尝试 1 次)
取舍
| 方案 | 优点 | 缺点 |
|---|---|---|
| 顺序调用 | 简单 | 慢 |
裸 gather |
快、代码少 | 无并发上限、一个失败全部失败 |
| Semaphore + 超时 + 重试 + 部分失败(本方案) | 可控、稳定 | 代码多一些,参数要调 |
| 消息队列 + 独立 worker | 削峰、可水平扩展、失败可重放 | 架构更复杂,结果是异步的 |
请求量继续增大时,把“工单 → 子任务”拆成队列消息,由 worker 池消费,Semaphore 换成 Redis 全局限流。
易错点
- 每个请求各自创建一个 Semaphore:等于没有限制。
- 重试不加抖动:所有任务在同一时刻重试,再次触发限流。
except Exception之后不区分错误类型就重试:参数错误被重试多次,浪费额度。- 捕获
CancelledError后不重新抛出:整体截止时间失效。 - 截止时间到了只是“不等了”,没有取消任务:后台还在消耗模型额度。
- 只记录“失败”不记录原因:无法判断是限流、超时还是参数问题。
面试怎么讲
“工单要同时调用几个模型和接口。我用 asyncio 并发,但加了全局 Semaphore 控制并发数以符合服务商的限流;每次调用有超时,只对超时和 429 做带抖动的指数退避重试;每个子任务单独记录结果和错误,单个失败不影响其他,失败项进补偿队列;整体有截止时间,到时取消剩余调用。处理时间从顺序调用的 8 到 15 秒降到了约等于最慢的那个调用。”
注意:面试中提到的耗时数据要来自你自己项目的实际测量,不要编造。和 Go 版本的同类场景对照:批量调用第三方接口(Go)。