--- 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)。